Elysia 1.1 - 成年人的天堂

以 Mili 的歌曲命名,"Grown-up's Paradise",并用作 Arknights 电视动画第三季商业公告 的开场。
作为一名从第一天起就玩 Arknights 的玩家和长期的 Mili 粉丝,我从未想过 Mili 会为 Arknights 创作一首歌,你应该去听听它们,它们是最棒的。
Elysia 1.1 专注于以下几项开发者体验改进:
OpenTelemetry
可观测性是生产环境中的一个重要方面。
它允许我们了解服务器在生产环境中的工作方式,识别问题和瓶颈。
最受欢迎的可观测性工具之一是 OpenTelemetry。然而,我们承认正确设置和为服务器插桩 OpenTelemetry 很困难且耗时。
将 OpenTelemetry 集成到大多数现有框架和库中很困难。
大多数解决方案围绕着 hacky 方法、monkey patching、原型污染或手动插桩,因为框架从一开始就没有为可观测性设计。
这就是为什么我们在 Elysia 上引入 官方支持 OpenTelemetry。
要开始使用 OpenTelemetry,请安装 @elysiajs/opentelemetry
并将其插件应用到任何实例。
import { Elysia } from 'elysia'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
new Elysia()
.use(
opentelemetry({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter()
)
]
})
)
Elysia OpenTelemetry 将 收集与 OpenTelemetry 标准兼容的任何库的 span,并自动应用父 span 和子 span。
在上面的代码中,我们应用 Prisma
来跟踪每个查询耗时多久。
通过应用 OpenTelemetry,Elysia 将:
- 收集遥测数据
- 将相关生命周期分组在一起
- 测量每个函数耗时多久
- 插桩 HTTP 请求和响应
- 收集错误和异常
你可以将遥测数据导出到 Jaeger、Zipkin、New Relic、Axiom 或任何其他与 OpenTelemetry 兼容的后端。
这是一个导出遥测数据到 Axiom 的示例
import { Elysia } from 'elysia'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
new Elysia()
.use(
opentelemetry({
spanProcessors: [
new BatchSpanProcessor(
new OTLPTraceExporter({
url: 'https://api.axiom.co/v1/traces',
headers: {
Authorization: `Bearer ${Bun.env.AXIOM_TOKEN}`,
'X-Axiom-Dataset': Bun.env.AXIOM_DATASET
}
})
)
]
})
)
Elysia OpenTelemetry 仅用于将 OpenTelemetry 应用到 Elysia 服务器。
你可以正常使用 OpenTelemetry SDK,span 将在 Elysia 的请求 span 下运行,它将自动出现在 Elysia 跟踪中。
然而,我们还提供了一个 getTracer
和 record
实用工具,从应用程序的任何部分收集 span。
import { Elysia } from 'elysia'
import { record } from '@elysiajs/opentelemetry'
export const plugin = new Elysia()
.get('', () => {
return record('database.query', () => {
return db.query('SELECT * FROM users')
})
})
record
等同于 OpenTelemetry 的 startActiveSpan
,但它将自动处理关闭并捕获异常。
你可以将 record
视为代码的标签,它将显示在跟踪中。
为可观测性准备你的代码库
Elysia OpenTelemetry 将分组生命周期并读取每个钩子的 函数名 作为 span 的名称。
现在是 为你的函数命名 的好时机。
如果你的钩子处理程序是箭头函数,你可以将其重构为命名函数以更好地理解跟踪,否则,你的跟踪 span 将被命名为 anonymous
。
const bad = new Elysia()
// ⚠️ span 名称将是 anonymous
.derive(async ({ cookie: { session } }) => {
return {
user: await getProfile(session)
}
})
const good = new Elysia()
// ✅ span 名称将是 getProfile
.derive(async function getProfile({ cookie: { session } }) {
return {
user: await getProfile(session)
}
})
Trace v2
Elysia OpenTelemetry 是基于 Trace v2 构建的,它取代了 Trace v1。
Trace v2 允许我们以 100% 同步行为跟踪服务器的任何部分,而不是依赖并行事件监听器桥接(再见,死锁)。
它被完全重写,不仅更快,而且可靠且精确到微秒,依赖 Elysia 的提前编译和代码注入。
Trace v2 使用回调监听器而不是 Promise 来确保在移动到下一个生命周期事件之前回调完成。
这是一个 Trace v2 的示例用法:
import { Elysia } from 'elysia'
new Elysia()
.trace(({ onBeforeHandle, set }) => {
// 监听 before handle 事件
onBeforeHandle(({ onEvent }) => {
// 按顺序监听所有子事件
onEvent(({ onStop, name }) => {
// 在子事件完成后执行某些操作
onStop(({ elapsed }) => {
console.log(name, '耗时', elapsed, 'ms')
// 回调在下一个事件之前同步执行
set.headers['x-trace'] = 'true'
})
})
})
})
你也可以在 trace 中使用 async
,Elysia 将阻塞事件,直到回调完成才继续到下一个事件。
Trace v2 是对 Trace v1 的破坏性变更,请查看 trace api 文档以获取更多信息。
标准化
Elysia 1.1 现在在数据被处理之前对其进行标准化。
为了确保数据一致且安全,Elysia 将尝试将数据强制转换为 schema 中定义的确切数据形状,移除额外字段,并将数据标准化为一致的格式。
例如,如果你有这样的 schema:
import { Elysia, t } from 'elysia'
import { treaty } from '@elysiajs/eden'
const app = new Elysia()
.post('/', ({ body }) => body, {
body: t.Object({
name: t.String(),
point: t.Number()
}),
response: t.Object({
name: t.String()
})
})
const { data } = await treaty(app).index.post({
name: 'SaltyAom',
point: 9001,
// ⚠️ 额外字段
title: 'maintainer'
})
// 'point' 在响应中被移除,如定义
console.log(data) // { name: 'SaltyAom' }
这段代码做了两件事:
- 在服务器使用 body 之前从 body 中移除
title
- 在发送到客户端之前从响应中移除
point
这有助于防止数据不一致,确保数据始终处于正确格式,并且不会泄露任何敏感信息。
数据类型强制转换
以前,Elysia 使用确切的数据类型而没有强制转换,除非明确指定。
例如,要将查询参数解析为数字,你需要明确将其转换为 t.Numeric
而不是 t.Number
。
import { Elysia, t } from 'elysia'
const app = new Elysia()
.get('/', ({ query }) => query, {
query: t.Object({
page: t.Numeric()
})
})
然而,在 Elysia 1.1 中,我们引入了数据类型强制转换,如果可能,它将自动将数据强制转换为正确的数据类型。
这允许我们简单地设置 t.Number
而不是 t.Numeric
来将查询参数解析为数字。
import { Elysia, t } from 'elysia'
const app = new Elysia()
.get('/', ({ query }) => query, {
query: t.Object({
// ✅ page 将自动被强制转换为数字
page: t.Number()
})
})
这也适用于 t.Boolean
、t.Object
和 t.Array
。
这是在提前编译阶段通过用可能强制转换的对应物交换 schema 完成的,与使用 t.Numeric
或其他强制转换对应物相同。
Guard as
以前,guard
仅适用于当前实例。
import { Elysia } from 'elysia'
const plugin = new Elysia()
.guard({
beforeHandle() {
console.log('called')
}
})
.get('/plugin', () => 'ok')
const main = new Elysia()
.use(plugin)
.get('/', () => 'ok')
使用这段代码,onBeforeHandle
仅在访问 /plugin
时被调用,但不在 /
时调用。
在 Elysia 1.1 中,我们在 guard
中添加了 as
属性,允许我们将 guard 应用为 scoped
或 global
,类似于添加事件监听器。
import { Elysia } from 'elysia'
const plugin1 = new Elysia()
.guard({
as: 'scoped',
beforeHandle() {
console.log('called')
}
})
.get('/plugin', () => 'ok')
// 等同于
const plugin2 = new Elysia()
.onBeforeHandle({ as: 'scoped' }, () => {
console.log('called')
})
.get('/plugin', () => 'ok')
这将确保 onBeforeHandle
也会在父级上调用,并遵循作用域机制。
将 as
添加到 guard 很有用,因为它允许我们一次性应用多个钩子,同时尊重作用域机制。
然而,它还允许我们应用 schema
来确保所有路由的一次性类型安全。
import { Elysia, t } from 'elysia'
const plugin = new Elysia()
.guard({
as: 'scoped',
response: t.String()
})
.get('/ok', () => 'ok')
.get('/not-ok', () => 1)
const instance = new Elysia()
.use(plugin)
.get('/no-ok-parent', () => 2)
const parent = new Elysia()
.use(instance)
// 这没问题,因为响应定义为 scoped
.get('/ok', () => 3)
批量 cast
继续上面的代码,有时我们想将插件重新应用到父实例,但由于受 scoped
机制限制,它仅限于 1 个父级。
要应用到父实例,我们需要 将作用域提升到父实例。
我们可以通过将其 cast 为 `as('plugin') 来实现。
import { Elysia, t } from 'elysia'
const plugin = new Elysia()
.guard({
as: 'scoped',
response: t.String()
})
.get('/ok', () => 'ok')
.get('/not-ok', () => 1)
const instance = new Elysia()
.use(plugin)
.as('plugin')
.get('/no-ok-parent', () => 2)
const parent = new Elysia()
.use(instance)
// 现在错误,因为 `scoped` 被提升到父级
.get('/ok', () => 3)
as
cast 将提升实例的所有作用域。
它的工作方式是读取所有钩子和 schema 作用域,并将其提升到父实例。
这意味着如果你有 local
作用域,并想将其应用到父实例,你可以使用 as('plugin')
来提升它。
import { Elysia, t } from 'elysia'
const plugin = new Elysia()
.guard({
response: t.String()
})
.onBeforeHandle(() => { console.log('called') })
.get('/ok', () => 'ok')
.get('/not-ok', () => 1)
.as('plugin')
const instance = new Elysia()
.use(plugin)
.get('/no-ok-parent', () => 2)
.as('plugin')
const parent = new Elysia()
.use(instance)
// 现在错误,因为 `scoped` 被提升到父级
.get('/ok', () => 3)
这将 cast guard 的响应 和 onBeforeHandle 为 scoped
,从而提升到父实例。
as 接受两个可能参数:
plugin
将事件 cast 为 scopedglobal
将事件 cast 为 global
import { Elysia, t } from 'elysia'
const plugin = new Elysia()
.guard({
response: t.String()
})
.onBeforeHandle(() => { console.log('called') })
.get('/ok', () => 'ok')
.get('/not-ok', () => 1)
.as('global')
const instance = new Elysia()
.use(plugin)
.get('/no-ok-parent', () => 2)
const parent = new Elysia()
.use(instance)
// 现在错误,因为 `scoped` 被提升到父级
.get('/ok', () => 3)
这允许我们一次性 cast 多个钩子作用域,而无需为每个钩子添加 as
或将其应用到 guard,或提升现有的插件作用域。
import { Elysia, t } from 'elysia'
// 在 1.0 上
const from = new Elysia()
// 在 1.0 上无法将 guard 应用到父级
.guard({
response: t.String()
})
.onBeforeHandle({ as: 'scoped' }, () => { console.log('called') })
.onAfterHandle({ as: 'scoped' }, () => { console.log('called') })
.onParse({ as: 'scoped' }, () => { console.log('called') })
// 在 1.1 上
const to = new Elysia()
.guard({
response: t.String()
})
.onBeforeHandle(() => { console.log('called') })
.onAfterHandle(() => { console.log('called') })
.onParse(() => { console.log('called') })
.as('plugin')
响应协调
在 Elysia 1.0 中,Elysia 将优先选择作用域中的一个 schema,而不会将它们合并。
然而,在 Elysia 1.1 中,Elysia 将尝试从每个状态代码的所有作用域中协调响应 schema 并将它们合并。
import { Elysia, t } from 'elysia'
const plugin = new Elysia()
.guard({
as: 'global',
response: {
200: t.Literal('ok'),
418: t.Literal('Teapot')
}
})
.get('/ok', ({ error }) => error(418, 'Teapot'))
const instance = new Elysia()
.use(plugin)
.guard({
response: {
418: t.String()
}
})
// 这没问题,因为本地响应覆盖
.get('/ok', ({ error }) => error(418, 'ok'))
const parent = new Elysia()
.use(instance)
// 错误,因为全局响应
.get('/not-ok', ({ error }) => error(418, 'ok'))
我们可以看出:
- 在 instance 上:来自全局作用域的响应 schema 与本地作用域合并,允许我们覆盖此实例中的全局响应 schema
- 在 parent 上:使用来自全局作用域的响应 schema,instance 的本地作用域未应用,因为作用域机制
这在类型级别和运行时都处理,为我们提供更好的类型完整性。
可选路径参数
Elysia 现在通过在路径参数末尾添加 ?
支持可选路径参数。
import { Elysia } from 'elysia'
new Elysia()
.get('/ok/:id?', ({ params: { id } }) => id)
.get('/ok/:id/:name?', ({ params: { id, name } }) => name)
在上面的示例中,如果我们访问: /ok/1
将返回 1
/ok
将返回 undefined
默认情况下,如果未提供可选路径参数,访问它将返回 undefined
。
你可以通过使用 JavaScript 默认值或 schema 默认值来提供默认值。
import { Elysia, t } from 'elysia'
new Elysia()
.get('/ok/:id?', ({ params: { id } }) => id, {
params: t.Object({
id: t.Number({
default: 1
})
})
})
在这个示例中,如果我们访问: /ok/2
将返回 1
/ok
将返回 1
生成器响应流
以前,你可以使用 @elysiajs/stream
包来流式传输响应。
然而,有一个限制:
- 不为 Eden 提供类型安全推断
- 流式传输响应不是那么直接的方式
现在,Elysia 通过使用生成器函数开箱即用地支持响应流式传输。
import { Elysia } from 'elysia'
const app = new Elysia()
.get('/ok', function* () {
yield 1
yield 2
yield 3
})
在这个示例中,我们可以使用 yield
关键字来流式传输响应。
使用生成器函数,我们现在可以从生成器函数推断返回类型并直接提供给 Eden。
Eden 现在将从生成器函数推断响应类型为 AsyncGenerator
import { Elysia } from 'elysia'
import { treaty } from '@elysiajs/eden'
const app = new Elysia()
.get('/ok', function* () {
yield 1
yield 2
yield 3
})
const { data, error } = await treaty(app).ok.get()
if (error) throw error
for await (const chunk of data)
console.log(chunk)
在流式传输响应时,请求可能在响应完全流式传输之前被取消,在这种情况下,Elysia 将在请求取消时自动停止生成器函数。
我们推荐从 @elysiajs/stream
迁移到生成器函数来流式传输响应,因为它更直接并提供更好的类型推断。
由于流插件将进入维护模式并在未来被弃用。
破坏性变更
- 除非明确指定,否则所有验证器将值解析为字符串。
- 将
onResponse
重命名为onAfterResponse
- [Internal] 使用 toResponse 替换 $passthrough
- [Internal] UnwrapRoute 类型现在始终解析状态代码
值得注意的变更:
- 为
set.headers
添加自动补全 - 从钩子中移除原型污染
- 移除查询名称的静态分析
- 移除查询替换 '+' 以利于移除静态查询分析
- 添加
server
属性 - mapResponse 现在在错误事件中调用
- 类型级别中的协调 decorator
onError
支持数组函数- 带和不带 schema 解析查询对象
- 为解析数组弃用
ObjectString
- Sucrose: 改进 isContextPassToFunction,并提取 MainParameter 稳定性
- 添加
replaceSchemaType
- 将
route
添加到context
- 优化递归 MacroToProperty 类型
- 解析查询数组和对象
- 为
composeGeneralHandler
优化代码路径 - 在编译器 panic 时添加调试报告
- 如果 schema 未定义,使用
Cookie<unknown>
而不是Cookie<any>
- 大型代码库中路由注册的内存使用减少 ~36%
- 减少编译代码路径
- 移除跟踪推断
- 减少路由器编译代码路径
- 移除路由处理程序编译缓存 (st${index}, stc${index})
- 在 cookie 中添加 undefined union 以防 cookie 不存在
- 优化响应状态解析类型推断
Bug 修复:
- 标准化标头意外使用查询验证器检查
onError
缺少跟踪符号- 标头验证器编译未缓存
- 去重宏传播
- 嵌套组中的 WebSocket 现在工作
- 除非提供成功状态代码,否则不检查错误响应
后记
嗨,我是 SaltyAom,又回来了,感谢你在过去 2 年中支持 Elysia。
这是一段美好的旅程,看到这么多对 Elysia 的压倒性支持让我感到非常高兴,以至于我不知道如何表达。
我仍然非常高兴地为 Elysia 工作,并期待与你和 Elysia 一起走更长的路。
然而,独自为 Elysia 工作并不容易,这就是为什么我需要你的帮助来支持 Elysia,通过报告 bug、创建 PR(我们毕竟是开源的),或分享你对 Elysia 的任何喜爱,甚至只是打个招呼。
过去 2 年,我知道 Elysia 并不完美,有时我可能没有时间回复所有问题,但我正在尽最大努力让它更好,并有一个关于它可能成为什么的愿景。
这就是为什么在未来,我们将有更多维护者来帮助维护 Elysia 插件,目前 Bogeychan 和 Fecony 在维护社区服务器方面做得很好。
正如你可能知道或不知道的,最初 ElysiaJS 的名字是 “KingWorld”,后来改名为 “Elysia”。
与 Elysia 的命名惯例相同,两者都受动漫/游戏/vtuber 子文化的启发。
KingWorld 是以 Shirakami Fubuki 和 Sasakure.uk 的歌曲 KINGWORLD 命名的,他们都是我最喜欢的 vtuber 和音乐制作人。
这就是为什么 标志设计成北极狐风格,以 Fubuki 为原型。
而 Elysia 显然是以 Elysia 命名的,她是我最喜欢的 Honkai Impact 3rd 游戏角色,我还以她的名字命名了我的猫。
另外,我有一个小礼物,正如你可能知道的,我在业余时间也是一名 cosplayer,我还有 Honkai 3rd Elysia 的 cosplay。
所以,呃,Elysia 在维护 Elysia,我猜?
我计划做 Elysia cosplay 的摄影分享给你,在未来,因为我非常喜欢她,我想让它完美。
话虽如此,我期待在下一个版本中见到你,感谢你支持 Elysia。
我们如此容易满足和快乐
即使我弄坏了你最喜欢的泰迪熊
一个“对不起”就能修复一切
什么时候改变了?我们什么时候忘记了?
为什么现在原谅如此困难?
我们前进,从不停止脚步
因为我们害怕回顾我们所做的事?
真相是,我知道只要我们活着
我们的理想会让河流染成绯红
回答我,我沉没的船
我们的明天在哪里?
我们的未来去向何方?
我们的希望必须播种在某人的悲伤上吗?
ขอให้โลกใจดีกับเธอบ้างนะ