在 Monorepo 架构下统一 Webpack 前端构建与 Hadoop 数据管道的平台工程实践


一个棘手的现实摆在面前:我们的前端团队使用 React 和 Webpack,以周为单位快速迭代;而数据团队则维护着一套基于 Java 的 Hadoop MapReduce 任务,发布周期以月计,流程繁琐。两个团队之间唯一的交集,是一份口头约定的数据格式,以及一次次因上游变更导致下游任务失败而引发的跨部门会议。这种技术栈、开发节奏和团队文化上的割裂,已经成为业务敏捷性的主要瓶颈。问题定义很明确:如何构建一个统一的工程平台,既能保留各自技术栈的优势,又能将开发、构建、部署流程标准化,实现真正的端到端持续集成?

方案 A:强化型多仓库(Multi-repo)协作模型

这是最先被提出的保守方案。其核心是维持现有的多个独立 Git 仓库(一个给前端,一个给数据任务,可能还有共享的 DTOs 库),但在上层通过 CI/CD 工具链进行强力整合。

优势分析:

  1. 低侵入性: 团队无需改变现有的代码仓库结构和开发习惯。前端团队依旧使用他们的 npm 工作流,数据团队继续使用 Maven
  2. 职责清晰: 每个仓库的 Owner 非常明确,代码访问权限控制简单。
  3. 构建隔离: 单个仓库的构建失败不会直接影响其他仓库的 CI 流程。

劣势分析:

  1. 跨仓库依赖地狱: 假设前端需要一个新的数据埋点,这个埋点会影响下游的 Hadoop 分析任务。典型的流程是:数据团队在 DTOs 仓库定义新的数据结构 -> 发布新版本 -> 前端仓库更新依赖版本并实现埋点 -> 数据仓库更新依赖版本并修改 MR 任务。这个过程涉及多次版本发布和跨仓库的协调,极易出错。
  2. 原子性变更的缺失: 无法通过一次提交完成一个横跨前后端和数据层的完整功能。这使得代码审查、版本回滚和功能追踪变得异常困难。
  3. 工具链碎片化: 前端有自己的 npm scripts,数据团队有 mvn clean package,CI/CD 管道需要维护多套异构的构建环境和脚本,平台维护成本高。
  4. 一致性挑战: 难以保证所有项目使用的 linter、测试框架、依赖库版本是协同一致的。

在真实项目中,方案 A 的结果往往是催生出一系列复杂的“同步脚本”和“版本协调矩阵”文档,这些东西本身就成为了新的技术债。

方案 B:基于 Lerna/Yarn Workspaces 的统一 Monorepo 平台

该方案主张将所有相关的代码,包括 React 应用、共享组件库、数据处理 DTOs、Hadoop MR 任务的 Java 源码,全部迁移到一个单一的 Git 仓库中进行管理。

优势分析:

  1. 原子化提交: 一个功能,一次提交。一个 Pull Request 可以同时包含前端 UI 的改动、共享 DTO 的更新以及后端 Hadoop 任务的调整。Code Review 的上下文是完整的。
  2. 简化依赖管理: 内部模块间的依赖通过 yarn workspaceslerna 的符号链接实现,无需发布到私有 npm/maven 仓库。调试时可以直接在本地源码间跳转,极大提升开发效率。
  3. 代码共享与复用: 共享的工具函数、类型定义、配置文件可以轻松地在不同项目间复用。
  4. 统一的工程标准: 可以在仓库根目录强制推行统一的 ESLintPrettierCheckstyle 配置,保证代码风格一致。CI/CD 流程也可以被标准化和集中管理。

劣势分析:

  1. 工具链挑战: 需要一个强大的构建系统来管理异构的技术栈。如何让 CI 系统智能地识别出一次提交只影响了前端代码,从而跳过重量级的 Java 编译和 Hadoop 任务打包?
  2. 仓库体积与性能: 随着项目增多,git clonenpm install 的时间会变长。需要引入 shallow clones、sparse checkouts 和高效的包管理器缓存策略。
  3. 权限控制复杂化: 需要基于目录或 CODEOWNERS 文件来实现更细粒度的访问控制。
  4. 文化转变: 需要团队成员适应新的工作流,特别是习惯了独立仓库的工程师。

最终决策与理由

我们选择了方案 B。尽管它带来了更高的初始实施复杂度,但它从根本上解决了跨团队协作的核心痛点——缺乏统一的变更视图和原子化的交付能力。我们认为,投入资源构建一个智能的、可感知变更范围的 CI/CD 平台,其长期收益远大于维护一个脆弱的、基于约定的多仓库协作体系。这不仅仅是代码组织方式的改变,更是向平台工程思维的转变。

