Skip to content

元数据卡

  • 前置知识:第4章(测试);会用 Git 分支
  • 预计时间:45 分钟
  • 核心难度:入门
  • 完成标志:能搭建一条包含测试、构建、部署的 CI/CD 流水线

你的进度

你重构完零件,本地测试全过,优雅地装进了成品箱。

第二天工匠之都的主仓库发现——你的零件和另一个学徒的零件尺寸冲突,两件东西装在同一个机器里卡死了。谁都不知道,直到交付那天才暴露。

你想起了从数据堡垒来的师兄说过:“我们在那边每件作品交付前都自动跑全量检验,没通过的不让入库。”你在工匠之都的流水线前站定:需要一条自动化的交付流水线。 你的任务

你写代码的环境(IDE、本地数据库、回旋余地)和线上环境(延迟、负载、网络抖动)之间存在一条鸿沟。你需要一座桥——在代码合并到主分支之前,自动完成测试、构建、部署验证。这座桥就是 CI/CD。这一章搭桥。

本章分层

  • 必读:GitHub Actions 流水线、Docker 构建、分支策略、三环境
  • 选读:自动化测试套件集成
  • 进阶:自托管 runner、蓝绿部署

本章不会要求你掌握

  • Kubernetes 编排
  • 监控告警系统

破局 · 溯源

你在分支上改了一个文件,提交,然后去接水。回来看到队友在群里发:"谁把测试搞挂了?"你一看,是你的代码有一个隐藏的类型错误——编辑器没报、本地测试通过了因为环境不同、但 CI 服务器上的 JDK 版本差异让代码抛了 ClassCastException。

"要是我 push 之前能自动跑一遍测试就好了。"

是的。这就是 CI(持续集成)的起点。

第一层:CI——每次 merge 前自动验证

持续集成的核心规则:每次 push 到共享分支,自动运行完整的构建、测试、静态检查。 任一项失败,阻止合并。

用 GitHub Actions 搭建你的第一条流水线。在你的仓库中创建 .github/workflows/ci.yml

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [ main, develop ]
  pull_request:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_DB: testdb
          POSTGRES_USER: test
          POSTGRES_PASSWORD: test
        ports:
          - 5432:5432
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v4
      
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      
      - name: Cache Gradle
        uses: actions/cache@v3
        with:
          path: ~/.gradle/caches
          key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }}
      
      - name: Build and test
        run: ./gradlew build
        env:
          SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/testdb
      
      - name: Static analysis
        run: ./gradlew check

      - name: Test report
        if: always()
        uses: dorny/test-reporter@v1
        with:
          name: JUnit Tests
          path: '**/build/test-results/test/*.xml'
          reporter: java-junit

这个工作流做了四件事:

  1. 启动一个 PostgreSQL 容器作为测试数据库
  2. 编译和运行所有测试
  3. 运行静态检查(Checkstyle、PMD、Error Prone)
  4. 发布测试报告到 PR 页面

你把 PR 发出去后,GitHub 自动跑这个流程。失败了?仓库配置了 "Require status checks to pass before merging"——你不能合入一个有红叉的分支。

第二层:CD——每次合并后自动部署

持续部署(CD)是 CI 的延伸:测试通过后,自动把构建产物(JAR、Docker 镜像)部署到目标环境。

第一步:Docker 化你的应用。

dockerfile
# Dockerfile
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY build/libs/tournament-*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
yaml
# .github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: [ main ]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Set up JDK 21
        uses: actions/setup-java@v4
        with:
          java-version: '21'
          distribution: 'temurin'
      
      - name: Build
        run: ./gradlew bootJar
      
      - name: Build Docker image
        run: |
          docker build -t tournament-api:${{ github.sha }} .
          docker tag tournament-api:${{ github.sha }} \
            ghcr.io/${{ github.repository }}:latest
      
      - name: Push to registry
        run: |
          echo ${{ secrets.GITHUB_TOKEN }} | docker login ghcr.io -u ${{ github.actor }} --password-stdin
          docker push ghcr.io/${{ github.repository }}:latest
      
      - name: Deploy to staging
        run: |
          # 假设你的服务器在某个环境变量指向的地址
          ssh deploy@${{ secrets.DEPLOY_HOST }} "
            docker pull ghcr.io/${{ github.repository }}:latest &&
            docker-compose up -d --no-deps api
          "

第三层:分支策略——代码流动的轨道

CI/CD 的基础是明确的分支策略。没有策略的 Git 仓库就像没有红绿灯的路口。

最常用的两种模式:

Git Flow(适合大团队/定期发布):

main ──┤ release-1.0 ├─── tag v1.0
         |               
