脚手架

脚手架生成与 codemod

脚手架生成(Scaffolding)指的是从静态模板(即 stubs)生成源文件的过程,而 codemod 指的是通过解析 AST 来更新 TypeScript 源代码。

AdonisJS 同时使用这两者来加速创建新文件和配置软件包的重复性任务。在本指南中,我们将了解脚手架生成的组成部分,并介绍你可以在 Ace 命令中使用的 codemod API。

组成部分

Stubs

Stubs 指的是用于在给定操作时创建源文件的模板。例如,make:controller 命令使用 controller stub 在宿主项目中创建一个控制器文件。

Generators

Generators 强制执行命名约定,并基于预定义的约定生成文件、类或方法名。

例如,controller stub 使用 controllerNamecontrollerFileName generators 来创建控制器。

由于 generators 是定义为一个对象的,你可以通过覆盖现有方法来调整这些约定。我们会在本指南后面了解更多相关内容。

Codemods

Codemod API 来自 @adonisjs/assembler 软件包,它在底层使用了 ts-morph

由于 @adonisjs/assembler 是一个开发依赖,ts-morph 不会让你的项目依赖在生产环境中变得臃肿。同时,这也意味着 codemod API 在生产环境中不可用。

AdonisJS 暴露的 codemod API 非常具体,用于完成诸如.adonisrc.ts 文件添加一个 provider,或start/kernel.ts 文件中注册一个中间件之类的高级任务。此外,这些 API 依赖于默认的命名约定,因此,如果你对项目做了大幅改动,你将无法运行 codemod。

Configure 命令

configure 命令用于配置一个 AdonisJS 软件包。在底层,该命令会导入主入口文件,并执行该软件包导出的 configure 方法。

软件包的 configure 方法会接收一个 Configure 命令 的实例,因此,它可以直接从命令实例访问 stub 和 codemod API。

使用 stubs

大多数情况下,你会在 Ace 命令中或你创建的软件包的 configure 方法内部使用 stubs。在这两种情况下,你都可以通过 Ace 命令的 createCodemods 方法初始化 codemod 模块。

codemods.makeUsingStub 方法从一个 stub 模板创建一个源文件。它接受以下参数:

  • 存放 stub 的目录根路径的 URL。
  • STUBS_ROOT 目录到 stub 文件(含扩展名)的相对路径。
  • 以及要与 stub 共享的数据对象。
Inside a command
import { BaseCommand } from '@adonisjs/core/ace'
const STUBS_ROOT = new URL('./stubs', import.meta.url)
export default class MakeApiResource extends BaseCommand {
async run() {
const codemods = await this.createCodemods()
await codemods.makeUsingStub(STUBS_ROOT, 'api_resource.stub', {})
}
}

Stubs 模板化

我们使用 Tempura 模板引擎,配合运行时数据来处理 stubs。Tempura 是一个超轻量级的、类似 handlebars 的 JavaScript 模板引擎。

由于 Tempura 的语法与 handlebars 兼容,你可以将你的代码编辑器设置为对 .stub 文件使用 handlebar 语法高亮。

在下面的示例中,我们创建一个输出 JavaScript 类的 stub。它使用双花括号来求值运行时值。

export default class {{ modelName }}Resource {
serialize({{ modelReference }}: {{ modelName }}) {
return {{ modelReference }}.toJSON()
}
}

使用 generators

如果你现在就执行上面的 stub,它会失败,因为我们还没有提供 modelNamemodelReference 数据属性。

我们建议在 stub 内部使用内联变量来计算这些属性。这样,宿主应用可以导出 stub 并修改变量。

