Elysia 1.0 - 堕落者的哀歌

Elysia 1.0 是开发 1.8 年后的第一个稳定版本。
从我们开始以来,我们一直期待着一个专注于开发者体验、速度以及如何让编写代码更适合人类而非机器的框架。
我们在各种情况下对 Elysia 进行了实战测试,模拟了中大型项目,向客户交付代码,这是我们感觉足够自信可以发布的第一个版本。
Elysia 1.0 引入了重大改进,并包含 1 个必要的破坏性变更。
- Sucrose - 使用模式匹配静态分析重写,而不是 RegEx
- 改进的启动时间 高达 14 倍
- 移除 ~40 个路由/实例 TypeScript 限制
- 更快的类型推断 高达 ~3.8 倍
- Treaty 2
- Hook 类型 (破坏性变更)
- 内联错误 用于严格错误检查
Elysia 的发布笔记有一个传统,即版本名称取自歌曲或媒体。
这个重要版本以"Lament of the Fallen"命名。
这是来自我最喜欢的弧线《崩坏3rd》的动画短片,以及我最喜欢的角色 "Raiden Mei" 的主题曲 "Honkai World Diva"。
这是一款非常好的游戏,你应该去试试。
ー SaltyAom
也称为 Gun Girl Z、崩坏3rd、崩坏:星穹铁道的 Raiden Mei。还有她的“变体”,原神中的 Raiden Shogun,以及可能来自崩坏:星穹铁道(因为她可能是星穹铁道 2.1 中提到的坏结局律者形式)的 Acheron。
TIP
记住,ElysiaJS 是一个由志愿者维护的开源库,与 Mihoyo 或 Hoyoverse 无关。但我们是崩坏系列的超级粉丝,好吗?
Sucrose
Elysia 针对性能进行了优化,在各种基准测试中证明了其优秀性能,其中一个主要因素归功于 Bun 和我们自定义的 JIT 静态代码分析。
如果你不知道,Elysia 内置了一个“编译器”,它读取你的代码并生成优化函数处理方式。
这个过程很快,并且在运行时即时发生,无需构建步骤。 然而,维护起来具有挑战性,因为它主要使用复杂的 RegEx 编写,如果发生递归,有时会变慢。
这就是为什么我们重写了静态分析部分,使用名为 "Sucrose" 的混合方法,将代码注入阶段与部分基于 AST 和模式匹配分离。
我们选择实现仅针对性能改进所需的部分规则子集,而不是使用更准确的全 AST,因为它需要在运行时快速。
Sucrose 擅长以低内存使用准确推断处理程序函数的递归属性,从而实现高达 37% 的更快推断时间和显著降低的内存使用。
Sucrose 从 Elysia 1.0 开始运行为替换基于 RegEx 的部分 AST 和模式匹配。
改进的启动时间
感谢 Sucrose 和与动态注入阶段的分离,我们可以将分析时间从 AOT 推迟到 JIT。
换句话说,“编译”阶段可以懒惰求值。
将求值阶段从 AOT 卸载到 JIT,当路由首次匹配时,并缓存结果,按需编译,而不是在服务器启动前编译所有路由。
在运行时性能中,单个编译通常很快,不超过 0.01-0.03 ms(毫秒而非秒)。
在中型应用和压力测试中,我们测量到启动时间高达 ~6.5-14 倍更快。
移除 ~40 个路由/实例限制
以前从 Elysia 0.1 开始,你只能在 1 个 Elysia 实例中堆叠最多 ~40 个路由。
这是 TypeScript 的限制,每个队列都有有限的内存,如果超过,TypeScript 会认为 "Type instantiation is excessively deep and possibly infinite"。
const main = new Elysia()
.get('/1', () => '1')
.get('/2', () => '2')
.get('/3', () => '3')
// repeat for 40 times
.get('/42', () => '42')
// Type instantiation is excessively deep and possibly infinite
作为变通方法,我们需要将实例分离成控制器来克服限制,并重新合并类型以卸载队列,如下所示。
const controller1 = new Elysia()
.get('/42', () => '42')
.get('/43', () => '43')
const main = new Elysia()
.get('/1', () => '1')
.get('/2', () => '2')
// repeat for 40 times
.use(controller1)
然而,从 Elysia 1.0 开始,经过一年的类型性能优化,特别是 Tail Call Optimization 和 variances,我们克服了这个限制。
这意味着理论上,我们可以堆叠无限数量的路由和方法,直到 TypeScript 崩溃。
(剧透:我们已经这样做过了,大约 558 个路由/实例之前 TypeScript CLI 和语言服务器就会因为 JavaScript 每个堆栈/队列的内存限制而崩溃)
const main = new Elysia()
.get('/1', () => '1')
.get('/2', () => '2')
.get('/3', () => '42')
// repeat for n times
.get('/550', () => '550')
因此,我们将 ~40 个路由的限制增加到 JavaScript 内存限制,所以尽量不要堆叠超过 ~558 个路由/实例,如果必要则分离成插件。
让我们感觉 Elysia 不适合生产的阻塞因素终于解决了。
类型推断改进
感谢我们投入的优化努力,我们在大多数 Elysia 服务器中测量到 高达 ~82% 的改进。
感谢移除堆栈限制和改进的类型性能,即使在 500 个路由堆叠后,我们也可以期待几乎即时的类型检查和自动完成。
Eden Treaty 高达 13 倍更快,通过预计算类型而不是卸载类型重映射到 Eden 来提升类型推断性能。
总体而言,Elysia 和 Eden Treaty 一起执行将 高达 ~3.9 倍更快。
以下是 450 个路由上 Elysia + Eden Treaty 在 0.8 和 1.0 之间的比较。
使用 450 个路由对 Elysia 与 Eden Treaty 进行压力测试,结果如下:
- Elysia 0.8 耗时 ~1500ms
- Elysia 1.0 耗时 ~400ms
感谢移除堆栈限制和重映射过程,现在可以为单个 Eden Treaty 实例堆叠超过 1,000 个路由。
Treaty 2
我们向你征求对 Eden Treaty 的反馈,你喜欢什么以及什么可以改进,你指出了 Treaty 设计中的一些缺陷和几个改进提案。
这就是为什么今天我们介绍 Eden Treaty 2,对更人性化的设计进行全面改版。
尽管我们不喜欢破坏性变更,Treaty 2 是 Treaty 1 的继任者。
Treaty 2 中的新功能:
- 更人性化的语法
- 单元测试的端到端类型安全
- 拦截器
- 无 "$" 前缀和属性
我们最喜欢的是单元测试的端到端类型安全。
因此,我们可以使用 Eden Treaty 2 来编写具有自动完成和类型安全的单元测试,而不是启动模拟服务器并发送 fetch 请求。
// test/index.test.ts
import { describe, expect, it } from 'bun:test'
import { Elysia } from 'elysia'
import { treaty } from '@elysiajs/eden'
const app = new Elysia().get('/hello', () => 'hi')
const api = treaty(app)
describe('Elysia', () => {
it('return a response', async () => {
const { data } = await api.hello.get()
expect(data).toBe('hi')
})
})
两者之间的区别是 Treaty 2 是 Treaty 1 的继任者。
我们不打算对 Treaty 1 引入任何破坏性变更,也不强迫你更新到 Treaty 2。
你可以选择继续在当前项目中使用 Treaty 1 而无需更新到 Treaty 2,我们将以维护模式维护它。
- 你可以导入
treaty
来使用 Treaty 2。 - 并导入
edenTreaty
用于 Treaty 1。
新 Treaty 的文档可以在 Treaty 概述 中找到,Treaty 1 的文档在 Treaty 遗留 中。
Hook 类型(破坏性变更)
我们讨厌破坏性变更,这是我们首次大规模进行。
我们投入了大量努力设计 API 以减少对 Elysia 的变更,但这是修复有缺陷设计的必要步骤。
以前,当我们使用 "on" 添加钩子如 onTransform
或 onBeforeHandle
时,它会成为全局钩子。
这对于创建插件很棒,但对于本地实例如控制器来说并不理想。
const plugin = new Elysia()
.onBeforeHandle(() => {
console.log('Hi')
})
// log Hi
.get('/hi', () => 'in plugin')
const app = new Elysia()
.use(plugin)
// will also log hi
.get('/no-hi-please', () => 'oh no')
然而,我们发现这种行为会引发几个问题。
- 我们发现许多开发者在新实例中也有很多嵌套守卫。守卫几乎被用作启动新实例以避免副作用的方式。
- 默认全局可能会导致不可预测的(副作用)行为,如果不小心,尤其是在经验不足的团队中。
- 我们咨询了许多熟悉和不熟悉 Elysia 的开发者,发现大多数人最初期望钩子是本地的。
- 基于前一点,我们发现默认使钩子全局很容易导致意外 bug(副作用),如果不仔细审查则难以调试和观察。
为了修复这个问题,我们引入了钩子类型,通过引入 "hook-type" 来指定钩子如何被继承。
钩子类型可以分类如下:
- local(默认) - 仅应用于当前实例及其后代
- scoped - 仅应用于 1 个祖先、当前实例及其后代
- global(旧行为) - 应用于应用插件的所有实例(所有祖先、当前及其后代)
要指定钩子的类型,只需在钩子中添加 { as: hookType }
。
const plugin = new Elysia()
.onBeforeHandle(() => {
.onBeforeHandle({ as: 'global' }, () => {
console.log('hi')
})
.get('/child', () => 'log hi')
const main = new Elysia()
.use(plugin)
.get('/parent', () => 'log hi')
这个 API 旨在修复 Elysia 的 守卫嵌套问题,开发者害怕在根实例上引入钩子,因为担心副作用。
例如,要为整个实例创建认证检查,我们需要将路由包装在守卫中。
const plugin = new Elysia()
.guard((app) =>
app
.onBeforeHandle(checkAuthSomehow)
.get('/profile', () => 'log hi')
)
然而,随着钩子类型的引入,我们可以移除嵌套守卫样板代码。
const plugin = new Elysia()
.guard((app) =>
app
.onBeforeHandle(checkAuthSomehow)
.get('/profile', () => 'log hi')
)
钩子类型将指定钩子如何被继承,让我们创建一个插件来说明钩子类型的工作原理。
// ? Value based on table value provided below
const type = 'local'
const child = new Elysia()
.get('/child', () => 'hello')
const current = new Elysia()
.onBeforeHandle({ as: type }, () => {
console.log('hi')
})
.use(child)
.get('/current', () => 'hello')
const parent = new Elysia()
.use(current)
.get('/parent', () => 'hello')
const main = new Elysia()
.use(parent)
.get('/main', () => 'hello')
通过更改 type
值,结果应如下所示:
type | child | current | parent | main |
---|---|---|---|---|
'local' | ✅ | ✅ | ❌ | ❌ |
'scope' | ✅ | ✅ | ✅ | ❌ |
'global' | ✅ | ✅ | ✅ | ✅ |
从 Elysia 0.8 迁移,如果你想使钩子全局,你必须指定钩子是全局的。
// From Elysia 0.8
new Elysia()
.onBeforeHandle(() => "A")
.derive(() => {})
// Into Elysia 1.0
new Elysia()
.onBeforeHandle({ as: 'global' }, () => "A")
.derive({ as: 'global' }, () => {})
尽管我们讨厌破坏性变更和迁移,但我们认为这是一个重要的修复,早晚都会发生以解决问题。
大多数服务器可能不需要自己应用迁移,但 严重依赖插件作者,或者如果需要迁移,通常不会超过 5-15 分钟。
完整的迁移说明,请参阅 Elysia#513。
钩子类型的文档,请参阅 Lifecycle#hook-type。
内联错误
从 Elysia 0.8 开始,我们可以使用 error
函数返回带有状态码的响应以供 Eden 推断。
然而,这有一些缺陷。
如果你为路由指定响应模式,Elysia 将无法为状态码提供准确的自动完成。
例如,缩小可用状态码。
内联错误可以从处理程序中解构,如下所示:
import { Elysia } from 'elysia'
new Elysia()
.get('/hello', ({ error }) => {
if(Math.random() > 0.5) return error(418, 'Nagisa')
return 'Azusa'
}, {
response: t.Object({
200: t.Literal('Azusa'),
418: t.Literal('Nagisa')
})
})
内联错误可以从模式生成细粒度类型,提供类型缩小、自动完成和类型检查,精确到值级别,在值而不是整个函数上显示红色下划线。
我们推荐使用内联错误而不是 import error 以获得更准确的类型安全。
v1 意味着什么,以及下一步
达到稳定版本意味着我们相信 Elysia 足够稳定,可以在生产中使用。
维护向后兼容性现在是我们目标之一,投入努力避免引入破坏性变更,除非出于安全考虑。
我们的目标是让后端开发感觉轻松、有趣和直观,同时确保使用 Elysia 构建的产品具有坚实的基础。
在此之后,我们将专注于完善我们的生态系统和插件。 引入处理冗余和琐碎任务的人性化方式,从一些内部插件重写、认证、JIT 和非 JIT 模式之间行为同步,以及 通用运行时支持 开始。
Bun 在运行时、包管理器以及所有提供的工具中都表现出色,我们相信 Bun 将是 JavaScript 的未来。
我们相信,通过将 Elysia 开放给更多运行时并提供有趣的 Bun 特定功能(或至少易于配置,例如 Bun Loaders API),最终会让更多人尝试 Bun,而不是 Elysia 选择只支持 Bun。
Bun 是对的,从 Node 迁移人们的最佳方式是具有兼容层并在 Bun 上提供更好的 DX 和性能
— SaltyAom (@saltyAom)2024 年 3 月 14 日
Elysia 核心本身部分兼容 WinterCG,但并非所有官方插件都与 WinterCG 兼容,有些具有 Bun 特定功能,我们想修复这个问题。
我们还没有通用运行时支持的具体日期或版本,因为我们将逐步采用和测试,直到确保没有意外行为。
你可以期待以下运行时的支持:
- Node
- Deno
- Cloudflare Worker
我们还想支持以下:
- Vercel Edge Function
- Netlify Function
- AWS Lambda / LLRT
此外,我们还支持并测试了以下支持服务器端渲染或边缘函数的框架:
- Nextjs
- Expo
- Astro
- SvelteKit
同时,有一个由 Elysia 活跃贡献者 Bogeychan 维护的 Elysia Polyfills。
此外,我们重写了 Eden 文档,以更深入地解释 Eden 的细节,我们认为你应该去看看。
我们还改进了几个页面,并删除了文档中的冗余部分,你可以在 Elysia 1.0 文档 PR 中查看受影响的页面。
最后,如果你有迁移问题或与 Elysia 相关的其他问题,请随时在 Elysia 的 Discord 服务器上提问。
显著改进
改进:
- 细粒度响应式 cookie
- 为 cookie 使用单一真相来源
- WebSocket 的宏支持
- 添加
mapResolve
- 在生命周期事件中添加
{ as: 'global' | 'scoped' | 'local' }
- 添加临时类型
- 将
error
内联到处理程序 - 内联
error
基于状态码具有自动完成和类型检查 - 处理程序现在检查
error
的返回类型基于状态码 - 实用工具
Elysia._types
用于类型推断 - #495 为解析失败提供用户友好的错误
- 处理程序现在为 Treaty 推断错误状态的返回类型
t.Date
现在允许字符串化日期- 改进类型测试用例
- 为所有生命周期添加测试用例
- resolve、mapResolve、derive、mapDerive 使用临时类型来准确缩小范围
- 推断查询动态变量
破坏性变更:
- #513 生命周期现在优先本地
变更:
- 分组私有 API 属性
- 将
Elysia.routes
移动到Elysia.router.history
- 在返回前检测可能的 JSON
- 未知响应现在按原样返回而不是 JSON.stringify()
- 将 Elysia 验证错误更改为 JSON 而非字符串
Bug 修复:
- #466 如果
aot: true
,异步 Derive 会将请求上下文泄漏到其他请求 - #505 查询模式中空 ObjectString 缺少内部验证
- #503 Beta:使用 decorate 和 derive 时未定义类
- 调用 .stop 时 onStop 回调被调用两次
- mapDerive 现在解析为
Singleton['derive']
而非Singleton['store']
ValidationError
不返回content-type
为application/json
- 验证
error(status, value)
按状态验证 - derive/resolve 始终限定为 Global
- 如果未处理,onError 调用重复
- #516 服务器计时破坏 beforeHandle 守卫
- cookie.remove() 不设置正确的 cookie 路径
后记
TIP
以下包含个人感受,可能发泄、咆哮,可能尴尬和不专业的内容,不应该写在软件发布笔记中。你可以选择不继续阅读,因为我们已经陈述了发布的所有必要内容。
2 年前,我有一个悲惨的回忆。
这是我最痛苦的回忆之一,白天和黑夜工作以跟上不公平的任务,这些任务利用了我们与某些软件公司的松散合同。
这花了超过 6 个月,我必须从醒来到睡觉(15 小时)重复工作,除了编码什么都不做,甚至一天没有 5 分钟的休息,没有放松时间,除了编码什么都没有,几乎 2 个月没有休息日,甚至工作日我敲晕了,几乎不得不在医院床上工作。
我失去了灵魂,生活毫无目的,我唯一的愿望是让它成为一场梦。
当时,有太多破坏性变更,从松散需求和合同的漏洞引入了无数新功能。
跟踪它几乎不可能,我们甚至被骗了,没有得到应得的报酬,因为“不满意”,我们对此无能为力。
我花了一个月从编写代码的恐惧中恢复,作为不专业的人,我无法正确履行工作,在创伤中咨询经理说我遭受了 burnout。
这就是为什么我们如此讨厌破坏性变更,并希望设计 Elysia 以 TypeScript 的健全性轻松处理变更,即使它不是最好的,但这是我们拥有的全部。
我不想让任何人经历这样的事情。
我们设计了一个框架来应对我们从那个合同中遇到的所有缺陷。
我在那里看到的的技术缺陷没有任何基于 JavaScript 的解决方案能满足我,所以我尝试了一个。
我本可以继续前进,因为我可以避免未来的松散合同,赚钱而不花费大部分空闲时间创建一个框架,但我没有。
有一个我最喜欢的部分,动画短片中的引述,Mei 反对 Kiana 牺牲自己为世界的想法,Mei 回答:
> 然而你独自承担一切,以生命为代价。
> 也许这是为了更大的利益...
> 但我怎么能假装这是正确的事?
> 我只知道内心深处...
> 没有你...
> 这个世界对我来说毫无意义
它描绘了两个人之间的二元性:一个是为世界牺牲自己的人,另一个是为拯救所爱之人牺牲自己的人。
如果我们看到问题并继续前进,我们怎么知道后续的人不会跌入我们曾经的问题,有人需要做些什么。
那个人会牺牲自己来拯救他人,但谁来拯救那个牺牲的人?
名称 "Lament of the Fallen" 描述了这一点,以及我们为什么创建 Elysia。
*尽管它的一切都是我最喜欢的,我可能个人上太过投入。
尽管从坏回忆和悲剧事件中构建。尽管从坏回忆和悲剧事件中构建,它是一种特权,看到 Elysia 成长为充满爱的东西。看到你构建的东西被喜爱并被他人良好接受。
Elysia 是开源开发者的作品,没有任何公司支持。
我们必须谋生,并在空闲时间构建 Elysia。
在某个时刻,我选择不立即找工作,只为 Elysia 工作几个月。
我们希望花费时间持续改进 Elysia,你可以通过 GitHub sponsors 帮助我们减少支持自己的工作,并有更多空闲时间从事 Elysia。
我们只是想创建一些东西来解决我们问题的制造者。
我们已经用 Elysia 创建并实验了很多,向客户交付真实代码,并在真实项目中使用 Elysia 来为我们本地社区 CreatorsGarten (本地技术社区,非组织)背后的工具提供动力。
确保 Elysia 准备好生产花了很多时间、准备和勇气。当然,会有 bug,但我们愿意倾听并修复。
这是新事物的开始。
这可能 因为你 而实现。
ー SaltyAom
天堂的所有炽热星星将在末日消逝,
你的温柔灵魂献给诅咒。
“血染的小镇沐浴在绯红的月光下”
歌姬泣诉哀歌。
所有那些甜蜜的小梦想深埋在记忆中直到最后。