Skip to content
我们的赞助商
博客

Elysia 0.5 - Radiant

Radiant

以《明日方舟》原声音乐命名,「Radiant」由 Monster Sirent Records 作曲。

Radiant 在性能边界上更进一步,同时带来了更多稳定性改进,尤其是类型和动态路由。

静态代码分析

在 Elysia 0.4 中引入了提前编译(Ahead of Time compilation),允许 Elysia 优化函数调用,并消除我们之前存在的许多开销。

今天,我们通过静态代码分析进一步扩展了提前编译,使其更快,从而成为最快的 Bun Web 框架。

静态代码分析允许 Elysia 读取您的函数、处理程序、生命周期和 schema,然后尝试调整 fetch 处理程序,在提前编译处理程序,并消除任何未使用的代码,并在可能的情况下进行优化。

例如,如果您使用 schema 并且 body 类型为 Object,Elysia 会假设此路由优先使用 JSON,并将 body 解析为 JSON,而不是依赖 Content-Type 标头的动态检查:

ts
app.post('/sign-in', ({ body }) => signIn(body), {
    schema: {
        body: t.Object({
            username: t.String(),
            password: t.String()
        })
    }
})

这使我们能够将 body 解析性能提高近 2.5 倍。

通过静态代码分析,我们不再依赖于猜测您是否会使用昂贵的属性。

Elysia 可以读取您的代码,检测您将要使用的内容,并提前调整自身以适应您的需求。

这意味着,如果您不使用昂贵的属性如 querybody,Elysia 将完全跳过解析以提高性能。

ts
// Body 未使用,跳过 body 解析
app.post('/id/:id', ({ params: { id } }) => id, {
    schema: {
        body: t.Object({
            username: t.String(),
            password: t.String()
        })
    }
})

通过静态代码分析和提前编译,您可以放心,Elysia 非常擅长读取您的代码并自动调整自身以最大化性能。

静态代码分析允许我们将 Elysia 的性能提升超出想象,以下是值得注意的改进:

  • 整体改进约 15%
  • 静态路由快约 33%
  • 空 query 解析快约 50%
  • 严格类型 body 解析快约 100%
  • 空 body 解析快约 150%

凭借这些改进,我们能够在性能方面超越 Stricjs,使用 Elysia 0.5.0-beta.0 与 Stricjs 2.0.4 进行比较。

我们计划在未来的研究论文中更详细地解释这一点,阐述这个主题以及我们如何通过静态代码分析改进性能。

新路由器,“Memoirist”

自 0.2 版本以来,我们一直在构建自己的路由器“Raikiri”。

Raikiri 实现了其目的,它从头构建,基于自定义的 Radix Tree 实现以实现高速。

但随着进展,我们发现 Raikiri 在 Radix Tree 的复杂协调方面表现不佳,这导致开发者报告了许多 bug,尤其是动态路由,通常通过重新排序路由来解决。

我们理解这一点,并修补了 Raikiri 的 Radix Tree 协调算法的许多区域,然而我们的算法很复杂,随着我们修补路由器,它变得越来越昂贵,直到不再适合我们的目的。

这就是为什么我们引入了新路由器“Memoirist”。

Memoirist 是一个稳定的 Radix Tree 路由器,用于基于 Medley Router 的算法快速处理动态路径,而在静态方面则利用提前编译的优势。

在本版本发布中,我们将从 Raikiri 迁移到 Memoirist,以实现稳定性改进和比 Raikiri 更快的路径映射。

我们希望您在使用 Raikiri 时遇到的任何问题都能通过 Memoirist 解决,我们鼓励您尝试一下。

TypeBox 0.28

TypeBox 是驱动 Elysia 严格类型系统(称为 Elysia.t)的核心库。

在本更新中,我们将 TypeBox 从 0.26 更新到 0.28,以实现更细粒度的类型系统,接近严格类型语言。

我们更新 TypeBox 以改进 Elysia 类型系统,使其匹配新 TypeBox 功能以及较新 TypeScript 版本,如 Constant Generic

ts
new Elysia()
    .decorate('version', 'Elysia Radiant')
    .model(
        'name',
        Type.TemplateLiteral([
            Type.Literal('Elysia '),
            Type.Union([
                Type.Literal('The Blessing'),
                Type.Literal('Radiant')
            ])
        ])
    )
    // 严格检查模板字面量
    .get('/', ({ version }) => version)

这允许我们同时在运行时和开发过程中严格检查模板字面量或字符串/数字模式,以进行验证。

提前编译与类型系统

通过提前编译,Elysia 可以调整自身以优化并匹配 schema 定义的类型,从而减少开销。

这就是为什么我们引入了新类型 URLEncoded

如我们之前所述,Elysia 现在可以利用 schema 的优势,在提前编译时优化自身,body 解析是 Elysia 中更昂贵的领域之一,这就是为什么我们引入了用于解析 URLEncoded 等 body 的专用类型。

默认情况下,Elysia 将基于 body 的 schema 类型解析 body,如下所示:

  • t.URLEncoded -> application/x-www-form-urlencoded
  • t.Object -> application/json
  • t.File -> multipart/form-data
  • 其余 -> text/plain

