脚手架与 codemods
本指南介绍 AdonisJS 中的脚手架与 codemods 系统。你将学到如何:
- 为你的 AdonisJS 包创建 configure 钩子
- 使用 codemods 修改宿主应用的源文件
- 创建 stubs 以生成配置文件和其他源代码
- 使用 generators 和变量自定义 stub 模板
- 从现有包中弹出(eject)并修改 stubs
概述
当你运行 node ace configure @adonisjs/lucid 时,该包会自动注册其提供者、设置环境变量,并在你的项目中创建一个配置文件。这种无缝的安装体验由 AdonisJS 的脚手架与 codemods 系统驱动。
脚手架指的是从称为 stubs 的模板生成源文件。Codemods 是通过解析和操作 AST(抽象语法树)来修改现有 TypeScript 源文件的程序化转换。两者结合,使包作者能够提供与官方 AdonisJS 包相同的精致 configure 体验。
Codemods API 由
ts-morph
驱动,位于 @adonisjs/assembler 包中。由于 assembler 是开发依赖,ts-morph 永远不会让你的生产包变得臃肿。
基本构成
在深入教程之前,让我们简要定义你将要使用的关键组件。
Stubs 是用于生成源文件的模板文件(扩展名为 .stub)。它们使用
Tempura
,一个轻量级的 handlebars 风格模板引擎。
Generators 是强制执行 AdonisJS 命名约定的辅助函数。它们将 user 之类的输入转换为格式正确的名称,如 UsersController 或 users_controller.ts。
Codemods 是用于常见修改(如注册提供者、添加中间件或定义环境变量)的高级 API。它们为你处理 AST 操作的复杂性。
Configure 钩子是包所导出的函数,当用户执行 node ace configure <package-name> 时运行。这是你结合 stubs 和 codemods 来设置包的地方。
创建 configure 钩子
脚手架和 codemods 最常见的用途是为 AdonisJS 包创建 configure 钩子。让我们以一个缓存包为例,一步步构建一个。
-
搭建包结构
一个带有 configure 钩子的典型 AdonisJS 包具有如下结构:
my-cache-package/ ├── src/ │ └── ... ├── stubs/ │ ├── config.stub │ └── main.ts ├── configure.ts ├── index.ts └── package.jsonstubs目录包含你的模板文件,configure.ts存放 configure 函数,index.ts导出所有内容,包括 configure 钩子。 -
将 @adonisjs/assembler 作为 peer 依赖安装
Codemods API 需要
@adonisjs/assembler,它必须作为peer 依赖安装在你的包中。这一点很重要,因为宿主应用已经将 assembler 作为开发依赖安装,它应在所有已配置的包之间共享,而不是被重复安装。package.json{ "name": "@adonisjs/cache", "peerDependencies": { "@adonisjs/assembler": "^7.0.0" } }当用户安装你的包并运行
node ace configure时,将使用他们项目中的 assembler。 -
导出 stubs 根路径
创建一个
stubs/main.ts文件,导出指向你 stubs 目录的路径。在调用makeUsingStub时会用到这个路径。stubs/main.tsexport const stubsRoot = import.meta.url -
编写 configure 函数
configure 函数会接收 Configure 命令实例,它提供对 codemods API 的访问。以下是缓存包的一个完整示例:
configure.tsimport type Configure from '@adonisjs/core/commands/configure' import { stubsRoot } from './stubs/main.ts' export async function configure(command: Configure) { const codemods = await command.createCodemods() /** * Register the provider and commands in the adonisrc.ts file */ await codemods.updateRcFile((rcFile) => { rcFile .addProvider('@adonisjs/cache/cache_provider') .addCommand('@adonisjs/cache/commands') }) /** * Add environment variables to .env and .env.example files */ await codemods.defineEnvVariables({ CACHE_STORE: 'redis', }) /** * Add validation rules to start/env.ts */ await codemods.defineEnvValidations({ variables: { CACHE_STORE: `Env.schema.string()`, }, }) /** * Create the config/cache.ts file from a stub */ await codemods.makeUsingStub(stubsRoot, 'config.stub', { store: 'redis', }) } -
从包入口点导出
从包的主入口点导出 configure 函数,以便
node ace configure命令能够找到它:index.tsexport { configure } from './configure.ts'当用户运行
node ace configure @adonisjs/cache时,AdonisJS 会导入这个文件并执行导出的configure函数。
创建 stubs
Stubs 是用于生成源代码的模板文件。它们将静态内容与运行时计算的动态值结合起来。
基本 stub 语法
Stubs 使用双花括号进行变量插值。这里是一个简单的配置 stub。
由于 Tempura 的语法与 Handlebars 兼容,请将你的编辑器配置为对 .stub 文件使用 Handlebars 语法高亮。
{{{
exports({
to: app.configPath('cache.ts')
})
}}}
import { defineConfig, stores } from '@adonisjs/cache'
export default defineConfig({
default: '{{ store }}',
stores: {
redis: stores.redis({}),
},
})
顶部的 exports 函数定义了关于所生成文件的元数据,其中最重要的是目标路径。app 变量提供对应用路径的访问,如 configPath、makePath 和 httpControllersPath。
使用 generators 遵循命名约定
在创建需要遵循 AdonisJS 命名约定的 stubs 时,请使用 generators 模块。Generators 将用户输入转换为格式正确的名称。
{{#var entity = generators.createEntity(name)}}
{{#var modelName = generators.modelName(entity.name)}}
{{#var modelReference = string.camelCase(modelName)}}
{{#var resourceFileName = string(modelName).snakeCase().suffix('_resource').ext('.ts').toString()}}
{{{
exports({
to: app.makePath('app/api_resources', entity.path, resourceFileName)
})
}}}
export default class {{ modelName }}Resource {
serialize({{ modelReference }}: {{ modelName }}) {
return {{ modelReference }}.toJSON()
}
}
{{#var ...}} 语法在 stub 内部创建行内变量。这种方式将所有命名逻辑都保留在 stub 自身内,当用户弹出 stubs 进行自定义时,这一点变得很重要。
向 stubs 传递数据
在调用 makeUsingStub 时,将一个数据对象作为第三个参数传入。这些值将在 stub 模板中可用:
await codemods.makeUsingStub(stubsRoot, 'config.stub', {
store: 'dynamodb',
region: 'us-east-1',
})
{{{
exports({
to: app.configPath('cache.ts')
})
}}}
export default defineConfig({
default: '{{ store }}',
region: '{{ region }}',
})
全局 stub 变量
每个 stub 都可以访问以下内置变量:
| 变量 | 说明 |
|---|---|
app | 对带有路径辅助方法的 应用类 实例的引用。 |
generators | 对用于命名约定的 generators 模块 的引用。 |
randomString | 对 randomString 辅助函数的引用。 |
string | 一个用于创建 字符串构建器 实例以进行转换的函数。 |
flags | 运行 ace 命令时传入的命令行标志。 |
在命令中使用 stubs
除了 configure 钩子,你还可以在自己的 Ace 命令中使用 stubs。这对于创建 make:resource 或 make:service 之类的脚手架命令很有用。
import { join } from 'node:path'
import { BaseCommand, args } from '@adonisjs/core/ace'
const STUBS_ROOT = join(import.meta.dirname, '../stubs')
export default class MakeResource extends BaseCommand {
static commandName = 'make:resource'
static description = 'Create a new API resource'
@args.string({ description: 'Name of the resource' })
declare name: string
async run() {
const codemods = await this.createCodemods()
await codemods.makeUsingStub(STUBS_ROOT, 'resource.stub', {
name: this.name,
})
}
}
弹出(eject)stubs
宿主应用可以通过弹出 stub 模板来自定义它们。node ace eject 命令会将 stubs 从包复制到项目的 stubs 目录中。
弹出单个 stub
node ace eject make/controller/main.stub
这会将控制器 stub 从 @adonisjs/core 复制到你项目中的 stubs/make/controller/main.stub。今后任何 make:controller 调用都将使用你自定义的版本。
弹出目录
复制整个目录的 stubs:
# All make stubs
node ace eject make
# All controller-related stubs
node ace eject make/controller
从其他包弹出
默认情况下,eject 从 @adonisjs/core 复制。要针对其他包,请使用 --pkg 标志:
node ace eject make/migration/main.stub --pkg=@adonisjs/lucid
使用 CLI 标志自定义输出
脚手架命令通过 flags 变量与 stub 模板共享 CLI 标志。你可以借此创建自定义工作流:
node ace make:controller invoice --feature=billing
{{#var controllerName = generators.controllerName(entity.name)}}
{{#var featureDirectoryName = flags.feature}}
{{#var controllerFileName = generators.controllerFileName(entity.name)}}
{{{
exports({
to: app.makePath('features', featureDirectoryName, controllerFileName)
})
}}}
// import type { HttpContext } from '@adonisjs/core/http'
export default class {{ controllerName }} {
}
查找可弹出的 stubs
每个包都在其包根目录的 stubs 目录中存放其 stubs。访问该包的 GitHub 仓库即可查看有哪些可用。
Stubs 执行流程
当你调用 makeUsingStub 时,会发生以下过程:
- AdonisJS 首先在宿主项目的
stubs目录中检查是否有已弹出的 stub - 如果没有找到,则使用你包中的原始 stub
- stub 模板由 Tempura 处理,计算所有变量和表达式
exports()函数确定输出路径- 生成的文件被写入目标位置
Codemods API 参考
Codemods API 提供了用于常见源文件修改的高级方法。所有方法都可在 command.createCodemods() 返回的 codemods 实例上使用。
Codemods API 依赖于 AdonisJS 的默认文件结构和命名约定。如果你对项目结构做了重大更改,某些 codemods 可能无法按预期工作。
updateRcFile
在 adonisrc.ts 中注册提供者、命令、meta 文件和命令别名。
await codemods.updateRcFile((rcFile) => {
rcFile
.addProvider('@adonisjs/lucid/db_provider')
.addCommand('@adonisjs/lucid/commands')
.setCommandAlias('migrate', 'migration:run')
})
输出:
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
commands: [
() => import('@adonisjs/lucid/commands')
],
providers: [
() => import('@adonisjs/lucid/db_provider')
],
commandAliases: {
migrate: 'migration:run'
}
})
defineEnvVariables
向 .env 和 .env.example 文件添加环境变量。
await codemods.defineEnvVariables({
REDIS_HOST: 'localhost',
REDIS_PORT: '6379',
})
要从 .env.example 中省略值(对密钥很有用),请使用 omitFromExample 选项:
await codemods.defineEnvVariables({
API_KEY: 'secret-key-here',
}, {
omitFromExample: ['API_KEY']
})
这会在 .env 中插入 API_KEY=secret-key-here,并在 .env.example 中插入 API_KEY=。
defineEnvValidations
向 start/env.ts 添加验证规则。该 codemod 不会覆盖现有规则,尊重用户所做的任何自定义。
await codemods.defineEnvValidations({
leadingComment: 'Cache environment variables',
variables: {
CACHE_STORE: 'Env.schema.string()',
CACHE_TTL: 'Env.schema.number.optional()',
}
})
输出:
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
/**
* Cache environment variables
*/
CACHE_STORE: Env.schema.string(),
CACHE_TTL: Env.schema.number.optional(),
})
registerMiddleware
将中间件注册到某个中间件栈:server、router 或 named。
// Router middleware
await codemods.registerMiddleware('router', [
{
path: '@adonisjs/core/bodyparser_middleware'
}
])
// Named middleware
await codemods.registerMiddleware('named', [
{
name: 'auth',
path: '@adonisjs/auth/auth_middleware'
}
])
输出:
import router from '@adonisjs/core/services/router'
router.use([
() => import('@adonisjs/core/bodyparser_middleware')
])
export const middleware = router.named({
auth: () => import('@adonisjs/auth/auth_middleware')
})
registerJapaPlugin
在 tests/bootstrap.ts 中注册一个 Japa 测试插件。
await codemods.registerJapaPlugin(
'sessionApiClient(app)',
[
{
isNamed: false,
module: '@adonisjs/core/services/app',
identifier: 'app'
},
{
isNamed: true,
module: '@adonisjs/session/plugins/api_client',
identifier: 'sessionApiClient'
}
]
)
输出:
import app from '@adonisjs/core/services/app'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
export const plugins: Config['plugins'] = [
sessionApiClient(app)
]
registerPolicies
在 app/policies/main.ts 中注册 bouncer 策略。
await codemods.registerPolicies([
{
name: 'PostPolicy',
path: '#policies/post_policy'
}
])
输出:
export const policies = {
PostPolicy: () => import('#policies/post_policy')
}
registerVitePlugin
在 vite.config.ts 中注册一个 Vite 插件。
await codemods.registerVitePlugin(
'vue({ jsx: true })',
[
{
isNamed: false,
module: '@vitejs/plugin-vue',
identifier: 'vue'
}
]
)
输出:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({ jsx: true })
]
})
installPackages
使用项目检测到的包管理器安装 npm 包。
await codemods.installPackages([
{ name: 'vinejs', isDevDependency: false },
{ name: '@types/lodash', isDevDependency: true }
])