核心实现概览

我们的目标是创建一个平台,让开发者提交代码后,系统能自动完成以下任务:

  1. 识别变更影响范围。
  2. 对受影响的前端应用执行 linttestbuild
  3. 对受影响的 Java 模块执行 compiletestpackage
  4. 将构建产物(静态资源、JAR 包)部署到目标环境。
  5. 在部署后,将部署元数据记录到 SQL 数据库中用于追踪和审计。

1. Monorepo 目录结构

我们采用基于 packagesapps 的典型结构。

# monorepo-project/
# │
# ├── .github/workflows/ci.yml       # 统一的CI/CD流水线定义
# ├── packages/
# │   ├── ui-components/             # 共享React组件库
# │   │   ├── src/
# │   │   └── package.json
# │   ├── data-models/               # 共享的Java DTOs (Maven项目)
# │   │   ├── src/main/java/
# │   │   ├── pom.xml
# │   │   └── package.json           # 用于被Lerna/Yarn识别
# │   └── analytics-utils/           # 共享的JS工具库
# │       └── ...
# ├── apps/
# │   ├── web-dashboard/             # 前端React应用
# │   │   ├── src/
# │   │   ├── webpack.config.js
# │   │   └── package.json
# │   └── data-pipelines/            # Hadoop MR任务
# │       ├── daily-report-mr/       # 每日报表任务 (Maven项目)
# │       │   ├── src/main/java/
# │       │   └── pom.xml
# │       └── ...
# ├── .eslintrc.js
# ├── lerna.json
# └── package.json

2. 异构技术栈的统一构建与依赖管理

我们使用 lerna 结合 yarn workspaces 来管理 JavaScript 包。对于 Java 项目,我们在其根目录也放置一个 package.json,让 lerna 能够发现它,并通过 scripts 钩子来调用 Maven。

lerna.json:

{
  "packages": [
    "packages/*",
    "apps/*",
    "apps/data-pipelines/*"
  ],
  "version": "independent",
  "npmClient": "yarn",
  "useWorkspaces": true,
  "command": {
    "publish": {
      "ignoreChanges": ["**/*.md", "**/test/**"],
      "message": "chore(release): publish"
    },
    "bootstrap": {
      "npmClientArgs": ["--frozen-lockfile"]
    }
  }
}

apps/data-pipelines/daily-report-mr/package.json 中,我们将 Maven 命令封装成 npm 脚本:

{
  "name": "@monorepo/daily-report-mr",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "mvn clean package -f ./pom.xml -DskipTests",
    "test": "mvn test -f ./pom.xml"
  }
}

这样,我们就可以在根目录通过 lerna run build --scope @monorepo/daily-report-mr 来构建这个 Java 项目。

3. 智能 CI/CD 流水线

这是整个平台的核心。我们使用 GitHub Actions,通过脚本判断自上次成功构建以来,哪些包发生了变更。

.github/workflows/ci.yml:

name: Unified CI Pipeline

