Skip to content

最佳实践

¥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
)
Property 'build' does not exist on type '{}'.
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:

  1. Elysia 类型复杂,严重依赖于插件和多层链接。
  2. 难以输入,Elysia 类型随时可能更改,尤其是在使用装饰器和存储时。
  3. 类型转换可能会导致类型完整性丧失,或无法确保类型与运行时代码之间的一致性。
  4. 这使得 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:

  1. 非请求依赖服务
  2. 请求依赖服务

✅ 应该做的:抽象出非请求依赖的服务

¥✅ 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 classstatic 来避免分配类实例。

¥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

建议仅将与请求相关的属性(例如 requestIPrequestTimesessiondecorate 化。

¥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:

  1. 允许我们命名模型并提供自动补齐功能。
  2. 修改模式以供以后使用,或执行 remap
  3. 在 OpenAPI 合规客户端(例如 OpenAPI)中显示为 "models"。
  4. 由于模型类型将在注册期间被缓存,因此提高 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.