但是,您可以使用 type 明确告诉 Elysia 以特定方法解析 body,如下所示:

ts
app.post('/', ({ body }) => body, {
    type: 'json'
})

type 可以是以下之一:

ts
type ContentType = |
    // 'text/plain' 的简写
    | 'text'
    // 'application/json' 的简写
    | 'json'
    // 'multipart/form-data' 的简写
    | 'formdata'
    // 'application/x-www-form-urlencoded' 的简写
    | 'urlencoded'
    | 'text/plain'
    | 'application/json'
    | 'multipart/form-data'
    | 'application/x-www-form-urlencoded'

您可以在概念中的 explicit body 页面找到更多细节。

数值类型

我们发现开发者在使用 Elysia 时的一个冗余任务是将数值字符串解析。

因此,我们引入了新的 Numeric 类型。

在之前的 Elysia 0.4 中,要解析数值字符串,我们需要使用 transform 手动解析字符串。

ts
app.get('/id/:id', ({ params: { id } }) => id, {
    schema: {
        params: t.Object({
            id: t.Number()
        })
    },
    transform({ params }) {
        const id = +params.id

        if(!Number.isNaN(id))
            params.id = id
    }
})

我们发现这一步是冗余的,充满了样板代码,我们想以声明式方式解决这个问题。

得益于静态代码分析,Numeric 类型允许您定义数值字符串并自动解析为数字。

一旦验证通过,数值类型将在运行时和类型级别自动解析为数字,以满足我们的需求。

ts
app.get('/id/:id', ({ params: { id } }) => id, {
    params: t.Object({
        id: t.Numeric()
    })
})

您可以在任何支持 schema 类型的属性中使用数值类型,包括:

  • params
  • query
  • headers
  • body
  • response

我们希望您会发现这个新 Numeric 类型在您的服务器中有用。

您可以在概念中的 numeric type 页面找到更多细节。

通过 TypeBox 0.28,我们使 Elysia 类型系统更加完整,我们很期待看到它在您那里的表现。

内联 Schema

您可能已经注意到,我们的示例不再使用 schema.type 来创建类型,因为我们进行了 breaking change,将 schema 移动并内联到 hook 语句中。

ts
// ? 从
app.get('/id/:id', ({ params: { id } }) => id, {
    schema: {
        params: t.Object({
            id: t.Number()
        })
    },
})

// ? 到
app.get('/id/:id', ({ params: { id } }) => id, {
    params: t.Object({
        id: t.Number()
    })
})

我们在进行 breaking change 时,尤其是对 Elysia 最重要的部分之一,考虑了很多。

基于大量的实验和实际使用,我们向社区提出这个新变更的投票建议,发现大约 60% 的 Elysia 开发者对迁移到内联 schema 感到满意。

但我们也听取了社区其余部分的意见,并试图解决反对此决定的论点:

清晰分离

使用旧语法,您必须明确告诉 Elysia 您创建的部分是 schema,使用 Elysia.t

在生命周期和 schema 之间创建清晰分离更清晰,具有更好的可读性。

但从我们的密集测试来看,我们发现大多数人阅读新语法时没有问题,无法将生命周期 hook 与 schema 类型区分开来,我们发现它仍然与 t.Type 和函数有清晰分离,在审查代码时有不同的语法高亮,虽然不如显式 schema 清晰,但人们可以很快适应新语法,尤其是如果他们熟悉 Elysia。

自动补全

人们关心的另一个领域是阅读自动补全。

合并 schema 和生命周期 hook 会导致自动补全有大约 10 个属性要建议,根据许多经过证实的通用用户体验研究,这可能会让用户沮丧,有太多选项可供选择,并导致更陡峭的学习曲线。

然而,我们发现 Elysia 的 schema 属性名称相当可预测,一旦开发者熟悉 Elysia 类型,就可以克服这个问题。

例如,如果您想访问 headers,您可以在 Context 中访问 headers,要类型化 headers,您可以在 hook 中类型化 header,两者共享相同的名称以实现可预测性。

因此,Elysia 可能会有稍多的学习曲线,但这是我们愿意为更好的开发者体验而做出的权衡。

"headers" 字段

以前,您可以通过访问 request.headers.get 来获取 headers 字段,您可能想知道为什么我们默认不提供 headers。

ts
app.post('/headers', ({ request: { headers } }) => {
    return headers.get('content-type')
})

因为解析 headers 有其自身的开销,我们发现许多开发者不经常访问 headers,因此我们决定不实现 headers。

但随着静态代码分析的引入,这发生了变化,Elysia 可以读取您的代码,判断您是否打算使用 header,然后根据您的代码动态解析 headers。

静态代码分析允许我们引入更多新功能,而不会带来任何开销。

ts
app.post('/headers', ({ headers }) => headers['content-type'])

解析后的 headers 将作为普通对象可用,键为 header 名称的小写形式。

State、Decorate、Model 重构

Elysia 的一个主要特性是能够根据您的需求自定义 Elysia。

