URL 构建器

本指南介绍 AdonisJS 应用中的 URL 生成。你将学习如何:

  • 为命名路由生成具有类型安全自动补全的 URL
  • 使用数组或对象传递路由参数
  • 为生成的 URL 添加查询字符串
  • 创建带有加密签名的签名 URL 以实现安全链接
  • 验证签名 URL 以防止篡改
  • 使用 Inertia 将 URL 生成集成到前端应用中

概述

URL 构建器提供了一个类型安全的 API,用于从命名路由生成 URL。与其在你的应用中的模板、前端组件、API 响应或重定向里到处硬编码 URL,你可以通过名称引用路由。这确保了当你更改路由路径时,无需查找并更新代码库中每个 URL 引用。

一旦路由被命名,你就可以在模板中使用 urlFor 辅助函数、在重定向中使用 response.redirect().toRoute() 方法,或通过从 URL 构建器服务导入 urlFor 函数(用于其他上下文)来为其生成 URL。

URL 构建器是类型安全的,这意味着你的 IDE 会为路由名提供自动补全,并且如果你引用了不存在的路由,TypeScript 会捕获错误。这消除了重构路由后 URL 可能悄无声息地失效的一类 bug。

定义命名路由

每个使用控制器的路由都会根据控制器和方法名自动分配一个名称。命名约定遵循 controller.method 模式(在 路由指南 中有详细说明)。

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

// 自动命名为 'posts.show'
router.get('/posts/:id', [controllers.posts, 'show'])

// 自动命名为 'posts.index'
router.get('/posts', [controllers.posts, 'index'])

对于没有控制器的路由,你必须使用 .as() 方法显式分配名称。

start/routes.ts
router.get('/about', async () => {
  return 'About page'
}).as('about')

你可以使用以下 Ace 命令查看应用中的所有命名路由。

node ace list:routes

在模板中生成 URL

Edge 模板默认可以使用 urlFor 辅助函数。该辅助函数为命名路由生成 URL,并接受路由参数(可以是数组或对象)。

resources/views/posts/index.edge
<a href="{{ urlFor('posts.show', { id: post.id }) }}">
  View post
</a>

使用 Hypermedia 起步套件时,你也可以使用 @link 组件,它接受路由和参数作为组件属性。

resources/views/posts/index.edge
@link({ route: 'posts.show', routeParams: { id: post.id } })
  View post
@end

在重定向期间生成 URL

将用户重定向到不同页面时,使用 response.redirect().toRoute() 方法而不是硬编码 URL。你只能重定向到 GET 路由。

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

export default class PostsController {
  async store({ request, response }: HttpContext) {
    const post = await Post.create(request.all())
    
    return response
      .redirect()
      .toRoute('posts.show', { id: post.id })
  }
}

在其他上下文中生成 URL

对于模板和 HTTP 响应之外的上下文,例如后台作业、邮件通知或服务类,请从 URL 构建器服务导入 urlFor 函数。

app/services/notification_service.ts
import { urlFor } from '@adonisjs/core/services/url_builder'

export default class NotificationService {
  async sendPostNotification(post: Post) {
    const postUrl = urlFor('posts.show', { id: post.id })
    
    await mail.send({
      subject: 'New post published',
      html: `<a href="${postUrl}">View post</a>`
    })
  }
}

传递路由参数

路由参数可以作为数组(按位置匹配)或对象(按名称匹配)传递。选择使你的代码更具可读性的方式。

数组(位置参数): 参数按位置与路由模式匹配。

// 路由:/posts/:id
urlFor('posts.show', [1])
// 输出:/posts/1

// 路由:/users/:userId/posts/:postId
urlFor('users.posts.show', [5, 10])
// 输出:/users/5/posts/10

对象(命名参数): 参数按名称与路由模式匹配。

// 路由:/posts/:id
urlFor('posts.show', { id: 1 })
// 输出:/posts/1

// 路由:/users/:userId/posts/:postId
urlFor('users.posts.show', { userId: 5, postId: 10 })
// 输出:/users/5/posts/10

添加查询字符串

可以通过将带有 qs 属性的第三个 options 参数传递给生成的 URL 来添加查询字符串。查询字符串对象可以包含嵌套值,这些值会自动序列化为正确的格式。

app/controllers/posts_controller.ts
import { urlFor } from '@adonisjs/core/services/url_builder'

const url = urlFor('posts.index', [], {
  qs: {
    filters: {
      title: 'typescript',
    },
    order: {
      direction: 'asc',
      column: 'id'
    },
  }
})

// 输出:/posts?filters[title]=typescript&order[direction]=asc&order[column]=id

相同的 qs 选项在模板和重定向中都有效。

resources/views/partials/pagination.edge
<a href="{{ urlFor('posts.index', [], { qs: { page: 2, sort: 'title' } }) }}">
  Next page
</a>
app/controllers/posts_controller.ts
response.redirect().toRoute('posts.index', [], {
  qs: { page: 2, sort: 'title' }
})

签名 URL

签名 URL 包含一个加密签名,可防止篡改。如果有人修改了 URL,签名将变为无效,请求会被拒绝。这对于那些公开可访问但需要防止被篡改的 URL 场景很有用,例如新闻简报退订链接或密码重置令牌。

创建签名 URL

签名 URL 使用从 URL 构建器服务导出的 signedUrlFor 辅助函数创建。其 API 与 urlFor 相同,但生成的 URL 包含签名。

