EN

模块

模块是 Func 框架中的基础组织单元,了解并学习如何自定义模块。

更新于

一个完整的 func 应用通常由许多相互协作的对象组成:Command 接收用户输入,service 处理业务,repository 访问数据,client 连接外部系统。当一个对象需要另一个对象才能完成工作时,它们就构成了依赖关系。随着应用增长,如果每个对象都自行创建和组织依赖,业务逻辑很快就会与具体实现和初始化过程混在一起。

依赖注入(Dependency Injection,简称 DI)将依赖的创建与使用分开。在 func 中,Command 或 service 只需声明自己依赖什么,框架会查找对应的 Provider、创建实例并将它传入;模块则负责登记当前功能拥有的 Commands 和 Providers,并确定它们之间的依赖关系。这样,业务对象可以专注于使用能力,而不必了解这些能力之间的复杂依赖关系。

DI 基础

我们通过一个简化的项目命令了解 DI 如何工作。

1. 定义 Provider

ProjectService 是我们假定的业务逻辑服务,@Injectable() 表示这个 class 可以由 func 管理:

src/projects/services/project.service.ts
import { Injectable } from 'func'

@Injectable()
export class ProjectService {
  list() {
    return ['func']
  }
}

Service 是 Provider 最常见的形式。Repository、client 和其他可复用能力也可以成为 Provider。

2. 声明依赖

随后我们认为,ProjectCommand 需要调用此业务逻辑,随即我们在 ProjectCommand 中声明依赖即可:

src/projects/commands/project.command.ts
import { Command, Handler } from 'func'
import { ProjectService } from '../services/project.service'

@Command('project')
export class ProjectCommand {
  constructor(private readonly projects: ProjectService) {}

  @Handler()
  list() {
    console.log(this.projects.list())
  }
}

ProjectCommand 不需要创建 service。构造函数参数已经说明:运行这个 Command 需要一个 ProjectService

3. 注册 Provider

最后,在模块中注册 Command 和 Provider:

src/projects/project.module.ts
import { Module } from 'func'
import { ProjectCommand } from './commands/project.command'
import { ProjectService } from './services/project.service'

@Module({
  commands: [ProjectCommand],
  providers: [ProjectService],
})
export class ProjectModule {}

commands 表示这个 Module 提供的 Commands,providers 表示其中可以被注入的依赖。现在 func 已经知道如何创建 ProjectCommand 需要的 ProjectService

这个过程包含三个关键步骤:

  1. @Injectable()ProjectService 标记为可管理的 class;
  2. ProjectCommand 通过构造函数请求 ProjectService
  3. ProjectModuleProjectService 注册到 providers

创建 ProjectCommand 时,func 会使用 ProjectService 这个 token 查找 Provider,创建实例并传入构造函数。如果 service 还有其他依赖,func 会继续解析它们。这样的自动化依赖分析在拥有复杂依赖关系的大型应用中格外有用。

标准 Provider

最常用的注册方式是直接写入 class:

providers: [ProjectService]

这是下面写法的简写:

providers: [
  {
    provide: ProjectService,
    useClass: ProjectService,
  },
]

provide 是查找依赖时使用的 token,useClass 表示这个 token 应当得到哪个 class 的实例。在标准写法中,class 同时充当 token 和实现。

标准 Provider 已经能满足大多数 service。需要提供配置值、替换实现或动态创建对象时,可以使用自定义 Provider。

自定义 Provider

自定义 Provider 仍然包含 provide,但可以选择不同的提供方式:

  • useValue 直接提供一个值;
  • useClass 指定一个 class 实现;
  • useFactory 使用函数创建结果。

Value Provider:useValue

useValue 适合配置、常量、已有对象和测试替身:

src/projects/providers/api-url.provider.ts
import { createToken } from 'func'

export const API_URL = createToken<string>('API_URL')

export const apiUrlProvider = {
  provide: API_URL,
  useValue: 'https://api.example.com',
}

apiUrlProvider 放入 Module 的 providers 后,请求 API_URL 就会得到 'https://api.example.com'

@Module({
  providers: [apiUrlProvider],
})
export class ProjectModule {}

useValue 返回注册时给出的原值,不会调用构造函数。若传入对象,同一个对象会在不同调用之间继续使用。

非 class token

Class 可以直接作为 token,但 TypeScript interface、字符串配置等内容在运行时没有可用的 class。func 使用 createToken<T>() 表示这类依赖:

export const API_URL = createToken<string>('API_URL')

使用非 class token 时,通过 @Inject() 指定它:

src/projects/clients/project.client.ts
import { Inject, Injectable } from 'func'
import { API_URL } from '../providers/api-url.provider'

@Injectable()
export class ProjectClient {
  constructor(@Inject(API_URL) readonly apiUrl: string) {}
}

Token 应定义为常量并在注册处与使用处导入。同样的描述文字不会让两个 createToken() 变成同一个 token。

Class Provider:useClass

useClass 可以让一个 token 对应具体的 class 实现:

src/projects/project.module.ts
import { Injectable, Module, createToken } from 'func'

interface ProjectStorage {
  findAll(): string[]
}

export const PROJECT_STORAGE =
  createToken<ProjectStorage>('PROJECT_STORAGE')

