首页 / 文章

Turborepo在NestJS单仓库中的定位以及何时无需使用它

Turborepo、pnpm workspaces与NestJS在TypeScript单仓库中如何分工:共享包、任务图、缓存机制、耦合风险以及何时无需使用这些工具。

发布者:REACTAPP.TOP阅读约 15 分钟3349 词

来源: Turborepo + NestJS: The Monorepo Architecture That Makes Large TypeScript Systems Manageable

NestJS 后端最初通常只有一个 API,包含一个 package.json、一个 node_modules 文件夹以及一个 CI 流水线。随着产品的发展,认证、订单、支付、库存和通知等功能会拆分出独立的服务,此时的运营重点也从“如何扩展这个服务?”转变为“如何在不重复复制相同配置、类型和流水线的情况下管理七个 TypeScript 应用程序?”本文将阐述单仓库架构、pnpm 和 Turborepo各自的优点,任务图和缓存如何节省工作量,哪些内容可以安全共享,以及何时搭建成本会高于其带来的收益。

始终要记住一个原则:Turborepo 并不会创建微服务,也不会替代 NestJS,更不能单独提升架构的可扩展性。它解决的只是更具体的问题——即在一个仓库中协调多个包之间的构建相关工作。

从单一服务到多个仓库

随着平台规模的扩大,其仓库中可能会为每个服务单独创建一个文件夹:

auth-service/
order-service/
payment-service/
inventory-service/
notification-service/
shipment-service/
api-gateway/

将服务拆分有助于实现扩展和明确责任归属,但每个服务都需要独立的依赖项、配置文件、构建流程、测试用例、代码检查规则以及流水线。

多仓库架构的协调成本

想象一个典型的电子商务系统:入口网关负责处理认证、订单和商品相关服务,订单服务会调用支付和库存服务,而支付功能又会触发通知功能:

                    API Gateway
                         |
       ┌─────────────────┼─────────────────┐
       ↓                 ↓                 ↓
     Auth              Order            Product
                         |
                ┌────────┴────────┐
                ↓                 ↓
             Payment          Inventory
                |
                ↓
           Notification

一种常见的初步做法是为每个服务单独设置一个仓库,即多仓库模式:

github.com/company/auth-service
github.com/company/order-service
github.com/company/product-service
github.com/company/payment-service
github.com/company/inventory-service
github.com/company/notification-service

这是一种合理的架构。问题出现在当你查看每个仓库时,会发现到处都是重复的框架结构:

package.json
tsconfig.json
eslint.config.js
Dockerfile
CI configuration
README.md
node_modules

更糟糕的是,还有多个服务同时需要的相同类型的代码:

DTOs
TypeScript types
logging utilities
configuration
authentication helpers
database utilities
Kafka/RabbitMQ abstractions
testing utilities

这样一来,对共享代码的任何修改都成了同步工作。当共享类型发生变化时,哪些仓库需要更新?当依赖项升级时,哪些服务需要随之升级?当代码检查规则或持续集成工作流发生变化时,需要修改并保持一致的副本有多少个?真正的问题并非服务的数量,而是协调问题。

单仓库模式是一种策略;Turborepo则是一种工具

另一种做法是将所有应用程序和共享代码放在同一个仓库中,应用程序置于 apps/ 目录下,内部库放在 packages/ 目录下,而工作区及任务配置则放在根目录中:

ecommerce-platform/
│
├── apps/
│   ├── api-gateway/
│   ├── auth-service/
│   ├── order-service/
│   ├── product-service/
│   ├── payment-service/
│   ├── inventory-service/
│   └── notification-service/
│
├── packages/
│   ├── types/
│   ├── logger/
│   ├── config/
│   ├── database/
│   └── messaging/
│
├── package.json
├── pnpm-workspace.yaml
└── turbo.json

这种架构就是单仓库结构:即决定代码的存放位置。Turborepo则是用于协调该架构内各项工作的工具。二者可以单独使用,也可以一起使用。

Turborepo的实际功能

Turborepo是专为JavaScript和TypeScript单仓库设计的构建系统。它能同时协调多个包中的任务,例如:

build
test
lint
type-check
dev

如果没有它,要构建多个服务就需要手动逐一操作。首先是认证服务:

cd apps/auth-service
pnpm build