app/mails/newsletter_mail.ts
import User from '#models/user'
import { appUrl } from '#config/app'
import { BaseMail } from '@adonisjs/mail'
import { signedUrlFor } from '@adonisjs/core/services/url_builder'

export default class NewsletterMail extends BaseMail {
  subject = 'Weekly Newsletter'

  constructor(protected user: User) {
    super()
  }

  prepare() {
    const unsubscribeUrl = signedUrlFor(
      'newsletter.unsubscribe',
      { email: this.user.email },
      {
        expiresIn: '30 days',
        prefixUrl: appUrl,
      }
    )

    this.message.htmlView('emails/newsletter', {
      user: this.user,
      unsubscribeUrl
    })
  }
}

expiresIn 选项设置签名 URL 的过期时间。过期后,签名不再有效。prefixUrl 选项在 URL 将被外部共享(例如在邮件或外部通知中)时是必需的,以确保 URL 包含完整的域名。对于内部应用导航,不带域名的相对 URL 就足够了。

生成的签名 URL 包含一个附加到 URL 的签名查询参数。

https://example.com/newsletter/unsubscribe?email=user@example.com&signature=eyJtZXNzYWdlIjoiL25ld3NsZXR0ZXIvdW5zdWJzY3JpYmU_ZW1haWw9dXNlckBleGFtcGxlLmNvbSIsInB1cnBvc2UiOiJzaWduZWRfdXJsIn0.1234567890abcdef
Note

签名 URL 只能在后端的代码中创建,而不能在前端应用中创建。这是因为它们依赖加密模块,而该模块使用了密钥。将该密钥暴露给前端会损害安全性。

验证签名 URL

为其生成签名 URL 的路由可以在 HTTP 请求期间使用 request.hasValidSignature() 方法验证签名。该方法会同时检查签名和过期时间。

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

export default class NewsletterController {
  async unsubscribe({ request, response }: HttpContext) {
    if (!request.hasValidSignature()) {
      return response.badRequest('Invalid or expired unsubscribe link')
    }
    
    const email = request.qs().email
    // 处理退订请求
  }
}

前端集成

URL 构建器服务仅在后端应用中可用,因为路由就是在那里定义的。但是,使用 Inertia 或在 monorepo 中拥有独立前端的应用可以为前端代码库生成类似的 URL 构建器。

为什么要分离前端和后端的 URL 构建器?

AdonisJS 在前端和后端代码库之间强制实施清晰的边界,以防止将敏感信息泄漏给客户端等问题。后端路由包含有关控制器映射和内部应用结构的细节。让所有这些信在在前端都可用,不仅会泄漏不必要的信息,还会显著增加你的打包体积。

此外,从运行时角度来看,你无法在两个不同的运行时(Node.js 和浏览器)之间共享对象。创建一个共享 URL 构建器的假象并非我们支持或认同的做法。

在 Inertia 应用中使用 URL 构建器

Inertia 的 React 和 Vue 起步套件都预配置了 URL 构建器。URL 构建器(连同 API 客户端)使用 Tuyau 生成,并写入到 .adonisjs/client 目录。

以与后端版本相同的 API 在前端组件中导入并使用 URL 构建器。

inertia/pages/posts/index.tsx
import { urlFor } from '~/client'

export default function PostsIndex({ posts }) {
  return (
    <div>
      {posts.map(post => (
        <a key={post.id} href={urlFor('posts.show', { id: post.id })}>
          {post.title}
        </a>
      ))}
    </div>
  )
}

其使用 API 与后端 URL 构建器服务完全相同,支持数组和对象参数,以及查询字符串。

// 使用位置参数
urlFor('posts.show', [post.id])

// 使用命名参数
urlFor('posts.show', { id: post.id })

// 添加查询字符串
urlFor('posts.index', [], {
  qs: { page: 2, sort: 'title' }
})

从前端包中排除路由

你可以配置前端 URL 构建器中可用的路由,以减小打包体积并防止暴露内部路由。URL 构建器是使用在你的 adonisrc.ts 文件中注册的名为 generateRegistry 的 Assembler hook 生成的。

使用 exclude 选项定义要排除的路由。

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

export default defineConfig({
  init: [
    generateRegistry({
      exclude: ['admin.*'],
    })
  ],
})

exclude 模式可以使用以下方法之一,针对路由名进行测试:

通配符模式

排除通配符之前所有以该前缀开头的路由。

adonisrc.ts
generateRegistry({
  exclude: ['admin.*', 'api.internal.*'],
})
正则表达式

使用自定义正则表达式排除匹配的路由。

adonisrc.ts
generateRegistry({
  exclude: [/^admin\./, /^api\.internal\./],
})
自定义函数

为高级过滤逻辑编写自定义函数。返回 false 以跳过该路由,返回 true 以包含它。

adonisrc.ts
generateRegistry({
  exclude: [
    (route) => {
      // 排除 admin 域名上的所有路由
      if (route.domain === 'admin.myapp.com') {
        return false
      }
      // 包含所有其他路由
      return true
    }
  ],
})

你可以组合多种模式以满足复杂的过滤需求。

adonisrc.ts
generateRegistry({
  exclude: [
    'admin.*',
    /^api\.internal\./,
    (route) => route.domain !== 'admin.myapp.com'
  ],
})