我们重新审视了 statedecoratesetModel,我们看到 API 不一致,可以改进。

我们发现许多人反复使用 statedecorate 设置多个值,并希望像 setModel 一样一次性设置它们,我们希望统一 setModel 的 API 规范,使其与 statedecorate 使用相同方式,更具可预测性。

因此,我们将 setModel 重命名为 model,并为 statedecoratemodel 添加了对设置单个和多个值的支持,使用函数重载。

ts
import { Elysia, t } from 'elysia'

const app = new Elysia()
	// ? 使用标签设置 model
	.model('string', t.String())
	.model({
		number: t.Number()
	})
	.state('visitor', 1)
	// ? 使用对象设置 model
	.state({
		multiple: 'value',
		are: 'now supported!'
	})
	.decorate('visitor', 1)
	// ? 使用对象设置 model
	.decorate({
		name: 'world',
		number: 2
	})

并且由于我们将 TypeScript 的最低支持版本提高到 5.0,以使用 Constant Generic 改进严格类型。

statedecoratemodel 现在支持字面量类型和模板字符串,在运行时和类型级别严格验证类型。

ts
	// ? state、decorate 现在支持字面量
app.get('/', ({ body }) => number, {
		body: t.Literal(1),
		response: t.Literal(2)
	})

Group 和 Guard

我们发现许多开发者经常将 groupguard 一起使用,我们发现嵌套它们可能会变得冗余,甚至充满样板代码。

从 Elysia 0.5 开始,我们为 .group 添加了可选的第二个参数作为 guard 作用域。

ts
// ✅ 以前,您需要在 group 内嵌套 guard
app.group('/v1', (app) =>
    app.guard(
        {
            body: t.Literal()
        },
        (app) => app.get('/student', () => 'Rikuhachima Aru')
    )
)

// ✅ 新语法,与旧语法兼容
app.group(
    '/v1', {
        body: t.Literal('Rikuhachima Aru')
    },
    app => app.get('/student', () => 'Rikuhachima Aru')
)

// ✅ 与函数重载兼容
app.group('/v1', app => app.get('/student', () => 'Rikuhachima Aru'))

我们希望您会发现所有这些重新审视的 API 更有用,并且更适合您的用例。

类型稳定性

Elysia 类型系统很复杂。

我们可以在类型级别声明变量,按名称引用类型,应用多个 Elysia 实例,甚至在类型级别支持类似闭包的功能,这对于提供最佳开发者体验尤其复杂,特别是与 Eden 一起使用。

但有时在更新 Elysia 版本时,类型不会按预期工作,因为我们必须在每个发布前手动检查它,并可能导致人为错误。

在 Elysia 0.5 中,我们添加了类型级别的单元测试,以防止未来的潜在 bug,这些测试将在每个发布前运行,如果发生错误,将阻止我们发布包,迫使我们修复类型问题。

这意味着您现在可以依赖我们检查每个发布的类型完整性,并确信类型引用相关的 bug 会更少。


值得注意的改进:

  • 添加对使用 Node 适配器运行 Elysia 的 CommonJS 支持
  • 移除手动片段映射以加速路径提取
  • composeHandler 中内联验证器以提高性能
  • 使用一次性上下文赋值
  • 通过静态代码分析添加对懒加载上下文注入的支持
  • 确保响应非空性
  • 添加联合 body 验证检查
  • 将默认对象处理程序设置为 inherits
  • 使用 constructor.name 映射而不是 instanceof 以提高速度
  • 添加专用错误构造函数以提高性能
  • 为 onRequest 迭代检查添加条件字面量函数
  • 改进 WebSocket 类型

Breaking Change:

  • innerHandle 重命名为 fetch
    • 迁移:将 .innerHandle 重命名为 fetch
  • .setModel 重命名为 .model
    • 迁移:将 setModel 重命名为 model
  • hook.schema 移除到 hook
    • 迁移:移除 schema 和大括号 schema.type
    ts
    // 从
    app.post('/', ({ body }) => body, {
        schema: {
            body: t.Object({
                username: t.String()
            })
        }
    })
    
    // 到
    app.post('/', ({ body }) => body, {
        body: t.Object({
            username: t.String()
        })
    })
  • 移除 mapPathnameRegex (internal)

后记

使用 Bun 推动 JavaScript 的性能边界是我们真正兴奋的地方!

即使每个发布都有新功能,Elysia 仍在不断加速,伴随着改进的可靠性和稳定性,我们希望 Elysia 成为下一代 TypeScript 框架的选择之一。

我们很高兴看到许多才华横溢的开源开发者通过他们的出色工作让 Elysia 焕发生机,比如 Bogeychan 的 Elysia Node 和 Deno 适配器、Rayriffy 的 Elysia 速率限制,我们也希望未来看到您的贡献!

感谢您对 Elysia 的持续支持,我们希望在下一个发布中见到您。

我不会让人们失望,我要让他们高高举起

我们每天都在变得更响亮,是的,我们被放大了

用光芒惊艳

你会想站在我这边

是的,你知道这是 全速前进

Elysia:人性化框架