on:
  push:
    branches:
      - main
  pull_request:

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      changed_js_apps: ${{ steps.filter.outputs.js_apps }}
      changed_java_pipelines: ${{ steps.filter.outputs.java_pipelines }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
        with:
          fetch-depth: 0 # 获取所有历史记录以进行比较

      - name: Get changed files
        id: changed-files
        uses: tj-actions/changed-files@v35
        with:
          files_ignore: |
            **/*.md
            .github/**

      - name: Use Lerna to find affected packages
        id: lerna-affected
        run: |
          yarn install --frozen-lockfile
          # Lerna's 'changed' command lists packages that have changed since the last git tag
          # We use this as a robust way to find affected packages
          AFFECTED=$(npx lerna changed --json)
          echo "AFFECTED_PACKAGES=$AFFECTED" >> $GITHUB_ENV

      - name: Filter affected packages into JS apps and Java pipelines
        id: filter
        run: |
          JS_APPS=()
          JAVA_PIPELINES=()
          # This jq script parses the JSON output from lerna and categorizes packages
          echo "${{ env.AFFECTED_PACKAGES }}" | jq -c '.[]' | while read -r package; do
            NAME=$(echo "$package" | jq -r '.name')
            LOCATION=$(echo "$package" | jq -r '.location')

            # Categorize based on location or presence of specific files
            if [[ -f "$LOCATION/webpack.config.js" ]]; then
              JS_APPS+=("$NAME")
            elif [[ -f "$LOCATION/pom.xml" ]]; then
              JAVA_PIPELINES+=("$NAME")
            fi
          done
          
          # Convert bash arrays to JSON strings for job output
          JS_APPS_JSON=$(printf '%s\n' "${JS_APPS[@]}" | jq -R . | jq -s .)
          JAVA_PIPELINES_JSON=$(printf '%s\n' "${JAVA_PIPELINES[@]}" | jq -R . | jq -s .)

          echo "js_apps=$JS_APPS_JSON" >> $GITHUB_OUTPUT
          echo "java_pipelines=$JAVA_PIPELINES_JSON" >> $GITHUB_OUTPUT

  build-js-apps:
    needs: detect-changes
    if: fromJson(needs.detect-changes.outputs.changed_js_apps)[0] != null
    runs-on: ubuntu-latest
    strategy:
      matrix:
        app: ${{ fromJson(needs.detect-changes.outputs.changed_js_apps) }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: 'yarn'
      - name: Install dependencies
        run: yarn install --frozen-lockfile
      - name: Build ${{ matrix.app }}
        run: npx lerna run build --scope ${{ matrix.app }} --stream
      # ... (upload artifacts, etc.)

  build-java-pipelines:
    needs: detect-changes
    if: fromJson(needs.detect-changes.outputs.changed_java_pipelines)[0] != null
    runs-on: ubuntu-latest
    strategy:
      matrix:
        pipeline: ${{ fromJson(needs.detect-changes.outputs.changed_java_pipelines) }}
    steps:
      - name: Checkout code
        uses: actions/checkout@v3
      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'
      - name: Build ${{ matrix.pipeline }}
        run: npx lerna run build --scope ${{ matrix.pipeline }} --stream
      - name: Upload JAR Artifact
        uses: actions/upload-artifact@v3
        with:
          name: ${{ matrix.pipeline }}-jar
          path: ${{ github.workspace }}/apps/data-pipelines/${{ matrix.pipeline }}/target/*.jar

这个流水线利用 lerna changed 来精确地识别出被修改过的包,然后动态地为每个受影响的前端应用和 Java 任务创建一个构建矩阵(matrix),实现了高效的增量构建。

4. Webpack 生产级配置

对于前端应用,一份健壮的 Webpack 配置至关重要。它需要处理代码分割、缓存、环境变量注入等。

apps/web-dashboard/webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const { DefinePlugin } = require('webpack');

module.exports = (env, argv) => {
    const isProduction = argv.mode === 'production';

    return {
        mode: isProduction ? 'production' : 'development',
        entry: './src/index.js',
        output: {
            path: path.resolve(__dirname, 'dist'),
            filename: isProduction ? '[name].[contenthash].js' : '[name].bundle.js',
            chunkFilename: isProduction ? '[name].[contenthash].chunk.js' : '[name].chunk.js',
            publicPath: '/',
            clean: true,
        },
        devtool: isProduction ? 'source-map' : 'eval-source-map',
        resolve: {
            // 这使得我们可以直接 'import Component from "ui-components/src/Component"'
            // Yarn workspaces 会处理好符号链接
            extensions: ['.js', '.jsx'],
        },
        module: {
            rules: [
                {
                    test: /\.jsx?$/,
                    exclude: /node_modules/,
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: ['@babel/preset-env', '@babel/preset-react'],
                        },
                    },
                },
                // ... other rules for CSS, images, etc.
            ],
        },
        plugins: [
            new HtmlWebpackPlugin({
                template: './public/index.html',
            }),
            // 注入环境变量,例如API端点
            new DefinePlugin({
                'process.env.API_URL': JSON.stringify(isProduction ? 'https://api.prod.com' : 'http://localhost:8080'),
            }),
        ],
        optimization: {
            minimize: isProduction,
            minimizer: [
                new TerserPlugin({
                    terserOptions: {
                        compress: {
                            drop_console: true,
                        },
                    },
                }),
            ],
            splitChunks: {
                chunks: 'all',
            },
        },
        // 生产环境错误处理和日志记录的考量
        // 在真实项目中,这里会集成Sentry或类似的错误监控服务
        performance: {
            hints: isProduction ? 'warning' : false,
        },
    };
};

5. Hadoop 部署与元数据记录

部署阶段,我们会将 JAR 包上传到 Hadoop 集群的边缘节点,然后通过 SSH 执行 hadoop jar 命令。同时,将部署信息写入一个 PostgreSQL 数据库。

deploy_hadoop_job.sh:

#!/bin/bash
set -e # Exit immediately if a command exits with a non-zero status.

JAR_PATH=$1
MAIN_CLASS=$2
HDFS_INPUT_PATH=$3
HDFS_OUTPUT_PATH=$4
JOB_NAME=$5
VERSION=$6 # Git SHA

HADOOP_EDGE_NODE="[email protected]"
DB_HOST="db.internal"
DB_USER="ci_bot"
DB_PASSWORD=$PGPASSWORD

echo "Deploying $JOB_NAME version $VERSION..."

# 1. Copy JAR to edge node
scp "$JAR_PATH" "${HADOOP_EDGE_NODE}:/path/to/jars/"

JAR_FILENAME=$(basename "$JAR_PATH")

# 2. Submit MapReduce job via SSH
ssh "$HADOOP_EDGE_NODE" << EOF
  set -e
  hdfs dfs -rm -r -skipTrash "$HDFS_OUTPUT_PATH" || true
  hadoop jar "/path/to/jars/${JAR_FILENAME}" "$MAIN_CLASS" "$HDFS_INPUT_PATH" "$HDFS_OUTPUT_PATH"
EOF

JOB_STATUS=$?
DEPLOYMENT_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# 3. Record deployment metadata to SQL database
if [ $JOB_STATUS -eq 0 ]; then
  STATUS="SUCCESS"
  echo "Job $JOB_NAME submitted successfully."
else
  STATUS="FAILURE"
  echo "Error: Job $JOB_NAME submission failed."
fi

psql "postgresql://$DB_USER:$DB_PASSWORD@$DB_HOST/deployments" -c \
"INSERT INTO hadoop_deployments (job_name, version, deployed_at, status, hdfs_input, hdfs_output) \
VALUES ('$JOB_NAME', '$VERSION', '$DEPLOYMENT_TIMESTAMP', '$STATUS', '$HDFS_INPUT_PATH', '$HDFS_OUTPUT_PATH');"

exit $JOB_STATUS

对应的 SQL 表结构:

CREATE TABLE hadoop_deployments (
    id SERIAL PRIMARY KEY,
    job_name VARCHAR(255) NOT NULL,
    version VARCHAR(40) NOT NULL, -- Git commit SHA
    deployed_at TIMESTAMPTZ NOT NULL,
    status VARCHAR(50) NOT NULL,
    hdfs_input TEXT,
    hdfs_output TEXT,
    triggered_by VARCHAR(100) -- e.g., GitHub Actions run ID
);

CREATE INDEX idx_job_name_deployed_at ON hadoop_deployments (job_name, deployed_at DESC);

6. 整体架构流程图

graph TD
    A[Developer pushes to Git] --> B{GitHub Actions CI};
    B --> C{Detect Changes};
    C --> D{Changed JS Apps?};
    C --> E{Changed Java Pipelines?};
    
    subgraph Frontend Pipeline
        D -- Yes --> F[Build Matrix for JS Apps];
        F --> G[Run Webpack Build];
        G --> H[Deploy to CDN/Server];
    end
    
    subgraph Data Pipeline
        E -- Yes --> I[Build Matrix for Java Pipelines];
        I --> J[Run Maven Package];
        J --> K[Copy JAR to Edge Node];
        K --> L[Submit Hadoop Job];
    end

    H --> M[Record Web Deployment];
    L --> N[Record Hadoop Deployment];
    
    subgraph Metadata Store
        M --> O[(SQL Database)];
        N --> O[(SQL Database)];
    end

架构的扩展性与局限性

当前这套方案成功地将两个孤立的团队整合到了一个统一的开发工作流中,显著提升了交付速度和质量。然而,它并非没有局限性。

首先,随着仓库中项目数量的增多,lerna changedyarn install 的执行时间会成为瓶颈。下一步的优化路径是引入基于内容哈希的远程构建缓存系统,如 NxTurborepo,它们可以跳过未发生实质性代码变更的包的构建和测试,进一步提升 CI 效率。

其次,通过 SSH 脚本提交 Hadoop 任务的方式虽然简单直接,但在生产环境中显得较为脆弱。它缺乏重试、依赖管理和状态监控。一个更成熟的演进方向是,将 CI 的产物(JAR包)推送到制品库,并触发一个专业的调度系统(如 Apache Airflow 或 Oozie)来执行任务,CI/CD 流水线只负责“构建”和“触发”,而不是“执行”。

最后,Monorepo 对代码治理提出了更高的要求。必须建立严格的 CODEOWNERS 规则和 Pull Request 审批流程,防止不同团队间的代码产生非预期的耦合,避免 Monorepo 最终退化成一个难以维护的“巨石仓库”。这个方案的技术实现只是平台工程的一部分,更重要的是配套的规范和文化建设。


  目录