Elysia 教程
我们将构建一个小型的 CRUD 笔记 API 服务器。
没有数据库或其他“生产就绪”功能。本教程仅关注 Elysia 的功能以及如何使用 Elysia。
预计如果跟随教程,大约需要 15-20 分钟。
不喜欢教程?
如果你更喜欢“自己试试”的方法,可以跳过本教程,直接转到 关键概念 页面,以更好地理解 Elysia 的工作原理。
来自其他框架?
如果你使用过其他流行框架,如 Express、Fastify 或 Hono,你会发现 Elysia 非常熟悉,只有一点差异。
llms.txt
或者,你可以下载 llms.txt 或 llms-full.txt,并将其输入到你喜欢的 LLM,如 ChatGPT、Claude 或 Gemini,以获得更互动的体验。
设置
Elysia 设计用于在 Bun 上运行,这是 Node.js 的替代运行时,但它也可以在 Node.js 或任何支持 Web 标准 API 的运行时上运行。
但是,在本教程中,我们将使用 Bun。
如果你还没有安装 Bun,请安装它。
curl -fsSL https://bun.sh/install | bash
powershell -c "irm bun.sh/install.ps1 | iex"
创建新项目
# 创建新项目
bun create elysia hi-elysia
# 进入项目
cd hi-elysia
# 安装依赖
bun install
这将创建一个带有 Elysia 和基本 TypeScript 配置的骨架项目。
启动开发服务器
bun dev
打开浏览器并访问 http://localhost:3000,你应该看到屏幕上显示 Hello Elysia 消息。
Elysia 使用 Bun 的 --watch
标志,在你进行更改时自动重新加载服务器。
路由
要添加新路由,我们指定 HTTP 方法、路径名和值。
让我们从打开 src/index.ts
文件开始,如下所示:
import { Elysia } from 'elysia'
const app = new Elysia()
.get('/', () => 'Hello Elysia')
.get('/hello', 'Do you miss me?')
.listen(3000)
打开 **http://localhost:3000/hello**,你应该看到 Do you miss me?。
我们可以使用几种 HTTP 方法,但本教程将使用以下方法:
- get
- post
- put
- patch
- delete
其他方法可用,使用与 get
相同的语法
import { Elysia } from 'elysia'
const app = new Elysia()
.get('/', () => 'Hello Elysia')
.get('/hello', 'Do you miss me?')
.post('/hello', 'Do you miss me?')
.listen(3000)
Elysia 接受值和函数作为响应。
但是,我们可以使用函数来访问 Context
(路由和实例信息)。
import { Elysia } from 'elysia'
const app = new Elysia()
.get('/', () => 'Hello Elysia')
.get('/', ({ path }) => path)
.post('/hello', 'Do you miss me?')
.listen(3000)
OpenAPI
在浏览器中输入 URL 只能与 GET 方法交互。要与其他方法交互,我们需要像 Postman 或 Insomnia 这样的 REST 客户端。
幸运的是,Elysia 带有 OpenAPI Schema 和 Scalar,用于与我们的 API 交互。
# 安装 OpenAPI 插件
bun add @elysiajs/openapi
然后将插件应用到 Elysia 实例。
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
const app = new Elysia()
// 应用 openapi 插件
.use(openapi())
.get('/', ({ path }) => path)
.post('/hello', 'Do you miss me?')
.listen(3000)
导航到 **http://localhost:3000/openapi**,你应该看到如下文档:
现在我们可以与我们创建的所有路由交互。
滚动到 /hello 并点击蓝色 Test Request 按钮以显示表单。
通过点击黑色 Send 按钮查看结果。
Decorate
但是,对于更复杂的数据,我们可能想要使用类来处理复杂数据,因为它允许我们定义自定义方法和属性。
现在,让我们创建一个单例类来存储我们的笔记。
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
}
const app = new Elysia()
.use(openapi())
.decorate('note', new Note())
.get('/note', ({ note }) => note.data)
.listen(3000)
decorate
允许我们将单例类注入到 Elysia 实例中,从而允许我们在路由处理程序中访问它。
打开 **http://localhost:3000/note**,我们应该在屏幕上看到 ["Moonhalo"]。
对于 Scalar 文档,我们可能需要重新加载页面以查看新更改。
路径参数
现在让我们通过其索引检索笔记。
我们可以通过在冒号前缀定义路径参数。
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
}
const app = new Elysia()
.use(openapi())
.decorate('note', new Note())
.get('/note', ({ note }) => note.data)
.get('/note/:index', ({ note, params: { index } }) => {
return note.data[index] })
.listen(3000)
现在忽略错误。
打开 **http://localhost:3000/note/0**,我们应该在屏幕上看到 Moonhalo。
路径参数允许我们从 URL 中检索特定部分。在我们的例子中,我们从 /note/0 中检索 "0" 并放入名为 index 的变量中。
验证
上面的错误是一个警告,路径参数可以是任何字符串,而数组索引应该是数字。
例如,/note/0 是有效的,但 /note/zero 不是。
我们可以通过声明 schema 来强制和验证类型:
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi'
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
}
const app = new Elysia()
.use(openapi())
.decorate('note', new Note())
.get('/note', ({ note }) => note.data)
.get(
'/note/:index',
({ note, params: { index } }) => {
return note.data[index]
},
{
params: t.Object({
index: t.Number()
})
}
)
.listen(3000)
我们从 Elysia 导入 t 来为路径参数定义 schema。
现在,如果我们尝试访问 **http://localhost:3000/note/abc**,我们应该看到错误消息。
这段代码解决了我们之前看到的错误,因为 TypeScript 警告。
Elysia schema 不仅在运行时强制验证,还推断 TypeScript 类型以实现自动完成和提前检查错误,以及 Scalar 文档。
大多数框架只提供这些功能之一,或分别提供,需要我们单独更新每个,但 Elysia 将它们全部作为 单一真相来源 提供。
验证类型
Elysia 为以下属性提供验证:
- params - 路径参数
- query - URL 查询字符串
- body - 请求体
- headers - 请求头
- cookie - cookie
- response - 响应体
它们都共享与上面示例相同的语法。
状态码
默认情况下,Elysia 将为所有路由返回 200 状态码,即使响应是错误。
例如,如果我们尝试访问 **http://localhost:3000/note/1**,我们应该在屏幕上看到 undefined,这不应该是 200 状态码 (OK)。
我们可以通过返回错误来更改状态码
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi'
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
}
const app = new Elysia()
.use(openapi())
.decorate('note', new Note())
.get('/note', ({ note }) => note.data)
.get(
'/note/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404)
},
{
params: t.Object({
index: t.Number()
})
}
)
.listen(3000)
现在,如果我们尝试访问 **http://localhost:3000/note/1**,我们应该在屏幕上看到 Not Found,状态码为 404。
我们也可以通过向错误函数传递字符串返回自定义消息。
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi'
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
}
const app = new Elysia()
.use(openapi())
.decorate('note', new Note())
.get('/note', ({ note }) => note.data)
.get(
'/note/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'oh no :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
.listen(3000)
插件
主实例开始变得拥挤,我们可以将路由处理程序移动到单独的文件并作为插件导入。
创建一个名为 note.ts 的新文件:
import { Elysia, t } from 'elysia'
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
}
export const note = new Elysia()
.decorate('note', new Note())
.get('/note', ({ note }) => note.data)
.get(
'/note/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'oh no :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
然后在 index.ts 上,将 note 应用到主实例:
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { note } from './note'
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
}
const app = new Elysia()
.use(openapi())
.use(note)
.decorate('note', new Note())
.get('/note', ({ note }) => note.data)
.get(
'/note/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'oh no :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
.listen(3000)
打开 **http://localhost:3000/note/1**,你应该再次看到 oh no 😦,就像之前一样。
我们刚刚创建了一个 note 插件,通过声明一个新的 Elysia 实例。
每个插件都是 Elysia 的单独实例,具有自己的路由、中间件和装饰器,可以应用到其他实例。
应用 CRUD
我们可以应用相同的模式来创建、更新和删除路由。
import { Elysia, t } from 'elysia'
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
add(note: string) {
this.data.push(note)
return this.data
}
remove(index: number) {
return this.data.splice(index, 1)
}
update(index: number, note: string) {
return (this.data[index] = note)
}
}
export const note = new Elysia()
.decorate('note', new Note())
.get('/note', ({ note }) => note.data)
.put('/note', ({ note, body: { data } }) => note.add(data), {
body: t.Object({
data: t.String()
})
})
.get(
'/note/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'Not Found :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
.delete(
'/note/:index',
({ note, params: { index }, status }) => {
if (index in note.data) return note.remove(index)
return status(422)
},
{
params: t.Object({
index: t.Number()
})
}
)
.patch(
'/note/:index',
({ note, params: { index }, body: { data }, status }) => {
if (index in note.data) return note.update(index, data)
return status(422)
},
{
params: t.Object({
index: t.Number()
}),
body: t.Object({
data: t.String()
})
}
)
现在让我们打开 http://localhost:3000/openapi 并尝试使用 CRUD 操作玩耍。
分组
如果我们仔细观察,note 插件中的所有路由共享 /note 前缀。
我们可以通过声明 prefix 来简化这一点
export const note = new Elysia({ prefix: '/note' })
.decorate('note', new Note())
.get('/', ({ note }) => note.data)
.put('/', ({ note, body: { data } }) => note.add(data), {
body: t.Object({
data: t.String()
})
})
.get(
'/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'Not Found :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
.delete(
'/:index',
({ note, params: { index }, status }) => {
if (index in note.data) return note.remove(index)
return status(422)
},
{
params: t.Object({
index: t.Number()
})
}
)
.patch(
'/:index',
({ note, params: { index }, body: { data }, status }) => {
if (index in note.data) return note.update(index, data)
return status(422)
},
{
params: t.Object({
index: t.Number()
}),
body: t.Object({
data: t.String()
})
}
)
Guard
现在我们可能注意到插件中有几个路由具有 params 验证。
我们可以定义一个 guard 来将验证应用到插件中的路由。
export const note = new Elysia({ prefix: '/note' })
.decorate('note', new Note())
.get('/', ({ note }) => note.data)
.put('/', ({ note, body: { data } }) => note.add(data), {
body: t.Object({
data: t.String()
})
})
.guard({
params: t.Object({
index: t.Number()
})
})
.get(
'/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'Not Found :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
.delete(
'/:index',
({ note, params: { index }, status }) => {
if (index in note.data) return note.remove(index)
return status(422)
},
{
params: t.Object({
index: t.Number()
})
}
)
.patch(
'/:index',
({ note, params: { index }, body: { data }, status }) => {
if (index in note.data) return note.update(index, data)
return status(422)
},
{
params: t.Object({
index: t.Number()
}),
body: t.Object({
data: t.String()
})
}
)
验证将应用到调用 guard 后的所有路由,并绑定到插件。
生命周期
现在在实际使用中,我们可能想要在请求处理之前做一些事情,比如记录日志。
与其为每个路由内联 console.log
,我们可以使用 lifecycle 在处理请求前后拦截它。
我们可以使用几种生命周期,但在此例中我们将使用 onTransform
。
export const note = new Elysia({ prefix: '/note' })
.decorate('note', new Note())
.onTransform(function log({ body, params, path, request: { method } }) {
console.log(`${method} ${path}`, {
body,
params
})
})
.get('/', ({ note }) => note.data)
.put('/', ({ note, body: { data } }) => note.add(data), {
body: t.Object({
data: t.String()
})
})
.guard({
params: t.Object({
index: t.Number()
})
})
.get('/:index', ({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'Not Found :(')
})
.delete('/:index', ({ note, params: { index }, status }) => {
if (index in note.data) return note.remove(index)
return status(422)
})
.patch(
'/:index',
({ note, params: { index }, body: { data }, status }) => {
if (index in note.data) return note.update(index, data)
return status(422)
},
{
body: t.Object({
data: t.String()
})
}
)
onTransform
在 路由后但验证前 调用,因此我们可以做一些事情,比如记录不触发 404 未找到 路由的请求日志。
这允许我们在处理请求之前记录它,我们可以看到请求体和路径参数。
范围
默认情况下,生命周期钩子是封装的。钩子应用到同一实例中的路由,不应用到其他插件(不在同一插件中定义的路由)。
这意味着 onTransform
钩子中的日志函数不会在其他实例上调用,除非我们明确将其定义为 scoped
或 global
。
认证
现在我们可能想要为我们的路由添加限制,以便只有笔记的所有者才能更新或删除它。
让我们创建一个 user.ts
文件来处理用户认证:
import { Elysia, t } from 'elysia'
export const user = new Elysia({ prefix: '/user' })
.state({
user: {} as Record<string, string>,
session: {} as Record<number, string>
})
.put(
'/sign-up',
async ({ body: { username, password }, store, status }) => {
if (store.user[username])
return status(400, {
success: false,
message: 'User already exists'
})
store.user[username] = await Bun.password.hash(password)
return {
success: true,
message: 'User created'
}
},
{
body: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 8 })
})
}
)
.post(
'/sign-in',
async ({
store: { user, session },
status,
body: { username, password },
cookie: { token }
}) => {
if (
!user[username] ||
!(await Bun.password.verify(password, user[username]))
)
return status(400, {
success: false,
message: 'Invalid username or password'
})
const key = crypto.getRandomValues(new Uint32Array(1))[0]
session[key] = username
token.value = key
return {
success: true,
message: `Signed in as ${username}`
}
},
{
body: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 8 })
}),
cookie: t.Cookie(
{
token: t.Number()
},
{
secrets: 'seia'
}
)
}
)
现在有很多事情需要展开:
- 我们创建了一个带有注册和登录两个路由的新实例。
- 在实例中,我们定义了一个内存存储
user
和session
- 2.1
user
将保存username
和password
的键值对 - 2.2
session
将保存session
和username
的键值对
- 2.1
- 在
/sign-up
中,我们使用 argon2id 插入用户名和哈希密码 - 在
/sign-in
中,我们执行以下操作:- 4.1 检查用户是否存在并验证密码
- 4.2 如果密码匹配,则生成一个新会话到
session
- 4.3 我们使用会话值设置 cookie
token
- 4.4 我们将
secret
附加到 cookie 以添加哈希并阻止攻击者篡改 cookie
TIP
由于我们使用内存存储,数据会在每次重新加载或编辑代码时被清除。
我们将在教程的后面部分修复这个问题。
现在,如果我们想要检查用户是否登录,我们可以检查 token
cookie 的值并与 session
存储检查。
引用模型
但是,我们可以认识到 /sign-in
和 /sign-up
共享相同的 body
模型。
与其到处复制粘贴模型,我们可以使用 引用模型 通过指定名称来重用模型。
要创建 引用模型,我们可以使用 .model
并传递模型的名称和值:
import { Elysia, t } from 'elysia'
export const user = new Elysia({ prefix: '/user' })
.state({
user: {} as Record<string, string>,
session: {} as Record<number, string>
})
.model({
signIn: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 8 })
}),
session: t.Cookie(
{
token: t.Number()
},
{
secrets: 'seia'
}
),
optionalSession: t.Cookie(
{
token: t.Optional(t.Number())
},
{
secrets: 'seia'
}
)
})
.put(
'/sign-up',
async ({ body: { username, password }, store, status }) => {
if (store.user[username])
return status(400, {
success: false,
message: 'User already exists'
})
store.user[username] = await Bun.password.hash(password)
return {
success: true,
message: 'User created'
}
},
{
body: 'signIn'
}
)
.post(
'/sign-in',
async ({
store: { user, session },
status,
body: { username, password },
cookie: { token }
}) => {
if (
!user[username] ||
!(await Bun.password.verify(password, user[username]))
)
return status(400, {
success: false,
message: 'Invalid username or password'
})
const key = crypto.getRandomValues(new Uint32Array(1))[0]
session[key] = username
token.value = key
return {
success: true,
message: `Signed in as ${username}`
}
},
{
body: 'signIn',
cookie: 'session'
}
)
添加模型/模型后,我们可以通过在 schema 中引用它们的名称来重用它们,而不是提供字面类型,同时提供相同的功能和类型安全。
Elysia.model
可以接受多个重载:
- 提供对象,然后注册所有键值作为模型
- 提供函数,然后访问所有先前模型并返回新模型
最后,我们可以添加 /profile
和 /sign-out
路由,如下所示:
import { Elysia, t } from 'elysia'
export const user = new Elysia({ prefix: '/user' })
.state({
user: {} as Record<string, string>,
session: {} as Record<number, string>
})
.model({
signIn: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 8 })
}),
session: t.Cookie(
{
token: t.Number()
},
{
secrets: 'seia'
}
),
optionalSession: t.Cookie(
{
token: t.Optional(t.Number())
},
{
secrets: 'seia'
}
)
})
.put(
'/sign-up',
async ({ body: { username, password }, store, status }) => {
if (store.user[username])
return status(400, {
success: false,
message: 'User already exists'
})
store.user[username] = await Bun.password.hash(password)
return {
success: true,
message: 'User created'
}
},
{
body: 'signIn'
}
)
.post(
'/sign-in',
async ({
store: { user, session },
status,
body: { username, password },
cookie: { token }
}) => {
if (
!user[username] ||
!(await Bun.password.verify(password, user[username]))
)
return status(400, {
success: false,
message: 'Invalid username or password'
})
const key = crypto.getRandomValues(new Uint32Array(1))[0]
session[key] = username
token.value = key
return {
success: true,
message: `Signed in as ${username}`
}
},
{
body: 'signIn',
cookie: 'optionalSession'
}
)
.get(
'/sign-out',
({ cookie: { token } }) => {
token.remove()
return {
success: true,
message: 'Signed out'
}
},
{
cookie: 'optionalSession'
}
)
.get(
'/profile',
({ cookie: { token }, store: { session }, status }) => {
const username = session[token.value]
if (!username)
return status(401, {
success: false,
message: 'Unauthorized'
})
return {
success: true,
username
}
},
{
cookie: 'session'
}
)
由于我们将要在 note
中应用 authorization
,我们将需要重复两件事:
- 检查用户是否存在
- 获取用户 ID(在我们的例子中是 'username')
对于 1.,与其使用 guard,我们可以使用 macro。
插件去重
由于我们将在多个模块(user 和 note)中重用这个钩子,让我们提取服务(实用程序)部分并将其应用到两个模块。
// @errors: 2538
import { Elysia, t } from 'elysia'
export const userService = new Elysia({ name: 'user/service' })
.state({
user: {} as Record<string, string>,
session: {} as Record<number, string>
})
.model({
signIn: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 8 })
}),
session: t.Cookie(
{
token: t.Number()
},
{
secrets: 'seia'
}
),
optionalSession: t.Cookie(
{
token: t.Optional(t.Number())
},
{
secrets: 'seia'
}
)
})
export const user = new Elysia({ prefix: '/user' })
.use(userService)
.state({
user: {} as Record<string, string>,
session: {} as Record<number, string>
})
.model({
signIn: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 8 })
}),
session: t.Cookie(
{
token: t.Number()
},
{
secrets: 'seia'
}
),
optionalSession: t.Cookie(
{
token: t.Optional(t.Number())
},
{
secrets: 'seia'
}
)
})
这里的 name
属性非常重要,因为它是插件的唯一标识符,用于防止重复实例(像单例一样)。
如果我们不带插件定义实例,钩子/生命周期和路由将每次使用插件时都被注册。
我们的意图是将这个插件(服务)应用到多个模块以提供实用函数,这使得去重非常重要,因为生命周期不应该被注册两次。
Macro
Macro 允许我们定义自定义钩子并带有自定义生命周期管理。
要定义 macro,我们可以使用 .macro
如以下所示:
import { Elysia, t } from 'elysia'
export const userService = new Elysia({ name: 'user/service' })
.state({
user: {} as Record<string, string>,
session: {} as Record<number, string>
})
.model({
signIn: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 8 })
}),
session: t.Cookie(
{
token: t.Number()
},
{
secrets: 'seia'
}
),
optionalSession: t.Cookie(
{
token: t.Optional(t.Number())
},
{
secrets: 'seia'
}
)
})
.macro({
isSignIn(enabled: boolean) {
if (!enabled) return
return {
beforeHandle({
status,
cookie: { token },
store: { session }
}) {
if (!token.value)
return status(401, {
success: false,
message: 'Unauthorized'
})
const username = session[token.value as unknown as number]
if (!username)
return status(401, {
success: false,
message: 'Unauthorized'
})
}
}
}
})
我们刚刚创建了一个名为 isSignIn
的新 macro,它接受一个 boolean
值,如果为 true,则添加一个 onBeforeHandle
事件,在 验证后但主处理程序前 执行,允许我们在这里提取认证逻辑。
要使用 macro,只需指定 isSignIn: true
如以下所示:
import { Elysia, t } from 'elysia'
export const user = new Elysia({ prefix: '/user' }).use(userService).get(
'/profile',
({ cookie: { token }, store: { session }, status }) => {
const username = session[token.value]
if (!username)
return status(401, {
success: false,
message: 'Unauthorized'
})
return {
success: true,
username
}
},
{
isSignIn: true,
cookie: 'session'
}
)
由于我们指定了 isSignIn
,我们可以提取命令式检查部分,并在多个路由上重用相同的逻辑,而无需再次复制粘贴相同的代码。
TIP
这可能看起来像是一个小的代码更改,以换取更大的样板代码,但随着服务器变得更加复杂,用户检查也可能成长为一个非常复杂的机制。
Resolve
我们的最后一个目标是从 token 获取用户名 (id)。我们可以使用 resolve
来定义一个新属性到与 store
相同的上下文中,但仅每个请求执行一次。
与 decorate
和 store
不同,resolve 在 beforeHandle
阶段定义,否则值将在 验证后 可用。
这确保了像 cookie: 'session'
这样的属性在创建新属性之前存在。
export const getUserId = new Elysia()
.use(userService)
.guard({
cookie: 'session'
})
.resolve(({ store: { session }, cookie: { token } }) => ({
username: session[token.value]
}))
在这个实例中,我们使用 resolve
定义一个新属性 username
,允许我们将获取 username
逻辑减少为一个属性。
我们在这个 getUserId
实例中不定义名称,因为我们希望 guard
和 resolve
重新应用到多个实例。
TIP
与 macro 类似,如果获取属性的逻辑复杂,resolve
会发挥作用,对于这个像这样的小操作可能不值得。但在现实世界中,我们将需要数据库连接、缓存和队列,它可能适合这个叙述。
范围
现在如果我们尝试使用 getUserId
,我们可能会注意到属性 username
和 guard
没有应用。
export const getUserId = new Elysia()
.use(userService)
.guard({
isSignIn: true,
cookie: 'session'
})
.resolve(({ store: { session }, cookie: { token } }) => ({
username: session[token.value]
}))
export const user = new Elysia({ prefix: '/user' })
.use(getUserId)
.get('/profile', ({ username }) => ({
success: true,
username
}))
这是因为 Elysia 封装生命周期 默认这样做,如 生命周期 中所述
这是有意设计,因为我们不希望每个模块对其他模块产生副作用。具有副作用在大型代码库中调试起来非常困难,尤其是具有多个 (Elysia) 依赖项。
如果我们希望生命周期应用到父级,我们可以明确标注它可以应用到父级,使用以下任一方式:
- scoped - 仅应用到上一级的父级,不再进一步
- global - 应用到所有父级
在我们的例子中,我们想要使用 scoped,因为它将仅应用到使用该服务的控制器。
要做到这一点,我们需要将生命周期标注为 scoped
:
export const getUserId = new Elysia()
.use(userService)
.guard({
as: 'scoped',
isSignIn: true,
cookie: 'session'
})
.resolve(
{ as: 'scoped' },
({ store: { session }, cookie: { token } }) => ({
username: session[token.value]
})
)
export const user = new Elysia({ prefix: '/user' })
.use(getUserId)
.get('/profile', ({ username }) => ({
// ^?
success: true,
username
}))
或者,如果我们定义了多个 scoped
,我们可以使用 as
来转换多个生命周期。
export const getUserId = new Elysia()
.use(userService)
.guard({
as: 'scoped',
isSignIn: true,
cookie: 'session'
})
.resolve(
{ as: 'scoped' },
({ store: { session }, cookie: { token } }) => ({
username: session[token.value]
})
)
.as('scoped')
export const user = new Elysia({ prefix: '/user' })
.use(getUserId)
.get('/profile', ({ username }) => ({
success: true,
username
}))
两者达到相同的效果,唯一的区别是单个或多个转换实例。
TIP
封装发生在运行时和类型级别。这允许我们提前捕获错误。
最后,我们可以使用 userService
和 getUserId
来帮助 note 控制器中的授权。
但首先,不要忘记在 index.ts
文件中导入 user
:
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { note } from './note'
import { user } from './user'
const app = new Elysia()
.use(openapi())
.use(user)
.use(note)
.listen(3000)
授权
首先,让我们修改 Note
类以存储创建笔记的用户。
但与其定义 Memo
类型,我们可以定义一个 memo schema 并从中推断类型,从而允许我们同步运行时和类型级别。
import { Elysia, t } from 'elysia'
const memo = t.Object({
data: t.String(),
author: t.String()
})
type Memo = typeof memo.static
class Note {
constructor(public data: string[] = ['Moonhalo']) {}
constructor(
public data: Memo[] = [
{
data: 'Moonhalo',
author: 'saltyaom'
}
]
) {}
add(note: string) {
add(note: Memo) {
this.data.push(note)
return this.data
}
remove(index: number) {
return this.data.splice(index, 1)
}
update(index: number, note: string) {
return (this.data[index] = note)
}
update(index: number, note: Partial<Memo>) {
return (this.data[index] = { ...this.data[index], ...note })
}
}
export const note = new Elysia({ prefix: '/note' })
.decorate('note', new Note())
.model({
memo: t.Omit(memo, ['author'])
})
.onTransform(function log({ body, params, path, request: { method } }) {
console.log(`${method} ${path}`, {
body,
params
})
})
.get('/', ({ note }) => note.data)
.put('/', ({ note, body: { data } }) => note.add(data), {
body: t.Object({
data: t.String()
}),
})
.put('/', ({ note, body: { data }, username }) =>
note.add({ data, author: username }),
{
body: 'memo'
}
)
.guard({
params: t.Object({
index: t.Number()
})
})
.get(
'/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'Not Found :(')
}
)
.delete(
'/:index',
({ note, params: { index }, status }) => {
if (index in note.data) return note.remove(index)
return status(422)
}
)
.patch(
'/:index',
({ note, params: { index }, body: { data }, status }) => {
if (index in note.data) return note.update(index, data)
({ note, params: { index }, body: { data }, status, username }) => {
if (index in note.data)
return note.update(index, { data, author: username }))
return status(422)
},
{
body: t.Object({
data: t.String()
}),
body: 'memo'
}
)
现在让我们导入并使用 userService
、getUserId
来将授权应用到 note 控制器。
import { Elysia, t } from 'elysia'
import { getUserId, userService } from './user'
const memo = t.Object({
data: t.String(),
author: t.String()
})
type Memo = typeof memo.static
class Note {
constructor(
public data: Memo[] = [
{
data: 'Moonhalo',
author: 'saltyaom'
}
]
) {}
add(note: Memo) {
this.data.push(note)
return this.data
}
remove(index: number) {
return this.data.splice(index, 1)
}
update(index: number, note: Partial<Memo>) {
return (this.data[index] = { ...this.data[index], ...note })
}
}
export const note = new Elysia({ prefix: '/note' })
.use(userService)
.decorate('note', new Note())
.model({
memo: t.Omit(memo, ['author'])
})
.onTransform(function log({ body, params, path, request: { method } }) {
console.log(`${method} ${path}`, {
body,
params
})
})
.get('/', ({ note }) => note.data)
.use(getUserId)
.put(
'/',
({ note, body: { data }, username }) =>
note.add({ data, author: username }),
{
body: 'memo'
}
)
.get(
'/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'Not Found :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
.guard({
params: t.Object({
index: t.Number()
})
})
.delete('/:index', ({ note, params: { index }, status }) => {
if (index in note.data) return note.remove(index)
return status(422)
})
.patch(
'/:index',
({ note, params: { index }, body: { data }, status, username }) => {
if (index in note.data)
return note.update(index, { data, author: username })
return status(422)
},
{
isSignIn: true,
body: 'memo'
}
)
就是这样 🎉
我们刚刚通过重用我们之前创建的服务实现了授权。
错误处理
API 的一个最重要的方面是确保一切正常,如果出错了,我们需要正确处理它。
我们使用 onError
生命周期来捕获服务器中抛出的任何错误。
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { note } from './note'
import { user } from './user'
const app = new Elysia()
.use(openapi())
.onError(({ error, code }) => {
if (code === 'NOT_FOUND') return
console.error(error)
})
.use(user)
.use(note)
.listen(3000)
我们刚刚添加了一个错误监听器,它将捕获服务器中抛出的任何错误(排除 404 未找到)并将其记录到控制台。
TIP
注意 onError
在 use(note)
之前使用。这很重要,因为 Elysia 从上到下应用方法。监听器必须在路由之前应用。
并且由于 onError
应用在根实例上,它不需要定义范围,因为它将应用到所有子实例。
返回真值将覆盖默认错误响应,因此我们可以返回一个继承状态码的自定义错误响应。
import { Elysia, t } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { note } from './note'
const app = new Elysia()
.use(openapi())
.onError(({ error, code }) => {
if (code === 'NOT_FOUND') return 'Not Found :('
console.error(error)
})
.use(note)
.listen(3000)
可观察性
现在我们有一个工作的 API,最终一步是确保部署服务器后一切正常。
Elysia 默认支持 OpenTelemetry,使用 @elysiajs/opentelemetry
插件。
bun add @elysiajs/opentelemetry
确保运行 OpenTelemetry 收集器,否则我们将使用 Docker 中的 Jaeger。
docker run --name jaeger \
-e COLLECTOR_ZIPKIN_HOST_PORT=:9411 \
-e COLLECTOR_OTLP_ENABLED=true \
-p 6831:6831/udp \
-p 6832:6832/udp \
-p 5778:5778 \
-p 16686:16686 \
-p 4317:4317 \
-p 4318:4318 \
-p 14250:14250 \
-p 14268:14268 \
-p 14269:14269 \
-p 9411:9411 \
jaegertracing/all-in-one:latest
现在让我们将 OpenTelemetry 插件应用到我们的服务器。
import { Elysia, t } from 'elysia'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { openapi } from '@elysiajs/openapi'
import { note } from './note'
import { user } from './user'
const app = new Elysia()
.use(opentelemetry())
.use(openapi())
.onError(({ error, code }) => {
if (code === 'NOT_FOUND') return 'Not Found :('
console.error(error)
})
.use(note)
.use(user)
.listen(3000)
现在尝试一些更多请求并打开 http://localhost:16686 查看跟踪。
选择服务 Elysia 并点击 Find Traces,我们应该能够看到我们所做的请求列表。
点击任何请求以查看每个生命周期钩子处理请求需要多长时间。
点击根父跨度以查看请求细节,这将显示请求和响应负载,以及任何错误。
Elysia 开箱即用支持 OpenTelemetry,它自动与其他支持 OpenTelemetry 的 JavaScript 库集成,如 Prisma、GraphQL Yoga、Effect 等。
你也可以使用其他 OpenTelemetry 插件将跟踪发送到其他服务,如 Zipkin、Prometheus 等。
代码库回顾
如果你跟随教程,你的代码库应该看起来像这样:
import { Elysia } from 'elysia'
import { openapi } from '@elysiajs/openapi'
import { opentelemetry } from '@elysiajs/opentelemetry'
import { note } from './note'
import { user } from './user'
const app = new Elysia()
.use(opentelemetry())
.use(openapi())
.onError(({ error, code }) => {
if (code === 'NOT_FOUND') return 'Not Found :('
console.error(error)
})
.use(user)
.use(note)
.listen(3000)
import { Elysia, t } from 'elysia'
export const userService = new Elysia({ name: 'user/service' })
.state({
user: {} as Record<string, string>,
session: {} as Record<number, string>
})
.model({
signIn: t.Object({
username: t.String({ minLength: 1 }),
password: t.String({ minLength: 8 })
}),
session: t.Cookie(
{
token: t.Number()
},
{
secrets: 'seia'
}
),
optionalSession: t.Cookie(
{
token: t.Optional(t.Number())
},
{
secrets: 'seia'
}
)
})
.macro({
isSignIn(enabled: boolean) {
if (!enabled) return
return {
beforeHandle({
status,
cookie: { token },
store: { session }
}) {
if (!token.value)
return status(401, {
success: false,
message: 'Unauthorized'
})
const username = session[token.value as unknown as number]
if (!username)
return status(401, {
success: false,
message: 'Unauthorized'
})
}
}
}
})
export const getUserId = new Elysia()
.use(userService)
.guard({
isSignIn: true,
cookie: 'session'
})
.resolve(({ store: { session }, cookie: { token } }) => ({
username: session[token.value]
}))
.as('scoped')
export const user = new Elysia({ prefix: '/user' })
.use(userService)
.put(
'/sign-up',
async ({ body: { username, password }, store, status }) => {
if (store.user[username])
return status(400, {
success: false,
message: 'User already exists'
})
store.user[username] = await Bun.password.hash(password)
return {
success: true,
message: 'User created'
}
},
{
body: 'signIn'
}
)
.post(
'/sign-in',
async ({
store: { user, session },
status,
body: { username, password },
cookie: { token }
}) => {
if (
!user[username] ||
!(await Bun.password.verify(password, user[username]))
)
return status(400, {
success: false,
message: 'Invalid username or password'
})
const key = crypto.getRandomValues(new Uint32Array(1))[0]
session[key] = username
token.value = key
return {
success: true,
message: `Signed in as ${username}`
}
},
{
body: 'signIn',
cookie: 'optionalSession'
}
)
.get(
'/sign-out',
({ cookie: { token } }) => {
token.remove()
return {
success: true,
message: 'Signed out'
}
},
{
cookie: 'optionalSession'
}
)
.use(getUserId)
.get('/profile', ({ username }) => ({
success: true,
username
}))
import { Elysia, t } from 'elysia'
import { getUserId, userService } from './user'
const memo = t.Object({
data: t.String(),
author: t.String()
})
type Memo = typeof memo.static
class Note {
constructor(
public data: Memo[] = [
{
data: 'Moonhalo',
author: 'saltyaom'
}
]
) {}
add(note: Memo) {
this.data.push(note)
return this.data
}
remove(index: number) {
return this.data.splice(index, 1)
}
update(index: number, note: Partial<Memo>) {
return (this.data[index] = { ...this.data[index], ...note })
}
}
export const note = new Elysia({ prefix: '/note' })
.use(userService)
.decorate('note', new Note())
.model({
memo: t.Omit(memo, ['author'])
})
.onTransform(function log({ body, params, path, request: { method } }) {
console.log(`${method} ${path}`, {
body,
params
})
})
.get('/', ({ note }) => note.data)
.use(getUserId)
.put(
'/',
({ note, body: { data }, username }) =>
note.add({ data, author: username }),
{
body: 'memo'
}
)
.get(
'/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'Not Found :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
.guard({
params: t.Object({
index: t.Number()
})
})
.delete('/:index', ({ note, params: { index }, status }) => {
if (index in note.data) return note.remove(index)
return status(422)
})
.patch(
'/:index',
({ note, params: { index }, body: { data }, status, username }) => {
if (index in note.data)
return note.update(index, { data, author: username })
return status(422)
},
{
isSignIn: true,
body: 'memo'
}
)
为生产构建
最后,我们可以使用 bun build
将服务器打包成二进制文件用于生产:
bun build \
--compile \
--minify-whitespace \
--minify-syntax \
--target bun \
--outfile server \
./src/index.ts
这个命令有点长,让我们分解它:
--compile
- 将 TypeScript 编译成二进制--minify-whitespace
- 移除不必要的空白--minify-syntax
- 最小化 JavaScript 语法以减少文件大小--target bun
- 针对bun
平台,这可以为目标平台优化二进制--outfile server
- 将二进制输出为server
./src/index.ts
- 服务器(代码库)的入口文件
现在我们可以使用 ./server
运行二进制,它将以与使用 bun dev
相同的端口 3000 启动服务器。
./server
打开浏览器并导航到 http://localhost:3000/openapi
,你应该看到与使用开发命令相同的结果。
通过最小化二进制,不仅使我们的服务器小而便携,我们还显著减少了它的内存使用。
TIP
Bun 确实有 --minify
标志,它将最小化二进制,但是包括 --minify-identifiers
,并且由于我们使用 OpenTelemetry,它将重命名函数名并使跟踪比应该的更难。
WARNING
练习:尝试运行开发服务器和生产服务器,并比较内存使用。
开发服务器将使用名为 'bun' 的进程,而生产服务器将使用名为 'server' 的进程。
总结
就是这样 🎉
我们使用 Elysia 创建了一个简单的 API,我们学习了如何创建简单的 API、如何处理错误,以及如何使用 OpenTelemetry 观察我们的服务器。
你可以进一步尝试连接到真实数据库、连接到真实前端或使用 WebSocket 实现实时通信。
本教程涵盖了创建 Elysia 服务器所需的大多数概念,但是还有其他几个有用的概念你可能想了解。
如果你卡住了
如果你有任何进一步的问题,请随时在 GitHub Discussions、Discord 和 Twitter 上向我们的社区提问。
祝你在 Elysia 的旅程中一切顺利 ❤️