控制器

本指南介绍 AdonisJS 应用中的控制器。你将学习如何:

  • 创建并组织控制器来处理 HTTP 请求
  • 使用 barrel 文件系统进行控制器的导入
  • 理解控制器的生命周期与请求处理
  • 使用 IoC 容器向控制器注入依赖
  • 遵循约定构建 RESTful 资源驱动控制器
  • 配置控制器的位置和 barrel 文件的生成
Note

前提条件:在学习控制器之前,你应该先熟悉 路由 ,因为控制器是通过路由连接到你的应用的。

概述

控制器将路由处理器组织到专门的 JavaScript 类中,解决了路由文件臃肿的问题。与其在所有路由中内联定义逻辑,控制器允许你将相关的请求处理器分组到单个类中,其中每个方法(称为 action)处理特定的路由。

一个典型的控制器代表一个资源(如 Users、Posts 或 Comments),并为该资源的创建、读取、更新和删除定义操作。控制器使你的路由文件保持清晰可读,支持为服务和其他依赖项注入依赖,并遵循基于资源的 CRUD 操作的 RESTful 约定。

如果不使用控制器,你的路由文件会被内联处理器弄得杂乱不堪。

start/routes.ts
import router from '@adonisjs/core/services/router'

router.get('/posts', async () => {
  // 获取所有文章的逻辑
  return { posts: [] }
})

router.get('/posts/:id', async ({ params }) => {
  // 获取单篇文章的逻辑
  return { post: {} }
})

router.post('/posts', async ({ request }) => {
  // 创建文章的逻辑
  return { post: {} }
})

// 随着路由增长,这个文件会变得难以管理

使用控制器后,你可以将处理器组织到可复用的类中。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

// 干净、有组织的路由定义
router.get('/posts', [controllers.Posts, 'index'])
router.get('/posts/:id', [controllers.Posts, 'show'])
router.post('/posts', [controllers.Posts, 'store'])
app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class PostsController {
  async index({ serialize }: HttpContext) {
    // 获取所有文章的逻辑
    return serialize({ posts: [] })
  }

  async show({ params, serialize }: HttpContext) {
    // 获取单篇文章的逻辑
    return serialize({ post: {} })
  }

  async store({ request, serialize }: HttpContext) {
    // 创建文章的逻辑
    return serialize({ post: {} })
  }
}

创建你的第一个控制器

  1. 生成控制器

    控制器存储在 app/controllers 目录中。创建控制器最简单的方法是使用 make:controller 命令。

    node ace make:controller posts
    # 输出
    DONE:    create app/controllers/posts_controller.ts
    

    该命令会创建一个带普通 JavaScript 类和默认导出的控制器脚手架。

    app/controllers/posts_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    
    export default class PostsController {
    }
  2. 添加你的第一个操作

    控制器操作(action)就是处理 HTTP 请求的一个方法。让我们添加一个 index 方法来列出所有文章。

    app/controllers/posts_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    
    export default class PostsController {
      /**
       * 处理 GET 请求以列出所有文章
       */
      async index({ response }: HttpContext) {
        const posts = [
          { id: 1, title: 'Getting started with AdonisJS' },
          { id: 2, title: 'Understanding controllers' },
        ]
        
        return response.json({ posts })
      }
    }

    关于控制器操作,有几个要点需要了解:

    • 第一个参数始终是 HTTPContext 对象
    • 你可以解构特定的属性,如 requestresponseparamssessionauth
    • 控制器方法可以直接返回值(对象、数组),或显式调用 response.json()response.send()
  3. 将控制器连接到路由

    现在将你的控制器操作绑定到一个路由。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { controllers } from '#generated/controllers'
    
    router.get('/posts', [controllers.Posts, 'index'])

    第一个参数(controllers.Posts)引用你的 PostsController 类,而第二个参数('index')指定要调用的方法。控制器是懒加载的,这意味着它只在路由被访问时才被导入。

  4. 测试一下

    如果你的开发服务器尚未运行,请启动它。

    node ace serve --hmr

    在浏览器中访问 http://localhost:3333/posts 。你应该能看到来自控制器的 JSON 响应。

    {
      "posts": [
        { "id": 1, "title": "Getting started with AdonisJS" },
        { "id": 2, "title": "Understanding controllers" }
      ]
    }

Barrel 文件

