Skip to content
Our Sponsors
Open in Anthropic

title: 最佳实践 - ElysiaJS head: - - meta - property: 'og:title' content: 最佳实践 - ElysiaJS

- - meta
  - name: 'description'
    content: Elysia 是一个模式无关的框架,我们将使用哪种编码模式的决定权留给您和您的团队。然而,我们发现有几个用户在 Elysia 上使用 MVC 模式(Model-View-Controller),并发现它很难解耦和处理类型。本页面是关于如何在 Elysia 中使用 MVC 模式的指南。

- - meta
  - property: 'og:description'
    content: Elysia 是一个模式无关的框架,我们将使用哪种编码模式的决定权留给您和您的团队。然而,我们发现有几个用户在 Elysia 上使用 MVC 模式(Model-View-Controller),并发现它很难解耦和处理类型。本页面是关于如何在 Elysia 中使用 MVC 模式的指南。

最佳实践

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

这种结构允许您轻松查找和管理您的代码,并将相关代码保持在一起。

以下是如何将您的代码分布到基于功能的文件夹结构的示例代码:

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
}

每个文件都有其各自的职责:

  • Controller:处理 HTTP 路由、请求验证和 cookie。
  • Service:处理业务逻辑,尽可能与 Elysia 控制器解耦。
  • Model:定义请求和响应的数据结构和验证。

请随时根据您的需求调整此结构,并使用您喜欢的任何编码模式。

Controller

由于 Elysia 的类型健全性,不建议使用传统的与 Elysia 的 Context 紧密耦合的控制器类,原因如下:

  1. Elysia 类型很复杂,并且严重依赖于插件和多级链式调用。
  2. 难以类型化,Elysia 类型可能随时改变,特别是使用装饰器时,并且会存储
  3. 类型完整性丢失,类型和运行时代码之间不一致。

我们推荐使用以下方法之一在 Elysia 中实现控制器。

  1. 将 Elysia 实例本身用作控制器
  2. 创建一个不与 HTTP 请求或 Elysia 绑定的控制器。

1. Elysia instance as a controller

1 Elysia instance = 1 controller

将 Elysia 实例视为控制器,直接在 Elysia 实例上定义您的路由。

typescript
// ✅ Do
import { Elysia } from 'elysia'
import { Service } from './service'

new Elysia()
    .get('/', ({ stuff }) => {
        Service.doStuff(stuff)
    })

这种方法允许 Elysia 自动推断 Context 类型,确保类型完整性以及类型和运行时代码之间的一致性。

typescript
// ❌ Don't
import { Elysia, t, type Context } from 'elysia'

abstract class Controller {
    static root(context: Context) {
        return Service.doStuff(context.stuff)
    }
}

new Elysia()
    .get('/', Controller.hi)

这种方法使得 Context 类型难以正确类型化,并可能导致类型完整性丢失。

2. Controller without HTTP request

如果您想创建一个控制器类,我们建议创建一个完全不与 HTTP 请求或 Elysia 绑定的类。

这种方法允许您将控制器与 Elysia 解耦,使其更易于测试、重用,甚至可以在仍然遵循 MVC 模式的同时更换框架。

typescript
import { Elysia } from 'elysia'

abstract class Controller {
	static doStuff(stuff: string) {
		return Service.doStuff(stuff)
	}
}

new Elysia()
	.get('/', ({ stuff }) => Controller.doStuff(stuff))

将控制器与 Elysia Context 绑定可能导致:

  1. 类型完整性丢失
  2. 使测试和重用更加困难
  3. 导致供应商锁定

我们建议尽可能保持控制器与 Elysia 解耦。

❌ Don't: Pass entire Context to a controller

Context 是一个高度动态的类型,可以从 Elysia 实例推断出来。

不要将整个 Context 传递给控制器,而是使用对象解构来提取您需要的内容并将其传递给控制器。

typescript
import type { Context } from 'elysia'

abstract class Controller {
	constructor() {}

	// ❌ Don't do this
	static root(context: Context) {
		return Service.doStuff(context.stuff)
	}
}

这种方法使得 Context 类型难以正确类型化,并可能导致类型完整性丢失。

Testing

如果您使用 Elysia 作为控制器,您可以使用 handle 直接调用一个函数(及其生命周期)来测试您的控制器。

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

您可以在 单元测试 中找到有关测试的更多信息。

Service

服务是一组实用工具/辅助函数,被解耦为业务逻辑以在模块/控制器中使用,在我们的例子中,是一个 Elysia 实例。

任何可以从控制器解耦的技术逻辑都可以存在于 Service 中。

Elysia 中有两种类型的服务:

  1. 不依赖请求的服务
  2. 依赖请求的服务

1. Abstract away Non-request dependent service

我们建议将服务类/函数从 Elysia 中抽象出来。

如果服务或函数不与 HTTP 请求绑定或不访问 Context,建议将其实现为静态类或函数。

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

2. Request dependent service as Elysia instance

如果服务是依赖请求的服务或需要处理 HTTP 请求,我们建议将其抽象为 Elysia 实例以确保类型完整性和推断:

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

TIP

Elysia 默认处理插件去重,所以您不必担心性能问题,因为如果您指定了 "name" 属性,它将是一个单例。

✅ Do: Decorate only request dependent property

建议只 decorate 依赖请求的属性,例如 requestIPrequestTimesession

过度使用装饰器可能会使您的代码与 Elysia 绑定,使其更难测试和重用。

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

Model

模型或 DTO (Data Transfer Object)Elysia.t (Validation) 处理。

Elysia 内置了验证系统,可以从您的代码中推断类型并在运行时进行验证。

✅ Do: Use Elysia's validation system

Elysia 的优势在于优先为类型和运行时验证提供单一真实来源。

与其声明一个接口,不如重用验证的模型:

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 属性来获取模型的类型。

然后您可以使用 CustomBody 类型来推断请求体的类型。

typescript
// ✅ Do
new 
Elysia
()
.
post
('/login', ({
body
}) => {
return
body
}, {
body
:
customBody
})

❌ Don't: 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
}

❌ Don't: Declare type separate from the model

不要声明与模型分离的类型,而是使用 typeof.static 属性来获取模型的类型。

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

您可以将多个模型分组到一个对象中,使其更有条理。

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 reference model

使用 Elysia 的模型引用

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

这种方法提供了几个好处:

  1. 允许我们为模型命名并提供自动完成功能。
  2. 修改架构以供后续使用,或执行重新映射
  3. 在符合 OpenAPI 的客户端中显示为“模型”,例如 OpenAPI。
  4. 提高 TypeScript 推断速度,因为模型类型将在注册期间被缓存。