develop ──┤ feature-X ├───┤ feature-Y ├───
  • main:只接受 release 分支的合并
  • develop:日常开发主干
  • feature/*:新功能分支
  • release/*:准备发布
  • hotfix/*:紧急修复

GitHub Flow(适合小团队/持续发布):

main ────────┤ feature-X (merged) ├──────
                |
                feature-X branch
  • main 永远是可部署状态
  • main 开功能分支
  • 功能完成后发 PR,测试通过后合入 main
  • 合入后自动部署

GitHub Flow 更简单,适合你的擂台系统——单个服务、小团队。

bash
# 你的日常工作流
git checkout -b feat/score-board
# 写代码...
git add .
git commit -m "feat: add score board endpoint"
git push origin feat/score-board
# 在 GitHub 上创建 PR
# PR 自动触发 CI → 测试通过 → 队友 review → 合入 main → 自动部署

第四层:三环境——开发、预发、生产

你注意到一个很不优雅的场景:本地建表 SQL 叫 V1__init.sql,线上跑了 V2__add_index.sql,你在本地建表时数据库 schema 和线上不一致——你测了"报名成功",但线上就是报 500。

解决方案:开发(dev)、预发(staging)、生产(production)三套环境,通过流水线逐步晋升。

开发者本地
   │ push
   v
开发环境 (dev)
   │ merge to main
   v
预发环境 (staging)  ← 生产环境镜像,外部不可见
   │ smoke test 通过
   v
生产环境 (production)

三环境的黄金规则:

  • 环境一致:预发环境和生产环境的配置(除 key/secret 外)尽量一致——同版本 JDK、同操作系统、同数据库版本。
  • 数据隔离:开发环境可以用伪造数据,预发环境用脱敏的生产数据子集。
  • 晋升唯一:只有经过了 CI/CD 流水线的某个构建产物,才能从一个环境晋升到下一个环境。
yaml
# application-dev.yml
server:
  port: 8080
spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/tournament_dev

# application-staging.yml
server:
  port: 8080
spring:
  datasource:
    url: jdbc:postgresql://staging-db:5432/tournament_staging

# application-prod.yml
server:
  port: 8080
spring:
  datasource:
    url: jdbc:postgresql://prod-db:5432/tournament_prod

第五层:基础设施即代码

你的队友在服务器上手动改了 Nginx 配置。三周后,服务器崩溃了,新搭一台——没人记得那个 Nginx 配置是什么。

基础设施即代码(IaC)的意思是:用代码描述你的基础设施,和你的应用代码一样受版本控制。

hcl
# ch06/terraform/main.tf
resource "aws_instance" "api" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"

  user_data = <<-EOF
    #!/bin/bash
    docker pull ghcr.io/your-org/tournament-api:latest
    docker run -d -p 8080:8080 \
      -e SPRING_DATASOURCE_URL=jdbc:postgresql://${aws_db_instance.main.endpoint}/tournament \
      ghcr.io/your-org/tournament-api:latest
  EOF

  tags = {
    Name = "tournament-api"
  }
}

resource "aws_db_instance" "main" {
  engine         = "postgres"
  engine_version = "16"
  instance_class = "db.t3.micro"
  db_name        = "tournament"
  username       = "admin"
  password       = var.db_password
}

不必要用 Terraform——docker-compose.ymlDockerfilenginx.conf 这些配置文件都受版本控制,也算 IaC。核心原则:人不碰服务器


常见陷阱

陷阱一:CI 跑 30 分钟。 你的测试很好,但每次 push 都要等半小时——团队开始绕过 CI 直接合并。解决方案:把测试拆成"快速检查"(5 分钟内的单元测试,每次 push 跑)和"完整套件"(定时或手动触发)。

yaml
# 快速检查(每次 push 跑)
name: Quick Check
on: push
jobs:
  unit-test:
    runs-on: ubuntu-latest
    steps:
      - run: ./gradlew test --tests "*.unit.*"
      
# 完整套件(nightly 或由 reviewer 触发)
name: Full Suite
on: workflow_dispatch
jobs:
  full-test:
    runs-on: ubuntu-latest
    steps:
      - run: ./gradlew test integrationTest e2eTest

陷阱二:凭据暴露。 数据库密码、API key 写死在代码里 push 到了 GitHub。补救:用 GitHub Secrets 或 Vault。

yaml
# 坏
- run: ./gradlew build -DDB_PASSWORD=supersecret123

# 好
- run: ./gradlew build
  env:
    DB_PASSWORD: ${{ secrets.DB_PASSWORD }}

陷阱三:"在我的机器上能跑"。 CI 环境和本地环境不一致——一个跑 Windows,一个跑 Linux;JDK 版本差一个点。用 Docker 统一环境,或者在 CI 上跑和线上同样的容器。

陷阱四:CD 部署到生产却没有回滚方案。 自动部署后出现了问题,你需要能快速回滚。至少做到的:保留前一个构建产物(Docker tag),部署脚本包含回滚命令。

yaml
# 回滚
- name: Rollback
  if: failure()
  run: |
    docker pull ghcr.io/${{ github.repository }}:${{ github.event.inputs.previous_sha }}
    ./deploy.sh ${{ github.event.inputs.previous_sha }}

通关挑战

  • 热身:为你当前的项目创建一个 .github/workflows/ci.yml——只需编译、跑测试、生成报告。
  • 挑战:把项目 Docker 化,写 Dockerfile 和 .dockerignore,验证本地 docker build && docker run 能正常工作。
  • 观察:找一个你常用的开源项目(Spring Boot、React),看看它的 .github/workflows/ 目录——它用了哪些 CI 步骤?测试跑在哪个操作系统上?

旅人笔记

CI/CD 把"写代码"到"上生产"之间的每一步自动化——push 触发测试、Merge 触发构建、Tag 触发部署——你不再是一个发版焦虑的程序员,而是一个通过流水线传递构建产物的工匠。


下一站预告

Part 1 到此结束。你掌握了软件工程基础——从构造到测试到重构到部署。但还有一个更大的领域等着你:当系统的规模继续膨胀,同一类问题反复出现时,有没有模板可以套?下一部分开始讲设计模式。首先,第7章——设计原则总复习。

Built with VitePress | Software Systems Atlas