构建支持 Nuxt.js 与 Python CV 服务的统一 Monorepo 及其高效 CI/CD 实践


项目初期,我们只有一个简单的 Nuxt.js 前端应用和一个独立的 Python FastAPI 后端,用于处理计算机视觉(CV)推理任务。它们位于两个独立的 Git 仓库中。很快,这种分离架构的弊端开始显现:API 契约变更导致前后端类型定义不同步,本地开发环境的搭建需要启动两个完全独立的项目,一个跨越前后端的功能变更需要提交两个 Pull Request,CI/CD 流程也是割裂的。这种摩擦力随着团队规模的扩大和业务复杂度的增加而变得不可接受。

显而易见的解决方案是转向 Monorepo。我们的核心目标是:实现代码库的原子化提交、统一的依赖管理、跨项目的类型安全共享,以及一个高效、智能的 CI/CD 流水线。我们最终选定的技术栈是 pnpm Workspaces 作为基础,Turborepo 提供构建系统与缓存,Poetry 管理 Python 环境,并通过 Docker 多阶段构建实现高效的容器化部署。这不仅仅是将代码放在一个文件夹,而是构建一套完整的工程化体系。

第一步:奠定 Monorepo 的基石

我们的项目结构必须清晰地反映出各个服务的职责。在真实项目中,合理的组织结构是可维护性的前提。

# 根目录文件结构
.
├── apps
│   ├── api-cv        # Python FastAPI CV服务
│   └── web           # Nuxt.js 前端应用
├── packages
│   ├── logger        # 共享的日志模块 (TypeScript)
│   ├── shared-types  # 自动生成的共享类型
│   └── ui            # 共享的Vue组件库
├── .dockerignore
├── .gitignore
├── Dockerfile        # 统一的、多阶段构建文件
├── package.json
├── pnpm-workspace.yaml
├── poetry.lock
├── pyproject.toml
└── turbo.json

pnpm-workspace.yaml 定义了工作区的范围,这是 pnpm 识别 Monorepo 的入口。

# pnpm-workspace.yaml
packages:
  - 'apps/*'
  - 'packages/*'

turbo.json 则是整个 Monorepo 的大脑,它定义了任务依赖关系和缓存策略。这里的配置是性能优化的关键。

// turbo.json
{
  "$schema": "https://turbo.build/schema.json",
  "globalDependencies": [
    "**/.env.*local"
  ],
  "pipeline": {
    "build": {
      "dependsOn": [
        "^build",
        "types:generate"
      ],
      "outputs": [
        "apps/web/.output/**",
        "apps/api-cv/dist/**",
        "packages/ui/dist/**",
        "packages/logger/dist/**"
      ]
    },
    "lint": {
      "outputs": []
    },
    "dev": {
      "cache": false,
      "persistent": true
    },
    "types:generate": {
      "cache": true,
      "inputs": ["apps/api-cv/app/schemas/**/*.py"],
      "outputs": ["packages/shared-types/src/index.ts"]
    }
  }
}

注意 types:generate 这个自定义任务。它声明了输入是 Python 的 Pydantic 模型文件,输出是 TypeScript 类型文件。Turborepo 会根据输入文件的哈希值来决定是否需要重新执行此任务,从而避免了不必要的类型生成,这是提升 CI 效率的第一步。

打通任督二脉:跨语言的类型安全

在混合语言的 Monorepo 中,最大的痛点之一就是数据契约的同步。Python 后端使用 Pydantic 定义数据模型,前端使用 TypeScript。我们拒绝手动维护两套类型定义。解决方案是自动化地从 Pydantic 模型生成 TypeScript 接口。

我们使用 pydantic-to-typescript 这个库,并将其封装成一个可执行的脚本。

首先,在根目录的 package.json 中添加开发依赖和脚本。