接着再依次处理其他服务(该代码片段将两个命令写在同一行;每个 cd 和 pnpm build 命令都应单独占一行):

cd ../order-service
pnpm buildcd ../payment-service
pnpm build

使用 Turborepo,只需声明一次任务,即可从根目录执行单个命令:

turbo build

它会自动判断需要哪些任务、哪些任务可以并行运行,以及哪些结果可以从缓存中获取而无需重新计算。

NestJS 应用与共享包的层次结构

面向生产环境的仓库版本会为每个 Nest 应用提供独立的 src/ 目录和 package.json 文件,从而使每个应用都成为一个完整的工作区包:

ecommerce-platform/
│
├── apps/
│   │
│   ├── api-gateway/
│   │   ├── src/
│   │   └── package.json
│   │
│   ├── auth-service/
│   │   ├── src/
│   │   └── package.json
│   │
│   ├── order-service/
│   │   ├── src/
│   │   └── package.json
│   │
│   └── payment-service/
│       ├── src/
│       └── package.json
│
├── packages/
│   │
│   ├── types/
│   ├── logger/
│   ├── config/
│   ├── database/
│   └── messaging/
│
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

从概念上讲,存在两层结构:一类是承载业务功能的 NestJS 服务即应用,另一类则是包含可重用代码的包。

                Monorepo
                    |
          ┌─────────┴─────────┐
          ↓                   ↓
        Apps              Packages
          |                   |
       NestJS            Shared Code

应用负责实现功能,包则提供可复用的构建模块,而 Turborepo 负责协调这两者之间的任务。

内部包作为唯一事实来源

单仓库架构最实用的日常优势在于内部包的功能。例如,类型包可以存储多个服务共同使用的模型:

packages/
└── types/
    ├── src/
    │   ├── user.ts
    │   ├── order.ts
    │   ├── payment.ts
    │   └── pagination.ts
    └── package.json

任何 Nest 服务都可以通过包名从该类型包中导入内容,这些包名是通过工作区而非注册表来解析的:

import { OrderStatus } from '@repo/types';

这样就无需在每个服务中重复粘贴相同的枚举值,也不用担心这些副本会出现差异:

enum OrderStatus {
  PENDING = 'pending',
  PAID = 'paid',
  CANCELLED = 'cancelled',
}

当枚举值发生变化时,编译器会在同一次修改中标记出所有受影响的位置。

共享契约与共享所有权不同

在单仓库架构中实现共享很方便,但这正因如此才需要严格的规范。订单服务的领域逻辑应属于该订单服务本身:

order-service
      ↓
order-domain

它不应悄悄地被移入某个包中:

packages/
└── order-domain/

然后让其他服务从该包中导入这些逻辑:

payment-service
inventory-service
notification-service

一旦支付、库存和通知相关代码依赖于订单域的内部结构,订单规则的变更就可能会破坏三个彼此无关的部署。经验法则是:有意识地共享基础设施和契约,但绝不要默认共享业务所有权。适合共享的组件通常具备以下特征:

@repo/types
@repo/logger
@repo/config
@repo/testing
@repo/messaging

业务逻辑通常应保留在负责该逻辑的服务中。关于这一权衡的更深入探讨,可参阅重复代码与耦合度:决定何时需要共享代码。

依赖关系图决定了哪些内容会被执行

Turborepo 会读取每个工作区中的 package.json 文件所声明的依赖关系。在这个例子中,两个服务都依赖于 types 包,而 order 服务还依赖于一个数据库包:

                  @repo/types
                  /       \
                 /         \
                ↓           ↓
        auth-service    order-service
                              |
                              ↓
                        @repo/database

由于 Turborepo 已知这一依赖关系图,因此如果你修改了 order 服务中的某个文件:

apps/order-service/src/orders/order.service.ts

就没有必要重新构建诸如以下这类无关的服务:

notification-service

通过过滤功能(例如 turbo build --filter=...),你可以将构建操作限制在已更改的包及其依赖项上。

缓存:跳过已完成的工作

首次运行构建时:

turbo build

所有必需的任务都会实际执行:

auth-service        → BUILD
order-service       → BUILD
payment-service     → BUILD
inventory-service   → BUILD

Turborepo会为每个任务的输入(源文件、依赖项、配置以及相关环境变量)生成指纹,并存储输出结果和日志。在未进行任何相关更改的情况下再次运行相同命令:

turbo build

