Skip to content
我们的赞助商

Elysia 教程 ~20 分钟

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

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

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


不喜欢教程?

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

来自其他框架?

如果您使用过其他流行框架,如 Express、Fastify 或 Hono,您会发现 Elysia 与它们非常相似,只有一些细微差异。

llms.txt

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

如果您卡住了

欢迎在 GitHub Discussions、Discord 和 Twitter 上向我们的社区提问。

您也可以查看 评论 如果您卡住了。

设置

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 Schema,带有 Scalar 来与我们的 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 Documentation landing

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

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

通过点击黑色的 Send 按钮,我们可以看到结果。 Scalar Documentation landing

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 Documentation landing

路径参数

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

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

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 类型以实现自动完成。

大多数框架只提供这些功能之一,或者单独提供它们,需要我们分别更新每个,但 Elysia 将它们全部作为 Single Source of Truth 提供。

验证类型

Elysia 为以下属性提供验证:

它们都共享与上面示例相同的语法。

状态码

如果我们尝试访问 **http://localhost:3000/note/1**,我们应该在屏幕上看到 undefined

由于这是一个错误,它不应使用 200 状态码 (OK)。

我们可以通过返回 status 来更改状态码:

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 :(,与之前一样。

我们刚刚通过声明新的 Elysia 实例创建了一个 note 插件。

每个插件都是 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 来简化这一点,并从每个路由中删除 /note

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

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

onTransformrouting 之后但验证之前 调用,因此我们可以做一些事情,比如记录没有触发 404 Not found 路由的请求。

这允许我们在请求处理之前记录它,并且我们可以看到请求体和路径参数。

范围

默认情况下,lifecycle hook 是封装的。Hook 应用到同一实例中的路由,不应用到其他插件(不在同一插件中定义的路由)。

这意味着 onTransform hook 中的日志函数不会在其他实例上调用,除非我们明确将其定义为 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 个路由的新实例,用于注册和登录。
  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)中重用这个 hook,让我们提取服务(实用)部分并将其应用到两个模块。

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

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

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('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,则执行以下操作:

  1. 使用 'session' 进行 Cookie 验证
  2. 运行 resolve 函数检查用户是否已认证,然后添加 username

要使用 macro,只需指定 auth: true 如如下所示:

ts
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 是一个特殊的生命周期,它允许我们在上下文中定义新属性。

但不同于 decoratestore。resolve 在 beforeHandle 阶段定义,否则值将在 验证之后 可用,并且每个请求可用。


但首先,不要忘记在 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'
        }
    )

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

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

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 Not Found 并将其记录到控制台。

TIP

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

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

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

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 collector,否则我们将使用来自 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 } 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,我们应该能够看到我们所做的请求列表。

Jaeger showing list of requests

点击任何请求以查看每个生命周期 hook 处理请求需要多长时间。 Jaeger showing request span

点击根父 span 以查看请求细节,这将显示请求和响应负载,以及如果有任何错误。 Jaeger showing request detail

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

您也可以使用其他 OpenTelemetry 插件将 traces 发送到其他服务,如 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
('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
} )
typescript
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 将服务器打包成生产环境的二进制文件:

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,您应该看到与使用 dev 命令相同的结果。

通过最小化二进制,不仅使我们的服务器小而便携,我们还显著减少了它的内存使用。

TIP

Bun 确实有 --minify 标志来最小化二进制,但是它包括 --minify-identifiers,由于我们使用 OpenTelemetry,它会重命名函数名并使 tracing 比应该的更难。

WARNING

练习:尝试运行开发服务器和生产服务器,并比较内存使用。

开发服务器将使用名为 'bun' 的进程,而生产服务器将使用名为 'server' 的进程。

总结

就是这样 🎉

我们使用 Elysia 创建了一个简单的 API,我们学习了如何创建简单的 API、如何处理错误,以及如何使用 OpenTelemetry 观察我们的服务器。

您可以进一步尝试连接到真实数据库、连接到真实前端或使用 WebSocket 实现实时通信。

本教程涵盖了创建 Elysia 服务器所需知道的大多数概念,但是还有其他几个有用的概念您可能想知道。

如果您卡住了

如果您有任何进一步的问题,欢迎在 GitHub Discussions、Discord 和 Twitter 上向我们的社区提问。

我们祝您在与 Elysia 的旅程中一切顺利 ❤️