Elysia 教程 ~20 分钟
我们将构建一个小型的 CRUD 笔记 API 服务器。
没有数据库或其他“生产就绪”功能。本教程仅关注 Elysia 的特性以及如何使用 Elysia。
如果您跟随教程,我们预计需要大约 15-20 分钟。
不喜欢教程?
如果您更喜欢“自己试试”的方法,可以跳过本教程,直接转到 关键概念 页面,以更好地理解 Elysia 的工作原理。
来自其他框架?
如果您使用过其他流行框架,如 Express、Fastify 或 Hono,您会发现 Elysia 与它们非常相似,只有一些细微差异。
llms.txt
或者,您可以下载 llms.txt 或 llms-full.txt,并将其提供给您喜欢的 LLM,如 ChatGPT、Claude 或 Gemini,以获得更互动的体验。
如果您卡住了
欢迎在 GitHub Discussions、Discord 和 Twitter 上向我们的社区提问。
您也可以查看 评论 如果您卡住了。
设置
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 类型以实现自动完成。
大多数框架只提供这些功能之一,或者单独提供它们,需要我们分别更新每个,但 Elysia 将它们全部作为 Single Source of Truth 提供。
验证类型
Elysia 为以下属性提供验证:
它们都共享与上面示例相同的语法。
状态码
如果我们尝试访问 **http://localhost:3000/note/1**,我们应该在屏幕上看到 undefined。
由于这是一个错误,它不应使用 200 状态码 (OK)。
我们可以通过返回 status 来更改状态码:
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 :(,与之前一样。
我们刚刚通过声明新的 Elysia 实例创建了一个 note 插件。
每个插件都是 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 来简化这一点,并从每个路由中删除 /note。
export const note = new Elysia({ prefix: '/note' })
.decorate('note', new Note())
.get('/', ({ note }) => note.data)
.get('/note', ({ note }) => note.data)
.put('/note', ({ note, body: { data } }) => note.add(data), {
.put('/', ({ note, body: { data } }) => note.add(data), {
body: t.Object({
data: t.String()
})
})
.get(
'/note/:index',
'/:index',
({ note, params: { index }, status }) => {
return note.data[index] ?? status(404, 'Not Found :(')
},
{
params: t.Object({
index: t.Number()
})
}
)
.delete(
'/note/:index',
'/: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',
'/: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()
})
}
)
守卫
现在我们可能注意到插件中有几个路由具有 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
在 routing 之后但验证之前 调用,因此我们可以做一些事情,比如记录没有触发 404 Not found 路由的请求。
这允许我们在请求处理之前记录它,并且我们可以看到请求体和路径参数。
范围
默认情况下,lifecycle hook 是封装的。Hook 应用到同一实例中的路由,不应用到其他插件(不在同一插件中定义的路由)。
这意味着 onTransform
hook 中的日志函数不会在其他实例上调用,除非我们明确将其定义为 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'
}
)
}
)
现在这里有很多东西需要展开:
- 我们创建了一个带有 2 个路由的新实例,用于注册和登录。
- 在实例中,我们定义了一个内存存储
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)中重用这个 hook,让我们提取服务(实用)部分并将其应用到两个模块。
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
属性非常重要,因为它是插件的唯一标识符,用于防止重复实例(如单例)。
如果我们不带插件定义实例,hook/lifecycle 和路由将每次使用插件时都被注册。
我们的意图是将此插件(服务)应用到多个模块以提供实用函数,这使得去重非常重要,因为生命周期不应被注册两次。
Macro
Macro 允许我们定义带有自定义生命周期管理的自定义 hook。
要定义 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('auth', {
cookie: 'session',
resolve({
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'
})
return { username }
}
})
我们刚刚创建了一个名为 auth 的新 macro,它接受一个 boolean
值。
如果是 true,则执行以下操作:
- 使用 'session' 进行 Cookie 验证。
- 运行 resolve 函数检查用户是否已认证,然后添加
username
。
要使用 macro,只需指定 auth: true
如如下所示:
import { Elysia, t } from 'elysia'
export const user = new Elysia({ prefix: '/user' })
.use(userService)
.get(
'/profile',
({ username }) => {
({ cookie: { token }, store: { session }, status }) => {
const username = session[token.value]
if (!username)
return status(401, {
success: false,
message: 'Unauthorized'
})
return {
success: true,
username
}
},
{
auth: true,
cookie: 'session'
}
)
请注意,我们移除了 cookie: 'session'
因为它已在 macro 中定义。
由于我们指定了 auth: true
,我们可以提取命令式检查部分,并在多个路由上重用相同的逻辑,而无需再次复制粘贴相同的代码。
Resolve
您可能注意到,在 macro 中我们使用 resolve 来添加新属性 username
。
resolve 是一个特殊的生命周期,它允许我们在上下文中定义新属性。
但不同于 decorate
和 store
。resolve 在 beforeHandle
阶段定义,否则值将在 验证之后 可用,并且每个请求可用。
但首先,不要忘记在 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
将授权应用到 note 控制器。
import { Elysia, t } from 'elysia'
import { 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)
.guard({
auth: true
})
.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, username }) => {
if (index in note.data)
return note.update(index, { data, author: username })
return status(422)
},
{
auth: 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 Not Found 并将其记录到控制台。
TIP
请注意 onError
在 use(note)
之前使用。这很重要,因为 Elysia 从上到下应用方法。监听器必须在路由之前应用。
并且由于 onError
应用在根实例上,它不需要定义范围,因为它将应用到所有子实例。
返回 truthy 值将覆盖默认错误响应,因此我们可以返回继承状态码的自定义错误响应。
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 collector,否则我们将使用来自 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 } 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 来查看 traces。
选择服务 Elysia 并点击 Find Traces,我们应该能够看到我们所做的请求列表。
点击任何请求以查看每个生命周期 hook 处理请求需要多长时间。
点击根父 span 以查看请求细节,这将显示请求和响应负载,以及如果有任何错误。
Elysia 开箱即用地支持 OpenTelemetry,它自动与其他支持 OpenTelemetry 的 JavaScript 库集成,如 Prisma、GraphQL Yoga、Effect 等。
您也可以使用其他 OpenTelemetry 插件将 traces 发送到其他服务,如 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('auth', {
cookie: 'session',
resolve({
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'
})
return { username }
}
})
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'
}
)
.get(
'/profile',
({ username }) => ({
success: true,
username
}),
{
auth: true
}
)
import { Elysia, t } from 'elysia'
import { 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)
.guard({
auth: true
})
.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)
},
{
auth: 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
,您应该看到与使用 dev 命令相同的结果。
通过最小化二进制,不仅使我们的服务器小而便携,我们还显著减少了它的内存使用。
TIP
Bun 确实有 --minify
标志来最小化二进制,但是它包括 --minify-identifiers
,由于我们使用 OpenTelemetry,它会重命名函数名并使 tracing 比应该的更难。
WARNING
练习:尝试运行开发服务器和生产服务器,并比较内存使用。
开发服务器将使用名为 'bun' 的进程,而生产服务器将使用名为 'server' 的进程。
总结
就是这样 🎉
我们使用 Elysia 创建了一个简单的 API,我们学习了如何创建简单的 API、如何处理错误,以及如何使用 OpenTelemetry 观察我们的服务器。
您可以进一步尝试连接到真实数据库、连接到真实前端或使用 WebSocket 实现实时通信。
本教程涵盖了创建 Elysia 服务器所需知道的大多数概念,但是还有其他几个有用的概念您可能想知道。
如果您卡住了
如果您有任何进一步的问题,欢迎在 GitHub Discussions、Discord 和 Twitter 上向我们的社区提问。
我们祝您在与 Elysia 的旅程中一切顺利 ❤️