结果如下所示:

auth-service        → CACHE HIT
order-service       → CACHE HIT
payment-service     → CACHE HIT
inventory-service   → CACHE HIT

输出结果是从缓存中恢复的。要实现这一功能,Turborepo必须知道某个任务会生成哪些文件,而这正是turbo.json中outputs设置所指定的内容(对于Nest构建,通常是dist/**)。那些未声明输出结果的任务可能会报告缓存命中,但实际上却无法获取构建产物。

为何CI系统受益最多

简单的流水线会在每次代码推送时重复所有工作:

Git Push
   ↓
Install dependencies
   ↓
Build everything
   ↓
Test everything
   ↓
Lint everything
   ↓
Deploy

在采用 Turborepo 的工作流中,每个任务都会首先与任务图及缓存进行比对,只有输入发生变化的任务才会被执行:

                Git Push
                    ↓
                   CI
                    ↓
               Turborepo
                    ↓
          ┌─────────┴─────────┐
          ↓                   ↓
      Dependency          Task Graph
       Analysis               ↓
                          Cache Check
                              ↓
                    ┌─────────┴─────────┐
                    ↓                   ↓
                 Cached              Execute
                  Tasks                Tasks

在大型代码库中,这能大幅减少冗余的持续集成工作。

本地缓存与远程缓存

缓存分为两个层级:

  • 本地缓存。你的机器会重复使用已执行过的任务结果,从而加快日常开发速度。
  • 远程缓存。共享缓存让开发人员和持续集成服务器能够互相复用结果。

远程缓存的配置如下:

Developer A
     │
     ↓
Remote Cache
     ↑
     │
Developer B
     ↑
     │
    CI

如果开发者已经构建了某个服务,而后续的 CI 遇到相同的任务输入时,CI 会直接下载已有结果而非重新构建。应将远程缓存视为供应链的一部分:严格控制谁有权限向其中写入数据,因为一旦缓存被污染,所有用户都会收到错误的结果。

并行执行独立任务

有些服务之间并不依赖彼此的构建输出:

auth-service
order-service
product-service

这些任务需要按顺序执行,每个任务都要等待前一个任务完成:

auth → finish
       ↓
order → finish
         ↓
product

Turborepo 可以在可用 CPU 资源允许的范围内让这些任务同时执行:

        ┌── auth ────┐
        │            │
turbo ──┼── order ───┼──→ Done
        │            │
        └── product ─┘

描述任务间的关系

这种调度机制适用于所有类型的任务,而不仅仅是构建任务:

turbo build
turbo test
turbo lint
turbo typecheck

关系在 turbo.json 中声明。在下面的配置中,"^build" 表示“首先构建该包的依赖项”,因此共享包会在导入它们的服务之前被编译。test 任务依赖于没有尖号的 build,它指的是同一个包的构建过程。lint 和 typecheck 没有依赖项,可以立即并行运行。

{
  "tasks": {
    "build": {
      "dependsOn": ["^build"]
    },
    "test": {
      "dependsOn": ["build"]
    },
    "lint": {},
    "typecheck": {}
  }
}

你描述了各任务之间的依赖关系,Turborepo 会据此生成执行计划。在真实项目中,还应如上所述为可缓存的任务添加 outputs,并确保每个任务名称都与工作区包的 package.json 文件中的脚本相匹配。

pnpm 和 Turborepo 承担不同的任务

这两种工具经常被一起使用,以至于看起来像是一个工具:

Turborepo + pnpm

实际上它们是独立的层次结构。pnpm负责管理包和工作空间:安装依赖项、链接内部包,并强制规定严格的node_modules目录结构。而Turborepo则在该工作空间之上协调各项任务并缓存其执行结果。

pnpm
              │
      Workspace Management
              │
              ↓
        ┌─────────────┐
        │  Monorepo   │
        └──────┬──────┘
               │
               ↓
          Turborepo
               │
       ┌───────┼────────┐
       ↓       ↓        ↓
     Build    Test     Lint

如需更深入地了解pnpm本身,可参阅npm与pnpm:存储、速度及实际应用中的权衡。

为何不直接使用Nest内置的monorepo模式?

Nest CLI 自带包含应用和库的单一仓库模式,因此没有必要再添加 Turborepo。两者的区别在于适用范围:Nest 的工作区仅理解 Nest 项目,而 Turborepo 能够管理工作区中的任何 JavaScript 或 TypeScript 包。一旦仓库中包含的不仅仅是 Nest 服务,比如前端代码和普通的 Node 工作进程:

apps/
├── web/              # Next.js
├── admin/            # Next.js
├── api-gateway/      # NestJS
├── order-service/    # NestJS
└── worker/           # Node.js

再加上共享的 UI、类型定义和配置包:

packages/
├── ui/
├── types/
├── config/
└── eslint-config/

你就不再是在管理 Nest 项目,而是在管理一个 TypeScript 平台,这时 Turborepo 的优势就体现出来了。

Turborepo、Nx 还是 Nest CLI

并不存在绝对正确的选择。一般而言:如果所有项目都使用 Nest,那么 Nest 的单仓库模式是最简单的选择。Turborepo 则是在包管理器的 workspace 之上添加的一层用于任务执行和缓存的轻量级机制。Nx 提供了项目结构图与缓存功能,同时还集成了更多插件、代码生成工具及规范,这些对某些团队很有价值,但对其他团队来说则可能过于复杂。应根据实际需求来选择工具,而非看哪个工具在社交媒体上最受关注。

何时使用 Turborepo 过于复杂

假设整个系统仅由 PostgreSQL 和 Redis 支持的 Nest API 组成:

NestJS API
   ↓
PostgreSQL
   ↓
Redis

对于小型团队且只有一个可部署的应用:

1 application
5 developers
1 deployment

Turborepo 很可能没有必要。普通的 Nest 仓库就已经足够简单,没必要引入这些复杂功能:

Turborepo
pnpm workspace
multiple packages
complex task graph

会增加认知负担,却无法带来相应回报。只有当问题确实需要时,这些工具才有存在的价值。

何时开始产生效益

当多个此类条件同时满足时,情况就会发生变化:

Multiple applications
        +
Shared packages
        +
Shared tooling
        +
Repeated CI tasks
        +
Large TypeScript codebase

采用 Next.js 前端、Nest API 以及共享包的背景工作进程构成的平台是典型案例:

                    Platform
                       │
        ┌──────────────┼──────────────┐
        ↓              ↓              ↓
    Next.js         NestJS         Workers
      Web             APIs           Jobs
        │              │              │
        └──────────────┼──────────────┘
                       ↓
                  Shared Packages

职责分明的成熟架构

成熟的 Nest 平台可能会在应用和包之外,另外设置一个用于管理基础设施的独立区域:

platform/
│
├── apps/
│   ├── api-gateway/
│   ├── auth-service/
│   ├── user-service/
│   ├── order-service/
│   ├── inventory-service/
│   ├── payment-service/
│   └── notification-service/
│
├── packages/
│   ├── config/
│   ├── logger/
│   ├── types/
│   ├── database/
│   ├── messaging/
│   └── testing/
│
├── infrastructure/
│   ├── docker/
│   └── deployment/
│
├── turbo.json
├── pnpm-workspace.yaml
└── package.json

每个工具都有其特定职责。NestJS 就是应用程序框架:

NestJS
   ↓
Application framework

其余层次的分工如下(在代码片段中,多个条目被合并显示;可理解为:pnpm用于管理依赖和工作空间,Turborepo用于任务协调与缓存,Docker用于容器化,Kafka或RabbitMQ用于异步消息传递,PostgreSQL或Redis用于数据存储与缓存):

pnpm
   ↓
Dependency & workspace managementTurborepo
   ↓
Task orchestration + cachingDocker
   ↓
ContainerizationKafka / RabbitMQ
   ↓
Asynchronous communicationPostgreSQL / Redis
   ↓
Data & caching

Turborepo并非基础设施、消息中间件或部署平台,而是围绕单仓库架构存在的构建与任务处理层。

一个仓库不等于一次部署

人们常常担心将所有内容放在一个仓库中会削弱微服务应有的独立性,但实际上并非如此。代码的存储位置与部署方式是两个独立的决策。一个仓库可以包含多个服务:

One Git repository
       │
       ├── auth-service
       ├── order-service
       ├── payment-service
       └── notification-service

每个组件仍以独立的容器形式存在:

auth-service        → Container A
order-service       → Container B
payment-service     → Container C
notification        → Container D

并且按自身规则扩展:

order-service × 10
payment-service × 5
notification × 3

单仓库并非单体架构,多仓库也不一定会带来微服务。它们是相互独立的概念。真正需要的是能够仅部署代码或依赖项发生变更的服务的 CI 系统,而这正是依赖图所实现的。

隐藏风险:包罗万象的通用包

便捷的共享机制有利有弊。它通常从一个普通包开始:

packages/common

逐渐为各个领域收集辅助工具:

packages/common
├── AuthUtils
├── OrderUtils
├── PaymentUtils
├── InventoryUtils
├── NotificationUtils
├── DatabaseUtils
└── EverythingUtils

直到所有服务都依赖它。此时,你在单仓库中构建了一个分布式单体架构:对某个辅助工具的修改就会迫使所有内容重新编译、测试和部署。顶层目录看起来依然井然有序:

apps/
packages/

但底层的架构存在紧密耦合。需严格遵守这一原则:共享基础设施、契约以及真正通用的工具函数;将领域逻辑保留在负责该逻辑的服务内部。

分层思维模型

可以将系统想象成多层结构,最上层是业务领域,下方是共享的契约与基础设施,最底层则是单仓库管理工具:

              Business Domains
                       │
       ┌───────────────┼───────────────┐
       ↓               ↓               ↓
     Order          Payment         Inventory
       │               │               │
       └───────────────┼───────────────┘
                       ↓
                Shared Contracts
                       │
                       ↓
               Shared Infrastructure
                       │
                       ↓
                Monorepo Tooling
                       │
                       ↓
                  Turborepo

Turborepo位于接近底层的位置。它应当为你的架构提供支持,而非决定架构的形态。

决定是否采用它

按顺序思考以下四个问题。

你是否有多个应用程序?

如果没有,答案很明确:

Keep it simple.

如果有,则继续下一步。

这些应用程序是否共享大量工具或代码?

如果不存在共享:

Polyrepo may be perfectly reasonable.

如果存在共享:

Monorepo becomes attractive.

构建、测试和代码检查的时间是否越来越长?

如果是:

Turborepo's orchestration and caching become valuable.

你是否同时运行 Next.js、NestJS、工作进程以及共享的 TypeScript 包?

如果是:

Turborepo becomes a strong candidate.

可预测性才是真正的优势

速度只是附带效果。更深层次的价值在于大型代码库变得可预测。随着应用程序不断增加,对于每一个变更,都有人需要回答:

What changed?

以及一系列后续问题(源代码将它们写在同一行):哪些需要重新构建,哪些需要重新测试,哪些可以并行运行,哪些可以从缓存中获取,还有哪些包依赖于该变更:

What needs to rebuild?What needs to be tested?What can run in parallel?What can come from cache?Which packages depend on this change?

在没有工具辅助的情况下,工程师需要手动回答这些问题;而有了单仓库构建系统后,大部分答案都可以从依赖关系图中得出。

关键要点

Turborepo 无法解决服务边界不清、数据库设计薄弱、耦合度过高、API 接口定义不合理、领域所有权不明确或微服务设计不佳等问题。它的作用是让不断扩大的 TypeScript 代码库保持可管理状态。整个技术栈的结构如下:

                Business Architecture
                         │
                         ↓
                    NestJS Apps
                         │
                         ↓
                  Shared Packages
                         │
                         ↓
                      pnpm
                         │
                         ↓
                    Turborepo
                         │
              ┌──────────┼──────────┐
              ↓          ↓          ↓
            Build      Test       Lint
              │          │          │
              └──────────┼──────────┘
                         ↓
                       CI/CD
  • 单仓库负责决定代码的存储位置;pnpm 负责管理工作空间;Turborepo 负责协调任务并缓存结果;NestJS 则负责构建服务。这些功能应相互独立。
  • 共享类型定义、配置文件、日志处理及消息传递相关的抽象层;将领域逻辑与其所属的服务绑定在一起,以避免形成分布式单体结构。
  • 通过 dependsOn 指定任务间的依赖关系,并在 turbo.json 中定义输出结果,从而实现高效且准确的缓存功能。
  • 代码库的架构与部署是相互独立的:每个服务仍可单独进行部署和扩展。
  • 当协调多个 TypeScript 应用程序所花费的成本高于引入 Turborepo 的成本时,才适合使用该工具。而对于单个 Nest API 来说,通常没有这种必要。