Transformers
本指南介绍 AdonisJS 应用中的数据转换。你将学习如何:
- 将类、BigInt、Lucid 模型等丰富的数据类型序列化为 JSON,同时保留类型信息
- 通过包含或排除字段来塑造 API 响应
- 为不同的输出上下文使用 transformer 变体
- 处理关联关系和分页
- 生成 TypeScript 类型,消除前后端之间重复的类型定义
概述
Transformers 提供了一种结构化的方式,将后端数据转换为供 HTTP 客户端使用的 JSON 响应。在构建 API 或全栈应用时,你在后端处理的数据结构(Lucid 模型、自定义类、DateTime 对象)无法直接通过 HTTP 发送——一切都必须先序列化为 JSON,而 JSON 本质上是一种字符串格式。
与让 AdonisJS 隐式处理序列化不同,transformers 让你对此过程拥有明确的控制。你可以精确定义包含哪些字段、如何格式化它们,以及响应应采用什么形状。这种方法有以下几个好处:
- 你可以将敏感信息排除在响应之外
- 在应用中应用一致的格式化规则
- 围绕前端的需求而非数据库结构来塑造响应
- 并且生成前端可直接引用的 TypeScript 类型
如果你正在构建 Inertia 应用或任何返回 JSON 数据的 API,我们强烈建议对所有 HTTP 响应使用 transformers。生成的 TypeScript 类型消除了维护重复类型定义的需要,确保你前后端自动保持同步。
理解 JSON 序列化
在深入 transformers 之前,理解它们存在的原因很重要。当你通过 HTTP 发送数据时,一切都必须转换为字符串——具体来说是 JSON 字符串。这意味着你编程语言中的丰富数据类型无法直接传输。
以一个带有 createdAt 字段(一个 Luxon DateTime 对象)的 Lucid 模型为例。在 JavaScript/TypeScript 中,这是一个带有 .toISO() 和 .diff() 等方法的复杂对象。但当它通过 HTTP 发送时,它必须变成一个像 "2024-01-15T10:30:00.000Z" 这样的简单字符串。类似地,BigInt 值或自定义类实例也必须转换为 JSON 兼容的格式。
如果没有显式的序列化控制,你可能会意外暴露敏感数据、发送不一致的日期格式,或者包含前端并不需要的内部实现细节。Transformers 通过要求你明确指定哪些内容被序列化以及如何序列化来解决这个问题。
创建你的第一个 transformer
让我们从一个实际的例子开始。假设你有一个 Post 模型,需要将文章数据返回给前端。首先,这是 Post 模型的样子。
import User from '#models/user'
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
export default class Post extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare userId: number
@column()
declare title: string
@column()
declare content: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
@belongsTo(() => User)
declare author: BelongsTo<typeof User>
}
-
生成 transformer
运行以下命令为文章创建 transformer。
node ace make:transformer post # CREATE: app/transformers/post_transformer.ts这会在
app/transformers目录中创建一个具有以下默认结构的文件。app/transformers/post_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import Post from '#models/post' export default class PostTransformer extends BaseTransformer<Post> { toObject() { return this.pick(this.resource, ['id']) } }该 transformer 使用带有泛型类型参数(指定它转换的内容,此处为
Post)的BaseTransformer进行扩展。toObject()方法定义了默认输出形状。this.resource属性让你访问正在被转换的 Post 实例。 -
定义输出形状
toObject()方法决定了你的 JSON 响应中出现哪些字段。this.pick()辅助函数从模型中选择特定字段。让我们扩展 transformer 以包含我们想要的字段。app/transformers/post_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' export default class PostTransformer extends BaseTransformer<Post> { toObject() { return this.pick(this.resource, [ 'id', 'title', 'content', 'createdAt', 'updatedAt' ]) } }这个 transformer 显式地只包含这五个字段。Post 模型上的任何其他字段(如内部元数据或敏感数据)都将从输出中排除。
-
在控制器中使用 transformer
现在让我们在控制器中使用这个 transformer 来返回数据。如果控制器尚不存在,使用
node ace make:controller posts命令创建它。app/controllers/posts_controller.tsimport Post from '#models/post' import type { HttpContext } from '@adonisjs/core/http' import PostTransformer from '#transformers/post_transformer' export default class PostsController { async index({ serialize }: HttpContext) { const posts = await Post.all() return serialize(PostTransformer.transform(posts)) } async show({ serialize, params }: HttpContext) { const post = await Post.findOrFail(params.id) return serialize(PostTransformer.transform(post)) } }模式很简单:用你的数据调用
PostTransformer.transform(),然后将结果包裹在 HTTP 上下文的serialize()辅助函数中。serialize()函数负责实际的 JSON 转换并发送响应。同一个 transformer 既能用于单篇文章,也能用于文章集合。AdonisJS 会自动检测你转换的是单项还是多项,并相应地构建输出。
-
注册路由
start/routes.tsimport router from '@adonisjs/core/services/router' import { controllers } from '#generated/controllers' router.get('posts', [controllers.Posts, 'index']) router.get('posts/:id', [controllers.Posts, 'show']) -
理解生成的类型
当你使用
node ace serve --hmr启动开发服务器时,AdonisJS 会自动为你的 transformers 生成 TypeScript 类型。这些类型存储在.adonisjs/client/data.d.ts中。.adonisjs/client/data.d.tsimport type { InferData, InferVariants } from '@adonisjs/core/types/transformers' import type PostTransformer from '#transformers/post_transformer' export namespace Data { export type Post = InferData<PostTransformer> export namespace Post { export type Variants = InferVariants<PostTransformer> } }你的前端代码现在可以导入并使用这些类型。在 Inertia 应用中,有一个预先配置好的别名,使其变得便捷。
import { Data } from '~/generated/data' type Post = Data.Post这意味着你的前端自动知道来自 API 的数据的确切形状。如果你在 transformer 中添加或删除字段,TypeScript 类型会在开发服务器重新加载时自动更新。你永远不需要手动维护重复的类型定义。
资源项与集合
Transformers 会自动处理单个资源和集合。当你调用 PostTransformer.transform() 时,它会根据你传入的内容返回 ResourceItem 或 ResourceCollection。
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async show({ serialize, params }: HttpContext) {
const post = await Post.findOrFail(params.id)
// 返回 ResourceItem(单篇文章)
return serialize(PostTransformer.transform(post))
}
async index({ serialize }: HttpContext) {
const posts = await Post.all()
// 返回 ResourceCollection(文章数组)
return serialize(PostTransformer.transform(posts))
}
}
序列化输出的结构在项和集合之间略有不同。单项直接返回你转换后的对象,而集合则将项包裹在 data 数组中。两者都经过你在 transformer 中定义的同一个 toObject() 方法。
分页数据
在处理大型数据集时,你通常会对结果进行分页。Lucid 的查询构建器提供了一个 paginate() 方法,它同时返回数据行和分页元数据。Transformers 有一个专门的方法来处理分页响应。
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ serialize, request }: HttpContext) {
const page = request.input('page', 1)
const posts = await Post.query().paginate(page, 20)
const data = posts.all()
const metadata = posts.getMeta()
return serialize(PostTransformer.paginate(data, metadata))
}
}
PostTransformer.paginate() 方法接受两个参数:要转换的数据数组和分页元数据。生成的 JSON 响应同时包含转换后的数据和分页信息。
{
"data": [
{
"id": 1,
"title": "First Post",
"content": "...",
"createdAt": "2024-01-15T10:30:00.000Z",
"updatedAt": "2024-01-15T10:30:00.000Z"
}
],
"metadata": {
"total": 100,
"perPage": 20,
"currentPage": 1,
"lastPage": 5,
"firstPage": 1,
"firstPageUrl": "/?page=1",
"lastPageUrl": "/?page=5",
"nextPageUrl": "/?page=2",
"previousPageUrl": null
}
}
你的前端可以使用 metadata 对象构建分页控件,而 data 数组包含转换后的文章。
在 Inertia 中使用 transformers
在构建 Inertia 应用时,你可以将 transformer 资源直接传递给 inertia.render(),而无需使用 serialize() 辅助函数。Inertia 适配器会自动处理 ResourceItem 和 ResourceCollection 对象的序列化。
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ inertia }: HttpContext) {
const posts = await Post.all()
return inertia.render('posts/index', {
posts: PostTransformer.transform(posts)
})
}
async show({ inertia, params }: HttpContext) {
const post = await Post.findOrFail(params.id)
return inertia.render('posts/show', {
post: PostTransformer.transform(post)
})
}
}
所有 transformer 特性在 Inertia 中都完全相同地工作——包括变体、关联关系和自定义数据。在本指南的其余部分,我们在示例中使用 serialize() 辅助函数,但在使用 inertia.render() 时同样的原则适用。
处理关联关系
Transformers 可以通过与其它 transformer 组合来包含关联数据。应用中的每个实体都应有自己的 transformer,这些 transformer 在包含关联关系时可以相互引用。
基础关联包含
首先,让我们为 User 模型创建一个 transformer。
import { BaseTransformer } from '@adonisjs/core/transformers'
import type User from '#models/user'
export default class UserTransformer extends BaseTransformer<User> {
toObject() {
return this.pick(this.resource, [
'id',
'fullName',
'email',
'createdAt',
'updatedAt',
'initials'
])
}
}
现在你可以通过使用 UserTransformer 将文章的作者包含在 PostTransformer 中。
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
export default class PostTransformer extends BaseTransformer<Post> {
toObject() {
return {
...this.pick(this.resource, [
'id',
'title',
'content',
'createdAt',
'updatedAt'
]),
author: UserTransformer.transform(this.resource.author)
}
}
}
关联关系只能作为 transformer 输出中的顶层属性出现。你通过调用它们的 transform() 方法并传入关联的模型实例来组合 transformers。
Transformers 不会发起任何数据库查询。它们只处理你已经加载的数据。如果你忘记预加载关联关系并尝试转换它,你会访问到 undefined 数据并得到一个运行时错误。
条件关联
有时一个关联关系可能加载了,也可能没有加载,这取决于请求上下文。例如,你可能只为特定端点预加载作者。在这些情况下,你需要显式地防止 undefined 值。
this.whenLoaded() 辅助函数会在尝试转换关联关系之前检查它是否已被加载。
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
export default class PostTransformer extends BaseTransformer<Post> {
toObject() {
return {
...this.pick(this.resource, [
'id',
'title',
'content',
'createdAt',
'updatedAt'
]),
author: UserTransformer.transform(
this.whenLoaded(this.resource.author)
)
}
}
}
现在,如果 author 关联关系尚未加载,transformer 不会抛出错误。author 字段将简单地从输出中省略。如果你更喜欢更显式的控制,也可以使用三元运算符。
author: this.resource.author
? UserTransformer.transform(this.resource.author)
: undefined
控制关联深度
默认情况下,transformers 会将关联序列化为最多一级深度。这可以防止意外地过度获取前端可能不需要的嵌套数据。例如,如果 User 拥有 Posts,而每个 Post 拥有 Comments,默认情况下只会序列化第一级(User → Posts)。
你可以使用 .depth() 方法手动控制这个深度。
toObject() {
return {
...this.pick(this.resource, ['id', 'fullName', 'email']),
posts: PostTransformer
.transform(this.resource.posts)
.depth(2) // 现在序列化 user → posts → comments
}
}
通过 .depth(2),如果你预加载了嵌套关联关系,它们会被包含在转换中。这让你对每个关联序列化的深度有了细粒度的控制。
深度如何跨嵌套 transformers 工作:
- 你在顶层设置的深度值控制整个关联关系树
- 在上面的示例中,当
UserTransformer在 posts 上设置.depth(2)时,该深度适用于 posts 内的所有嵌套关联——包括 comments - 即使
PostTransformer有自己的深度设置,它们也不会覆盖父 transformer 设置的深度 - 深度在起点确定,并向下级联贯穿整个树
使用变体
单个 transformer 可以为不同的上下文生成不同的输出形状。当同一个资源需要在应用的不同地方以不同方式展示时——例如,列表视图的轻量版本和单项的详细版本——这非常有用。
定义变体
变体通过在与 toObject() 一起的 transformer 中创建额外的方法来定义。这些方法可以任意命名,但我们建议使用像 for<Purpose> 这样一致的约定,以使其意图清晰。
import type Post from '#models/post'
import UserTransformer from '#transformers/user_transformer'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class PostTransformer extends BaseTransformer<Post> {
/**
* 用于列出文章的默认变体
*/
toObject() {
return {
...this.pick(this.resource, ['id', 'title', 'createdAt', 'updatedAt']),
author: UserTransformer.transform(this.resource.author)
}
}
/**
* 用于展示单篇文章的详细变体
* 包含转换为 HTML 的完整内容(markdown)
*/
async forDetailedView() {
return {
...this.toObject(),
content: await markdownToHtml(this.resource.content)
}
}
}
默认的 toObject() 方法返回适合展示文章列表的基本字段。而 forDetailedView() 变体通过额外的计算数据——在本例中是转换 markdown 内容为 HTML——来扩展它。注意,变体方法如果需要执行异步操作,可以是 async 的。
你可以通过调用 this.toObject() 并展开其结果,然后添加或覆盖特定字段来复用默认变体。这使你的变体保持 DRY(不重复),并确保一致性。
在控制器中使用变体
要使用变体,请在转换后的资源上调用 .useVariant() 方法。
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
/**
* 使用默认变体列出所有文章
*/
async index({ serialize }: HttpContext) {
const posts = await Post.query().preload('author')
return serialize(PostTransformer.transform(posts))
}
/**
* 使用详细变体展示单篇文章
*/
async show({ serialize, params }: HttpContext) {
const post = await Post.query()
.where('id', params.id)
.preload('author')
.firstOrFail()
return serialize(
PostTransformer.transform(post).useVariant('forDetailedView')
)
}
}
.useVariant() 方法接受变体方法的名称作为字符串。当响应被序列化时,它会调用 forDetailedView() 而不是默认的 toObject()。
前端中的变体类型
生成的 TypeScript 类型包含你所有变体的定义。你的前端代码可以指定它期望的变体类型。
import { Data } from '~/generated/data'
import { InertiaProps } from '~/types'
export default function ShowPost(
props: InertiaProps<{
post: Data.Post.Variants['forDetailedView']
}>
) {
// TypeScript 知道这篇文章包含 'content' 字段
// 该字段来自 forDetailedView 变体
}
使用 Data.<Resource>.Variants['<variantName>'] 访问变体类型。这确保你的前端组件接收到正确类型化的 props,匹配该特定变体由 API 返回的确切形状。
依赖注入
Transformer 方法可以使用 @inject() 装饰器从 AdonisJS 的 IoC 容器注入依赖。当你在转换期间需要访问服务或上下文信息时,这非常有用。
注入 HttpContext
一个常见的用例是注入 HttpContext 以访问当前已认证的用户。这让你能够在转换期间计算授权权限或用户特定的数据。
import type Post from '#models/post'
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class PostTransformer extends BaseTransformer<Post> {
toObject() {
return {
...this.pick(this.resource, [
'id',
'title',
'createdAt',
'updatedAt'
]),
author: UserTransformer.transform(this.resource.author)
}
}
/**
* 带授权检查的详细变体
*/
@inject()
async forDetailedView({ auth }: HttpContext) {
return {
...this.toObject(),
content: await markdownToHtml(this.resource.content),
can: {
view: true,
edit: auth.user?.id === this.resource.userId,
delete: auth.user?.id === this.resource.userId
}
}
}
}
@inject() 装饰器告诉 AdonisJS 为此方法解析依赖。你可以从注入的 HttpContext 中解构属性,例如 auth、request 或你需要的任何其他上下文属性。
依赖注入如何工作
当你调用 PostTransformer.transform(post) 时,它返回一个 ResourceItem 或 ResourceCollection 对象。这些对象不会立即执行你的 transformer 方法。相反,当你将它们传给 serialize() 时,IoC 容器才会解析依赖,并使用注入的值调用你的 transformer 方法。
这意味着依赖注入在你的控制器序列化阶段自动发生。你无需手动传递 HttpContext 或其他依赖——框架会为你处理这些。
向 transformers 传递自定义数据
有时你需要向 transformer 传递超出被序列化资源本身的额外上下文。例如,你可能想要包含用户特定的数据,比如当前用户是否点赞了某篇文章,或者影响数据转换方式的配置。
你可以通过将 transformer 的构造函数接受额外参数,来向 transformer 传递自定义数据。这些参数位于 transform() 方法中的资源参数之后。
import { BaseTransformer } from '@adonisjs/core/transformers'
import type Post from '#models/post'
export default class PostTransformer extends BaseTransformer<Post> {
constructor(
resource: Post,
protected likedPostsIds: number[]
) {
super(resource)
}
toObject() {
return {
...this.pick(this.resource, [
'id',
'title',
'createdAt',
'updatedAt'
]),
isLiked: this.likedPostsIds.includes(this.resource.id)
}
}
}
调用 transformer 时,将自定义数据作为资源之后的额外参数传入。
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
import PostTransformer from '#transformers/post_transformer'
export default class PostsController {
async index({ serialize, auth }: HttpContext) {
const posts = await Post.all()
// 获取当前用户点赞过的文章 ID
const likedPosts = await auth.user!
.related('likedPosts')
.query()
.select('id')
return serialize(
PostTransformer.transform(posts, likedPosts.map(({ id }) => id))
)
}
}
由于自定义数据被存储为实例属性,它在所有变体方法中也会自动可用。这种模式与依赖注入、变体以及所有其他 transformer 特性无缝协作。
重要区分
在你开始在应用中使用 transformers 之前,理解它们是什么以及何时使用它们很重要。
Transformers 不是 DTO
理解 transformers 不是用于请求验证的数据传输对象(DTO)很重要。它们服务于不同的目的:
- DTO 定义输入契约:它们验证并塑造从请求进入应用的数据
- Transformers 定义输出契约:它们塑造从应用输出到响应中的数据
不要使用 transformers 来验证或转换请求数据。为此请使用 AdonisJS 的验证系统。Transformers 专用于将后端数据结构序列化为供客户端使用的 JSON 响应。
何时使用 transformers
在任何后端向客户端返回 JSON 数据的情况下都使用 transformers:
- Inertia 应用:前端和后端位于同一代码库中的全栈 TypeScript 应用
- REST API:由独立前端应用(React、Vue、Angular SPA)消费的 API
- 移动端 API:为移动应用提供服务的后端
- 任何 JSON 响应:任何你通过 HTTP 发送结构化数据的地方
生成的 TypeScript 类型在 Inertia 应用或全栈 TypeScript monorepo(前端可以直接从后端导入类型)中最有用。移动客户端通常不会利用生成的类型,但它们仍然受益于 transformers 提供的一致且形状良好的 JSON 响应。