{{#var entity = generators.createEntity('user')}}
{{#var modelName = generators.modelName(entity.name)}}
{{#var modelReference = string.camelCase(modelName)}}
export default class {{ modelName }}Resource {
serialize({{ modelReference }}: {{ modelName }}) {
return {{ modelReference }}.toJSON()
}
}

输出目标位置

最后,我们必须指定将使用 stub 创建的文件的输出目标路径。同样,我们在 stub 文件内部指定目标路径,因为这样宿主应用可以导出 stub 并自定义其输出目标位置。

目标路径使用 exports 函数来定义。该函数接受一个对象,并将其作为 stub 的输出状态导出。随后,codemod API 使用该对象在指定位置创建文件。

{{#var entity = generators.createEntity('user')}}
{{#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()
}
}

通过命令接收实体名

目前,我们已在 stub 内部将实体名硬编码为 user。不过,你应该将它作为命令参数接收,并作为模板状态与 stub 共享。

import { BaseCommand, args } from '@adonisjs/core/ace'
export default class MakeApiResource extends BaseCommand {
@args.string({
description: 'The name of the resource'
})
declare name: string
async run() {
const codemods = await this.createCodemods()
await codemods.makeUsingStub(STUBS_ROOT, 'api_resource.stub', {
name: this.name,
})
}
}
{{#var entity = generators.createEntity('user')}}
{{#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()
}
}

全局变量

以下全局变量始终会与 stub 共享。

VariableDescription
appapplication 类 实例的引用。
generatorsgenerators 模块 的引用。
randomStringrandomString 辅助函数的引用。
string一个用于创建 string builder 实例的函数。你可以使用 string builder 对字符串应用转换。
flags运行 ace 命令时定义的命令行标志。

导出 stubs

你可以使用 node ace eject 命令在 AdonisJS 应用内部导出/复制 stubs。eject 命令接受一个指向原始 stub 文件或其父目录的路径,并将模板复制到项目根目录下的 stubs 目录中。

在下面的示例中,我们将从 @adonisjs/core 软件包复制 make/controller/main.stub 文件。

node ace eject make/controller/main.stub

如果你打开该 stub 文件,它会包含以下内容。

{{#var controllerName = generators.controllerName(entity.name)}}
{{#var controllerFileName = generators.controllerFileName(entity.name)}}
{{{
exports({
to: app.httpControllersPath(entity.path, controllerFileName)
})
}}}
// import type { HttpContext } from '@adonisjs/core/http'
export default class {{ controllerName }} {
}
  • 在前两行中,我们使用 generators 模块 来生成控制器类名和控制器文件名。
  • 从第 3 到 7 行,我们使用 exports 函数为控制器文件定义目标路径(自定义目标路径)。
  • 最后,我们定义了脚手架生成的控制器的内容。

你可以随意修改这个 stub。下次,在你运行 make:controller 命令时,这些更改会被采用。

导出目录

你可以使用 eject 命令导出一个完整的 stub 目录。传入目录的路径,该命令就会复制整个目录。

# Publish all the make stubs
node ace eject make
# Publish all the make:controller stubs
node ace eject make/controller

使用 CLI 标志自定义 stub 输出目标位置

所有脚手架生成命令都会与 stub 模板共享 CLI 标志(包括不支持的标志)。因此,你可以使用它们来创建自定义工作流或更改输出目标位置。

在下面的示例中,我们使用 --feature 标志在指定的 features 目录中创建一个控制器。

node ace make:controller invoice --feature=billing
Controller stub
{{#var controllerName = generators.controllerName(entity.name)}}
{{#var featureDirectoryName = flags.feature}}
{{#var controllerFileName = generators.controllerFileName(entity.name)}}
{{{
exports({
to: app.httpControllersPath(entity.path, controllerFileName)
to: app.makePath('features', featureDirectoryName, controllerFileName)
})
}}}
// import type { HttpContext } from '@adonisjs/core/http'
export default class {{ controllerName }} {
}

从其他软件包导出 stubs

默认情况下,eject 命令会从 @adonisjs/core 软件包复制模板。不过,你可以使用 --pkg 标志从其他软件包复制 stubs。

node ace eject make/migration/main.stub --pkg=@adonisjs/lucid

如何找到要复制哪些 stubs?

你可以通过访问某个软件包的 GitHub 仓库来找到它的 stubs。我们将所有的 stubs 存放在该软件包根目录下的 stubs 目录中。

Stubs 执行流程

以下是我们通过 makeUsingStub 方法查找并执行 stubs 的可视化表示。

Codemods API

Codemod API 由 ts-morph 提供动力,并且仅在开发期间可用。你可以使用 command.createCodemods 方法惰性实例化 codemod 模块。createCodemods 方法会返回一个 Codemods 类的实例。

import type Configure from '@adonisjs/core/commands/configure'
export async function configure(command: ConfigureCommand) {
const codemods = await command.createCodemods()
}

defineEnvValidations

为环境变量定义校验规则。该方法接受一个键值对形式的变量。key 是环境变量名,value 是作为字符串的校验表达式。

该 codemod 要求 start/env.ts 文件存在,并且必须包含 export default await Env.create 方法调用。

此外,该 codemod 不会覆盖给定环境变量的现有校验规则。这样做是为了尊重应用内部的修改。

const codemods = await command.createCodemods()
try {
await codemods.defineEnvValidations({
leadingComment: 'App environment variables',
variables: {
PORT: 'Env.schema.number()',
HOST: 'Env.schema.string()',
}
})
} catch (error) {
console.error('Unable to define env validations')
console.error(error)
}
Output
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
/**
* App environment variables
*/
PORT: Env.schema.number(),
HOST: Env.schema.string(),
})

defineEnvVariables

.env.env.example 文件添加一个或多个新的环境变量。该方法接受一个键值对形式的变量。

const codemods = await command.createCodemods()
try {
await codemods.defineEnvVariables({
MY_NEW_VARIABLE: 'some-value',
MY_OTHER_VARIABLE: 'other-value'
})
} catch (error) {
console.error('Unable to define env variables')
console.error(error)
}

有时你可能想在 .env.example 文件中插入变量值。你可以通过 omitFromExample 选项来实现这一点。

const codemods = await command.createCodemods()
await codemods.defineEnvVariables({
MY_NEW_VARIABLE: 'SOME_VALUE',
}, {
omitFromExample: ['MY_NEW_VARIABLE']
})

上面的代码会在 .env 文件中插入 MY_NEW_VARIABLE=SOME_VALUE,并在 .env.example 文件中插入 MY_NEW_VARIABLE=

registerMiddleware

将 AdonisJS 中间件注册到某个已知的中间件栈中。该方法接受中间件栈以及要注册的中间件数组。

中间件栈可以是 server | router | named 之一。

该 codemod 要求 start/kernel.ts 文件存在,并且必须包含你试图注册中间件的中间件栈的函数调用。

const codemods = await command.createCodemods()
try {
await codemods.registerMiddleware('router', [
{
path: '@adonisjs/core/bodyparser_middleware'
}
])
} catch (error) {
console.error('Unable to register middleware')
console.error(error)
}
Output
import router from '@adonisjs/core/services/router'
router.use([
() => import('@adonisjs/core/bodyparser_middleware')
])

你可以如下定义命名中间件。

const codemods = await command.createCodemods()
try {
await codemods.registerMiddleware('named', [
{
name: 'auth',
path: '@adonisjs/auth/auth_middleware'
}
])
} catch (error) {
console.error('Unable to register middleware')
console.error(error)
}

updateRcFile

providerscommands 注册到 adonisrc.ts 文件,并定义 metaFilescommandAliases

该 codemod 要求 adonisrc.ts 文件存在,并且必须包含 export default defineConfig 函数调用。

const codemods = await command.createCodemods()
try {
await codemods.updateRcFile((rcFile) => {
rcFile
.addProvider('@adonisjs/lucid/db_provider')
.addCommand('@adonisjs/lucid/commands'),
.setCommandAlias('migrate', 'migration:run')
})
} catch (error) {
console.error('Unable to update adonisrc.ts file')
console.error(error)
}
Output
import { defineConfig } from '@adonisjs/core/app'
export default defineConfig({
commands: [
() => import('@adonisjs/lucid/commands')
],
providers: [
() => import('@adonisjs/lucid/db_provider')
],
commandAliases: {
migrate: 'migration:run'
}
})

registerJapaPlugin

将一个 Japa 插件注册到 tests/bootstrap.ts 文件。

该 codemod 要求 tests/bootstrap.ts 文件存在,并且必须包含 export const plugins: Config['plugins'] 导出。

const codemods = await command.createCodemods()
const imports = [
{
isNamed: false,
module: '@adonisjs/core/services/app',
identifier: 'app'
},
{
isNamed: true,
module: '@adonisjs/session/plugins/api_client',
identifier: 'sessionApiClient'
}
]
const pluginUsage = 'sessionApiClient(app)'
try {
await codemods.registerJapaPlugin(pluginUsage, imports)
} catch (error) {
console.error('Unable to register japa plugin')
console.error(error)
}
Output
import app from '@adonisjs/core/services/app'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
export const plugins: Config['plugins'] = [
sessionApiClient(app)
]

registerPolicies

将 AdonisJS bouncer 策略注册到从 app/policies/main.ts 文件导出的 policies 对象列表中。

该 codemod 要求 app/policies/main.ts 文件存在,并且必须从中导出一个 policies 对象。

const codemods = await command.createCodemods()
try {
await codemods.registerPolicies([
{
name: 'PostPolicy',
path: '#policies/post_policy'
}
])
} catch (error) {
console.error('Unable to register policy')
console.error(error)
}
Output
export const policies = {
PostPolicy: () => import('#policies/post_policy')
}

registerVitePlugin

将一个 Vite 插件注册到 vite.config.ts 文件。

该 codemod 要求 vite.config.ts 文件存在,并且必须包含 export default defineConfig 函数调用。

const transformer = new CodeTransformer(appRoot)
const imports = [
{
isNamed: false,
module: '@vitejs/plugin-vue',
identifier: 'vue'
},
]
const pluginUsage = 'vue({ jsx: true })'
try {
await transformer.addVitePlugin(pluginUsage, imports)
} catch (error) {
console.error('Unable to register vite plugin')
console.error(error)
}
Output
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [
vue({ jsx: true })
]
})

installPackages

使用在用户项目中检测到的包管理器安装一个或多个软件包。

const codemods = await command.createCodemods()
try {
await codemods.installPackages([
{ name: 'vinejs', isDevDependency: false },
{ name: 'edge', isDevDependency: false }
])
} catch (error) {
console.error('Unable to install packages')
console.error(error)
}

getTsMorphProject

getTsMorphProject 方法返回一个 ts-morph 的实例。当你想要执行 Codemods API 未能覆盖的自定义文件转换时,这会很有用。

const project = await codemods.getTsMorphProject()
project.getSourceFileOrThrow('start/routes.ts')

请务必阅读 ts-morph 文档 以了解可用的 API。