Skip to content
我们的赞助商

Elysia 教程

我们将构建一个小型的 CRUD 笔记 API 服务器。

没有数据库或其他“生产就绪”功能。本教程仅关注 Elysia 的功能以及如何使用 Elysia。

预计如果跟随教程,大约需要 15-20 分钟。


不喜欢教程?

如果你更喜欢“自己试试”的方法,可以跳过本教程,直接转到 关键概念 页面,以更好地理解 Elysia 的工作原理。

来自其他框架?

如果你使用过其他流行框架,如 Express、Fastify 或 Hono,你会发现 Elysia 非常熟悉,只有一点差异。

llms.txt

或者,你可以下载 llms.txtllms-full.txt,并将其输入到你喜欢的 LLM,如 ChatGPT、Claude 或 Gemini,以获得更互动的体验。

设置

Elysia 设计用于在 Bun 上运行,这是 Node.js 的替代运行时,但它也可以在 Node.js 或任何支持 Web 标准 API 的运行时上运行。

但是,在本教程中,我们将使用 Bun。

如果你还没有安装 Bun,请安装它。

bash
curl -fsSL https://bun.sh/install | bash
bash
powershell -c "irm bun.sh/install.ps1 | iex"

创建新项目

bash
# 创建新项目
bun create elysia hi-elysia

# 进入项目
cd hi-elysia

# 安装依赖
bun install

这将创建一个带有 Elysia 和基本 TypeScript 配置的骨架项目。

启动开发服务器

bash
bun dev

打开浏览器并访问 http://localhost:3000,你应该看到屏幕上显示 Hello Elysia 消息。

Elysia 使用 Bun 的 --watch 标志,在你进行更改时自动重新加载服务器。

路由

要添加新路由,我们指定 HTTP 方法、路径名和值。

让我们从打开 src/index.ts 文件开始,如下所示:

typescript
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 相同的语法

typescript
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(路由和实例信息)。

typescript
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 SchemaScalar,用于与我们的 API 交互。

bash
# 安装 OpenAPI 插件
bun add @elysiajs/openapi

然后将插件应用到 Elysia 实例。

typescript
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**,你应该看到如下文档:Scalar 文档登陆页

现在我们可以与我们创建的所有路由交互。

滚动到 /hello 并点击蓝色 Test Request 按钮以显示表单。

通过点击黑色 Send 按钮查看结果。 Scalar 文档请求

Decorate

但是,对于更复杂的数据,我们可能想要使用类来处理复杂数据,因为它允许我们定义自定义方法和属性。

现在,让我们创建一个单例类来存储我们的笔记。

typescript
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 文档,我们可能需要重新加载页面以查看新更改。 Scalar 文档 Moonhalo

路径参数

现在让我们通过其索引检索笔记。

我们可以通过在冒号前缀定义路径参数。

typescript
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]
Element implicitly has an 'any' type because index expression is not of type 'number'.
}) .
listen
(3000)

现在忽略错误。

打开 **http://localhost:3000/note/0**,我们应该在屏幕上看到 Moonhalo

路径参数允许我们从 URL 中检索特定部分。在我们的例子中,我们从 /note/0 中检索 "0" 并放入名为 index 的变量中。

验证

上面的错误是一个警告,路径参数可以是任何字符串,而数组索引应该是数字。

例如,/note/0 是有效的,但 /note/zero 不是。

我们可以通过声明 schema 来强制和验证类型:

typescript
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)。

我们可以通过返回错误来更改状态码

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

我们也可以通过向错误函数传递字符串返回自定义消息。

typescript
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 的新文件:

typescript
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 应用到主实例:

typescript
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

我们可以应用相同的模式来创建、更新和删除路由。

typescript
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 来简化这一点

typescript
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 来将验证应用到插件中的路由。

typescript
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

typescript
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 钩子中的日志函数不会在其他实例上调用,除非我们明确将其定义为 scopedglobal

认证

现在我们可能想要为我们的路由添加限制,以便只有笔记的所有者才能更新或删除它。

让我们创建一个 user.ts 文件来处理用户认证:

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

现在有很多事情需要展开:

  1. 我们创建了一个带有注册和登录两个路由的新实例。
  2. 在实例中,我们定义了一个内存存储 usersession
    • 2.1 user 将保存 usernamepassword 的键值对
    • 2.2 session 将保存 sessionusername 的键值对
  3. /sign-up 中,我们使用 argon2id 插入用户名和哈希密码
  4. /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 并传递模型的名称和值:

typescript
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 可以接受多个重载:

  1. 提供对象,然后注册所有键值作为模型
  2. 提供函数,然后访问所有先前模型并返回新模型

