主题
最佳实践
¥Best Practice
Elysia 是一个模式无关的框架,使用哪种编码模式由你和你的团队自行决定。
¥Elysia is a pattern-agnostic framework, leaving the decision of which coding patterns to use up to you and your team.
然而,在尝试将 MVC 模式 (模型-视图-控制器) 与 Elysia 结合时,存在一些问题,我们发现很难解耦和处理类型。
¥However, there are several concerns when trying to adapt an MVC pattern (Model-View-Controller) with Elysia, and we found it hard to decouple and handle types.
本页是如何遵循 Elysia 结构最佳实践并结合 MVC 模式的指南,但可以适应你喜欢的任何编码模式。
¥This page is a guide on how to follow Elysia structure best practices combined with the MVC pattern, but it can be adapted to any coding pattern you prefer.
文件夹结构
¥Folder Structure
Elysia 对文件夹结构没有硬性要求,你可以自行决定如何组织代码。
¥Elysia is unopinionated about folder structure, leaving you to decide how to organize your code yourself.
但是,如果你没有考虑具体的结构,我们建议使用基于功能的文件夹结构,其中每个功能都有自己的文件夹,其中包含控制器、服务和模型。
¥However, if you don't have a specific structure in mind, we recommend a feature-based folder structure where each feature has its own folder containing controllers, services, and models.
| 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
这种结构允许你轻松查找和管理代码,并将相关代码放在一起。
¥This structure allows you to easily find and manage your code and keep related code together.
以下是如何将代码分发到基于特性的文件夹结构的示例代码:
¥Here's an example code of how to distribute your code into a feature-based folder structure:
typescript
// 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
}
}
)
typescript
// 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)
}
}
}
typescript
// 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
}
每个文件都有其各自的职责,如下所示:
¥Each file has its own responsibility as follows:
控制器:处理 HTTP 路由、请求验证和 Cookie。
服务:处理业务逻辑,尽可能与 Elysia 控制器分离。
模型:定义请求和响应的数据结构和验证。
你可以根据需要调整此结构,并使用你喜欢的任何编码模式。
¥Feel free to adapt this structure to your needs and use any coding pattern you prefer.
方法链
¥Method Chaining
Elysia 代码应始终使用方法链。
¥Elysia code should always use method chaining.
由于 Elysia 的类型系统复杂,Elysia 中的每个方法都会返回一个新的类型引用。
¥As Elysia's type system is complex, every method in Elysia returns a new type reference.
这对于确保类型完整性和推断非常重要。
¥This is important to ensure type integrity and inference.
typescript
import { Elysia } from 'elysia'
new Elysia()
.state('build', 1)
// Store is strictly typed
.get('/', ({ store: { build } }) => build)
.listen(3000)
在上面的代码中,state 返回了一个新的 ElysiaInstance 类型,并添加了一个 build
类型。
¥In the code above state returns a new ElysiaInstance type, adding a build
type.
❌ 不要做的:不使用方法使用 Elysia 链式调用
¥❌ Don't: Use Elysia without method chaining
如果没有方法链,Elysia 不会保存这些新类型,从而导致无法进行类型推断。
¥Without using method chaining, Elysia doesn't save these new types, leading to no type inference.
typescript
import { Elysia } from 'elysia'
const app = new Elysia()
app.state('build', 1)
app.get('/', ({ store: { build } }) => build)
app.listen(3000)
我们建议始终使用方法链来提供准确的类型推断。
¥We recommend to always use method chaining to provide accurate type inference.
控制器
¥Controller
1 个 Elysia 实例 = 1 个控制器
Elysia 会采取多种措施来确保类型完整性,如果你将整个 Context
类型传递给控制器,可能会出现以下问题:
¥Elysia does a lot to ensure type integrity, if you pass an entire Context
type to a controller, these might be the problems:
- Elysia 类型复杂,严重依赖于插件和多层链接。
- 难以输入,Elysia 类型随时可能更改,尤其是在使用装饰器和存储时。
- 类型转换可能会导致类型完整性丧失,或无法确保类型与运行时代码之间的一致性。
- 这使得 Sucrose(Elysia 的 "种类" 编译器)静态分析你的代码更具挑战性。
❌ 不要做的:创建单独的控制器
¥❌ Don't: Create a separate controller
不要创建单独的控制器,而是使用 Elysia 本身作为控制器:
¥Don't create a separate controller, use Elysia itself as a controller instead:
typescript
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 模式本身的设计。
¥By passing an entire Controller.method
to Elysia is an equivalent of having 2 controllers passing data back and forth. It's against the design of framework and MVC pattern itself.
✅ 应该做的:使用 Elysia 作为控制器
¥✅ Do: Use Elysia as a controller
不如将 Elysia 实例本身视为控制器。
¥Instead treat an Elysia instance as a controller itself instead.
typescript
import { Elysia } from 'elysia'
import { Service } from './service'
new Elysia()
.get('/', ({ stuff }) => {
Service.doStuff(stuff)
})
测试
¥Testing
你可以使用 handle
直接调用函数(及其生命周期)来测试你的控制器。
¥You can test your controller using handle
to directly call a function (and it's lifecycle)
typescript
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')
})
})
你可以在 单元测试 中找到更多关于测试的信息。
¥You may find more information about testing in Unit Test.
服务
¥Service
服务是一组实用程序/辅助函数,它们被解耦为业务逻辑,用于模块/控制器(在本例中为 Elysia 实例)。
¥Service is a set of utility/helper functions decoupled as a business logic to use in a module/controller, in our case, an Elysia instance.
任何可以与控制器解耦的技术逻辑都可以存在于服务中。
¥Any technical logic that can be decoupled from controller may live inside a Service.
Elysia 中有两种类型的服务:
¥There are 2 types of service in Elysia:
- 非请求依赖服务
- 请求依赖服务
✅ 应该做的:抽象出非请求依赖的服务
¥✅ Do: Abstract away non-request dependent service
我们建议从 Elysia 中抽象出一个服务类/函数。
¥We recommend abstracting a service class/function away from Elysia.
如果服务或函数未绑定到 HTTP 请求或不访问 Context
,建议将其实现为静态类或函数。
¥If the service or function isn't tied to an HTTP request or doesn't access a Context
, it's recommended to implement it as a static class or function.
typescript
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
来避免分配类实例。
¥If your service doesn't need to store a property, you may use abstract class
and static
instead to avoid allocating class instance.
✅ 应该做的:将依赖服务作为 Elysia 实例请求
¥✅ Do: Request dependent service as Elysia instance
如果服务是依赖于请求的服务或需要处理 HTTP 请求,我们建议将其抽象为 Elysia 实例,以确保类型完整性和推断:
¥If the service is a request-dependent service or needs to process HTTP requests, we recommend abstracting it as an Elysia instance to ensure type integrity and inference:
typescript
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
})
提示
Elysia 默认处理 插件数据去重,因此你无需担心性能问题,因为如果你指定 "name" 属性,它将是单例。
¥Elysia handles plugin deduplication by default, so you don't have to worry about performance, as it will be a singleton if you specify a "name" property.
✅ 应该做的:仅装饰请求依赖属性
¥✅ Do: Decorate only request dependent property
建议仅将与请求相关的属性(例如 requestIP
、requestTime
或 session
)decorate
化。
¥It's recommended to decorate
only request-dependent properties, such as requestIP
, requestTime
, or session
.
过度使用装饰器可能会将你的代码与 Elysia 绑定,使其更难测试和重用。
¥Overusing decorators may tie your code to Elysia, making it harder to test and reuse.
typescript
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
传递给服务
¥❌ Don't: Pass entire Context
to a service
Context 是一种高度动态的类型,可以从 Elysia 实例推断出来。
¥Context is a highly dynamic type that can be inferred from Elysia instance.
不要将整个 Context
传递给服务,而是使用对象解构提取所需内容并将其传递给服务。
¥Do not pass an entire Context
to a service, instead use object destructuring to extract what you need and pass it to the service.
typescript
import type { Context } from 'elysia'
class AuthService {
constructor() {}
// ❌ Don't do this
isSignIn({ status, cookie: { session } }: Context) {
if (session.value)
return status(401)
}
}
由于 Elysia 类型复杂,并且严重依赖于插件和多层级的链接,因此由于其高度动态,手动输入类型可能具有挑战性。
¥As Elysia type is complex, and heavily depends on plugin and multiple level of chaining, it can be challenging to manually type as it's highly dynamic.
⚠️ 从 Elysia 实例推断上下文
¥⚠️ Infers Context from Elysia instance
如果绝对必要,你可以从 Elysia 实例本身推断 Context
类型:
¥In case of absolute necessity, you may infer the Context
type from the Elysia instance itself:
typescript
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 即服务。
¥However we recommend to avoid this if possible, and use Elysia as a service instead.
你可以在 必备:处理程序 中找到更多关于 InferContext 的信息。
¥You may find more about InferContext in Essential: Handler.
模型
¥Model
模型或 DTO(数据传输对象) 由 Elysia.t (验证) 处理。
¥Model or DTO (Data Transfer Object) is handle by Elysia.t (Validation).
Elysia 内置了验证系统,可以从代码中推断类型并在运行时进行验证。
¥Elysia has a validation system built-in which can infers type from your code and validate it at runtime.
❌ 不要做的:将类实例声明为模型
¥❌ Don't: Declare a class instance as a model
不要将类实例声明为模型:
¥Do not declare a class instance as a model:
typescript
// ❌ 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 的验证系统
¥✅ Do: Use Elysia's validation system
与其声明类或接口,不如使用 Elysia 的验证系统来定义模型:
¥Instead of declaring a class or interface, use Elysia's validation system to define a model:
typescript
// ✅ 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
属性来获取模型的类型。
¥We can get type of model by using typeof
with .static
property from the model.
然后你可以使用 CustomBody
类型推断请求主体的类型。
¥Then you can use the CustomBody
type to infer the type of the request body.
typescript
// ✅ Do
new Elysia()
.post('/login', ({ body }) => {
return body
}, {
body: customBody
})
❌ 不要做的:声明与模型不同的类型
¥❌ Don't: Declare type separate from the model
不要声明与模型不同的类型,而是使用 typeof
和 .static
属性来获取模型的类型。
¥Do not declare a type separate from the model, instead use typeof
with .static
property to get the type of the model.
typescript
// ❌ 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
你可以将多个模型组合成一个对象,使其更具条理。
¥You can group multiple models into a single object to make it more organized.
typescript
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 参考模型
¥Though this is optional, if you are strictly following MVC pattern, you may want to inject like a service into a controller. We recommended using Elysia reference model
使用 Elysia 的模型引用
¥Using Elysia's model reference
typescript
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'
})
这种方法有几个好处:
¥This approach provide several benefits:
- 允许我们命名模型并提供自动补齐功能。
- 修改模式以供以后使用,或执行 remap。
- 在 OpenAPI 合规客户端(例如 OpenAPI)中显示为 "models"。
- 由于模型类型将在注册期间被缓存,因此提高 TypeScript 推断速度。
复用插件
¥Reuse a plugin
可以多次复用插件来提供类型推断。
¥It's ok to reuse plugins multiple time to provide type inference.
Elysia 默认自动处理插件数据去重,性能几乎可以忽略不计。
¥Elysia handle plugin deduplication automatically by default, and the performance is negligible.
要创建一个唯一的插件,你可以为 Elysia 实例提供名称或可选种子。
¥To create a unique plugin, you may provide a name or optional seed to an Elysia instance.
typescript
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 通过重用已注册的插件来提高性能,而无需一遍又一遍地处理插件。
¥This allows Elysia to improve performance by reusing the registered plugins instead of processing the plugin over and over again.