← 文章 / 未分类
nx 7小时前 · 2026-09-07 06:00:58 · 2 阅读

使用 @nx/dotnet 实现跨语言的完整堆栈类型安全

本文属于 Nx 多语言 Monorepo 系列:

当后端使用 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-typesshared-product-data 指我们反复提及的两个库。

该 API 仅包含一个端点,对应一条记录:

apps/products-api/Program.cs
app.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.csproj
  • runtime; build; native; contentfiles; analyzers; buildtransitive
  • all
``` 现在构建时会输出文档(以项目命名): ``` ❯ nx build ProductsApi

nx run ProductsApi:build

dotnet build --no-restore --no-dependencies

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` 推断为构建产物,所以文档会和其余内容一起被缓存和恢复。

为 API 生成 TypeScript 客户端

在客户端这一侧,我们可以用 @hey-api/openapi-ts CLI 读取机器可读的 API 规范,把它转换成 TypeScript 类型。这个库本身就是 TypeScript 包,所以这一步放在它平时的构建脚本位置即可,作为一个 script:

libs/shared/product/types/package.json
{
  "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 生成 apps/products-api/obj/ProductsApi.json,接着是生成 TypeScript 类型的 codegen,然后是 types:typecheck,最后是所有消费这些类型的任务。最后一步无需额外的 Nx 配置,因为这些库通过普通的包依赖彼此关联。

这条流水线中只有中间部分是手写的。ProductsApi:build 来自插件,而最后一条依赖边之所以存在,是因为 shared-product-data 和其他普通包一样依赖 shared-product-types

其余部分描述的是这个目标与其他任务之间的关系,写在同一个文件的 nx 配置块里:

libs/shared/product/types/package.json
{
  "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 不会自动推断代码

原始来源: nx

评论 (0)