// package.json
{
  "name": "hybrid-monorepo",
  "private": true,
  "scripts": {
    "build": "turbo run build",
    "dev": "turbo run dev",
    "lint": "turbo run lint",
    "types:generate": "python ./scripts/generate_types.py"
  },
  "devDependencies": {
    "turbo": "^1.10.16",
    "typescript": "^5.2.2"
    // ... 其他 dev-dependencies
  },
  "packageManager": "pnpm@8.6.10"
}

然后是 Python CV 服务中的 Pydantic 模型定义。

# apps/api-cv/app/schemas/inference.py
from pydantic import BaseModel, Field
from typing import List
import uuid

class BoundingBox(BaseModel):
    """
    定义检测到的对象的边界框。
    坐标是相对于原始图像尺寸的归一化值 [0, 1]。
    """
    x_min: float = Field(..., ge=0.0, le=1.0)
    y_min: float = Field(..., ge=0.0, le=1.0)
    x_max: float = Field(..., ge=0.0, le=1.0)
    y_max: float = Field(..., ge=0.0, le=1.0)

class DetectionResult(BaseModel):
    """
    单个对象的检测结果。
    """
    box: BoundingBox
    label: str = Field(..., min_length=1)
    confidence: float = Field(..., ge=0.0, le=1.0)

class InferenceRequest(BaseModel):
    """
    图像推理请求体。
    image_b64 是 Base64 编码的图像字符串。
    """
    request_id: uuid.UUID = Field(default_factory=uuid.uuid4)
    image_b64: str = Field(..., description="Base64 encoded image string.")

class InferenceResponse(BaseModel):
    """
    图像推理响应体。
    """
    request_id: uuid.UUID
    results: List[DetectionResult]

接着,是实现类型生成的 Python 脚本。

# scripts/generate_types.py
import sys
import os
from pydantic_to_typescript import generate_typescript_defs

# 确保脚本可以找到 api-cv 模块
# 在真实 CI/CD 环境中,PYTHONPATH 可能需要更明确的设置
sys.path.insert(0, os.path.abspath('./apps/api-cv'))

# 目标输出文件
output_path = os.path.abspath('./packages/shared-types/src/index.ts')

# 要转换的 Pydantic 模型所在的模块
# 注意:这里需要模块可以被 import
module_path = 'app.schemas.inference'

