Skip to content
第 90 / 250 章后端⏱ 10 分钟阅读

第 90 章:测试与部署

学习目标

  • 编写单元测试覆盖核心业务
  • 集成测试与覆盖率报告
  • Docker 镜像构建与生产部署
  • K8s 部署与 CI/CD 集成

一、单元测试

1.1 测试 UserService

java
@SpringBootTest
@Transactional
@Rollback
class UserServiceTest {

    @Autowired
    private UserService userService;

    @Autowired
    private UserMapper userMapper;

    @Test
    void testCreateUser() {
        UserCreateDTO dto = new UserCreateDTO();
        dto.setUsername("testuser");
        dto.setPassword("123456");
        dto.setNickname("测试用户");
        dto.setEmail("test@example.com");

        Long userId = userService.createUser(dto);

        assertNotNull(userId);
        User user = userMapper.selectById(userId);
        assertEquals("testuser", user.getUsername());
        assertNotEquals("123456", user.getPassword());  // 已加密
        assertTrue(user.getPassword().startsWith("$2a$"));  // BCrypt
    }

    @Test
    void testCreateUser_DuplicateUsername() {
        UserCreateDTO dto = new UserCreateDTO();
        dto.setUsername("admin");
        dto.setPassword("123456");
        dto.setNickname("管理员");

        assertThrows(BusinessException.class,
                () -> userService.createUser(dto));
    }

    @Test
    void testLogin() {
        LoginDTO dto = new LoginDTO();
        dto.setUsername("admin");
        dto.setPassword("admin123");

        LoginVO vo = userService.login(dto);
        assertNotNull(vo.getAccessToken());
        assertNotNull(vo.getRefreshToken());
        assertEquals("admin", vo.getUser().getUsername());
    }

    @Test
    void testLogin_WrongPassword() {
        LoginDTO dto = new LoginDTO();
        dto.setUsername("admin");
        dto.setPassword("wrong");

        assertThrows(BusinessException.class,
                () -> userService.login(dto));
    }
}

1.2 权限服务测试

java
@SpringBootTest
class PermissionServiceTest {

    @Autowired
    private PermissionService permissionService;

    @Autowired
    private UserMapper userMapper;

    @Test
    void testGetUserPermissions() {
        Long userId = 1L;  // admin
        Set<String> perms = permissionService.getUserPermissions(userId);
        assertFalse(perms.isEmpty());
        assertTrue(perms.contains("user:create"));
    }

    @Test
    void testCacheEffect() {
        Long userId = 1L;
        long start = System.currentTimeMillis();
        permissionService.getUserPermissions(userId);
        long first = System.currentTimeMillis() - start;

        start = System.currentTimeMillis();
        permissionService.getUserPermissions(userId);
        long second = System.currentTimeMillis() - start;

        // 第二次应该从缓存读,快很多
        assertTrue(second < first);
    }
}

1.3 Controller Mock 测试

