Skip to content
我们的赞助商
博客

Elysia 1.1 - 成年人的天堂

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 并将其插件应用到任何实例。

typescript
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()
				)
			]
		})
	)

jaeger 自动显示收集的跟踪

Elysia OpenTelemetry 将 收集与 OpenTelemetry 标准兼容的任何库的 span,并自动应用父 span 和子 span。

在上面的代码中,我们应用 Prisma 来跟踪每个查询耗时多久。

通过应用 OpenTelemetry,Elysia 将:

  • 收集遥测数据
  • 将相关生命周期分组在一起
  • 测量每个函数耗时多久
  • 插桩 HTTP 请求和响应
  • 收集错误和异常

你可以将遥测数据导出到 Jaeger、Zipkin、New Relic、Axiom 或任何其他与 OpenTelemetry 兼容的后端。

这是一个导出遥测数据到 Axiom 的示例

typescript
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
						} 
					})
				)
			]
		})
	)

axiom 显示从 OpenTelemetry 收集的跟踪

Elysia OpenTelemetry 仅用于将 OpenTelemetry 应用到 Elysia 服务器。

你可以正常使用 OpenTelemetry SDK,span 将在 Elysia 的请求 span 下运行,它将自动出现在 Elysia 跟踪中。

然而,我们还提供了一个 getTracerrecord 实用工具,从应用程序的任何部分收集 span。

typescript
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

typescript
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 的示例用法:

typescript
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:

typescript
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

typescript
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 来将查询参数解析为数字。

typescript
import { Elysia, t } from 'elysia'

const app = new Elysia()
	.get('/', ({ query }) => query, {
		query: t.Object({
			// ✅ page 将自动被强制转换为数字
			page: t.Number()
		})
	})

这也适用于 t.Booleant.Objectt.Array

这是在提前编译阶段通过用可能强制转换的对应物交换 schema 完成的,与使用 t.Numeric 或其他强制转换对应物相同。

Guard as

以前,guard 仅适用于当前实例。

typescript
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 应用为 scopedglobal,类似于添加事件监听器。

typescript
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 来确保所有路由的一次性类型安全。

typescript
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') 来实现。

typescript
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') 来提升它。

typescript
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 的响应onBeforeHandlescoped,从而提升到父实例。

as 接受两个可能参数:

  • plugin 将事件 cast 为 scoped
  • global 将事件 cast 为 global
typescript
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,或提升现有的插件作用域。

typescript
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 并将它们合并。

typescript
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 现在通过在路径参数末尾添加 ? 支持可选路径参数。

typescript
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 默认值来提供默认值。

typescript
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 通过使用生成器函数开箱即用地支持响应流式传输。

typescript
import { Elysia } from 'elysia'

const app = new Elysia()
	.get('/ok', function* () {
		yield 1
		yield 2
		yield 3
	})

在这个示例中,我们可以使用 yield 关键字来流式传输响应。

使用生成器函数,我们现在可以从生成器函数推断返回类型并直接提供给 Eden。

Eden 现在将从生成器函数推断响应类型为 AsyncGenerator

typescript
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 迁移到生成器函数来流式传输响应,因为它更直接并提供更好的类型推断。

由于流插件将进入维护模式并在未来被弃用。

破坏性变更

  • 除非明确指定,否则所有验证器将值解析为字符串。
    • 参见 50a5d9244bf279
    • 除非通过 query 明确指定,否则从查询中移除对象的自动解析
    • 除查询字符串按 RFC 3986 定义,TLDR;查询字符串可以是字符串或字符串数组。
  • 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,我猜?

我计划做 Elysia cosplay 的摄影分享给你,在未来,因为我非常喜欢她,我想让它完美。

话虽如此,我期待在下一个版本中见到你,感谢你支持 Elysia。

我们如此容易满足和快乐

即使我弄坏了你最喜欢的泰迪熊

一个“对不起”就能修复一切

什么时候改变了?我们什么时候忘记了?

为什么现在原谅如此困难?

我们前进,从不停止脚步

因为我们害怕回顾我们所做的事?

真相是,我知道只要我们活着

我们的理想会让河流染成绯红

回答我,我沉没的船

我们的明天在哪里?

我们的未来去向何方?

我们的希望必须播种在某人的悲伤上吗?

ขอให้โลกใจดีกับเธอบ้างนะ

Elysia:人性化框架