你在路由步骤中使用的 #generated/controllers 导入是由一个 barrel 文件 驱动的——一个将所有控制器导入合并到单一便捷位置的文件。这个 barrel 文件由 AdonisJS 自动生成并维护

barrel 文件位于 .adonisjs/server/controllers.ts,并在你启动开发服务器时自动创建。当你添加或移除控制器时,它会保持最新。

如果没有 barrel 文件,你需要在路由文件中手动逐个导入每个控制器。

没有 barrel 文件
import router from '@adonisjs/core/services/router'

const PostsController = () => import('#controllers/posts_controller')
const UsersController = () => import('#controllers/users_controller')
const CommentsController = () => import('#controllers/comments_controller')
// ...随着应用增长,还有几十个导入

router.get('/posts', [PostsController, 'index'])
router.get('/users', [UsersController, 'index'])
router.get('/comments', [CommentsController, 'index'])

barrel 文件消除了这种重复。

有 barrel 文件
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router.get('/posts', [controllers.Posts, 'index'])
router.get('/users', [controllers.Users, 'index'])
router.get('/comments', [controllers.Comments, 'index'])

另请参阅: Barrel 文件生成指南 了解详细的配置选项。

理解控制器生命周期

AdonisJS 中的控制器是 按请求实例化 的。每次 HTTP 请求匹配到绑定了控制器的路由时,AdonisJS 都会使用 IoC 容器创建该控制器类的一个全新实例。

这意味着:

  • 每个请求都获得自己独立的控制器实例
  • 不存在请求之间状态泄漏的风险
  • 如有需要,你可以安全使用实例属性
  • 请求完成后,控制器实例会被垃圾回收
