EN

核心概念

参考 Func 的设计思路,理解框架如何组织命令、模块与 Provider。

更新于

func 的核心哲学深受 Angular 与 NestJS 启发,旨在为命令式应用提供一致、高度解耦、易于维护且可靠的现代应用架构。

func 中,你通常不需要关心具体的调用在底层如何完成。只需遵循统一的应用结构,声明应用支持的命令以及所依赖的服务,框架便会负责组织并连接这些能力。

这意味着,使用 func 开发命令式应用,与开发常见的 Web 应用并没有本质区别。理解 func 的设计模型后,大多数开发工作都可以停留在应用层,而不必深入框架内部。这允许开发者将更多精力投入真正重要的业务逻辑。

架构概览

一个 func 应用主要围绕以下概念组织:

  1. 应用(Application):应用是所有能力的组合入口,定义有哪些命令和 Provider 共同参与运行。
  2. 命令(Command):命令描述应用可以完成的具体工作,是用户意图进入应用的入口。
  3. Provider:Provider 封装可复用的业务或基础设施能力,例如数据访问、外部服务和领域逻辑,并可以被命令或其他 Provider 使用。
  4. 依赖关系:命令只声明自己需要哪些能力,而不负责创建或连接这些能力。func 根据声明解析依赖,并在运行时完成组合。

定义与调用

func 以 Command 作为组织命令能力的基本单元。一个 Command Class 可以包含字段选项、一个或多个 Handler,以及完成业务所需的 Provider。其中:

一次调用会根据完整输入匹配一个命令节点,并从中选择唯一的 Handler。Command Class 中的多个 Handler 不是顺序执行的步骤,而是不同的可执行入口。

例如,一个 Command 可以同时提供默认动作、路径动作和标志动作,但 func 每次只会执行实际匹配的那一个。被选中的 Handler 可以读取已经解析和校验的输入,也可以调用注入的 Provider 完成业务。

Func 心智模型
应用定义
@Module()
├─ 注册命令图
│  ├─ @CommandMajor()       没有具名路径时的入口
│  ├─ @Command('<name>')    匹配一段或多段命令路径
│  └─ @CommandMissing()     无法匹配时的可选后备
├─ 注册 options             跨命令字段与动作
└─ 注册 providers           可复用的业务能力

从这张模型中可以看到,应用首先由 Module 注册 Commands 与相关依赖;用户调用进入应用后,func 选择对应的 Command 与 Handler,再将输入和所需能力交给目标方法。

对于刚开始使用 func 的开发者,可以按下述简要规则:

  • Command 组织命令能力
  • Handler 执行具体动作,输入提供数据,
  • Provider 完成业务

Module 的组合方式、Provider 可见性与依赖注入规则将在模块中系统介绍。

分析调用链

下述是一个命令示例,它表达的用户意图是:为 project 添加成员 alice,将角色设为 owner,并允许强制执行。

输入片段与 Func 概念
ship project member add alice --role owner --force
│    │       └───┬────┘ │     └────┬─────┘ └──┬──┘
│    │           │      │          │          └─ @Flag()
│    │           │      │          └─ @Value()
│    │           │      └─ @Args().inputs
│    │           └─ @Handler(['member', 'add'])
│    └─ @Command('project')
└─ package.json#bin
输入片段对应概念声明方式作用
ship可执行文件package.json#bin启动应用,不属于 Command path。
projectCommand path@Command('project')选择负责项目领域的 Command。
member addHandler path@Handler(['member', 'add'])选择 Command 中添加成员的动作。
alice位置输入@Args().inputs向动作提供无法预先写成固定路径的数据。
--role值选项@Value()接收一个带名称的字符串或数字。
--force标志选项@Flag()接收一个带名称的布尔状态。
无输入片段Provider@Injectable()Module.providers封装并向 Command 提供可复用的应用内部能力。

这些概念可以分成两组:Command、Handler、位置输入和字段选项共同描述应用的调用界面;Module 与 Provider 描述应用内部的代码组织与能力依赖

下面是这条调用对应的完整应用定义:

src/app.module.ts
import { Args, Command, Flag, Handler, Injectable, Module, Value } from 'func'
import type { Args as CommandArgs } from 'func'

@Injectable()
class ProjectService {
  addMember(username: string, role = 'member', force = false) {
    return { force, role, username }
  }
}

@Command('project')
export class ProjectCommand {
  @Value()
  role?: string

  @Flag()
  force = false

  constructor(private project: ProjectService) {}

  @Handler(['member', 'add'])
  addMember(@Args() args: CommandArgs) {
    const [username = ''] = args.inputs
    console.log(this.project.addMember(username, this.role, this.force))
  }
}

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

Application:整个应用

Application 表示一组可以共同运行的命令与内部能力,不对应某一条具体的 CLI 路径。根模块 (app.module.ts) 是它的定义入口:func 从这里发现应用包含哪些 Command、Provider 和共享能力,并将这些声明编译为可调用的应用。

同一份 Application 定义既可以作为 CLI 进程启动,也可以由其他程序发起命令式调用。两种方式共享相同的 Command、输入规则与 Provider;区别只在于调用从哪里进入应用。

Command:调用入口

