最佳实践
Elysia 是一个模式无关的框架,将使用哪种编码模式的决定留给您和您的团队。
然而,在尝试将 MVC 模式(Model-View-Controller) 与 Elysia 结合时,有几个问题,我们发现很难解耦和处理类型。
本页是关于如何遵循 Elysia 结构最佳实践结合 MVC 模式的指南,但它可以适应您喜欢的任何编码模式。
文件夹结构
Elysia 对文件夹结构没有特定意见,让您决定如何组织您的代码。
然而,如果您没有特定的结构想法,我们推荐基于功能的文件夹结构,其中每个功能都有自己的文件夹,包含控制器、服务和模型。
| src
| modules
| auth
| index.ts (Elysia controller)
| service.ts (service)
| model.ts (model)
| user
| index.ts (Elysia controller)
| service.ts (service)
| model.ts (model)
| utils
| a
| index.ts
| b
| index.ts
这种结构让您更容易找到和管理代码,并将相关代码保持在一起。
以下是如何将您的代码分布到基于功能的文件夹结构的示例代码:
// Controller handle HTTP related eg. routing, request validation
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)
// Set session cookie
session.value = response.token
return response
}, {
body: AuthModel.signInBody,
response: {
200: AuthModel.signInResponse,
400: AuthModel.signInInvalid
}
}
)
// Service handle business logic, decoupled from Elysia controller
import { status } from 'elysia'
import type { AuthModel } from './model'
// If the class doesn't need to store a property,
// you may use `abstract class` to avoid class allocation
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))
// You can throw an HTTP error directly
throw status(
400,
'Invalid username or password' satisfies AuthModel.signInInvalid
)
return {
username,
token: await generateAndSaveTokenToDB(user.id)
}
}
}
// Model define the data structure and validation for the request and response
import { t } from 'elysia'
export namespace AuthModel {
// Define a DTO for Elysia validation
export const signInBody = t.Object({
username: t.String(),
password: t.String(),
})
// Define it as TypeScript type
export type signInBody = typeof signInBody.static
// Repeat for other models
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
}
每个文件都有其自身责任如下:
- Controller:处理 HTTP 路由、请求验证和 cookie。
- Service:处理业务逻辑,如果可能,则与 Elysia 控制器解耦。
- Model:为请求和响应定义数据结构和验证。
请随意根据您的需求调整此结构,并使用您喜欢的任何编码模式。
Controller
1 Elysia 实例 = 1 controller
Elysia 做了很多工作来确保类型完整性,如果您将整个 Context
类型传递给控制器,这些可能是问题:
- Elysia 类型复杂且高度依赖插件和多级链式调用。
- 难以类型化,Elysia 类型可能随时改变,特别是使用装饰器和 store 时。
- 类型转换可能导致类型完整性丢失或无法确保类型与运行时代码之间的一致性。
- 这会使 Sucrose (Elysia 的“某种”编译器) 更难静态分析您的代码。
❌ 不要:创建单独的控制器
不要创建单独的控制器,而是将 Elysia 本身用作控制器:
import { Elysia, t, type Context } from 'elysia'
abstract class Controller {
static root(context: Context) {
return Service.doStuff(context.stuff)
}
}
// ❌ Don't
new Elysia()
.get('/', Controller.hi)
将整个 Controller.method
传递给 Elysia 相当于有两个控制器来回传递数据。这违背了框架和 MVC 模式本身的设计。
✅ 可以:将 Elysia 用作控制器
相反,将 Elysia 实例本身视为控制器。
import { Elysia } from 'elysia'
import { Service } from './service'
new Elysia()
.get('/', ({ stuff }) => {
Service.doStuff(stuff)
})
否则,如果您确实想分离控制器,您可以创建一个与 HTTP 请求无关的控制器类。
import { Elysia } from 'elysia'
abstract class Controller {
static doStuff(stuff: string) {
return Service.doStuff(stuff)
}
}
new Elysia()
.get('/', ({ stuff }) => Controller.doStuff(stuff))
测试
您可以使用 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')
})
})
您可以在 Unit Test 中找到更多关于测试的信息。
Service
Service 是一组与模块/控制器(在我们的情况下,是 Elysia 实例)解耦的实用程序/辅助函数,作为业务逻辑使用。
任何可以从控制器解耦的技术逻辑都可以存在于 Service 中。
在 Elysia 中有两种类型的 service:
- 非请求依赖的服务
- 请求依赖的服务
✅ 可以:抽象非请求依赖的服务
我们推荐将服务类/函数从 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
来避免分配类实例。
✅ 可以:请求依赖的服务作为 Elysia 实例
如果服务是请求依赖的服务 或需要处理 HTTP 请求,我们推荐将其抽象为 Elysia 实例,以确保类型完整性和推断:
import { Elysia } from 'elysia'
// ✅ Do
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 }
})
❌ 不要:将整个 Context
传递给服务
Context 是一个高度动态的类型,可以从 Elysia 实例推断。
不要将整个 Context
传递给服务,而是使用对象解构提取您需要的内容并传递给服务。
import type { Context } from 'elysia'
class AuthService {
constructor() {}
// ❌ Don't do this
isSignIn({ status, cookie: { session } }: Context) {
if (session.value)
return status(401)
}
}
由于 Elysia 类型复杂且高度依赖插件和多级链式调用,手动类型化可能具有挑战性,因为它是高度动态的。
⚠️ 从 Elysia 实例推断 Context
在绝对必要的情况下,您可以从 Elysia 实例本身推断 Context
类型:
import { Elysia, type InferContext } from 'elysia'
const setup = new Elysia()
.state('a', 'a')
.decorate('b', 'b')
class AuthService {
constructor() {}
// ✅ Do
isSignIn({ status, cookie: { session } }: InferContext<typeof setup>) {
if (session.value)
return status(401)
}
}
然而,我们推荐尽量避免这样做,而是使用 Elysia 作为服务。
您可以在 Essential: Handler 中找到更多关于 InferContext 的信息。
Model
Model 或 DTO (Data Transfer Object) 由 Elysia.t (Validation) 处理。
Elysia 内置了一个验证系统,可以从您的代码推断类型并在运行时验证它。
❌ 不要:将类实例声明为模型
不要将类实例声明为模型:
// ❌ Don't
class CustomBody {
username: string
password: string
constructor(username: string, password: string) {
this.username = username
this.password = password
}
}
// ❌ Don't
interface ICustomBody {
username: string
password: string
}
✅ 可以:使用 Elysia 的验证系统
不要声明类或接口,而是使用 Elysia 的验证系统来定义模型:
// ✅ Do
import { Elysia, t } from 'elysia'
const customBody = t.Object({
username: t.String(),
password: t.String()
})
// Optional if you want to get the type of the model
// Usually if we didn't use the type, as it's already inferred by Elysia
type CustomBody = typeof customBody.static
export { customBody }
我们可以通过使用 typeof
与模型的 .static
属性来获取模型的类型。
然后,您可以使用 CustomBody
类型来推断请求体类型。
// ✅ Do
new Elysia()
.post('/login', ({ body }) => {
return body
}, {
body: customBody
})
❌ 不要:将类型与模型分开声明
不要将类型与模型分开声明,而是使用 typeof
与 .static
属性来获取模型的类型。
// ❌ Don't
import { Elysia, t } from 'elysia'
const customBody = t.Object({
username: t.String(),
password: t.String()
})
type CustomBody = {
username: string
password: string
}
// ✅ Do
const customBody = t.Object({
username: t.String(),
password: t.String()
})
type CustomBody = typeof customBody.static
Group
您可以将多个模型分组到一个对象中,以使其更组织化。
import { Elysia, t } from 'elysia'
export const AuthModel = {
sign: t.Object({
username: t.String(),
password: t.String()
})
}
const models = AuthModel.models
Model Injection
虽然这是可选的,如果您严格遵循 MVC 模式,您可能想像服务一样注入到控制器中。我们推荐使用 Elysia 引用模型。
使用 Elysia 的模型引用
import { Elysia, t } from 'elysia'
const customBody = t.Object({
username: t.String(),
password: t.String()
})
const AuthModel = new Elysia()
.model({
'auth.sign': customBody
})
const models = AuthModel.models
const UserController = new Elysia({ prefix: '/auth' })
.use(AuthModel)
.post('/sign-in', async ({ body, cookie: { session } }) => {
return true
}, {
body: 'auth.sign'
})
这种方法提供了几个好处:
- 允许我们命名模型并提供自动完成。
- 为后续使用修改 schema,或执行 remap。
- 在 OpenAPI 合规客户端中显示为“models”,例如 OpenAPI。
- 提高 TypeScript 推断速度,因为模型类型将在注册期间缓存。
重复使用插件
重复使用插件多次以提供类型推断是可以的。
Elysia 默认自动处理插件去重,性能影响可以忽略不计。
要创建唯一插件,您可以为 Elysia 实例提供 name 或可选 seed。
import { Elysia } from 'elysia'
const plugin = new Elysia({ name: 'my-plugin' })
.decorate("type", "plugin")
const app = new Elysia()
.use(plugin)
.use(plugin)
.use(plugin)
.use(plugin)
.listen(3000)
这允许 Elysia 通过重用已注册的插件而不是反复处理插件来提高性能。