diff --git a/backend/02-layering.md b/backend/02-layering.md index 9f9d6eb..72cac7f 100644 --- a/backend/02-layering.md +++ b/backend/02-layering.md @@ -20,6 +20,24 @@ domains/xxx/ - **domain**(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。 - **infrastructure**:`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、`WebClient`/Feign 外部调用实现。 +## 对象命名约定(PO / DAO / BO / DTO / VO) + +Java 生态里这几个缩写来源不一、经常被混用,这里把我们实际用的名字和这些通用叫法对应清楚,避免团队内部各叫各的: + +| 通用叫法 | 全称 | 所在层 | 我们的命名 | +| --- | --- | --- | --- | +| PO | Persistent Object | infrastructure | `XxxEntity`(JPA entity,见 [03-persistence.md](./03-persistence.md)) | +| DAO | Data Access Object | infrastructure | `XxxJpaRepository`(Spring Data JPA repository 接口) | +| BO | Business Object | domain(可选) | `domain` 层的领域模型,如本文 `WebviewTicket` | +| DTO | Data Transfer Object | api | **统称**,不是单独的类;`Request`/`Response` 都是 DTO 的具体形态 | +| VO | View Object | api | `Xxx*Response`,即返回给前端的对象 | + +落地规则: + +- **类名统一用 `Request`/`Response` 后缀**,不额外起 `XxxDTO`/`XxxVO` 这样的名字——`Request`/`Response` 已经把方向(输入/输出)表达清楚了,`DTO`/`VO` 只是这两者的统称,没必要在类名上重复。 +- **`domain` 层模型(BO)不是必须的**,规则见下一节;没有 `domain` 层时,`Entity`(PO)直接由 `application`/`api` 层转换成 `Response`,不会凭空多出一个 BO。 +- **`Entity`(PO)永远不跨出 `infrastructure` 层**,`api`/`application` 看到的最多是 `domain` 层模型或 `Response`,见 [06-api-design.md](./06-api-design.md) 里 `Entity → Response` 的 MapStruct 转换约定。 + ## 何时可以跳过 domain 层 - **可以跳过**:简单 CRUD、没有跨 repository 协调、没有状态机——`application` 直接依赖 `infrastructure` 里定义的 repository/client 接口即可(接口和实现放在同一层)。 diff --git a/backend/06-api-design.md b/backend/06-api-design.md index 215ff3f..849aaca 100644 --- a/backend/06-api-design.md +++ b/backend/06-api-design.md @@ -96,6 +96,37 @@ data class StoreResponse( Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段(比如某个只有 `infrastructure` 层需要的标记位)被不小心带出去。 +## DTO 与 entity 的转换:用 MapStruct + +转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写。字段名一致的直接映射,不一致的用 `@Mapping` 指定,编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。 + +```groovy +// build.gradle(Kotlin 项目用 kapt 做注解处理) +plugins { + id 'org.jetbrains.kotlin.kapt' +} + +dependencies { + implementation 'org.mapstruct:mapstruct:1.6.3' + kapt 'org.mapstruct:mapstruct-processor:1.6.3' +} +``` + +```kotlin +// api/mapper/StoreMapper.kt +@Mapper(componentModel = "spring") +interface StoreMapper { + fun toResponse(entity: StoreEntity): StoreResponse + + @Mapping(target = "displayName", source = "name") + fun toSummary(entity: StoreEntity): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定 + + fun toResponseList(entities: List): List +} +``` + +`componentModel = "spring"` 让 MapStruct 生成的实现类自动注册成 Spring bean,直接在 `application` 层注入 `StoreMapper` 使用,不用手动 `new`。 + ## 关键规则 - Controller 不直接返回 entity,统一走 `Xxx*Response` DTO,避免持久层字段变更影响 API 契约。 @@ -115,7 +146,6 @@ Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`—— ## 待补充 -- DTO 与 entity 的转换方式(MapStruct 还是手写 mapper,模块变多之后再评估)。 - 分页/排序参数的统一约定。 - 错误码表(按 domain 分段还是全局统一编码)。 @@ -124,3 +154,4 @@ Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`—— - [springdoc-openapi](https://springdoc.org/) - [Spring 官方 Bean Validation 指南](https://docs.spring.io/spring-framework/reference/core/validation/beanvalidation.html) - [Microsoft REST API 设计指南](https://github.com/microsoft/api-guidelines) +- [MapStruct 官方文档](https://mapstruct.org/documentation/stable/reference/html/) diff --git a/backend/07-config-governance.md b/backend/07-config-governance.md index d83c865..7cfef4c 100644 --- a/backend/07-config-governance.md +++ b/backend/07-config-governance.md @@ -101,6 +101,62 @@ class WorkbenchProperties { } ``` +## 本地开发怎么跑(不需要真的连 K8s) + +`spring-cloud-starter-kubernetes-client-config` 启动时默认会尝试读 `~/.kube/config` 或 in-cluster 凭证去调 K8s API 拿 ConfigMap,本地电脑上没有这些东西的话,启动会报错或者卡住去连一个不存在的 API Server。本地开发不需要也不应该依赖真实 K8s,两种可选方案: + +**方案一(推荐,日常开发默认用这个):本地 profile 直接关掉 Spring Cloud Kubernetes** + +```yaml +# application-local.yml +spring: + cloud: + kubernetes: + config: + enabled: false # 本地不连 K8s API,配置全部走本地文件 + reload: + enabled: false + datasource: + url: jdbc:postgresql://localhost:5432/conti_backend + username: conti + password: conti_local_password # 仅本地开发用,不是真实密钥 +security: + jwt: + secret: local-dev-only-secret-not-for-real-use +``` + +```bash +# 本地起依赖(DB 等),配合 docker-compose 用 +docker compose up -d postgres + +# 用 local profile 启动 +SPRING_PROFILES_ACTIVE=local ./gradlew :bootstrap:bootRun +``` + +`application-local.yml` 不提交敏感真实值(本来也没有,本地密码本身就是假的),可以放进代码库方便新人直接跑起来;`local` profile 和 dev/uat/prod 的关键区别就是 `spring.cloud.kubernetes.config.enabled=false`,其余代码逻辑完全一致——这也是为什么 [06-api-design.md](./06-api-design.md) 强调的"配置外置"很重要:业务代码不知道、也不需要知道配置到底来自 K8s 还是本地文件。 + +这是**个人本机调试**用的,跟团队共享的 **Dev 环境**不是一回事——Dev 环境跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,走真实的 K8s ConfigMap/Secret(跟下面"方案二"是同一套思路,只是长期跑着给团队用,而不是临时验证),团队通过公司 VPN 访问,具体见 [09-build-deploy.md](./09-build-deploy.md#环境层级local个人本机vs-dev内网-ubuntu-k3s-集群vs-uatprodazure-aks)。 + +**方案二(需要验证 ConfigMap 热更新等 K8s 特有行为时才用):本地起一个真实的小集群** + +用 Docker Desktop 自带的 Kubernetes、[Kind](https://kind.sigs.k8s.io/)(Kubernetes in Docker)或 [Minikube](https://minikube.sigs.k8s.io/) 在本机跑一个单节点集群,把上面"ConfigMap / Secret 示例"里的 yaml 应用到本地集群,验证 `@RefreshScope` 热更新、`ServiceAccount` 权限这些真正依赖 K8s API 的行为: + +```bash +kind create cluster --name conti-local +kubectl config use-context kind-conti-local + +kubectl create namespace retailapp-local +kubectl apply -f k8s/configmap-local.yaml -n retailapp-local +kubectl apply -f k8s/secret-local.yaml -n retailapp-local + +# 应用本身也可以跑成本地集群里的 Pod(用本地构建的镜像), +# 或者在宿主机直接跑 jar、用 KUBECONFIG 指向 kind 集群验证配置读取 +export KUBECONFIG=~/.kube/config +SPRING_PROFILES_ACTIVE=dev ./gradlew :bootstrap:bootRun +``` + +日常业务开发用方案一就够了,只有专门验证"配置中心相关能力本身"(比如调试 `spring.cloud.kubernetes.reload` 轮询逻辑)才需要方案二。 + ## 关键规则 - 配置变更优先走 ConfigMap 热更新(`@RefreshScope` + `spring.cloud.kubernetes.reload`),不重新构建镜像;涉及 Secret 轮换的走正常发布流程(Secret 变化通常需要重启 Pod 才能生效,不像 ConfigMap 可以做到无重启热更)。 @@ -116,11 +172,70 @@ class WorkbenchProperties { 所以规则很简单:**这个值如果出现在日志里、被同事在 `kubectl get configmap -o yaml` 时看到会不会造成安全问题**——会,就放 Secret;不会,就放 ConfigMap。DB 密码、JWT 签名密钥、第三方 API key 毫无疑问要放 Secret;而"首页降级提示文案"这种放哪都无所谓的东西放 ConfigMap 就行,还能享受到热更新不用走发布流程的好处。 +## Azure 上 Secret 的真正来源:Key Vault(不是手写 K8s Secret) + +前面 `k8s/secret-uat.yaml` 示例里 `stringData` 写的是占位符(`__injected_by_pipeline__`),这一节说清楚这个占位符具体是怎么被替换成真实值的。 + +根据现有部署架构(见 [Architecture-Diagram/deployment-architecture-diagram.drawio](../Architecture-Diagram/deployment-architecture-diagram.drawio)),我们的 AKS 是 **Private AKS Cluster**,Key Vault 也是通过 **Private Endpoint**(`privatelink.vaultcore.azure.net`)访问的——也就是说真实密钥长期存在 Azure Key Vault 里,代码库、镜像、Git 历史里都不出现明文。落地到 K8s Secret 有两种方式,我们现在用的是方式一(跟 CI/GitLab 侧的配置习惯一致,不需要额外在 AKS 上装东西)。 + +**方式一(现用):CI/CD 流水线在部署前从 Key Vault 读值,渲染成 K8s Secret** + +```bash +# GitLab CI job 里(Runner 需要有权限访问 Key Vault,见 09-build-deploy.md) +JWT_SECRET=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-secret --query value -o tsv) +kubectl create secret generic conti-backend-secret \ + --namespace retailapp-uat \ + --from-literal=SECURITY_JWT_SECRET="$JWT_SECRET" \ + --dry-run=client -o yaml | kubectl apply -f - +``` + +- 密钥值只在 CI job 的执行过程中短暂出现(不会写进 CI 日志、不会落盘到镜像里),`kubectl apply` 之后就是一个普通的 K8s Secret,`Deployment` 照常用 `envFrom.secretRef` 引用(见前面 `k8s/deployment-uat.yaml` 示例)。 +- 好处是**不需要在 AKS 上额外装插件、不需要给节点/Pod 配置 Managed Identity 绑定**,全部配置集中在 GitLab(CI 变量里存 Runner 访问 Key Vault 所需的 Service Principal/Managed Identity,业务代码和 K8s yaml 完全不感知 Key Vault 的存在),跟本地开发用 `application-local.yml` 手工填值、只是"谁来填值"变成了流水线,心智负担更小。 +- 权衡:Key Vault 里密钥更新后,**不会自动同步**到已经跑着的 Secret,需要重新跑一次部署(或专门加一个"仅同步 Secret,不发版本"的 job)才能生效——这点上不如方式二自动。日常密钥轮换频率不高的情况下这个权衡是划算的。 + +**方式二(可选的未来增强):CSI Secret Store Driver,运行时直接挂载,K8s Secret 不落地明文** + +
+展开查看(AKS 需装 `azure-keyvault-secrets-provider` 插件 + 配置 Managed Identity,运维成本更高,暂不采用) + +```yaml +# k8s/secretproviderclass-uat.yaml +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: conti-backend-kv-uat + namespace: retailapp-uat +spec: + provider: azure + parameters: + usePodIdentity: "false" + useVMManagedIdentity: "true" # 用 AKS 节点/Pod 的 Managed Identity 免密访问 Key Vault + userAssignedIdentityID: "" + keyvaultName: "conti-backend-kv" + tenantId: "" + objects: | + array: + - | + objectName: security-jwt-secret + objectType: secret + secretObjects: # 顺便同步成一个 K8s Secret,供 envFrom 引用 + - secretName: conti-backend-secret + type: Opaque + data: + - objectName: security-jwt-secret + key: SECURITY_JWT_SECRET +``` + +优点是密钥更新后 CSI driver 会定期轮询自动同步、Pod 用 Managed Identity 直连 Key Vault 不经过 CI;代价是要在 AKS 上启用插件、每个环境配置对应的 `SecretProviderClass` 和身份绑定,运维配置面更大。如果以后密钥轮换频率变高、或者审计要求"密钥不能经过 CI 执行上下文",再切换到这条路径,现阶段先用方式一。 + +
+ +两种方式**不需要同时维护**——选一个用,文档里保留方式二只是留个参考路径,不是说两者要并存。 + ## 待补充 - 具体 ConfigMap/Secret 命名规范和 namespace 划分细节。 - 多环境 profile 的详细参数列表。 -- 是否需要接入 Azure Key Vault 做 Secret 的进一步加固。 ## 参考链接 diff --git a/backend/08-observability.md b/backend/08-observability.md index 07d9daa..7dab3b2 100644 --- a/backend/08-observability.md +++ b/backend/08-observability.md @@ -90,6 +90,53 @@ implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'io.micrometer:micrometer-registry-prometheus' ``` +## K8s 探针配置(liveness / readiness) + +`management.endpoint.health.probes.enabled=true` 只是让 Spring Boot 暴露出 `/actuator/health/liveness`、`/actuator/health/readiness` 两个分组端点,真正让 K8s 用起来还需要在 Deployment 里配置探针指向这两个端点: + +```yaml +# k8s/deployment-uat.yaml(节选,补充探针配置) +spec: + containers: + - name: conti-backend + livenessProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + initialDelaySeconds: 30 # 给 JVM 启动、Flyway migration 留够时间,太短会导致刚启动就被误杀重启 + periodSeconds: 10 + readinessProbe: + httpGet: + path: /actuator/health/readiness + port: 8080 + initialDelaySeconds: 10 + periodSeconds: 5 +``` + +两者失败后的处理完全不同,容易搞混: + +- **`livenessProbe` 失败** → K8s 认为这个 Pod 已经"死掉"(比如死锁、内存泄漏导致完全无响应),直接**重启**这个 Pod。 +- **`readinessProbe` 失败** → K8s 只是把这个 Pod 从 Service 的 Endpoints 里**摘除**(不再转发流量给它),不重启;等探针恢复健康后自动重新加回来——典型场景是数据库连接池暂时耗尽、正在处理慢请求,这种情况不需要重启,只需要暂时别把新流量导过去。 + +`readiness` group 默认会包含数据库连接(`DataSourceHealthIndicator`)等下游依赖检查,`liveness` group 默认只检查应用自身状态(不含外部依赖)——这个区分本身也是为了避免"F6 挂了导致 liveness 失败、Pod 被不断重启"这种误杀,外部依赖异常应该走 [05-integration-layer.md](./05-integration-layer.md) 的熔断降级,而不是拖累 K8s 探针。 + +## Resilience4j 指标接入 Micrometer + +[05-integration-layer.md](./05-integration-layer.md) 里给 F6/Mini 调用配置的超时、重试、熔断器,本身的运行状态(比如熔断器当前是 `CLOSED`/`OPEN`/`HALF_OPEN`,重试了多少次)也应该能在监控里看到,不然只能等到线上报错才知道降级生效了: + +```groovy +// build.gradle +implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0' +``` + +加上这个依赖后,`CircuitBreakerRegistry`/`RetryRegistry`/`TimeLimiterRegistry` 会自动把状态注册成 Micrometer meter,不需要手写埋点代码,跟着现有的 `/actuator/prometheus` 一起暴露出去,常用的几个: + +- `resilience4j_circuitbreaker_state{name="f6-api", state="open"}`:熔断器当前状态(0/1),可以直接在 Grafana 上画出"F6 熔断器什么时候跳闸"的时间线。 +- `resilience4j_circuitbreaker_calls{name="f6-api", kind="failed"}`:调用失败次数,配合 `kind="successful"` 算出实时失败率。 +- `resilience4j_retry_calls{name="f6-api", kind="successful_with_retry"}`:重试后成功的次数,能看出"降级到底靠不靠重试兜住的"。 + +这几个指标配合 [Prometheus 告警规则](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/),可以在熔断器进入 `OPEN` 状态时直接告警,而不是等用户反馈"下单功能卡住了"才发现。 + ## 审计日志示例 ```kotlin @@ -145,3 +192,5 @@ fun issueTicket(userId: Long, storeId: Long): WebviewTicket { ... } - [SLF4J MDC 官方文档](https://www.slf4j.org/manual.html#mdc) - [Micrometer 官方文档](https://docs.micrometer.io/micrometer/reference/) - [Spring Boot Actuator 官方文档](https://docs.spring.io/spring-boot/reference/actuator/index.html) +- [Spring Boot Kubernetes Probes 官方文档](https://docs.spring.io/spring-boot/reference/actuator/kubernetes-probes.html) +- [Resilience4j Micrometer 官方文档](https://resilience4j.readme.io/docs/micrometer) diff --git a/backend/09-build-deploy.md b/backend/09-build-deploy.md index f8c641b..e0bccac 100644 --- a/backend/09-build-deploy.md +++ b/backend/09-build-deploy.md @@ -2,7 +2,7 @@ ## 决策 -Gradle 多模块统一构建,`bootstrap` 产出单一 jar/Docker 镜像;沿用现有 GitLab CI/CD → Azure 部署链路(见 [Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio](../Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio))。 +Gradle 多模块统一构建,`bootstrap` 产出单一 jar/Docker 镜像;三个环境里 UAT/Prod 部署到 Azure AKS(见 [Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio](../Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio)),Dev 部署到公司内网一台 Ubuntu 服务器上的 k3s 集群(团队通过公司 VPN 访问,手动执行部署脚本,不接入 CI/CD 自动触发),个人日常调试用的是更轻量的 `local` profile(不经过任何 K8s,见下文区分)。 ## 结构约定 @@ -31,50 +31,202 @@ ENTRYPOINT ["java", "-jar", "app.jar"] 多阶段构建的好处:最终镜像只包含 JRE + 一个 jar,不带 Gradle 缓存、源码、编译工具链,镜像体积和攻击面都更小。 -## GitLab CI 示例(节选) +## 环境层级:local(个人本机)vs Dev(内网 Ubuntu k3s 集群)vs UAT/Prod(Azure AKS) + +三层环境的定位不一样,容易混淆,先说清楚区别: + +| 环境 | 跑在哪 | 是否过 K8s | 是否走 CI/CD | 访问方式 | +| --- | --- | --- | --- | --- | +| **local** | 开发者自己电脑 | 否,`local` profile 直接关掉 Spring Cloud Kubernetes(见 [07-config-governance.md](./07-config-governance.md#本地开发怎么跑不需要真的连-k8s)) | 否 | 只有自己,localhost | +| **Dev** | 公司内网一台 Ubuntu 服务器,跑 [k3s](https://k3s.io/)(轻量级单节点 K8s 发行版) | 是,真实 K8s 集群 | 否,手动执行部署脚本(见下文) | 团队通过公司 **VPN** 访问(连上 VPN 后即可直接访问这台机器的内网地址) | +| **UAT / Prod** | Azure **Private AKS** | 是 | 是 | 见后面阶段四/五 | + +`local` 是纯个人编码调试用的,跑得最快、依赖最少;`Dev` 是团队共享的、真实跑在 K8s 里的验证环境,行为上(ConfigMap 热更新、Secret 挂载方式、Deployment 滚动更新)跟 UAT/Prod 是一致的,只是物理上跑在公司内网的一台 Ubuntu 服务器而不是 Azure——这也是为什么选 k3s 这样一个真实的、哪怕是单节点的 K8s 发行版,而不是简单用 `docker compose` 起一堆容器:**能验证真实 K8s 行为,而不只是"能不能跑起来"**。这台机器本身就是 Ubuntu(跟 AKS 节点同为 Linux),k3s 直接跑在宿主机上,没有额外的虚拟化层。 + +### Dev 环境怎么部署(手动脚本,不接入 CI/CD 自动触发) + +Dev 不需要跟 UAT/Prod 一样接自动化流水线,谁想更新 Dev 环境,连上公司 VPN,本机配置好指向这台机器的 `KUBECONFIG`,手动跑一下部署脚本就行: + +```bash +#!/usr/bin/env bash +# scripts/deploy-dev.sh +# 用法:./scripts/deploy-dev.sh +set -euo pipefail +IMAGE_TAG=${1:?"必须传一个镜像 tag,比如某次 main 分支的 commit-sha"} + +kubectl create secret generic conti-backend-secret -n retailapp-dev \ + --from-literal=SECURITY_JWT_SECRET="dev-only-fake-secret" \ + --from-literal=DB_PASSWORD="dev-only-fake-password" \ + --dry-run=client -o yaml | kubectl apply -f - +kubectl apply -f k8s/configmap-dev.yaml -n retailapp-dev +kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$IMAGE_TAG -n retailapp-dev +kubectl rollout status deployment/conti-backend -n retailapp-dev --timeout=180s +``` + +几个和 UAT/Prod 不一样的地方: + +- **不需要 Runner,也不需要接进 `.gitlab-ci.yml`**:镜像已经由阶段三的 `docker-build-push` job(合入 `main` 时自动触发)推到 ACR 了,Dev 这一步只是"把已经存在的镜像 apply 到这台机器",谁需要验证最新代码,自己连 VPN 跑一下脚本,不需要为此单独维护一条自动化流水线。 +- **不用 Key Vault**:这台机器到不了 Azure Key Vault 的 Private Endpoint,Dev 环境的 Secret 就是写死的假值(跟 `local` profile 里的假密码同一个思路),不是真实密钥,本来 Dev 环境也不该碰生产密钥。 +- **镜像可以是任意 commit-sha**:想验证哪次提交,脚本参数传哪个 tag,不需要等到打 release tag,因为 Dev 不参与"Build once, promote across environments"这条只针对 UAT/Prod 的发布晋升链路。 +- 需要提前把 `KUBECONFIG`(k3s 默认生成在 `/etc/rancher/k3s/k3s.yaml`,把里面 `https://127.0.0.1:6443` 换成机器的内网 IP)分发给需要部署/排查 Dev 环境的团队成员,连上 VPN 后即可直接用。 + +## CI/CD 到 Kubernetes 的完整流程 + +整体沿用 [Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio](../Architecture-Diagram/gitlab-cicd-azure-deployment-diagram.drawio) 里已经确认的流水线阶段,核心原则是 **"Build once, promote across environments with versioned artifacts and gated approvals"**——这条原则针对的是 UAT/Prod 之间的晋升;Dev 不在这条流水线里(见上一节,手动脚本部署)。下面按阶段展开 UAT/Prod 这条主链路,并结合我们 [部署架构](../Architecture-Diagram/deployment-architecture-diagram.drawio) 是 **Private AKS**(只能通过 Private Endpoint 访问)这个关键约束说明每一步具体怎么落地。 + +### 阶段一:Source & Triggers(触发) + +```yaml +# .gitlab-ci.yml(节选) +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' # MR 触发 CI Validation(不部署) + - if: '$CI_COMMIT_BRANCH == "main"' # main 分支合入触发 CI Validation + - if: '$CI_COMMIT_TAG' # 打 protected tag 触发"选择版本发布"流程 +``` + +- 日常开发:Developer 提 Merge Request → 触发 **CI Validation**(下一阶段),只做质量门禁,不产出可发布制品。 +- 发布:在 `main` 分支上打一个 **protected tag**(如 `v1.4.0`),触发"选择要部署的版本"这条链路——这是唯一能进入 Artifact & Release Controls 之后阶段的入口,普通分支/MR 流水线到 CI Validation 就结束,避免任何未评审代码意外流入生产。 + +### 阶段二:CI Validation(质量门禁) ```yaml -# .gitlab-ci.yml stages: - - build - - test + - validate - package - - deploy + - release + - deploy-uat + - deploy-prod -build: - stage: build +lint: + stage: validate script: - - ./gradlew build -x test --no-daemon + - ./gradlew ktlintCheck detekt --no-daemon # Lint / Static Checks -test: - stage: test +unit-integration-test: + stage: validate script: - - ./gradlew test --no-daemon + - ./gradlew test --no-daemon # Unit/Integration Tests,含 Testcontainers(见 10-testing.md) artifacts: reports: junit: '**/build/test-results/test/TEST-*.xml' -docker-build: - stage: package +build-package: + stage: validate script: - - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . - - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA + - ./gradlew :bootstrap:bootJar --no-daemon # Build/Package -deploy-uat: - stage: deploy - environment: uat - only: - - main +security-scan: + stage: validate script: - - kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -n retailapp-uat + - ./gradlew dependencyCheckAnalyze # Security/Quality Scan(依赖漏洞扫描) ``` -具体 stage 划分、部署到 Azure AKS 的凭证配置沿用现有 `gitlab-cicd-azure-deployment-diagram` 里已经跑通的流程,这里只体现"单一镜像、按环境部署"这条主线。 +这四个 job 对应架构图里 CI Validation 阶段的四项检查,都跑在 GitLab Runner 上,任意一项失败流水线即中止——这一步只验证代码质量,**不产出会被部署的镜像**,MR 流水线到这里就结束。 + +### 阶段三:Artifact & Release Controls(制品与发布控制) + +```yaml +docker-build-push: + stage: package + rules: + - if: '$CI_COMMIT_BRANCH == "main"' + - if: '$CI_COMMIT_TAG' + script: + - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA . + - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA # 推送到 Azure Container Registry(ACR) + +cut-release: + stage: release + rules: + - if: '$CI_COMMIT_TAG' + script: + # 把已经验证过的 commit-sha 镜像"打标"成不可变的发布版本,而不是重新构建 + - az acr import --name $ACR_NAME --source $ACR_NAME.azurecr.io/conti-backend:$CI_COMMIT_SHORT_SHA --image conti-backend:$CI_COMMIT_TAG +``` + +- `docker-build-push` 把镜像推到 **Azure Container Registry**,Runner 需要有 ACR 的 `AcrPush` 权限(通过 Managed Identity 或 Service Principal 认证,不使用固定密码)。 +- `cut-release` 是"Promotion Gate"的起点:只有打了 tag 才会生成一个**不可变的发布版本**(`az acr import` 把 commit-sha 镜像复制成一个语义化 tag,源镜像内容不变,只是多一个别名),后续 UAT/Prod 部署的都是这同一个镜像摘要(digest),保证"UAT 验证过的和 Prod 部署的字节级一致",呼应前面"一个镜像走所有环境"的原则。 +- 回滚就是这一层的直接应用:出问题时不重新构建,而是把 Deployment 的镜像 tag 改回上一个已批准的 release 版本(见下面 `rollback` job)。 + +### 阶段四:CD to Azure(部署到 UAT/Prod) + +**私有 AKS 对 Runner 的网络要求**:架构图确认 AKS 是 **Private Cluster**(Kubernetes API Server 只能通过 Private Endpoint 访问),这意味着 GitLab 默认的共享公网 Runner **连不上**这个 API Server。落地方式:在 AKS 所在 VNet(或对等互联的 VNet)内部署 **self-hosted GitLab Runner**(跑成 AKS 里的一个专门 namespace,或者 VNet 里的一台 VM/VMSS),只有这个 Runner 能执行 `deploy-*` 系列 job。 + +```yaml +deploy-uat: + stage: deploy-uat + tags: + - azure-vnet-runner # 指定跑在能访问私有 AKS 的 self-hosted runner 上 + environment: + name: uat + rules: + - if: '$CI_COMMIT_TAG' + when: manual # Promotion Gate:需要人工点击"Promote to UAT" + script: + - az login --identity # Runner 用 Managed Identity 登录 Azure + - az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing + # Key Vault / Config Retrieval:从 Key Vault 读值渲染成 K8s Secret(见 07-config-governance.md 方式一) + - JWT_SECRET=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-secret --query value -o tsv) + - DB_PASSWORD=$(az keyvault secret show --vault-name conti-backend-kv --name db-password --query value -o tsv) + - kubectl create secret generic conti-backend-secret -n retailapp-uat + --from-literal=SECURITY_JWT_SECRET="$JWT_SECRET" + --from-literal=DB_PASSWORD="$DB_PASSWORD" + --dry-run=client -o yaml | kubectl apply -f - + - kubectl apply -f k8s/configmap-uat.yaml -n retailapp-uat + - kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-uat + - kubectl rollout status deployment/conti-backend -n retailapp-uat --timeout=180s + - curl -sf https://uat.internal.example.com/actuator/health || exit 1 + +deploy-prod: + stage: deploy-prod + tags: + - azure-vnet-runner + environment: + name: production + rules: + - if: '$CI_COMMIT_TAG' + when: manual # Promotion Gate:需要更高权限的人工审批 + script: + - az login --identity + - az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing + - kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG -n retailapp-prod + - kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s + - curl -sf https://api.example.com/actuator/health || exit 1 + +rollback-prod: + stage: deploy-prod + tags: + - azure-vnet-runner + environment: + name: production + when: manual # 手动触发,回滚到"上一个已批准的镜像 tag" + script: + - az login --identity + - az aks get-credentials --resource-group $RG --name $AKS_NAME --overwrite-existing + - kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$LAST_APPROVED_TAG -n retailapp-prod + - kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s +``` + +几个关键点: + +- **`tags: [azure-vnet-runner]`**:强制这几个 job 只能被部署在 AKS 私有网络内、能直连 API Server 的 self-hosted Runner 执行,公网共享 Runner 没有这个 tag,天然不会被误调度去执行部署。 +- **Runner 认证 Azure 用 Managed Identity**(`az login --identity`),不在 CI 变量里存长期有效的 Service Principal 密码,减少凭证泄漏面。 +- **Key Vault/Config Retrieval**:Runner 用 `az keyvault secret show` 现取值,`kubectl create secret --dry-run=client -o yaml | kubectl apply -f -` 渲染成 K8s Secret(见 [07-config-governance.md](./07-config-governance.md#azure-上-secret-的真正来源key-vault不是手写-k8s-secret));密钥值只在这个 job 的执行过程中短暂存在(不会打印到日志、不落盘到镜像),换来的好处是不需要在 AKS 上额外装 CSI 插件、不需要给节点配 Managed Identity 绑定,配置都集中在 GitLab 侧。 +- **`when: manual` = Promotion Gate**:UAT/Prod 部署都设成手动触发(GitLab Protected Environments 可以进一步限制"只有某些角色能点这个按钮")——打了 tag 之后不会自动上线,需要专人点一次"Promote to UAT",验证通过后再点一次"Promote to Prod"。 +- **部署的是 tag 不是 commit-sha**:`deploy-uat`/`deploy-prod` 用的镜像引用都是 `$CI_COMMIT_TAG`(对应阶段三里 `az acr import` 生成的不可变发布版本),而不是重新拿 commit-sha 构建——这就是"同一个制品在环境间晋升"而不是"每个环境各自构建"。 +- **回滚**不重新跑构建流水线,只是把 `LAST_APPROVED_TAG`(上一个已经在 Prod 跑过的 release tag,记录在部署记录/GitLab Environment 历史里)重新 `kubectl set image` 一次,几秒钟内完成,这也是为什么"发布版本必须不可变"很重要——回滚目标必须是确定性的、镜像内容不会变的一个 tag。 + +### 阶段五:Azure Environments(部署目标) + +UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespace(`retailapp-uat` / `retailapp-prod`),各自有独立的 Deployment/Pod/Service,各自的 ConfigMap/Secret(见 [07-config-governance.md](./07-config-governance.md))、各自的资源配额(`ResourceQuota`/`LimitRange`,防止某个环境的异常负载影响另一个)。两个环境共享同一个物理集群,靠 namespace + NetworkPolicy 隔离,而不是各自起一个集群——集群运维成本更低,也符合"环境差异只在配置层面"的原则。Dev 不在这个集群里,跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,见前面"环境层级"一节。 ## 关键规则 -- 多环境(dev/uat/prod)通过 K8s namespace + ConfigMap/Secret 区分(见 [07-config-governance.md](./07-config-governance.md)),**镜像本身不区分环境**,同一个镜像跨环境部署,只是挂载的 ConfigMap/Secret 和 `SPRING_PROFILES_ACTIVE` 不同——避免"UAT 验证过的镜像和 Prod 部署的镜像不是同一个产物"这种环境不一致风险。 -- CI 流程顺序:Gradle build(含单元测试)→ 打 Docker 镜像 → 推送镜像仓库 → GitLab CI/CD 触发 Azure/K8s 部署。 +- UAT/Prod 通过 K8s namespace + ConfigMap/Secret 区分(见 [07-config-governance.md](./07-config-governance.md)),**镜像本身不区分环境**,同一个镜像跨环境部署,只是挂载的 ConfigMap/Secret 和 `SPRING_PROFILES_ACTIVE` 不同——避免"UAT 验证过的镜像和 Prod 部署的镜像不是同一个产物"这种环境不一致风险。 +- Dev 是独立的一层:跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,不接入 CI/CD 自动触发,谁需要更新就手动跑 `scripts/deploy-dev.sh`(不接 Key Vault),跟 UAT/Prod 的"打 tag 才能晋升"这条链路是分开的,见前面"环境层级"一节。 +- CI 流程顺序:Gradle build(含单元测试)→ 打 Docker 镜像 → 推送 ACR → 打 release tag → 按 UAT(人工晋升)/Prod(人工审批)顺序部署;Dev 不在这条流水线里,需要时手动执行部署脚本。 +- 部署到私有 AKS 的 job 必须跑在能访问集群私有网络的 self-hosted Runner 上,公网共享 Runner 无法执行这些 job(网络层面直接不通,不是权限层面的限制);Dev 环境没有 Runner,直接由团队成员在自己电脑上连 VPN 手动执行部署脚本。 - 模块化单体阶段只有一个部署产物(一个 Deployment);如果后续拆分微服务,每个 domain 各自补一份 `Dockerfile` 和 CI job,工程结构上已经按模块划好边界(见 [01-project-structure.md](./01-project-structure.md)),拆分成本较低——本质上是把 `bootstrap` 依赖的某个 `domains/xxx` 模块摘出来,单独套一层 `@SpringBootApplication` 入口和自己的 `Dockerfile`。 ## 附录:为什么坚持"一个镜像走所有环境" @@ -85,12 +237,13 @@ deploy-uat: ## 待补充 -- 具体 CI pipeline 的完整 stage 定义和镜像版本/tag 策略。 -- 灰度发布/回滚方案。 +- 灰度发布(金丝雀/蓝绿)方案——目前只有整体切流的滚动更新,还没有按流量比例灰度的方案。 - Gradle 构建缓存/并行构建的 CI 加速配置。 +- self-hosted Runner 本身的高可用和运维(比如 Runner 所在 VM/VMSS 的扩缩容、镜像更新)。 ## 参考链接 - [The Twelve-Factor App](https://12factor.net/zh_cn/) - [Gradle 官方 Docker 集成建议](https://docs.gradle.org/current/userguide/multi_project_builds.html) - [Spring Boot 官方 Docker 打包指南](https://docs.spring.io/spring-boot/reference/packaging/container-images/dockerfiles.html) +- [k3s 官方文档](https://docs.k3s.io/) diff --git a/backend/10-testing.md b/backend/10-testing.md index 213d93d..0edce1d 100644 --- a/backend/10-testing.md +++ b/backend/10-testing.md @@ -132,6 +132,127 @@ class F6ApiClientResilienceTest { } ``` +## `ArchUnit`:把分层规则变成可执行的测试 + +[01-project-structure.md](./01-project-structure.md) 和 [02-layering.md](./02-layering.md) 里定的依赖方向规则(`domain` 不依赖 Spring/JPA、domains 之间不互相依赖、`api` 不直接依赖 `infrastructure`),光靠 code review 肉眼盯着容易漏,模块一多更盯不过来。用 [ArchUnit](https://www.archunit.org/) 把这些规则写成测试,每次构建自动检查: + +```groovy +// build.gradle(专门放架构测试的模块,或加进 bootstrap 的 test 依赖) +testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0' +``` + +```kotlin +// architecture-test/src/test/kotlin/.../LayeringRulesTest.kt +class LayeringRulesTest { + private val classes = ClassFileImporter().importPackages("com.continental.retailapp") + + @Test + fun `domain 层不能依赖 Spring 或 JPA`() { + noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..") + .check(classes) + } + + @Test + fun `domains 之间不能互相依赖(bff-orchestration 除外)`() { + slices() + .matching("com.continental.retailapp.(*)..") + .should().notDependOnEachOther() + .ignoreDependency( + DescribedPredicate.describe("来自 bff-orchestration") { it.resideInAPackage("..bffOrchestration..") }, + DescribedPredicate.alwaysTrue(), + ) // bff-orchestration 允许依赖多个 domain,是唯一的例外,见 02-layering.md + .check(classes) + } + + @Test + fun `api 层不能直接依赖 infrastructure 层`() { + noClasses() + .that().resideInAPackage("..api..") + .should().dependOnClassesThat().resideInAPackage("..infrastructure..") + .check(classes) + } +} +``` + +这类测试一次写好,覆盖的是全代码库范围的结构性规则,跑在 CI Validation 阶段(见 [09-build-deploy.md](./09-build-deploy.md)),比"等 code review 时人工发现某个 domain 偷偷 import 了另一个 domain 的 entity"要可靠得多,而且不区分改动大小——哪怕只加了一行 `import`,只要违反规则就会立刻挂红。 + +## CI 里跑 Testcontainers 的前提条件 + +`infrastructure` 层的集成测试(前面 `StoreJpaRepositoryTest` 那个例子)依赖 Testcontainers 起真实容器,这要求执行 `./gradlew test` 的 GitLab Runner 本身**能起 Docker 容器**,不是随便一个 Runner 都能跑,两种常见配置: + +**方式一:Docker-in-Docker(dind),托管 Runner 的默认选择** + +```yaml +# .gitlab-ci.yml +unit-integration-test: + stage: validate + image: eclipse-temurin:21-jdk + services: + - docker:24-dind + variables: + DOCKER_HOST: tcp://docker:2375 + DOCKER_TLS_CERTDIR: "" + script: + - ./gradlew test --no-daemon +``` + +**方式二:挂载宿主机 Docker socket,适合 [09-build-deploy.md](./09-build-deploy.md) 里那种自建 self-hosted Runner(`azure-vnet-runner`)** + +```toml +# GitLab Runner 的 config.toml +[[runners]] + [runners.docker] + privileged = true + volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"] +``` + +方式二没有 dind 的嵌套虚拟化开销,跑起来更快,但要求 Runner 能访问宿主机的 Docker socket(等价于 Runner 对宿主机有较高权限),只适合放在我们自己管控的 self-hosted Runner 上;如果以后接入公共共享 Runner 跑这一类测试,只能用方式一,不应该在共享 Runner 上开 Docker socket 权限。 + +## 覆盖率门禁(Jacoco) + +```groovy +// build.gradle +plugins { + id 'jacoco' +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true // CI 里给 GitLab 覆盖率可视化用 + } +} + +jacocoTestCoverageVerification { + violationRules { + rule { + limit { + minimum = 0.70 + } + } + } +} + +check.dependsOn jacocoTestCoverageVerification // 覆盖率不达标,./gradlew check 直接失败 +``` + +```yaml +# .gitlab-ci.yml,CI Validation 阶段追加覆盖率门禁 +unit-integration-test: + stage: validate + script: + - ./gradlew test jacocoTestCoverageVerification --no-daemon + artifacts: + reports: + coverage_report: + coverage_format: cobertura + path: '**/build/reports/jacoco/test/jacocoTestReport.xml' +``` + +阈值定在 70% 而不是追求 90%+:`domain` 层因为纯逻辑、mock 成本低,覆盖率天然会很高;`infrastructure`/`api` 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖",统一定一个较低的整体阈值,作用是**拦住完全没写测试就合入的代码**,而不是逼着每个模块都卷到很高的数字——那样容易导致为了凑覆盖率写没有意义的测试。 + ## 附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖 这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实数据库、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。 @@ -140,7 +261,6 @@ Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实 ## 待补充 -- 各 domain 覆盖率要求。 - 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。 ## 参考链接 @@ -149,3 +269,5 @@ Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实 - [Testcontainers 官方文档](https://testcontainers.com/) - [WireMock 官方文档](https://wiremock.org/docs/) - [Martin Fowler: Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html) +- [ArchUnit 官方文档](https://www.archunit.org/userguide/html/000_Index.html) +- [Jacoco Gradle Plugin 官方文档](https://docs.gradle.org/current/userguide/jacoco_plugin.html)