app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class PostsController {
  // 该属性对每个请求都是唯一的
  #requestId = Math.random()

  async index({ response }: HttpContext) {
    // 每个请求都会看到不同的 requestId
    return response.json({ requestId: this.#requestId })
  }
}

依赖注入

控制器支持依赖注入,允许你将服务、仓库或其他类注入到控制器方法或构造函数中。IoC 容器会自动解析并注入这些依赖。

另请参阅: 依赖注入指南 ,全面了解依赖注入在 AdonisJS 中的工作原理。

构造函数注入

构造函数注入在控制器实例化时一次性注入依赖。当你的控制器中所有或大多数方法都需要相同的依赖时使用。

app/controllers/users_controller.ts
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import UserService from '#services/user_service'

@inject() 
export default class UsersController {
  constructor(protected userService: UserService) {}

  async index(ctx: HttpContext) {
    return this.userService.all()
  }

  async show({ params }: HttpContext) {
    return this.userService.find(params.id)
  }

  async store({ request }: HttpContext) {
    const data = request.all()
    return this.userService.create(data)
  }
}
  • @inject() 装饰器告诉 AdonisJS 对该控制器使用依赖注入。
  • 依赖在构造函数参数中进行类型提示,IoC 容器会在控制器实例化时自动解析并注入它们。

方法注入

方法注入将依赖注入到单独的控制器方法中。当只有特定方法需要某些依赖,或不同方法需要不同依赖时使用。

app/controllers/users_controller.ts
import { inject } from '@adonisjs/core'
import type { HttpContext } from '@adonisjs/core/http'
import UserService from '#services/user_service'
import EmailService from '#services/email_service'

export default class UsersController {
  async index(ctx: HttpContext) {
    return [{ id: 1, name: 'John' }]
  }

  @inject()
  async store(ctx: HttpContext, userService: UserService) {
    const data = ctx.request.all()
    return userService.create(data)
  }

  @inject()
  async sendEmail(
    ctx: HttpContext,
    userService: UserService,
    emailService: EmailService
  ) {
    const user = await userService.find(ctx.params.id)
    await emailService.send(user.email, 'Welcome!')
    return { sent: true }
  }
}

使用方法的:

  • @inject() 装饰器应用于各个方法而非整个类。
  • 第一个参数必须始终是 HTTPContext,依赖随后排列其后。
  • 这使得每个方法可以根据其特定需求拥有不同的依赖。

资源驱动控制器

资源驱动控制器遵循 RESTful 约定,用于处理某个资源的 CRUD(创建、读取、更新、删除)操作。AdonisJS 提供了特殊的路由方法,可自动将 HTTP 动词映射到标准控制器方法。

七个资源操作

一个典型的资源控制器定义了七个处理所有 CRUD 操作的方法。

app/controllers/posts_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class PostsController {
  /**
   * 显示所有文章的列表
   * GET /posts
   */
  async index({ response }: HttpContext) {
    const posts = [] // 从数据库获取
    return response.json({ posts })
  }

  /**
   * 渲染创建新文章的表单
   * GET /posts/create
   * 
   * 纯 API 应用不需要
   */
  async create({ view }: HttpContext) {
    return view.render('posts/create')
  }

  /**
   * 处理创建新文章的表单提交
   * POST /posts
   */
  async store({ request }: HttpContext) {
    const data = request.all()
    // 在数据库中创建文章
    return { post: data }
  }

  /**
   * 按 id 显示单篇文章
   * GET /posts/:id
   */
  async show({ params }: HttpContext) {
    // 从数据库获取文章
    return { post: { id: params.id } }
  }

  /**
   * 渲染编辑现有文章的表单
   * GET /posts/:id/edit
   * 
   * 纯 API 应用不需要
   */
  async edit({ params, view }: HttpContext) {
    return view.render('posts/edit', { id: params.id })
  }

  /**
   * 处理更新文章的表单提交
   * PUT/PATCH /posts/:id
   */
  async update({ params, request }: HttpContext) {
    const data = request.all()
    // 在数据库中更新文章
    return { post: { id: params.id, ...data } }
  }

  /**
   * 按 id 删除文章
   * DELETE /posts/:id
   */
  async destroy({ params }: HttpContext) {
    // 在数据库中删除文章
    return { deleted: true }
  }
}

生成资源控制器

使用 --resource 标志创建一个预先填充好全部七个方法的控制器。这会生成一个已经包含全部七个方法桩的控制器,为你节省时间并确保遵循 RESTful 约定。

node ace make:controller posts --resource

注册资源路由

与其手动定义七个独立的路由,不如使用 router.resource() 方法在一行内创建全部七个路由。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router.resource('posts', controllers.Posts)

这会生成以下路由。

GET /posts
  • 操作: PostsController.index
  • 名称: posts.index
  • 用途: 显示所有文章的列表
GET /posts/create
  • 操作: PostsController.create
  • 名称: posts.create
  • 用途: 显示创建新文章的表单
POST /posts
  • 操作: PostsController.store
  • 名称: posts.store
  • 用途: 存储新创建的文章
GET /posts/:id
  • 操作: PostsController.show
  • 名称: posts.show
  • 用途: 显示单篇文章
GET /posts/:id/edit
  • 操作: PostsController.edit
  • 名称: posts.edit
  • 用途: 显示编辑文章的表单
PUT|PATCH /posts/:id
  • 操作: PostsController.update
  • 名称: posts.update
  • 用途: 更新单篇文章
DELETE /posts/:id
  • 操作: PostsController.destroy
  • 名称: posts.destroy
  • 用途: 删除单篇文章

嵌套资源

嵌套资源表示资源之间的层级关系。例如,属于文章的评论。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router.resource('posts.comments', controllers.Comments)

这会创建同时包含父级和子级 ID 的路由。

GET        /posts/:post_id/comments
GET        /posts/:post_id/comments/create
POST       /posts/:post_id/comments

GET        /posts/:post_id/comments/:id
GET        /posts/:post_id/comments/:id/edit
PUT/PATCH  /posts/:post_id/comments/:id
DELETE     /posts/:post_id/comments/:id

你的控制器会同时接收到父级和子级参数。

app/controllers/comments_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class CommentsController {
  async index({ params }: HttpContext) {
    // params.post_id - 父级文章 ID
    // 获取该文章的所有评论
    return { post_id: params.post_id, comments: [] }
  }

  async show({ params }: HttpContext) {
    // params.post_id - 父级文章 ID
    // params.id - 评论 ID
    return { post_id: params.post_id, comment: { id: params.id } }
  }
}

浅层嵌套资源

浅层资源会在子资源可以独立唯一标识的路由中省略父级 ID。当子级 ID 全局唯一且不需要限定在父级范围内时,这很有用。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router.shallowResource('posts.comments', controllers.Comments)

对于浅层资源,showeditupdatedestroy 操作会省略父级 ID,因为评论可以通过自身 ID 查找到:

GET        /posts/:post_id/comments
GET        /posts/:post_id/comments/create
POST       /posts/:post_id/comments

# 没有父级 ID
GET        /comments/:id
GET        /comments/:id/edit
PUT/PATCH  /comments/:id
DELETE     /comments/:id

当子资源具有全局唯一标识符且查找时不需要父级 ID 时,使用浅层嵌套。这能创建更简洁、更短的 URL,同时在需要的地方(创建和列出)保持层级关系。

为资源路由命名

router.resource() 创建的路由会自动使用资源名和控制器操作的组合来命名。资源名会被转换为 snake_case,并使用点(.)分隔符与操作名拼接。

例如,router.resource('posts', controllers.Posts) 会生成如下路由名称:

  • posts.index
  • posts.show
  • posts.store

你可以使用 .as() 方法自定义路由名称。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router
  .resource('posts', controllers.Posts)
  .as('articles')

这会改变路由名称,同时保持 URL 路径不变。

资源操作名路由名
postsindexarticles.index
postsshowarticles.show
postsstorearticles.store
postsupdatearticles.update
postsdestroyarticles.destroy

为路由命名 很重要,因为它允许你通过名称引用路由,而不是在整个应用中硬编码 URL。

过滤资源路由

默认情况下,router.resource() 会创建全部七个 RESTful 路由。你可以使用几种方法过滤生成哪些路由。

仅 API 资源

在构建 API 时,你通常不需要 createedit 路由,因为表单由客户端代码展示。.apiOnly() 方法会排除这些路由,只创建五个路由:indexstoreshowupdatedestroy

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router.resource('posts', controllers.Posts).apiOnly()

使用 onlyexcept 进行选择性过滤

对于更精细的控制,可以使用 .only().except() 方法。这些方法接受一个操作名数组。

.only() 方法只创建指定的路由。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router
  .resource('posts', controllers.Posts)
  .only(['index', 'store', 'destroy'])

.except() 方法会创建除指定路由之外的所有路由。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router
  .resource('posts', controllers.Posts)
  .except(['create', 'edit'])

重命名资源参数

默认情况下,资源路由使用 :id 作为参数名。你可以使用 .params() 方法自定义,该方法接受一个对象,键为资源名,值为所需的参数名。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router
  .resource('posts', controllers.Posts)
  .params({ posts: 'post' })

这会将 URL 参数从 :id 更改为 :post

之前(默认)之后(使用自定义参数)
/posts/:id/posts/:post
/posts/:id/edit/posts/:post/edit
/posts/:id (update/destroy)/posts/:post (update/destroy)

同样的方法适用于嵌套资源,生成类似 /posts/:post/comments/:comment 的 URL。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'

router
  .resource('posts.comments', controllers.Comments)
  .params({ posts: 'post', comments: 'comment' })

为资源分配中间件

你可以使用 .use() 方法将中间件应用到特定的资源路由。该方法接受一个操作名数组和要应用的中间件。例如,要仅将数据修改路由(create、store、update、destroy)应用身份验证中间件,而让只读路由(index、show)保持公开。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'

router
  .resource('posts', controllers.Posts)
  .use(
    ['create', 'store', 'update', 'destroy'],
    middleware.auth()
  )

要将中间件应用到所有资源路由,请使用通配符 *,以确保资源中的所有路由都需要身份验证。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
import { middleware } from '#start/kernel'

router
  .resource('posts', controllers.Posts)
  .use('*', middleware.auth())

配置

控制器开箱即用,无需初始配置。不过,你可以自定义控制器生成和组织方式的某些方面。

自定义控制器位置

默认情况下,控制器存储在 app/controllers 目录中。你可以在 adonisrc.ts 文件中更改此位置。

另请参阅: AdonisRC 参考

adonisrc.ts
import { defineConfig } from '@adonisjs/core/app'

export default defineConfig({
  directories: {
    controllers: 'app/http/controllers'
  }
})

Barrel 文件配置

#generated/controllers 处自动生成的 barrel 文件可以被自定义,以控制包含或排除哪些控制器,以及文件的生成方式。

另请参阅: Barrel 文件生成指南

adonisrc.ts
import { defineConfig } from '@adonisjs/core/app'

export default defineConfig({
  barrelFiles: {
    controllers: {
      enabled: true,
      export: (path) => `export * as ${path.name} from '${path.modulePath}'`
    }
  }
})