脚手架与 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 之类的输入转换为格式正确的名称,如 UsersControllerusers_controller.ts

Codemods 是用于常见修改(如注册提供者、添加中间件或定义环境变量)的高级 API。它们为你处理 AST 操作的复杂性。

Configure 钩子是包所导出的函数,当用户执行 node ace configure <package-name> 时运行。这是你结合 stubs 和 codemods 来设置包的地方。

创建 configure 钩子

脚手架和 codemods 最常见的用途是为 AdonisJS 包创建 configure 钩子。让我们以一个缓存包为例,一步步构建一个。

  1. 搭建包结构

    一个带有 configure 钩子的典型 AdonisJS 包具有如下结构:

    my-cache-package/
    ├── src/
    │   └── ...
    ├── stubs/
    │   ├── config.stub
    │   └── main.ts
    ├── configure.ts
    ├── index.ts
    └── package.json
    

    stubs 目录包含你的模板文件,configure.ts 存放 configure 函数,index.ts 导出所有内容,包括 configure 钩子。

  2. 将 @adonisjs/assembler 作为 peer 依赖安装

    Codemods API 需要 @adonisjs/assembler,它必须作为peer 依赖安装在你的包中。这一点很重要,因为宿主应用已经将 assembler 作为开发依赖安装,它应在所有已配置的包之间共享,而不是被重复安装。

    package.json
    {
      "name": "@adonisjs/cache",
      "peerDependencies": {
        "@adonisjs/assembler": "^7.0.0"
      }
    }

    当用户安装你的包并运行 node ace configure 时,将使用他们项目中的 assembler。

  3. 导出 stubs 根路径

    创建一个 stubs/main.ts 文件,导出指向你 stubs 目录的路径。在调用 makeUsingStub 时会用到这个路径。

    stubs/main.ts
    export const stubsRoot = import.meta.url
  4. 编写 configure 函数

    configure 函数会接收 Configure 命令实例,它提供对 codemods API 的访问。以下是缓存包的一个完整示例:

    configure.ts
    import 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',
      })
    }
  5. 从包入口点导出

    从包的主入口点导出 configure 函数,以便 node ace configure 命令能够找到它:

    index.ts
    export { configure } from './configure.ts'

    当用户运行 node ace configure @adonisjs/cache 时,AdonisJS 会导入这个文件并执行导出的 configure 函数。

创建 stubs

Stubs 是用于生成源代码的模板文件。它们将静态内容与运行时计算的动态值结合起来。

基本 stub 语法

Stubs 使用双花括号进行变量插值。这里是一个简单的配置 stub。

Tip

由于 Tempura 的语法与 Handlebars 兼容,请将你的编辑器配置为对 .stub 文件使用 Handlebars 语法高亮。

stubs/config.stub
{{{
  exports({
    to: app.configPath('cache.ts')
  })
}}}
import { defineConfig, stores } from '@adonisjs/cache'

export default defineConfig({
  default: '{{ store }}',
  
  stores: {
    redis: stores.redis({}),
  },
})

顶部的 exports 函数定义了关于所生成文件的元数据,其中最重要的是目标路径。app 变量提供对应用路径的访问,如 configPathmakePathhttpControllersPath

使用 generators 遵循命名约定

在创建需要遵循 AdonisJS 命名约定的 stubs 时,请使用 generators 模块。Generators 将用户输入转换为格式正确的名称。

stubs/resource.stub
{{#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 模板中可用:

configure.ts
await codemods.makeUsingStub(stubsRoot, 'config.stub', {
  store: 'dynamodb',
  region: 'us-east-1',
})
stubs/config.stub
{{{
  exports({
    to: app.configPath('cache.ts')
  })
}}}
export default defineConfig({
  default: '{{ store }}',
  region: '{{ region }}',
})

全局 stub 变量

每个 stub 都可以访问以下内置变量:

变量说明
app对带有路径辅助方法的 应用类 实例的引用。
generators对用于命名约定的 generators 模块 的引用。
randomStringrandomString 辅助函数的引用。
string一个用于创建 字符串构建器 实例以进行转换的函数。
flags运行 ace 命令时传入的命令行标志。

在命令中使用 stubs

除了 configure 钩子,你还可以在自己的 Ace 命令中使用 stubs。这对于创建 make:resourcemake:service 之类的脚手架命令很有用。

commands/make_resource.ts
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
stubs/make/controller/main.stub
{{#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 时,会发生以下过程:

  1. AdonisJS 首先在宿主项目的 stubs 目录中检查是否有已弹出的 stub
  2. 如果没有找到,则使用你包中的原始 stub
  3. stub 模板由 Tempura 处理,计算所有变量和表达式
  4. exports() 函数确定输出路径
  5. 生成的文件被写入目标位置

Codemods API 参考

Codemods API 提供了用于常见源文件修改的高级方法。所有方法都可在 command.createCodemods() 返回的 codemods 实例上使用。

Note

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')
})

输出:

adonisrc.ts
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()',
  }
})

输出:

start/env.ts
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

将中间件注册到某个中间件栈:serverrouternamed

// Router middleware
await codemods.registerMiddleware('router', [
  {
    path: '@adonisjs/core/bodyparser_middleware'
  }
])

// Named middleware
await codemods.registerMiddleware('named', [
  {
    name: 'auth',
    path: '@adonisjs/auth/auth_middleware'
  }
])

输出:

start/kernel.ts
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'
    }
  ]
)

输出:

tests/bootstrap.ts
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'
  }
])

输出:

app/policies/main.ts
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'
    }
  ]
)

输出:

vite.config.ts
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 }
])