java
@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Autowired
    private ObjectMapper objectMapper;

    @Test
    @WithMockUser(authorities = "user:create")
    void testCreateUser() throws Exception {
        UserCreateDTO dto = new UserCreateDTO();
        dto.setUsername("newuser");
        dto.setPassword("123456");
        dto.setNickname("新用户");

        mockMvc.perform(post("/api/user")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(objectMapper.writeValueAsString(dto)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.code").value(200));
    }

    @Test
    void testCreateUser_Unauthorized() throws Exception {
        mockMvc.perform(post("/api/user")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content("{}"))
                .andExpect(status().isUnauthorized());
    }
}

1.4 Mockito 单元测试

java
@ExtendWith(MockitoExtension.class)
class UserServiceMockTest {

    @Mock private UserMapper userMapper;
    @Mock private RoleMapper roleMapper;
    @Mock private PasswordEncoder passwordEncoder;
    @Mock private JwtUtil jwtUtil;
    @Mock private AuthenticationManager authenticationManager;

    @InjectMocks
    private UserServiceImpl userService;

    @Test
    void testCreateUser_Mock() {
        // ① given
        UserCreateDTO dto = new UserCreateDTO();
        dto.setUsername("mockuser");
        dto.setPassword("123456");
        dto.setNickname("Mock");

        when(userMapper.selectByUsername("mockuser")).thenReturn(null);
        when(passwordEncoder.encode("123456")).thenReturn("$2a$10$encoded");
        when(userMapper.insert(any(User.class))).thenAnswer(inv -> {
            User u = inv.getArgument(0);
            u.setId(1L);
            return 1;
        });

        // ② when
        Long id = userService.createUser(dto);

        // ③ then
        assertEquals(1L, id);
        verify(userMapper).insert(any(User.class));
        verify(passwordEncoder).encode("123456");
    }
}

二、覆盖率

2.1 pom.xml 配置 JaCoCo

xml
<plugin>
    <groupId>org.jacoco</groupId>
    <artifactId>jacoco-maven-plugin</artifactId>
    <version>0.8.11</version>
    <executions>
        <execution>
            <id>prepare-agent</id>
            <goals><goal>prepare-agent</goal></goals>
        </execution>
        <execution>
            <id>report</id>
            <phase>test</phase>
            <goals><goal>report</goal></goals>
        </execution>
        <execution>
            <id>check</id>
            <phase>verify</phase>
            <goals><goal>check</goal></goals>
            <configuration>
                <rules>
                    <rule>
                        <element>BUNDLE</element>
                        <limits>
                            <limit>
                                <counter>LINE</counter>
                                <value>COVEREDRATIO</value>
                                <minimum>0.80</minimum>
                            </limit>
                        </limits>
                    </rule>
                </rules>
            </configuration>
        </execution>
    </executions>
</plugin>

2.2 运行

bash
mvn clean test
mvn jacoco:report
# 报告:target/site/jacoco/index.html

三、Docker 镜像

3.1 多阶段 Dockerfile

dockerfile
# ============ 阶段 1:构建 ============
FROM maven:3.9-openjdk-17 AS builder

WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -B

COPY src ./src
RUN mvn clean package -DskipTests -B

# ============ 阶段 2:运行 ============
FROM eclipse-temurin:17-jre-jammy

LABEL maintainer="taskflow@example.com"

RUN apt-get update && apt-get install -y --no-install-recommends \
        curl tini \
    && rm -rf /var/lib/apt/lists/*

# JVM 调优
ENV JAVA_OPTS="\
    -XX:+UseG1GC \
    -XX:MaxRAMPercentage=75.0 \
    -XX:+PrintGCDetails \
    -XX:+HeapDumpOnOutOfMemoryError \
    -XX:HeapDumpPath=/logs/heap.hprof \
    -Djava.security.egd=file:/dev/./urandom \
    -Dspring.profiles.active=prod"

WORKDIR /app

COPY --from=builder /app/target/taskflow-application-*.jar app.jar

# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
    CMD curl -fs http://localhost:8080/actuator/health/liveness || exit 1

EXPOSE 8080

ENTRYPOINT ["/usr/bin/tini", "--", "sh", "-c", "exec java $JAVA_OPTS -jar app.jar"]

3.2 .dockerignore

target/
.git/
.idea/
.vscode/
*.iml
.mvn/
.m2/
logs/
*.log
README.md
docker-compose.yml
Dockerfile

3.3 构建并推送

bash
docker build -t taskflow:1.0.0 .
docker tag taskflow:1.0.0 registry.example.com/taskflow:1.0.0
docker push registry.example.com/taskflow:1.0.0

四、Docker Compose 生产部署

yaml
# docker-compose.prod.yml
version: '3.8'

services:
  mysql:
    image: mysql:8.0
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD_FILE: /run/secrets/mysql_root_password
    secrets:
      - mysql_root_password
    volumes:
      - mysql-data:/var/lib/mysql
      - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
      - ./sql/migrations:/docker-entrypoint-initdb.d/migrations:ro
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 10
    networks:
      - backend

  redis:
    image: redis:7-alpine
    restart: always
    command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis-data:/data
    networks:
      - backend

  rabbitmq:
    image: rabbitmq:3-management-alpine
    restart: always
    environment:
      RABBITMQ_DEFAULT_USER: user
      RABBITMQ_DEFAULT_PASS_FILE: /run/secrets/rabbitmq_password
    secrets:
      - rabbitmq_password
    volumes:
      - rabbitmq-data:/var/lib/rabbitmq
    networks:
      - backend

  app:
    image: registry.example.com/taskflow:1.0.0
    restart: always
    depends_on:
      mysql:
        condition: service_healthy
      redis:
        condition: service_started
      rabbitmq:
        condition: service_started
    environment:
      SPRING_PROFILES_ACTIVE: prod
      DB_HOST: mysql
      DB_PORT: 3306
      DB_USER: root
      DB_PASSWORD_FILE: /run/secrets/mysql_root_password
      REDIS_HOST: redis
      REDIS_PASSWORD: ${REDIS_PASSWORD}
      RABBITMQ_HOST: rabbitmq
      JWT_SECRET: ${JWT_SECRET}
    secrets:
      - mysql_root_password
    ports:
      - "8080:8080"
    networks:
      - backend
      - frontend

  nginx:
    image: nginx:alpine
    restart: always
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
      - ./dist:/usr/share/nginx/html:ro
    ports:
      - "80:80"
      - "443:443"
    depends_on:
      - app
    networks:
      - frontend

secrets:
  mysql_root_password:
    file: ./secrets/mysql_root_password.txt
  rabbitmq_password:
    file: ./secrets/rabbitmq_password.txt

volumes:
  mysql-data:
  redis-data:
  rabbitmq-data:

networks:
  backend:
  frontend:

五、Nginx 配置

nginx
# /etc/nginx/nginx.conf
events {
    worker_connections 1024;
}

http {
    upstream taskflow {
        server app:8080;
    }

    server {
        listen 80;
        server_name taskflow.example.com;

        # 前端
        location / {
            root /usr/share/nginx/html;
            try_files $uri $uri/ /index.html;
        }

        # 后端 API
        location /api/ {
            proxy_pass http://taskflow;
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;
            proxy_read_timeout 60s;
        }

        # Actuator 仅内网访问
        location /actuator/ {
            allow 10.0.0.0/8;
            deny all;
            proxy_pass http://taskflow;
        }

        # Swagger
        location /swagger-ui.html {
            proxy_pass http://taskflow;
        }
        location /v3/api-docs {
            proxy_pass http://taskflow;
        }
    }
}

六、K8s 部署

6.1 Deployment

yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: taskflow
  namespace: taskflow
spec:
  replicas: 3
  selector:
    matchLabels:
      app: taskflow
  template:
    metadata:
      labels:
        app: taskflow
    spec:
      containers:
        - name: app
          image: registry.example.com/taskflow:1.0.0
          ports:
            - containerPort: 8080
              name: http
          env:
            - name: SPRING_PROFILES_ACTIVE
              value: k8s
            - name: DB_HOST
              value: mysql
            - name: REDIS_HOST
              value: redis
          envFrom:
            - secretRef:
                name: taskflow-secrets
          resources:
            requests:
              memory: "512Mi"
              cpu: "500m"
            limits:
              memory: "1Gi"
              cpu: "1000m"
          livenessProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 60
            periodSeconds: 10
            timeoutSeconds: 3
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
            initialDelaySeconds: 30
            periodSeconds: 5
            timeoutSeconds: 3
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /actuator/health/liveness
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 5
            failureThreshold: 30
          volumeMounts:
            - name: heap-dump
              mountPath: /logs
      volumes:
        - name: heap-dump
          emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: taskflow
  namespace: taskflow
spec:
  selector:
    app: taskflow
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP

6.2 ConfigMap / Secret

yaml
# k8s/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: taskflow-config
  namespace: taskflow
data:
  application-k8s.yml: |
      spring:
        datasource:
          url: jdbc:mysql://mysql:3306/taskflow
        data:
          redis:
            host: redis
---
apiVersion: v1
kind: Secret
metadata:
  name: taskflow-secrets
  namespace: taskflow
type: Opaque
stringData:
  DB_USER: root
  DB_PASSWORD: your-secret-password
  REDIS_PASSWORD: your-redis-password
  JWT_SECRET: jwt-secret-must-be-at-least-32-bytes-long-xxxxxxx

6.3 Ingress

yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: taskflow
  namespace: taskflow
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: "100m"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - taskflow.example.com
        secretName: taskflow-tls
  rules:
    - host: taskflow.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: taskflow
                port:
                  number: 80

七、CI/CD

7.1 GitHub Actions

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

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

jobs:
  test:
    name: Test & Build
    runs-on: ubuntu-latest
    services:
      mysql:
        image: mysql:8.0
        env:
          MYSQL_ROOT_PASSWORD: root123
          MYSQL_DATABASE: taskflow_test
        ports:
          - 3306:3306
        options: >-
          --health-cmd "mysqladmin ping -h localhost"
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5
      redis:
        image: redis:7-alpine
        ports:
          - 6379:6379
    steps:
      - uses: actions/checkout@v4
      - name: Set up JDK 17
        uses: actions/setup-java@v4
        with:
          java-version: 17
          distribution: temurin
          cache: maven
      - name: Build & Test
        run: mvn -B clean verify
        env:
          DB_HOST: localhost
          DB_PORT: 3306
          REDIS_HOST: localhost
      - name: Upload Coverage
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: coverage-report
          path: target/site/jacoco/

  docker:
    name: Docker Build & Push
    needs: test
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Login to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_PASSWORD }}
      - name: Build & Push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: |
            ${{ secrets.DOCKER_USERNAME }}/taskflow:latest
            ${{ secrets.DOCKER_USERNAME }}/taskflow:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

  deploy:
    name: Deploy to K8s
    needs: docker
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        uses: azure/k8s-set-context@v4
        with:
          kubeconfig: ${{ secrets.KUBE_CONFIG }}
      - name: Apply manifests
        run: |
          kubectl apply -f k8s/
          kubectl set image deployment/taskflow \
            app=${{ secrets.DOCKER_USERNAME }}/taskflow:${{ github.sha }} \
            -n taskflow
          kubectl rollout status deployment/taskflow -n taskflow

八、生产环境检查清单

8.1 应用配置

  • [ ] spring.profiles.active=prod
  • [ ] Swagger UI 已关闭
  • [ ] Actuator 端点最小化暴露
  • [ ] 日志输出到文件(异步 + 滚动)
  • [ ] 关闭开发期错误堆栈输出
  • [ ] HTTPS 强制
  • [ ] 启用 CSRF(同源场景可关闭)

8.2 数据库

  • [ ] 慢 SQL 监控开启
  • [ ] 连接池参数根据 QPS 调优
  • [ ] 备份策略:每日全量 + 实时增量 binlog
  • [ ] 主从架构 + 读写分离

8.3 缓存

  • [ ] Redis 持久化(AOF)
  • [ ] 缓存预热脚本
  • [ ] 大 key 监控
  • [ ] 慢查询日志

8.4 监控

  • [ ] Prometheus 采集
  • [ ] Grafana 仪表盘
  • [ ] 关键告警(QPS/错误率/响应时间/GC/JVM)
  • [ ] 链路追踪(SLEUTH/ZIPKIN/SkyWalking)

8.5 性能

  • [ ] JVM 参数调优(G1 / 内存比例 / GC 日志)
  • [ ] 启动时间 < 30s
  • [ ] 接口 P99 < 500ms
  • [ ] 关键路径压测报告

8.6 安全

  • [ ] 漏洞扫描(OWASP Dependency-Check)
  • [ ] 密钥管理(K8s Secret / Vault)
  • [ ] WAF + DDoS 防护
  • [ ] 等保合规

九、上线步骤

bash
# 1. 构建镜像
docker build -t registry.example.com/taskflow:1.0.0 .

# 2. 推送
docker push registry.example.com/taskflow:1.0.0

# 3. 数据库迁移
kubectl exec -it mysql-pod -- mysql -uroot -p taskflow < migrations/V2__add_index.sql

# 4. 滚动更新
kubectl set image deployment/taskflow \
    app=registry.example.com/taskflow:1.0.0 -n taskflow

# 5. 观察
kubectl rollout status deployment/taskflow -n taskflow
kubectl logs -f deployment/taskflow -n taskflow

# 6. 灰度(按需)
# 蓝绿 / 金丝雀发布

# 7. 验证
curl https://taskflow.example.com/actuator/health

十、本章小结

要点关键
单元测试JUnit 5 + Mockito + AssertJ
覆盖率JaCoCo(≥ 80%)
镜像构建多阶段 Dockerfile + distroless
部署方式Docker Compose / K8s
K8s 探针Liveness / Readiness / Startup
CI/CDGitHub Actions 自动构建 + 推送 + 部署
灰度发布蓝绿 / 金丝雀
上线清单配置 / 监控 / 性能 / 安全

动手练习

练习 1:基础题

编写 UserServiceTest,覆盖登录、创建用户、重复用户名的异常路径。

练习 2:进阶题

用 JaCoCo 跑覆盖率报告,达到 80% 行覆盖。

练习 3:思考题

灰度发布有哪几种方案?蓝绿 vs 金丝雀 vs 滚动更新各自的优缺点?


TaskFlow 项目实战总结

到这里 TaskFlow RBAC 权限管理系统 的全部 9 个实战章节就完成了。我们从项目初始化、数据库设计、通用模块,到用户认证、角色权限、部门字典、操作日志,再到代码生成、测试与部署,构建了一个完整的企业级 SpringBoot 3 后端

学到的不只是代码,更是工程化的思考方式

  • 多模块拆分:parent / common / system / application
  • 统一响应 + 全局异常:每个接口都有相同的返回结构
  • 双 Token + Refresh 轮换:兼顾安全与体验
  • RBAC + 数据权限:灵活的权限体系
  • Redis 缓存:权限、字典加速
  • AOP 日志 + 异步线程池:不阻塞业务
  • 代码生成器:从数据库到 CRUD 一键完成
  • Docker + CI/CD:从代码到上线全流程自动化

这套项目结构与最佳实践可以直接复用到你的下一个企业级项目中。


🎉 第一批 90 章全部完成!

部分章节主题
Java 基础1-40语法、OOP、集合、并发、JVM
SpringBoot 企业级41-77配置、异常、缓存、MQ、安全、监控
综合实战78-90Docker、CI/CD、RBAC 项目

后续批次将按照相同风格继续推进:

  • TypeScript + Vue3 + React + NestJS
  • 中间件深入(Redis / Kafka / ES)
  • 部署运维(Linux / CI/CD / 监控告警)
  • 架构与安全(DDD / 微服务 / 分布式)

本站基于 VitePress 构建 · 由 Codebook 团队维护