为Elixir与Angular混合型Monorepo构建增量式Jenkins CI/CD流水线


我们的项目迁移到 Monorepo 架构后面临的第一个、也是最尖锐的痛点,就是 CI/CD 流水线的执行效率。最初的 Jenkinsfile 简单粗暴,任何一次提交,无论改动的是 Elixir 后端的某个 Phoenix Context,还是 Angular 前端的一个 CSS 样式,都会触发完整的后端编译、测试、前端构建、测试,以及两者的 Docker 镜像打包。一次流水线跑下来,平均耗时超过15分钟,这对于追求快速迭代的团队是不可接受的。开发人员的反馈循环被严重拉长,等待 CI 结果成了一种新的精神内耗。

问题的根源在于流水线缺乏对变更的感知能力。它将整个代码仓库视为一个不可分割的单元。而 Monorepo 的核心优势之一,恰恰是独立部署与构建不同应用的能力。我们的目标很明确:重构 Jenkins 流水线,使其能够智能识别变更范围,只对受影响的应用执行必要的构建与测试步骤。

初步构想与技术栈约束

我们的技术栈是固定的:后端是 Elixir + Phoenix,使用 Absinthe 提供 GraphQL API;前端是 Angular,通过 Apollo Client 与后端通信;CI/CD 工具则是公司内部统一的 Jenkins。整个项目结构如下:

/
├── apps/
│   ├── backend_elixir/         # Elixir/Phoenix aplication
│   │   ├── lib/
│   │   ├── test/
│   │   ├── mix.exs
│   │   └── mix.lock
│   ├── frontend_angular/       # Angular application
│   │   ├── src/
│   │   ├── angular.json
│   │   ├── package.json
│   │   └── package-lock.json
│   └── ...
├── libs/
│   └── shared_types/           # (未来) 可能的共享 TypeScript 类型
└── Jenkinsfile

初步的构想是利用 Git 命令来检测两次提交之间的文件差异。通过分析变更文件的路径,我们可以判断出本次提交影响了哪个应用。例如,如果所有变更文件都在 apps/backend_elixir/ 目录下,那么我们只需要执行后端的 CI 流程。

基于这个思路,新的流水线逻辑应该是:

  1. 在流水线开始时,执行一个脚本来检测变更。
  2. 脚本输出一个或多个标志,如 BACKEND_CHANGEDFRONTEND_CHANGED
  3. Jenkins Pipeline 的各个 Stage 根据这些标志,通过 when 指令决定是否执行。

失败的初版:一个简单的 Jenkinsfile

这是我们最初那个效率低下的 Jenkinsfile,它为后续的优化提供了一个性能基准。

// Jenkinsfile.naive - 效率低下的版本
pipeline {
    agent any

    stages {
        stage('Install Dependencies') {
            parallel {
                stage('Backend Deps') {
                    steps {
                        dir('apps/backend_elixir') {
                            sh 'mix local.rebar --force'
                            sh 'mix local.hex --force'
                            sh 'mix deps.get'
                        }
                    }
                }
                stage('Frontend Deps') {
                    steps {
                        dir('apps/frontend_angular') {
                            sh 'npm install'
                        }
                    }
                }
            }
        }

        stage('Run Tests') {
            parallel {
                stage('Backend Tests') {
                    steps {
                        dir('apps/backend_elixir') {
                            sh 'mix test'
                        }
                    }
                }
                stage('Frontend Tests') {
                    steps {
                        dir('apps/frontend_angular') {
                            // --watch=false is crucial for CI environments
                            sh 'npm run test -- --watch=false --browsers=ChromeHeadless'
                        }
                    }
                }
            }
        }

        stage('Build Artifacts') {
            parallel {
                stage('Backend Release') {
                    steps {
                        dir('apps/backend_elixir') {
                            // Elixir releases require this setup
                            sh 'mix deps.get --only prod'
                            sh 'MIX_ENV=prod mix release'
                        }
                    }
                }
                stage('Frontend Build') {
                    steps {
                        dir('apps/frontend_angular') {
                            // Assuming production configuration is set in angular.json
                            sh 'npm run build -- --configuration production'
                        }
                    }
                }
            }
        }

        // ... 后续的 Docker 打包和部署阶段
    }
}