@Injectable()
class FileProjectStorage implements ProjectStorage {
  findAll() {
    return ['func']
  }
}

export const projectStorageProvider = {
  provide: PROJECT_STORAGE,
  useClass: FileProjectStorage,
}

@Module({
  providers: [projectStorageProvider],
})
export class ProjectModule {}

业务代码可以依赖 PROJECT_STORAGE,而不必知道当前使用 FileProjectStorage。以后切换到内存或远程实现时,只需要替换 Provider 配置。

useClass 指定的 class 需要使用 @Injectable()

Factory Provider:useFactory

当依赖需要根据配置或其他 Providers 创建时,可以使用 factory:

src/projects/project.module.ts
import { Module } from 'func'
import { API_URL, apiUrlProvider } from './providers/api-url.provider'

class ProjectClient {
  constructor(readonly apiUrl: string) {}
}

export const projectClientProvider = {
  provide: ProjectClient,
  inject: [API_URL],
  useFactory: (apiUrl: string) => new ProjectClient(apiUrl),
}

@Module({
  providers: [apiUrlProvider, projectClientProvider],
})
export class ProjectModule {}

inject 中的 tokens 会按顺序解析,并传给 useFactory 的参数。Factory 可以直接返回结果,也可以返回 Promise。

Provider 不只用于 service。Factory 可以返回 client、配置对象、函数或任何业务需要的值。

Module 组织依赖

Module 是带有 @Module() 的 class。它将 Commands 和 Providers 组织为一项功能,并决定哪些能力可以被其他 Modules 使用。

字段用途
commands当前 Module 提供的 Commands。
providers当前 Module 中可以注入的 Providers。
imports当前 Module 使用的其他 Modules。
exports允许其他 Modules 使用的 Provider tokens。
options多个 Commands 共享的字段或动作。

Module class 通常保持为空。一个简单 Module 只需要 commandsproviders,不必声明没有使用的字段。

在 Modules 之间共享 Provider

Provider 默认只在声明它的 Module 中可见。如果另一个功能需要 ProjectServiceProjectModule 必须将它导出:

src/projects/project.module.ts
import { Module } from 'func'

import { ProjectCommand } from './commands/project.command'
import { ProjectService } from './services/project.service'

@Module({
  commands: [ProjectCommand],
  providers: [ProjectService],
  exports: [ProjectService],
})
export class ProjectModule {}

然后由使用方导入 ProjectModule

src/releases/release.module.ts
import { Injectable, Module } from 'func'

import { ReleaseCommand } from './commands/release.command'
import { ProjectModule } from '../projects/project.module'
import { ProjectService } from '../projects/services/project.service'

@Injectable()
class ReleaseService {
  constructor(private readonly projects: ProjectService) {}
}

@Module({
  imports: [ProjectModule],
  commands: [ReleaseCommand],
  providers: [ReleaseService],
})
export class ReleaseModule {}

现在 ReleaseService 可以注入 ProjectService

exports 是 Module 对外提供的能力。只在项目功能内部使用的 repository、client 和配置应保持私有。文件中的 export class 只允许 TypeScript import;Module.exports 才允许其他 Modules 通过 DI 使用它。

自定义 Provider 通过 token 导出:

@Module({
  providers: [apiUrlProvider],
  exports: [API_URL],
})
export class ProjectModule {}

组合应用

每个应用至少有一个根模块。根模块通常只负责组合功能:

src/app.module.ts
import { Module } from 'func'
import { HelpOption } from 'func/help'
import { ProjectModule } from './projects/project.module'
import { ReleaseModule } from './releases/release.module'

@Module({
  imports: [ProjectModule, ReleaseModule],
  options: [HelpOption],
})
export class AppModule {}

导入 ProjectModuleReleaseModule 后,它们的 Commands 都会加入应用,根模块不需要重新注册这些 Commands 和 Providers。

部分通用功能需要在导入时接收配置。它们通常提供 register() 方法:

src/releases/release.module.ts
import { Module } from 'func'
import { LogModule } from 'func/log'
import { ReleaseCommand } from './commands/release.command'
import { ReleaseService } from './services/release.service'

@Module({
  imports: [LogModule.register()],
  commands: [ReleaseCommand],
  providers: [ReleaseService],
})
export class ReleaseModule {}

LogModule.register() 返回一个配置好的 Module。使用方可以使用它导出的日志能力,不需要关心内部 Providers 如何创建。

按业务划分 Module

Module 应该表示一项完整的业务功能,而不是一种文件类型:

  • ProjectModule 组织项目命令、项目 service 和内部 repository;
  • ReleaseModule 组织发布命令与发布流程;
  • StorageModule 提供多个业务共同使用的存储能力;
  • AppModule 负责组合这些功能。

推荐按功能组织目录:

src/
src/
├─ app.module.ts
├─ index.ts
├─ projects/
│  ├─ project.module.ts
│  ├─ commands/
│  ├─ services/
│  └─ repositories/
├─ releases/
│  ├─ release.module.ts
│  ├─ commands/
│  └─ services/
└─ infrastructure/
   └─ storage/
      ├─ storage.module.ts
      └─ storage.service.ts

这样修改项目业务时,大部分工作都发生在 projects/ 内,而不需要在全局 commands/services/repositories/ 目录之间来回寻找。

接下来