第 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
Dockerfile3.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: ClusterIP6.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-xxxxxxx6.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/CD | GitHub 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-90 | Docker、CI/CD、RBAC 项目 |
后续批次将按照相同风格继续推进:
- TypeScript + Vue3 + React + NestJS
- 中间件深入(Redis / Kafka / ES)
- 部署运维(Linux / CI/CD / 监控告警)
- 架构与安全(DDD / 微服务 / 分布式)