这个版本的问题显而易见:没有逻辑判断,所有阶段都会执行。

核心改造:引入变更检测逻辑

要实现增量构建,核心在于准确识别变更范围。我们决定在 Jenkinsfile 内部编写一个 Groovy 函数,该函数调用 git 命令来完成这个任务。在真实项目中,目标分支通常是 maindevelop,对于 Pull Request 构建,则是与目标分支进行比较。

// Jenkinsfile - 引入变更检测函数

// ... pipeline定义之前

/**
 * Detects changes in specified application paths within the monorepo.
 * @param targetBranch The branch to compare against, e.g., 'origin/main'.
 * @return A map indicating which paths have changed, e.g., [backend: true, frontend: false]
 */
def detectChangedApps(String targetBranch = 'origin/main') {
    // 默认情况下,我们假设所有应用都未变更
    def changes = [backend: false, frontend: false]

    // 使用 git diff 获取从目标分支到当前 HEAD 的变更文件列表
    // --name-only 只输出文件名
    def changedFiles = sh(
        script: "git diff --name-only ${targetBranch}...HEAD",
        returnStdout: true
    ).trim().split('\n')

    echo "Detected changed files: ${changedFiles}"

    // 遍历变更文件列表,检查其路径
    for (file in changedFiles) {
        if (file.startsWith('apps/backend_elixir/')) {
            echo "Change detected in backend: ${file}"
            changes.backend = true
        }
        if (file.startsWith('apps/frontend_angular/')) {
            echo "Change detected in frontend: ${file}"
            changes.frontend = true
        }
        // 如果根目录的 Jenkinsfile 发生变化,我们认为需要全量构建,以防流水线逻辑本身变更
        if (file.startsWith('Jenkinsfile')) {
            echo "Jenkinsfile changed. Triggering a full build."
            changes.backend = true
            changes.frontend = true
        }
    }

    // 在一个PR中,如果没有任何可识别的应用代码变更(例如只改了README.md),
    // 那么两个标志都将是false,流水线会快速结束。
    // 但在实践中,如果变更集为空,可能意味着git命令有问题或这是一个全新的分支。
    // 一个更鲁棒的策略是,如果分支是第一次构建,或者无法与目标分支比较,就执行全量构建。
    if (currentBuild.changeSets.isEmpty() && env.BRANCH_NAME != 'main') {
         echo "No changesets detected or new branch. Forcing full build for safety."
         changes.backend = true
         changes.frontend = true
    }


    return changes
}

这个 detectChangedApps 函数是整个优化的心脏。它返回一个 map,清晰地告诉我们哪个部分需要重新构建。这里的坑在于:git diff 的比较基准需要小心选择。对于合并到主干前的 PR 构建,应该是 origin/main...HEAD。对于直接推送到主干的构建,可以是 HEAD~1...HEAD。为了通用性,我们将其参数化。

重构 Jenkinsfile:实现条件化 Stage

有了变更检测函数,我们就可以重构 Jenkinsfile,在 environment 块中调用它,并将结果存储为环境变量,然后在每个 stagewhen 指令中使用这些变量。

我们还需要考虑缓存。对于 Elixir,需要缓存 deps_build 目录。对于 Angular,则是 node_modules。Jenkins 的 cache 指令(需要 Cache Plugin)或者在 Docker Agent 中挂载卷都可以实现这一点。这里我们采用更为现代和推荐的 cache 指令。

// Jenkinsfile.optimized - 最终优化版本

// 全局变量,用于存储变更检测结果
def changes