def main():
    """
    从 Pydantic 模型生成 TypeScript 定义文件。
    """
    print(f"Generating TypeScript types from module '{module_path}'...")
    
    try:
        # 执行生成
        ts_defs = generate_typescript_defs(module_path)
        
        # 写入文件,并添加一些头部注释,例如 lint-disable
        with open(output_path, 'w') as f:
            f.write("/* eslint-disable */\n")
            f.write("/* tslint:disable */\n")
            f.write("/**\n * This file was automatically generated by pydantic-to-typescript.\n * DO NOT MODIFY IT BY HAND. Instead, modify the Python Pydantic models,\n * and run `pnpm types:generate` to regenerate this file.\n */\n\n")
            f.write(ts_defs)
        
        print(f"Successfully generated TypeScript definitions at: {output_path}")

    except Exception as e:
        print(f"Error generating TypeScript types: {e}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()

现在,在任何地方运行 pnpm types:generatepackages/shared-types/src/index.ts 就会被自动更新。

/* eslint-disable */
/* tslint:disable */
/**
 * This file was automatically generated by pydantic-to-typescript.
 * DO NOT MODIFY IT BY HAND. Instead, modify the Python Pydantic models,
 * and run `pnpm types:generate` to regenerate this file.
 */

/**
 * 定义检测到的对象的边界框。
 * 坐标是相对于原始图像尺寸的归一化值 [0, 1]。
 */
export interface BoundingBox {
  x_min: number;
  y_min: number;
  x_max: number;
  y_max: number;
}
/**
 * 单个对象的检测结果。
 */
export interface DetectionResult {
  box: BoundingBox;
  label: string;
  confidence: number;
}
/**
 * 图像推理请求体。
 * image_b64 是 Base64 编码的图像字符串。
 */
export interface InferenceRequest {
  request_id?: string;
  /**
   * Base64 encoded image string.
   */
  image_b64: string;
}
/**
 * 图像推理响应体。
 */
export interface InferenceResponse {
  request_id: string;
  results: DetectionResult[];
}

前端应用 apps/web 可以直接从工作区中引用这个包,实现完全的类型安全。

// apps/web/utils/apiClient.ts
import type { InferenceRequest, InferenceResponse } from '@repo/shared-types';

export async function performInference(imageDataB64: string): Promise<InferenceResponse> {
  const body: InferenceRequest = {
    image_b64: imageDataB64,
  };

  // 使用 Nuxt 的 $fetch 工具,它提供了类型推断
  const response = await $fetch<InferenceResponse>('/api/cv/detect', {
    method: 'POST',
    body,
    // 在真实项目中,这里应该有错误处理
    // onResponseError({ response }) { ... }
  });

  return response;
}

构建高效、可缓存的 CI/CD 流水线

挑战在于如何为一个包含 Node.js 和 Python 项目的 Monorepo 构建一个高效的 Docker 镜像。一个常见的错误是创建一个巨大的、包含所有语言环境和源代码的“万能”镜像,这会导致镜像体积臃肿,构建速度缓慢,且缓存命中率极低。

我们的策略是利用 Docker 的多阶段构建(Multi-stage builds)和 Turborepo 的远程缓存。

graph TD
    subgraph "CI Pipeline"
        A[Git Push] --> B{Turborepo Remote Cache Check};
        B --> |Cache Miss| C[Execute Tasks on CI Runner];
        B --> |Cache Hit| D[Download Artifacts from Cache];
        C --> E[Upload Artifacts to Cache];
        F[Docker Build] --> G[Push Image to Registry];
    end
    
    subgraph "Docker Multi-stage Build"
        S1[Stage: python-base] --> S2[Stage: node-base];
        S2 --> S3[Stage: builder];
        S3 --> S4[Final Stage: api-cv-runner];
        S3 --> S5[Final Stage: web-runner];
    end

    E --> F;
    D --> F;

以下是一个生产级的 Dockerfile,它为 api-cv 服务和 web 应用分别构建了优化的运行时镜像。

# ==============================================================================
# Stage 1: Python Base Image
# 包含 Poetry 和 Python 运行时,用于安装 Python 依赖
# ==============================================================================
FROM python:3.10-slim as python-base
ENV PYTHONUNBUFFERED=1 \
    POETRY_NO_INTERACTION=1 \
    POETRY_VIRTUALENVS_IN_PROJECT=1

WORKDIR /app

# 安装 Poetry
RUN pip install poetry

# 仅拷贝依赖定义文件,利用 Docker 层缓存
# 只有当这些文件变化时,下面的 RUN 才会重新执行
COPY poetry.lock pyproject.toml ./
RUN poetry install --no-dev --no-root


# ==============================================================================
# Stage 2: Node.js Base Image
# 包含 pnpm 和 Node.js 运行时,用于安装 Node 依赖
# ==============================================================================
FROM node:18-alpine as node-base
WORKDIR /app

# 安装 pnpm
RUN npm install -g pnpm

# 仅拷贝依赖定义文件
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

# ==============================================================================
# Stage 3: Builder
# 这个阶段用于构建所有需要的应用和包
# 它会从前面的阶段拷贝依赖,并利用 Turborepo 构建
# ==============================================================================
FROM node:18-alpine as builder
WORKDIR /app

# 安装 pnpm
RUN npm install -g pnpm

# 拷贝 Turborepo 配置和所有源代码
COPY turbo.json .
COPY . .

# 从 node-base 阶段拷贝已安装的 node_modules,避免重新安装
COPY --from=node-base /app/node_modules ./node_modules

# 运行 Turborepo 构建
# Turborepo 会自动处理类型生成和所有应用的构建
# --filter=api-cv... 用于确保只构建这两个目标应用及其依赖
RUN turbo run build --filter=api-cv... --filter=web...


# ==============================================================================
# Stage 4: Python API Runner
# 一个轻量的最终镜像,只包含运行 Python 服务所需的代码和依赖
# ==============================================================================
FROM python:3.10-slim as api-cv-runner
WORKDIR /app

# 从 python-base 阶段拷贝 Python 虚拟环境
COPY --from=python-base /app/.venv ./.venv
ENV PATH="/app/.venv/bin:$PATH"

# 从 builder 阶段拷贝构建好的 Python 应用代码
# 注意我们只拷贝 api-cv 应用的代码,而不是整个 Monorepo
COPY --from=builder /app/apps/api-cv/ ./apps/api-cv/

# 设置工作目录
WORKDIR /app/apps/api-cv

# 暴露端口并启动服务
# 在生产环境中,应使用 Gunicorn 或 Uvicorn 的 worker 管理器
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]