最后,我们可以添加 /profile/sign-out 路由,如下所示:

typescript
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,我们将需要重复两件事:

  1. 检查用户是否存在
  2. 获取用户 ID(在我们的例子中是 'username')

对于 1.,与其使用 guard,我们可以使用 macro

插件去重

由于我们将在多个模块(user 和 note)中重用这个钩子,让我们提取服务(实用程序)部分并将其应用到两个模块。

ts
// @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 如以下所示:

ts
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 如以下所示:

ts
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 相同的上下文中,但仅每个请求执行一次。

decoratestore 不同,resolve 在 beforeHandle 阶段定义,否则值将在 验证后 可用。

这确保了像 cookie: 'session' 这样的属性在创建新属性之前存在。

ts
export const getUserId = new Elysia() 
    .use(userService) 
    .guard({ 
        cookie: 'session'
    }) 
    .resolve(({ store: { session }, cookie: { token } }) => ({ 
        username: session[token.value] 
    })) 

在这个实例中,我们使用 resolve 定义一个新属性 username,允许我们将获取 username 逻辑减少为一个属性。

我们在这个 getUserId 实例中不定义名称,因为我们希望 guardresolve 重新应用到多个实例。

TIP

与 macro 类似,如果获取属性的逻辑复杂,resolve 会发挥作用,对于这个像这样的小操作可能不值得。但在现实世界中,我们将需要数据库连接、缓存和队列,它可能适合这个叙述。

范围

现在如果我们尝试使用 getUserId,我们可能会注意到属性 usernameguard 没有应用。

ts
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) 依赖项。

如果我们希望生命周期应用到父级,我们可以明确标注它可以应用到父级,使用以下任一方式:

  1. scoped - 仅应用到上一级的父级,不再进一步
  2. global - 应用到所有父级

在我们的例子中,我们想要使用 scoped,因为它将仅应用到使用该服务的控制器。

要做到这一点,我们需要将生命周期标注为 scoped

typescript
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 来转换多个生命周期。

ts
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

封装发生在运行时和类型级别。这允许我们提前捕获错误。

最后,我们可以使用 userServicegetUserId 来帮助 note 控制器中的授权。

但首先,不要忘记在 index.ts 文件中导入 user

typescript
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 并从中推断类型,从而允许我们同步运行时和类型级别。

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

现在让我们导入并使用 userServicegetUserId 来将授权应用到 note 控制器。

typescript
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 生命周期来捕获服务器中抛出的任何错误。

typescript
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

注意 onErroruse(note) 之前使用。这很重要,因为 Elysia 从上到下应用方法。监听器必须在路由之前应用。

并且由于 onError 应用在根实例上,它不需要定义范围,因为它将应用到所有子实例。

返回真值将覆盖默认错误响应,因此我们可以返回一个继承状态码的自定义错误响应。

typescript
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 插件。

bash
bun add @elysiajs/opentelemetry

确保运行 OpenTelemetry 收集器,否则我们将使用 Docker 中的 Jaeger。

bash
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 插件应用到我们的服务器。

typescript
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,我们应该能够看到我们所做的请求列表。

Jaeger 显示请求列表

点击任何请求以查看每个生命周期钩子处理请求需要多长时间。 Jaeger 显示请求跨度

点击根父跨度以查看请求细节,这将显示请求和响应负载,以及任何错误。 Jaeger 显示请求细节

Elysia 开箱即用支持 OpenTelemetry,它自动与其他支持 OpenTelemetry 的 JavaScript 库集成,如 Prisma、GraphQL Yoga、Effect 等。

你也可以使用其他 OpenTelemetry 插件将跟踪发送到其他服务,如 Zipkin、Prometheus 等。

代码库回顾

如果你跟随教程,你的代码库应该看起来像这样:

typescript
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)
typescript
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
}))
typescript
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 将服务器打包成二进制文件用于生产:

bash
bun build \
	--compile \
	--minify-whitespace \
	--minify-syntax \
	--target bun \
	--outfile server \
	./src/index.ts

这个命令有点长,让我们分解它:

  1. --compile - 将 TypeScript 编译成二进制
  2. --minify-whitespace - 移除不必要的空白
  3. --minify-syntax - 最小化 JavaScript 语法以减少文件大小
  4. --target bun - 针对 bun 平台,这可以为目标平台优化二进制
  5. --outfile server - 将二进制输出为 server
  6. ./src/index.ts - 服务器(代码库)的入口文件

现在我们可以使用 ./server 运行二进制,它将以与使用 bun dev 相同的端口 3000 启动服务器。

bash
./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 的旅程中一切顺利 ❤️