pipeline {
    agent {
        // 使用一个包含所有构建工具的 Docker 镜像,确保环境一致性
        docker {
            image 'your-company/elixir-node-builder:latest'
            args '-u root' // 某些 docker 镜像需要 root 权限来安装工具或写缓存
        }
    }

    options {
        // 在发生故障时保留最近几次构建的工作空间用于调试
        preserveStashes(buildCount: 5)
    }

    stages {
        stage('Initialize & Detect Changes') {
            steps {
                script {
                    // 在执行任何操作前,首先拉取目标分支的最新信息
                    sh 'git fetch origin main'
                    // 调用变更检测函数
                    changes = detectChangedApps('origin/main')
                    echo "Build flags: Backend=${changes.backend}, Frontend=${changes.frontend}"
                }
            }
        }

        // 使用 parallel 来并行化后端和前端的流程,当两者都需要构建时可以节省时间
        stage('Build & Test Changed Applications') {
            parallel {
                // --- Backend Stage ---
                stage('Backend CI') {
                    when { expression { return changes.backend } }
                    steps {
                        dir('apps/backend_elixir') {
                            // 使用 cache 指令缓存 Elixir 依赖和编译产物
                            // key 包含了 mix.lock 的 hash,当依赖变更时缓存自动失效
                            cache(path: 'deps', key: "elixir-deps-${hashFiles('mix.lock')}") {
                                sh 'mix deps.get'
                            }
                            cache(path: '_build', key: "elixir-build-${env.BRANCH_NAME}-${hashFiles('mix.lock')}") {
                                echo "Running Backend Tests..."
                                // 在CI环境中,确保数据库已准备好
                                // sh 'mix ecto.create'
                                // sh 'mix ecto.migrate'
                                sh 'mix test'

                                echo "Building Backend Release..."
                                sh 'mix deps.get --only prod'
                                sh 'MIX_ENV=prod mix release'
                            }
                        }
                    }
                }

                // --- Frontend Stage ---
                stage('Frontend CI') {
                    when { expression { return changes.frontend } }
                    steps {
                        dir('apps/frontend_angular') {
                            // 缓存 node_modules
                            // key 包含了 package-lock.json 的 hash
                            cache(path: 'node_modules', key: "node-modules-${hashFiles('package-lock.json')}") {
                                echo "Installing Frontend Dependencies..."
                                sh 'npm ci' // 'npm ci' is faster and more reliable for CI
                            }

                            // Apollo Client 代码生成
                            // 这一步依赖于后端的 GraphQL schema,在真实项目中可能需要先从某个地方获取schema.json
                            // 如果 schema 在后端仓库里,那么后端变更时前端也需要重新生成代码
                            // 这里我们简化处理,假设 schema 是稳定的
                            echo "Generating Apollo GraphQL types..."
                            sh 'npm run generate-graphql-types' // 假设这是一个 package.json 中的脚本

                            echo "Running Frontend Tests..."
                            sh 'npm run test -- --watch=false --browsers=ChromeHeadless'
                            
                            echo "Building Frontend Application..."
                            sh 'npm run build -- --configuration production'
                        }
                    }
                }
            }
        }
        
        stage('Package Artifacts') {
            // 只有当有任何一个应用被构建时,才进入打包阶段
            when { expression { return changes.backend || changes.frontend } }
            steps {
                script {
                    // 使用 stash 来暂存构建产物,以便在后续的 stage (可能在不同的 agent 上) 使用
                    if (changes.backend) {
                        echo "Stashing backend release..."
                        dir('apps/backend_elixir') {
                            // 将 Elixir release 的 tarball 暂存起来
                            def release_path = sh(returnStdout: true, script: 'ls _build/prod/rel/backend_elixir/releases/*/*.tar.gz').trim()
                            stash name: 'backend-release', includes: release_path
                        }
                    }
                    if (changes.frontend) {
                        echo "Stashing frontend distribution..."
                        dir('apps/frontend_angular') {
                            // 将 Angular 构建出的静态文件暂存起来
                            stash name: 'frontend-dist', includes: 'dist/**'
                        }
                    }
                }
            }
        }
        
        // 部署阶段同样应该是条件化的
        stage('Deploy') {
            parallel {
                stage('Deploy Backend') {
                    when { expression { return changes.backend } }
                    steps {
                        // ... 从 stash 中取出 backend-release 并部署
                        echo "Deploying backend... (Implementation skipped)"
                    }
                }
                stage('Deploy Frontend') {
                    when { expression { return changes.frontend } }
                    steps {
                        // ... 从 stash 中取出 frontend-dist 并部署
                        echo "Deploying frontend... (Implementation skipped)"
                    }
                }
            }
        }
    }
}