# ==============================================================================
# Stage 5: Nuxt.js Web App Runner (if running in Node.js server mode)
# 如果是静态站点生成 (SSG),则可以使用 Nginx 镜像
# 这里展示的是 Node.js 服务器模式
# ==============================================================================
FROM node:18-alpine as web-runner
WORKDIR /app

# 设置生产环境
ENV NODE_ENV=production

# 从 builder 阶段拷贝构建好的 Nuxt 应用
# .output 目录包含了所有需要运行的服务器代码和静态资源
COPY --from=builder /app/apps/web/.output ./
COPY --from=builder /app/apps/web/package.json ./package.json
COPY --from=builder /app/apps/web/node_modules ./node_modules

# 暴露端口并启动 Nuxt 服务器
EXPOSE 3000
CMD ["node", "./server/index.mjs"]

这个 Dockerfile 的精妙之处在于:

  1. 依赖层缓存: python-basenode-base 阶段首先只拷贝依赖定义文件(pyproject.toml, package.json等)并安装依赖。只要这些文件不变,这两个重量级的层就会被缓存,大大加快了后续构建。
  2. 构建与运行分离: builder 阶段负责编译、打包等耗时操作。最终的 api-cv-runnerweb-runner 镜像则非常轻量,它们只从 builder 阶段拷贝必要的产物,不包含任何构建工具链或源代码,这既减小了镜像体积,也增强了安全性。
  3. Monorepo 感知: builder 阶段利用 turbo run build --filter=... 来精确构建目标应用。如果 CI/CD 系统集成了 Turborepo 的远程缓存(如 Vercel 或自托管),那么只要代码没有变化,turbo run build 甚至可以在几秒内完成,因为它会直接下载预先构建好的产物。

方案的局限性与未来展望

尽管这套架构解决了我们最初面临的核心痛点,但它并非银弹。在真实项目中,我们必须清楚它的边界。

首先,Python 的工具链与 Node.js 的集成并非天衣无缝。虽然 Poetry 提供了可靠的依赖管理,但其虚拟环境 .venv 的概念与 Node.js 的 node_modules 扁平化结构在 Monorepo 根目录下的交互有时会带来困惑。开发者需要同时理解两种包管理生态。

其次,当前的类型生成是单向的(Python -> TypeScript)。如果业务需求需要从前端的类型定义反向生成 Python 模型,整个流程将变得更加复杂,可能需要引入 JSON Schema 作为中间表示。

再者,此方案专注于应用层。对于基础设施(如数据库、缓存)的管理,还需要引入 Terraform 或 Pulumi 等 IaC 工具,并将其纳入 Monorepo 的工作流中,这会引入新的挑战,例如环境配置的管理和状态同步。

最后,随着 Monorepo 规模的指数级增长,Turborepo 的性能优势可能会遇到瓶颈。对于拥有数百个包的超大型代码库,社区正在探索如 Bazel 这样更为底层和严格的构建系统。但 Bazel 的学习曲线和配置复杂度远高于 Turborepo,这种权衡需要根据团队的规模和项目的复杂度来审慎做出。未来的迭代方向可能是在保持当前开发体验的同时,逐步引入更强大的构建分析和分布式执行能力。


  目录