使用 @nx/dotnet 实现跨语言的完整堆栈类型安全
本文属于 Nx 多语言 Monorepo 系列:
- 探索使用 Nx、TanStack 和 Rust 的多语言 Monorepo
- 借助 @nx/dotnet 实现跨语言的全栈类型安全
当后端使用 C#、前端使用 TypeScript 时,同一份数据会被描述两次。如果你在 C# DTO 中修改了某个属性,没有任何机制会提示 TypeScript 接口已过期,你只能在运行时才能发现问题。
Monorepo 可以填补这一空白。在本文中,我们将深入探讨如何将 .NET API 以机器可读的格式暴露出来,使 Nx generator 能够读取该格式并将其转换为 TypeScript 类型。特别是如何通过将其完全集成到任务图中来实现自动化。
下文所述的每一项更改都已合入针对 nx-examples 的单个 PR(Nx 教程背后的代码库),因此你可以将整个配置视为一个完整的 diff 来阅读。
示例配置
为保持简洁,我们以一个商品 API 为例。它位于现有工作区中,紧邻已经渲染商品的前端:
apps/
├── products/ # storefront 应用
├── cart/ # 购物车应用
└── products-api/ # .NET API
├── Program.cs
└── ProductsApi.csproj
libs/
└── shared/
└── product/
├── types/ # Product 类型,由 API 生成
├── data/ # 商品数据,由上方库提供类型支持
├── state/
└── ui/以上是文件夹路径。在本文中,我会改用 Nx 项目名称来引用:ProductsApi 指 .NET 项目,shared-product-types 和 shared-product-data 指我们反复提及的两个库。
该 API 仅包含一个端点,对应一条记录:
apps/products-api/Program.csapp.MapGet("/products", () => Products.All);
public record Product(
[property: Description("Stable identifier.")] string Id,
[property: Description("Display name.")] string Name,
[property: Description("Price in cents.")] int Price,
[property: Description("Optional path to a product image.")] string? Image = null);
同样,这个例子中的 record 结构与 storefront 渲染的格式完全一致,因此前端可以直接消费。`[Description]` 特性会一路跟随,通过 OpenAPI 文档最终映射到生成 TypeScript 里的 JSDoc。
把 .NET 能力加入 monorepo
要让 Nx monorepo 跑 .NET 项目,安装 [Nx .NET 插件](https://nx.dev/docs/technologies/dotnet/introduction): ``` nx add @nx/dotnet ``` 插件会读取你的 `.csproj`、`.fsproj` 和 `.vbproj` 文件,把每个项目转为带目标的图节点。项目间的引用变成图边,所以对一个 WebAPI 项目执行 `nx build` 时,会自动先构建它依赖的类库。 其余管道部分由你来定义——就是普通的 targets。用 OpenAPI 描述 API 接口
`webapi` 模板自带 `Microsoft.AspNetCore.OpenApi`,可在构建时写出 OpenAPI 文档。再装一个负责写文档的包: ``` dotnet add apps/products-api package Microsoft.Extensions.ApiDescription.Server ``` 项目文件里会多出一行引用: ``` apps/products-api/ProductsApi.csprojruntime; build; native; contentfiles; analyzers; buildtransitive all
nx run ProductsApi:build
ProductsApi -> apps/products-api/bin/Debug/net9.0/ProductsApi.dll GenerateOpenApiDocuments: Generating document named 'v1'. Writing document named 'v1' to 'apps/products-api/obj/ProductsApi.json'. Build succeeded. ``` 插件已把 `obj` 推断为构建产物,所以文档会和其余内容一起被缓存和恢复。dotnet build --no-restore --no-dependencies
为 API 生成 TypeScript 客户端
在客户端这一侧,我们可以用 @hey-api/openapi-ts CLI 读取机器可读的 API 规范,把它转换成 TypeScript 类型。这个库本身就是 TypeScript 包,所以这一步放在它平时的构建脚本位置即可,作为一个 script:
{
"scripts": {
"codegen": "openapi-ts -i ../../../../apps/products-api/obj/ProductsApi.json -o src/generated -p @hey-api/typescript"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.99.0"
}
}Nx 会从这个脚本自动推断出一个 codegen 目标。脚本运行时的工作目录是该库本身,因此这里的路径是相对于库而不是工作区根目录的。三个参数的含义:
-i:指定 .NET 构建刚生成的文档。-o:指定生成代码的输出位置,必须与下面的outputs配置一致。-p:选择生成什么内容。这里只生成类型,这正是前端需要的。如果换成 client 插件,或者干脆用别的生成器,同一个目标也能生成完整的 API 客户端。
现在你可以手动运行它,但它不会作为构建的一部分自动执行,因为还没有告诉 Nx 这两个目标之间的关系。这就是接下来要做的事。
把 OpenAPI 代码生成接入 Nx 任务图
我们理想的任务流水线是这样的。
这条流水线中只有中间部分是手写的。ProductsApi:build 来自插件,而最后一条依赖边之所以存在,是因为 shared-product-data 和其他普通包一样依赖 shared-product-types。
其余部分描述的是这个目标与其他任务之间的关系,写在同一个文件的 nx 配置块里:
{
"nx": {
"implicitDependencies": ["ProductsApi"],
"targets": {
"codegen": {
"dependsOn": ["^build"],
"inputs": [
{ "dependentTasksOutputFiles": "**/obj/ProductsApi.json" },
"{projectRoot}/package.json",
"sharedGlobals"
],
"outputs": ["{projectRoot}/src/generated"],
"cache": true
}
}
}
}这里有几个值得注意的地方:
- 添加
^build是为了让 codegen 任务确保其依赖项先完成构建。 implicitDependencies用于在项目图中手动建立依赖边,将类型包与 .NET 项目关联起来。由于生成的代码被 gitignore 忽略,且不存在 Nx 可读取的导入语句,因此仓库中并没有显式表明该库依赖于 .NET 项目,这里就需要明确声明这种关系。dependentTasksOutputFiles对依赖任务的输出文件进行哈希计算,而不是 Nx 默认对源文件哈希。指定inputs会替换默认配置而非追加,因此也需要在这里显式添加sharedGlobals。库自身的package.json也在其中,因为它固定了生成器的版本,而不同版本的生成器可能从同一份文档产出不同的结果。
因此,当我们修改 C# API 契约时,文档发生变化,进而导致 build 的输出改变,最终影响 codegen 的哈希值。但如果在 Program.cs 中仅编辑注释,重建后生成的文档完全相同,类型代码仍可从缓存中恢复。
将上述输入与输出配置结合后,我们可以安全地启用缓存机制,使得开发者仅在前端侧改动时,相关目标能够近乎瞬时完成。
保持前端自动同步
前端部分几乎不需要额外的 Nx 配置。只需为客户端库指定包名,让应用像依赖其他包一样依赖它,项目图中的依赖边就会从导入语句中自动推导出来:
libs/shared/product/data/package.json{
"devDependencies": {
"@nx-example/shared-product-types": "workspace:*"
}
}
此外,我们需要确保任何编译生成源码的任务都先在磁盘上获取这些文件,并将其纳入缓存哈希中。以下三个 target 默认配置可实现该目标:
nx.json{
"targetDefaults": {
"build": { "dependsOn": ["...", "^codegen"] },
"serve": { "dependsOn": ["...", "^codegen"] },
"typecheck": {
"dependsOn": ["...", "codegen"],
"inputs": [
"...",
{ "dependentTasksOutputFiles": "**/*.ts", "transitive": true }
]
}
}
}^codegen 依赖会穿透中间库,无论这些库是否自身拥有 codegen 目标。在 typecheck 中,transitive 对输入侧也做同样的事,越过直接依赖直达两层外的 codegen。
现在运行 nx run-many -t typecheck,它会按顺序构建 API、生成文档、产生类型,并对所有消费方进行类型检查,同时跳过已缓存的内容。
如果你将 C# 记录中的 Name 重命名为 Title,再运行 nx run-many -t typecheck,便会看到消费该类型的库无法通过编译:
src/lib/shared-product-data.ts(6,5): error TS2353: Object literal may only
specify known properties, and 'name' does not exist in type 'Product'.
Failed tasks:
- shared-product-data:typecheck我们基本上实现了从后端到前端的全链路 .NET API 变更类型安全。
总结
我们现在已经走完了在单个 monorepo 中以类型安全方式将 .NET 后端与前端集成的完整链路:
@nx/dotnet从项目文件中推断构建图dotnet build将 OpenAPI 文档写入obj,该插件已对此缓存- 一个 command target 将该文档转换为 TypeScript 类型
dependentTasksOutputFiles确保这些类型与文档保持同步- 前端导入这些类型
这其实并不局限于 OpenAPI。一个构建产物生成 schema,一个生成器将 schema 转为代码,任务图保证两者有序执行并缓存。将其替换为 protobuf、GraphQL SDL 或数据库 schema,整体结构不变。前端和后端仍然共享同一份类型定义,无论各自使用何种语言编写。
你可能不需要自己动手接线
以上所有内容都是手写的,因为 @nx/dotnet 不会自动推断代码