流程可视化

这个经过优化的流水线逻辑可以用 Mermaid 图来清晰地表示:

graph TD
    A[Start] --> B{Detect Changes};
    B --> C{Backend Changed?};
    B --> D{Frontend Changed?};

    C -- Yes --> E[Run Backend CI];
    C -- No --> F[Skip Backend];
    
    D -- Yes --> G[Run Frontend CI];
    D -- No --> H[Skip Frontend];
    
    subgraph Parallel Stages
        E; G;
    end
    
    subgraph Endpoints
        F; H;
    end

    E --> I{Package Backend};
    G --> J{Package Frontend};
    
    I --> K[Deploy Backend];
    J --> L[Deploy Frontend];

    F --> M{Check if Any Build Ran};
    H --> M;

    M -- No --> N[End Pipeline];
    K --> N;
    L --> N;

结果与分析

切换到新的 Jenkinsfile 后,效果是立竿见影的:

  • 只修改前端代码: 流水线跳过了所有后端相关的阶段。mix deps.getmix testmix release 这些耗时操作被完全规避。构建时间从原来的 15 分钟缩短到了 4-5 分钟。
  • 只修改后端代码: npm ci 和 Angular 的测试与构建被跳过。构建时间缩短到 6-7 分钟。
  • 同时修改前后端代码: 由于 parallel 的使用,总时间约等于后端和前端中耗时较长的一方,大约在 8-10 分钟,也比原来的 15 分钟有显著提升。
  • 只修改文档(非应用代码): 流水线在“Detect Changes”阶段后就快速结束,耗时不到 1 分钟。

一个常见的错误是在 detectChangedApps 中使用 git diff HEAD~1...HEAD。这个命令只比较最近两次提交,对于一个包含了多个提交的 Pull Request,它会漏掉之前的变更。因此,使用 origin/main...HEAD 这种与目标分支比较的方式,在 PR 场景下是更稳健的选择。

局限性与未来迭代方向

尽管当前的方案极大地提升了效率,但它并非完美,依然存在一些局限和可以改进的方向。

首先,基于文件路径的变更检测逻辑相对脆弱。它无法处理更复杂的依赖关系。例如,如果我们引入了一个 libs/shared_types 目录,其中的代码被前端和后端同时依赖,那么当这个目录发生变更时,我们的脚本需要更新逻辑以触发前后端的双重构建。随着 Monorepo 变得越来越复杂,手动维护这些依赖规则会成为一个新的负担。

其次,Jenkins 本身并不是为处理 Monorepo 优化的最佳工具。像 Nx、Bazel 或 Lerna 这样的构建系统,它们内置了更强大的依赖关系图分析和受影响项目检测(affected commands)的能力。迁移到这类工具,可以让我们从手写 git diff 脚本的模式中解放出来,获得更精确、更可靠的增量构建。例如,使用 nx affected:build 命令会自动完成所有这些检测。

最后,缓存策略仍有优化空间。Jenkins 的 cache 指令依赖于 SCM 的实现,有时在不同 agent 间共享缓存可能效率不高。对于依赖项这种不常变动的内容,一个更优的策略是构建一个基础 Docker 镜像,预先安装好所有依赖,CI 流水线直接使用这个镜像,从而完全跳过 npm cimix deps.get 这两个步骤,只在依赖锁文件更新时才触发基础镜像的重建。


  目录