最佳实践
Elysia 是一个与模式无关的框架,编码模式的选择留给你和你的团队决定。
然而,在尝试用 Elysia 适配 MVC 模式 (模型-视图-控制器) 时,存在一些问题,我们发现很难实现解耦和处理类型。
本页面是结合 MVC 模式遵循 Elysia 结构最佳实践的指南,但同样可以适配你喜欢的任何编码模式。
文件夹结构
Elysia 对文件夹结构持无偏见态度,留给你 自己决定 如何组织代码。
不过,如果你没有具体结构想法,我们推荐使用基于功能的文件夹结构,每个功能都有自己的文件夹,包含控制器、服务以及模型。
| src
| modules
| auth
| index.ts (Elysia 控制器)
| service.ts (服务)
| model.ts (模型)
| user
| index.ts (Elysia 控制器)
| service.ts (服务)
| model.ts (模型)
| utils
| a
| index.ts
| b
| index.ts该结构方便你查找和管理代码,将相关代码聚集在一起。
以下示例代码展示了如何分配代码到基于功能的文件夹结构:
// 控制器处理 HTTP 相关,如路由,请求验证
import { Elysia } from 'elysia'
import { Auth } from './service'
import { AuthModel } from './model'
export const auth = new Elysia({ prefix: '/auth' })
.get(
'/sign-in',
async ({ body, cookie: { session } }) => {
const response = await Auth.signIn(body)
// 设置 session cookie
session.value = response.token
return response
}, {
body: AuthModel.signInBody,
response: {
200: AuthModel.signInResponse,
400: AuthModel.signInInvalid
}
}
)// 服务处理业务逻辑,与 Elysia 控制器解耦
import { status } from 'elysia'
import type { AuthModel } from './model'
// 如果类不需要存储属性,
// 可以使用 `abstract class` 避免创建实例
export abstract class Auth {
static async signIn({ username, password }: AuthModel.signInBody) {
const user = await sql`
SELECT password
FROM users
WHERE username = ${username}
LIMIT 1`
if (!await Bun.password.verify(password, user.password))
// 你可以直接抛出 HTTP 错误
throw status(
400,
'Invalid username or password' satisfies AuthModel.signInInvalid
)
return {
username,
token: await generateAndSaveTokenToDB(user.id)
}
}
}// 模型定义请求和响应的数据结构及验证
import { t } from 'elysia'
export namespace AuthModel {
// 定义用于 Elysia 验证的 DTO
export const signInBody = t.Object({
username: t.String(),
password: t.String(),
})
// 定义 TypeScript 类型
export type signInBody = typeof signInBody.static
// 其他模型依此类推
export const signInResponse = t.Object({
username: t.String(),
token: t.String(),
})
export type signInResponse = typeof signInResponse.static
export const signInInvalid = t.Literal('Invalid username or password')
export type signInInvalid = typeof signInInvalid.static
}每个文件的职责如下:
- 控制器:处理 HTTP 路由、请求验证和 Cookie。
- 服务:处理业务逻辑,尽量与 Elysia 控制器解耦。
- 模型:定义请求和响应的数据结构及验证。
可以根据你的需求调整此结构,并使用你喜欢的任何编码模式。
控制器
鉴于 Elysia 的类型系统健壮,我们不推荐使用传统的紧耦合 Elysia Context 的控制器类,因为:
- Elysia 类型复杂,且强依赖插件及多层链式调用。
- 难以准确类型化,特别是使用装饰器和存储时,Elysia 类型可能随时变化。
- 类型完整性丢失,代码的类型和运行时不一致。
推荐以下两种方式实现 Elysia 控制器:
- 使用 Elysia 实例本身作为控制器
- 创建不依赖于 HTTP 请求或 Elysia 的控制器
1. 使用 Elysia 实例作为控制器
1 个 Elysia 实例 = 1 个控制器
将 Elysia 实例视为控制器,直接在 Elysia 实例上定义路由。
// ✅ 推荐做法
import { Elysia } from 'elysia'
import { Service } from './service'
new Elysia()
.get('/', ({ stuff }) => {
Service.doStuff(stuff)
})该方式允许 Elysia 自动推断 Context 类型,保证类型完整性和运行时与类型的一致性。
// ❌ 不推荐
import { Elysia, t, type Context } from 'elysia'
abstract class Controller {
static root(context: Context) {
return Service.doStuff(context.stuff)
}
}
new Elysia()
.get('/', Controller.root)该方式难以正确类型化 Context,可能导致类型完整性丢失。
2. 不依赖 HTTP 请求的控制器
若要创建控制器类,建议创建与 HTTP 请求或 Elysia 完全无关的类。
这样允许你解耦控制器和 Elysia,使测试、复用,甚至替换框架更为简单,同时保持 MVC 模式。
import { Elysia } from 'elysia'
abstract class Controller {
static doStuff(stuff: string) {
return Service.doStuff(stuff)
}
}
new Elysia()
.get('/', ({ stuff }) => Controller.doStuff(stuff))绑定控制器到 Elysia Context 可能导致:
- 类型完整性丢失
- 不易测试和复用
- 产生厂商锁定
推荐尽量让控制器与 Elysia 解耦。
❌ 不推荐:传递整个 Context 给控制器
Context 是高度动态的类型,可由 Elysia 实例推断。
不要将整个 Context 传给控制器,应通过对象解构提取所需参数传入。
import type { Context } from 'elysia'
abstract class Controller {
constructor() {}
// ❌ 不要这样做
static root(context: Context) {
return Service.doStuff(context.stuff)
}
}此做法难以正确类型化 Context,可能导致类型完整性丧失。
测试
如果使用 Elysia 作为控制器,可用 handle 方法直接调用函数(包括其生命周期)进行测试:
import { Elysia } from 'elysia'
import { Service } from './service'
import { describe, it, expect } from 'bun:test'
const app = new Elysia()
.get('/', ({ stuff }) => {
Service.doStuff(stuff)
return 'ok'
})
describe('Controller', () => {
it('should work', async () => {
const response = await app
.handle(new Request('http://localhost/'))
.then((x) => x.text())
expect(response).toBe('ok')
})
})更多测试信息可查看 单元测试。
服务
服务是一组解耦的工具/辅助函数,用于模块/控制器中的业务逻辑。
所有可以与控制器解耦的技术逻辑,都可以放在 服务 中。
Elysia 中有两种服务类型:
- 非请求依赖的服务
- 请求依赖的服务
1. 抽象非请求依赖服务
推荐将服务类/函数抽象出来,独立于 Elysia。
如果服务或函数不依赖 HTTP 请求或不访问 Context,建议实现为静态类或函数。
import { Elysia, t } from 'elysia'
abstract class Service {
static fibo(number: number): number {
if(number < 2)
return number
return Service.fibo(number - 1) + Service.fibo(number - 2)
}
}
new Elysia()
.get('/fibo', ({ body }) => {
return Service.fibo(body)
}, {
body: t.Numeric()
})如果服务不需存储属性,可以使用 abstract class 和 static 避免实例化。
2. 请求依赖服务作为 Elysia 实例
如果服务是请求依赖的,或需要处理 HTTP 请求,推荐抽象成 Elysia 实例以保证类型完整性和推断:
import { Elysia } from 'elysia'
// ✅ 推荐做法
const AuthService = new Elysia({ name: 'Auth.Service' })
.macro({
isSignIn: {
resolve({ cookie, status }) {
if (!cookie.session.value) return status(401)
return {
session: cookie.session.value,
}
}
}
})
const UserController = new Elysia()
.use(AuthService)
.get('/profile', ({ Auth: { user } }) => user, {
isSignIn: true
})TIP
Elysia 默认支持插件去重,你无需担心性能问题,只要指定 "name" 属性,即保证为单例。
✅ 推荐:只装饰请求依赖属性
建议 decorate 仅用于请求依赖属性,如 requestIP、requestTime 或 session。
过度使用装饰器会使代码紧耦合 Elysia,影响测试和复用。
import { Elysia } from 'elysia'
new Elysia()
.decorate('requestIP', ({ request }) => request.headers.get('x-forwarded-for') || request.ip)
.decorate('requestTime', () => Date.now())
.decorate('session', ({ cookie }) => cookie.session.value)
.get('/', ({ requestIP, requestTime, session }) => {
return { requestIP, requestTime, session }
})模型
模型或 DTO(数据传输对象) 由 Elysia.t (验证) 处理。
Elysia 内置验证系统,可自动推断类型并在运行时验证。
✅ 推荐使用 Elysia 验证系统
Elysia 的优势在于优先保证类型和运行时验证的单一可信来源。
不要声明接口,复用验证模型:
// ✅ 推荐
import { Elysia, t } from 'elysia'
const customBody = t.Object({
username: t.String(),
password: t.String()
})
// 可选:如果想获取模型的类型
// 通常不需要专门声明类型,Elysia 会推断
type CustomBody = typeof customBody.static
export { customBody }用 typeof 和 .static 属性获取模型类型。
然后用 CustomBody 推断请求体类型。
import { Elysia, t } from 'elysia'
const customBody = t.Object({
username: t.String(),
password: t.String()
})
// ---截断---
// ✅ 推荐做法
new Elysia()
.post('/login', ({ body }) => {
return body
}, {
body: customBody
})❌ 不要声明类实例作为模型
不要用类实例声明模型:
// ❌ 不推荐
class CustomBody {
username: string
password: string
constructor(username: string, password: string) {
this.username = username
this.password = password
}
}
// ❌ 不推荐
interface ICustomBody {
username: string
password: string
}❌ 不要将类型和模型分开声明
不要分开声明类型,应使用 typeof 和 .static 直接获取模型类型。
// ❌ 不推荐
import { Elysia, t } from 'elysia'
const customBody = t.Object({
username: t.String(),
password: t.String()
})
type CustomBody = {
username: string
password: string
}
// ✅ 推荐
const customBody = t.Object({
username: t.String(),
password: t.String()
})
type CustomBody = typeof customBody.static分组
你可以将多个模型聚合到单个对象中,更加组织化。
import { Elysia, t } from 'elysia'
export const AuthModel = {
sign: t.Object({
username: t.String(),
password: t.String()
})
}
const models = AuthModel.models模型注入
虽然可选,但若严格遵循 MVC 模式,你可能希望像注入服务一样注入模型。我们推荐使用 Elysia 引用模型。
使用 Elysia 的模型引用示例:
import { Elysia, t } from 'elysia'
const customBody = t.Object({
username: t.String(),
password: t.String()
})
const AuthModel = new Elysia()
.model({
sign: customBody
})
const models = AuthModel.models
const UserController = new Elysia({ prefix: '/auth' })
.use(AuthModel)
.prefix('model', 'auth.')
.post('/sign-in', async ({ body, cookie: { session } }) => {
return true
}, {
body: 'auth.Sign'
})该方式带来多个好处:
- 允许模型命名并支持自动补全。
- 便于后续修改 Schema 或执行 重映射。
- 在 OpenAPI 合规客户端(如 OpenAPI)中显示为“models”。
- 提高 TypeScript 类型推断速度,因为模型类型注册时会被缓存。