Command 是 func 中面向用户组织能力的基本单元,由带有 Command 装饰器的 class 表示。它通常对应一个业务领域或一组紧密相关的任务,例如 projectconfig profiledeploy

Command path 是用户必须输入的固定语法。它可以包含一段或多段路径;多个 Command 也可以共享路径前缀。func 会将 Command path 与 Handler path 编译成命令图,并在调用时选择匹配最完整且唯一的可执行节点。

Command 子类型

大多数业务使用具名命令。主命令与缺失命令用于处理没有进入具名命令的调用,是应用可以按需声明的特殊入口:

命令入口选择条件func API
主命令没有匹配具名路径,可执行其默认或 path Handler@CommandMajor()
具名命令位置 token 最长匹配 Command path 与 Handler path@Command('<name>')
缺失命令位置 token 无法匹配具名路径,并且仍有未消费位置输入@CommandMissing()(可选)

Handler:执行处理器

Handler 是 Command class 中实际执行业务动作的方法,由 @Handler() 声明。同一个 Command 可以提供多个 Handler。

Handler 有三种选择方式:

  • 默认处理器:没有路径或标志,在当前命令节点没有选择其他动作时执行;
  • 路径处理器:使用一段固定的位置 token 扩展命令路径,例如 member add
  • 标志处理器:使用 --version 一类选项切换到互斥动作。

一次调用最终只会执行一个 Handler,不会因为一个 Command class 中声明了多个方法就依次执行它们。模块级 @OptionCommand() 也可以提供 --help 一类跨命令动作,它同样会替代当前业务动作,而不是与业务 Handler 一同运行。

判断一个输入应该建模为 Handler path 还是位置数据,关键在于它是否属于固定语法:member add 是开发者预先声明的动作路径,而 alice 是每次调用都可能变化的业务数据。

输入:提供数据

字段选项与位置输入决定“当前动作使用什么数据执行”。

字段选项

字段选项是带名称的输入,声明在 Command 或 option Provider 的属性上。func 会负责识别名称与别名、转换类型、写入属性并执行校验,Handler 可以直接读取最终字段值。

用户需要的数据输入示例func API
布尔状态--force@Flag()
单个字符串或数字--role owner--port 3000@Value()
可以重复出现的字符串或数字列表--include src --include tests@ArrayString()@ArrayNumber()

字段选项适合拥有稳定名称、类型、默认值或校验规则的数据。详细定义方式见字段选项

位置输入

位置输入没有选项名,它的含义由所在位置决定。Command path 和 Handler path 完成匹配后,剩余的位置 token 可以通过 @Args().inputs 读取。例如示例中的 alice 不是固定语法,因此会作为位置输入交给 addMember()

位置输入适合文件路径、搜索关键词、用户名等动态数据。@Args() 还提供标准路径、实际调用路径和归一化选项快照;这些运行时参数见参数与上下文

Provider:可复用服务

Provider 通常封装领域逻辑或基础设施能力,例如 service、repository、配置和外部 API client,使 Handler 只需要编排一次调用,而不必负责创建依赖或实现全部细节。

在上面的示例中,ProjectCommand 负责理解 project member add 及其输入,ProjectService 负责真正的成员管理能力。两者通过构造函数依赖关联:Command 声明需要 ProjectServicefunc 在执行时创建并传入实例。

这种分工有几个直接作用:

  • 同一服务可以被不同 Command 或其他 Provider 复用;
  • Command 专注于输入与动作编排,业务逻辑保持独立;
  • 依赖可以被替换,便于测试和演进基础设施实现;
  • 生命周期与资源清理由框架统一协调。

带有 @Injectable() 的 class 是最常见的 Provider,但 Provider 也可以由固定值或 factory 提供。这里只需要理解它代表“由应用提供并受框架管理的能力”。

把概念串成一次调用

回到 ship project member add alice --role owner --forcefunc 会按职责完成以下工作:

  1. project 将调用定位到 ProjectCommand
  2. member add 在该命令中选择 addMember() Handler;
  3. --role owner--force 被解析、转换并绑定到 Command 字段;
  4. alice 作为剩余位置输入出现在 @Args().inputs 中;
  5. ProjectService 按 Command 的依赖声明被创建并注入;
  6. 校验通过后,func 只执行这一次调用选中的 addMember()

这就是 func 最核心的协作模型:Command 划分用户能力,Handler 表示具体动作,字段选项和位置输入提供数据,Provider 承载可复用能力,Module 将这些声明组合成应用。

本页关注的是这些概念之间的职责关系。命令图如何选择最长路径、实例何时创建、校验和生命周期按什么顺序发生,可继续阅读运行时执行模型

接下来读什么

  • 命令:从最小 Command 开始,定义路径、别名与多个 Handler。
  • 字段选项:接收标志、单值与重复值,并为输入添加校验。
  • 参数与上下文:读取位置输入、标准路径、IO 与当前调用上下文。
  • 模块:系统学习应用组合、Provider 注册与共享,以及依赖注入规则。
  • 共享字段:在多个命令间共享字段与动作。
  • 输入与输出:正确使用 stdin、stdout、stderr 和管道。
  • 运行时执行模型:查看命令图匹配、实例创建和完整执行顺序。
  • 术语索引:查询本文概念的精确定义。