feat: add documentation for cross-domain collaboration and aggregation
- Introduced a new section on cross-domain collaboration and aggregation, detailing decision-making processes, contract module usage for cross-domain reads, and domain events for writes. - Added guidelines for parallel aggregation using a dedicated thread pool and context propagation. - Established rules for transaction boundaries, idempotency, optimistic locking, scheduled tasks, and caching strategies in a concurrent environment. - Included examples and best practices for implementing these concepts in the application.
This commit is contained in:
@@ -45,20 +45,22 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
|
||||
|
||||
### backend/
|
||||
|
||||
后端(Kotlin + Spring Boot + Gradle)架构决策文档,按序号阅读:
|
||||
后端(Kotlin 2.4 + Spring Boot 4.1 + Java 21 + Gradle 多模块)架构决策文档,按序号阅读:
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| [backend/01-project-structure.md](./backend/01-project-structure.md) | 工程结构 / 模块划分(Gradle 多模块,模块化单体) |
|
||||
| [backend/02-layering.md](./backend/02-layering.md) | 分层规范(api/application/domain/infrastructure) |
|
||||
| [backend/03-persistence.md](./backend/03-persistence.md) | 持久层方案(Spring Data JPA + Hibernate,Flyway) |
|
||||
| [backend/04-security-auth.md](./backend/04-security-auth.md) | 安全与认证方案(Spring Security + JWT,门店上下文) |
|
||||
| [backend/05-integration-layer.md](./backend/05-integration-layer.md) | 集成层设计(F6 Adapter / Mini 域客户端) |
|
||||
| [backend/06-api-design.md](./backend/06-api-design.md) | API 设计规范(统一响应、DTO、版本化) |
|
||||
| [backend/07-config-governance.md](./backend/07-config-governance.md) | 配置与服务治理(K8s ConfigMap/Secret) |
|
||||
| [backend/08-observability.md](./backend/08-observability.md) | 可观测性(Trace ID、日志、审计) |
|
||||
| [backend/09-build-deploy.md](./backend/09-build-deploy.md) | 构建与多环境部署(Gradle、Docker、GitLab CI/CD) |
|
||||
| [backend/10-testing.md](./backend/10-testing.md) | 测试策略 |
|
||||
| [backend/01-project-structure.md](./backend/01-project-structure.md) | 工程结构 / 模块划分(模块化单体,`-contract` 契约模块,版本基线) |
|
||||
| [backend/02-layering.md](./backend/02-layering.md) | 分层规范(api/application/domain/infrastructure,Entity 边界) |
|
||||
| [backend/03-persistence.md](./backend/03-persistence.md) | 持久层方案(Spring Data JPA + Hibernate + MySQL,Flyway 多实例) |
|
||||
| [backend/04-security-auth.md](./backend/04-security-auth.md) | 安全与认证(Spring Security + JWT,refresh 轮换,门店上下文与越权隔离) |
|
||||
| [backend/05-integration-layer.md](./backend/05-integration-layer.md) | 集成层设计(同步 RestClient + Resilience4j,F6 Adapter / Mini 客户端) |
|
||||
| [backend/06-api-design.md](./backend/06-api-design.md) | API 设计规范(统一响应、错误码、请求头、分页与序列化约定) |
|
||||
| [backend/07-config-governance.md](./backend/07-config-governance.md) | 配置与服务治理(K8s ConfigMap/Secret、Key Vault、启动期校验) |
|
||||
| [backend/08-observability.md](./backend/08-observability.md) | 可观测性(Micrometer Tracing、结构化日志与脱敏、指标与告警、审计) |
|
||||
| [backend/09-build-deploy.md](./backend/09-build-deploy.md) | 构建与多环境部署(Docker、GitLab CI/CD、优雅停机、迁移与回滚协同) |
|
||||
| [backend/10-testing.md](./backend/10-testing.md) | 测试策略(Testcontainers、WireMock、ArchUnit、覆盖率聚合) |
|
||||
| [backend/11-cross-domain-collaboration.md](./backend/11-cross-domain-collaboration.md) | 跨域协作与聚合(契约模块、领域事件、并行 fan-out 与局部降级) |
|
||||
| [backend/12-concurrency-and-scheduling.md](./backend/12-concurrency-and-scheduling.md) | 并发、事务与定时任务(事务边界、幂等、乐观锁、ShedLock、本地缓存) |
|
||||
|
||||
## 已知文档间差异(PRD 待修订)
|
||||
|
||||
@@ -89,6 +91,17 @@ Flutter APP 的分包、分层、技术选型等决策记录,按序号阅读
|
||||
| **内测分发渠道未定** | [08](./08-build-flavors.md) | Firebase App Distribution 国内可达性存疑,需选替代方案 |
|
||||
| **车牌识别技术路径未验证** | [07](./07-native-integration.md) | 通用扫码库只能解条码/二维码,VIN 印刷字符和车牌需要 OCR,车牌可能需要商用 SDK |
|
||||
|
||||
### 已解决(2026-08 后端文档评审)
|
||||
|
||||
| 原待确认项 | 结论 | 出处 |
|
||||
| --- | --- | --- |
|
||||
| 数据库选型未定 | **MySQL 8.4**(UAT/Prod 用 Azure Database for MySQL Flexible Server + Private Endpoint) | [backend/03](./backend/03-persistence.md)、[backend/09](./backend/09-build-deploy.md) |
|
||||
| `X-Trace-Id` 请求头契约未定 | 客户端传 32 位小写 hex;服务端校验通过则复用,否则忽略并自行生成,最终值在响应头和 `ApiResult.traceId` 里回写 | [backend/08](./backend/08-observability.md)、[05](./05-networking.md) |
|
||||
| 分页参数约定未定 | `pageNum`(1 起)/ `pageSize`(≤100)/ `sort`(白名单),响应含 `hasMore` | [backend/06](./backend/06-api-design.md) |
|
||||
| 集成层同步还是响应式 | **同步 `RestClient`**,不引入 WebClient/Mono | [backend/05](./backend/05-integration-layer.md) |
|
||||
|
||||
后端错误码表仍未定(见上表阻塞项):分段规则已在 [backend/06](./backend/06-api-design.md) 定好,缺的是各业务域把自己的码填进去。
|
||||
|
||||
## 语言约定
|
||||
|
||||
文档以中文为主。
|
||||
|
||||
+144
-50
@@ -4,6 +4,29 @@
|
||||
|
||||
Kotlin + Spring Boot + Gradle(Groovy DSL,`build.gradle`)。
|
||||
|
||||
## 版本基线
|
||||
|
||||
| 组件 | 版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Spring Boot | `4.1.0` | OSS 支持至 2027-07。3.x 全系列 OSS 支持已结束(3.5 于 2026-06-30 结束),新项目不应再从 3.x 起步 |
|
||||
| Spring Cloud | `2025.1.2`(Oakwood) | Boot 4.1 需要 2025.1.2 及以上;提供 `spring-cloud-kubernetes` 5.0.2,见 [07-config-governance.md](./07-config-governance.md) |
|
||||
| Kotlin | `2.4.10` | |
|
||||
| Java | `21`(LTS,用 Gradle toolchain 锁定) | |
|
||||
|
||||
Spring Boot 4 连带升级了 Spring Framework 7 / Spring Security 7 / **Jackson 3**,第三方库必须选对应版本线,各篇文档里出现的坐标已按此对齐:
|
||||
|
||||
| 库 | 版本 | 注意点 |
|
||||
| --- | --- | --- |
|
||||
| Resilience4j | `2.4.0` | artifact 是 **`resilience4j-spring-boot4`**,不是 `-spring-boot3`;2.4.0 才加的 Boot 4 支持 |
|
||||
| springdoc-openapi | `3.0.3` | Boot 4 对应 springdoc **3.x**,Boot 3 才是 2.x |
|
||||
| logstash-logback-encoder | `9.0` | 9.0 起迁到 Jackson 3,正好匹配 Boot 4;8.x 及以前是 Jackson 2 |
|
||||
| MapStruct | `1.6.3` + kapt | MapStruct 至今没有正式的 KSP 支持,Kotlin 项目仍走 kapt |
|
||||
| springmockk | `5.0.1` | Boot 4 删除了 `@MockBean`/`@SpyBean`;springmockk 5.x 对应 Framework 7,`@SpykBean` 已改名 `@MockkSpyBean` |
|
||||
| ArchUnit | `1.4.2` | |
|
||||
| Testcontainers | 跟随 Spring Boot BOM | 2.x 做了模块化拆分,坐标和包名都变了(`org.testcontainers:testcontainers-mysql` / `org.testcontainers.mysql.MySQLContainer`),升级时以 `./gradlew dependencies` 的实际解析结果为准,见 [10-testing.md](./10-testing.md) |
|
||||
|
||||
升级 Boot 版本时,这张表要整体复核一遍,不要只改 Boot 版本号。
|
||||
|
||||
## 决策
|
||||
|
||||
单一 Gradle 多模块工程,落地为**模块化单体(Modular Monolith)**:只有一个可执行部署单元,内部按 [architecture-diagram](../Architecture-Diagram/architecture-diagram-explanation.md) 里 App Backend 的职责边界拆成多个 Gradle 子模块,用编译期依赖规则强制边界,而不是先拆成多个独立部署的微服务。
|
||||
@@ -12,71 +35,122 @@ Kotlin + Spring Boot + Gradle(Groovy DSL,`build.gradle`)。
|
||||
|
||||
- 现阶段团队规模和运维能力还撑不起"多个独立部署单元 + 服务发现 + 分布式事务/一致性"的复杂度。
|
||||
- App Backend 内部这几个模块(Identity/BFF/Workbench/WebView Ticket/Integration)本来就是高内聚的一套业务,拆早了只是把进程内调用换成网络调用,徒增延迟和故障点,业务上并没有获得隔离收益。
|
||||
- 但如果不做任何边界约束、堆成一个大包,后期想拆也拆不动。Gradle 多模块正好卡在中间:**部署简单(一个 jar),边界物理存在(模块间依赖编译期强制)**,未来如果某个模块(比如 `f6-integration`)流量或迭代速度明显超过其他模块,再单独拆出来部署成本也低。
|
||||
- 但如果不做任何边界约束、堆成一个大包,后期想拆也拆不动。Gradle 多模块正好卡在中间:**部署简单(一个 jar),边界物理存在(模块间依赖编译期强制)**,未来如果某个模块(比如 `f6-adapter`)流量或迭代速度明显超过其他模块,再单独拆出来部署成本也低。
|
||||
|
||||
## 模块结构总览
|
||||
|
||||
```
|
||||
conti-backend/
|
||||
settings.gradle
|
||||
build.gradle # 根工程:统一插件版本、公共依赖约束(Spring Boot BOM)
|
||||
build.gradle # 根工程:统一插件版本、公共依赖约束(Spring Boot / Spring Cloud BOM)
|
||||
bootstrap/ # 唯一可执行模块:装配所有模块,产出单一 jar/镜像
|
||||
build.gradle # 依赖所有 platform-* / domains/* 模块 + @SpringBootApplication 启动类
|
||||
build.gradle # 依赖所有 platform-* / domains/* / integration/* + @SpringBootApplication 启动类
|
||||
platform/
|
||||
platform-web/ # 统一异常处理、Result包装、参数校验、GlobalExceptionHandler
|
||||
platform-web/ # 统一异常处理、ApiResult 包装、参数校验、GlobalExceptionHandler
|
||||
platform-security/ # Spring Security + JWT 解析、门店/角色上下文注入
|
||||
platform-persistence/ # JPA 基础设施:审计字段、BaseEntity、分页封装、Flyway 配置
|
||||
platform-observability/ # 日志格式、Trace ID 透传、Micrometer配置
|
||||
platform-integration/ # WebClient/Resilience4j 基础封装(超时/重试/熔断通用能力)
|
||||
platform-persistence/ # JPA 基础设施:审计字段、BaseEntity、分页封装、Flyway 多实例配置
|
||||
platform-observability/ # 日志格式、Trace 透传、Micrometer 配置
|
||||
platform-integration/ # RestClient/Resilience4j 基础封装(超时/重试/熔断/舱壁通用能力)
|
||||
domains/
|
||||
identity-store/ # Identity & Store Center:登录、token、门店上下文、菜单权限
|
||||
bff-orchestration/ # BFF Orchestration:面向APP的统一接口聚合与协议标准化
|
||||
workbench/ # Workbench Aggregation:首页聚合、局部降级
|
||||
webview-ticket/ # WebView Ticket Center:F6 WebView 换票、会话绑定
|
||||
f6-integration/ # Integration Layer:F6 Adapter,对应架构图里的适配层
|
||||
mini-clients/ # 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装
|
||||
identity-store-contract/ # ↑ 对外契约:接口 + 传输模型 + 领域事件,其他 domain 只能依赖这个
|
||||
bff-orchestration/ # BFF Orchestration:面向 APP 的统一接口聚合与协议标准化
|
||||
workbench/ # Workbench Aggregation:首页聚合、局部降级
|
||||
webview-ticket/ # WebView Ticket Center:F6 WebView 换票、会话绑定
|
||||
integration/
|
||||
f6-adapter/ # F6 Adapter:对应架构图里的适配层,换票、供应商访问上下文准备
|
||||
mini-clients/ # 对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装
|
||||
architecture-test/ # ArchUnit 架构规则测试,见 10-testing.md
|
||||
```
|
||||
|
||||
(模块名称先按架构图职责命名,实际开发中如果和团队习惯冲突可以再改,不影响这套结构本身。)
|
||||
|
||||
### 为什么把集成层从 `domains/` 里拿出来
|
||||
|
||||
`f6-adapter` 和 `mini-clients` 不是业务域,是**对外部系统的适配层**——架构图里 Integration Layer 本来也是单独一层。放在 `domains/` 下会直接和"domains 之间不允许互相依赖"这条规则冲突:`workbench` 要拿 O2O 的数据,就必须依赖 `mini-clients`,于是要么破规则,要么给规则打补丁。单独一组 `integration/` 之后,规则变成干净的一句"所有 domain 都可以依赖 integration",不需要例外。
|
||||
|
||||
## 依赖规则(编译期强制边界,是这套结构的核心价值)
|
||||
|
||||
- `bootstrap` 是唯一持有 `@SpringBootApplication` 的模块,依赖所有 `domains/*` 和 `platform-*`;其余模块都是普通 library 模块(没有主启动类),不能单独跑成一个服务。
|
||||
- `domains/*` 之间**不允许**互相依赖,`bff-orchestration` 是唯一例外——它是编排层,天然需要依赖其他所有 domain 才能做聚合。
|
||||
- `platform-*` 不含业务逻辑,各 `domains/*` 均可依赖;`platform-*` 之间尽量不互相依赖(`platform-security` 依赖 `platform-web` 里的异常类型是可以接受的例外)。
|
||||
- 跨 domain 的数据/事件传递,只能走 `bff-orchestration` 编排,或者在某个 `platform-*` 定义抽象接口、各 domain 各自实现(依赖倒置),不允许 `identity-store` 直接 import `workbench` 的类。
|
||||
- `bootstrap` 是唯一持有 `@SpringBootApplication` 的模块,依赖所有 `domains/*`、`integration/*` 和 `platform-*`;其余模块都是普通 library 模块(没有主启动类),不能单独跑成一个服务。
|
||||
- **`domains/x` 不可以依赖 `domains/y`。**
|
||||
- **`domains/x` 可以依赖 `domains/y-contract`**(契约模块,见下一节)。
|
||||
- **`domains/*` 都可以依赖 `integration/*` 和 `platform-*`。**
|
||||
- `platform-*` 不含业务逻辑;`platform-*` 之间尽量不互相依赖(`platform-security` 依赖 `platform-web` 里的异常类型是可以接受的例外)。
|
||||
|
||||
这些规则由 Gradle 的模块依赖机制**物理强制**:`domains/identity-store` 的 `build.gradle` 里根本不会声明对 `domains/workbench` 的依赖,编译时 import 不到,不是靠 code review 口头约束。
|
||||
这三条规则的价值在于**可以被 ArchUnit 精确表达**,不是靠 code review 口头约束——测试写法见 [10-testing.md](./10-testing.md)。同时它们也由 Gradle 物理强制:`domains/workbench` 的 `build.gradle` 里根本不会声明对 `domains/identity-store` 的依赖,编译时 import 不到。
|
||||
|
||||
### 模块目录名与包名的对应关系
|
||||
|
||||
目录名带连字符,包名不能带——这个映射必须写死,因为 [10-testing.md](./10-testing.md) 的 ArchUnit 规则是按包名匹配的,改一个就要改另一个:
|
||||
|
||||
| 模块目录 | 基础包名 |
|
||||
| --- | --- |
|
||||
| `domains/identity-store`、`domains/identity-store-contract` | `com.continental.retailapp.identitystore`(契约在 `.identitystore.contract`) |
|
||||
| `domains/bff-orchestration` | `com.continental.retailapp.bff` |
|
||||
| `domains/workbench` | `com.continental.retailapp.workbench` |
|
||||
| `domains/webview-ticket` | `com.continental.retailapp.webviewticket` |
|
||||
| `integration/f6-adapter` | `com.continental.retailapp.integration.f6` |
|
||||
| `integration/mini-clients` | `com.continental.retailapp.integration.mini` |
|
||||
| `platform/platform-*` | `com.continental.retailapp.platform.*` |
|
||||
|
||||
规则是**去掉连字符直接拼接**(`identity-store` → `identitystore`),唯一的例外是 `bff-orchestration` → `bff`(`bfforchestration` 读不出来)。新增模块时按这个规则取名,并同步更新 `10-testing.md` 里 ArchUnit 的 `domains` 列表——那个列表漏了谁,谁的边界就没人管。
|
||||
|
||||
`integration/*` 和 `platform/*` 不按 api/application/domain/infrastructure 分四层(它们本来就不是业务域),内部按自己的职责组织包即可,见 [02-layering.md](./02-layering.md)。
|
||||
|
||||
### 契约模块(`-contract`):跨 domain 协作的唯一通道
|
||||
|
||||
`workbench` 聚合首页时需要门店名称,`webview-ticket` 需要知道"用户切了门店"——这类跨域需求是真实存在的,不可能全部塞进 `bff-orchestration`(那会让 BFF 变成什么都知道的上帝模块)。做法是让被依赖方**显式发布一个最小契约**:
|
||||
|
||||
```
|
||||
domains/identity-store-contract/
|
||||
src/main/kotlin/com/continental/retailapp/identitystore/contract/
|
||||
StoreQueryService.kt # 接口:fun listStoresByUserId(userId: Long): List<StoreInfo>
|
||||
StoreInfo.kt # 传输模型:只含对外承诺的字段
|
||||
StoreSwitchedEvent.kt # 领域事件,见 11-cross-domain-collaboration.md
|
||||
```
|
||||
|
||||
约束:
|
||||
|
||||
- **契约模块里只放接口、传输模型和事件类型**,不放实现、不依赖 Spring Web/JPA,不依赖任何其他 domain。
|
||||
- **实现方(`identity-store`)依赖自己的契约模块并实现它**;调用方(`workbench`)只依赖契约模块,拿不到 `identity-store` 内部的任何类型(包括 Entity)。
|
||||
- **按需创建**,不预先给每个 domain 都建一个空的 contract 模块——没有跨域调用就不需要它。
|
||||
- 命名用 `-contract` 而不是 `-api`,避免和模块内部的 `api/` 包(Controller 层,见 [02-layering.md](./02-layering.md))混淆。
|
||||
|
||||
跨 domain 的写操作和状态联动优先走**领域事件**而不是直接调接口,规则见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)。
|
||||
|
||||
### 根 `build.gradle` 示例
|
||||
|
||||
```groovy
|
||||
plugins {
|
||||
id 'org.jetbrains.kotlin.jvm' version '2.0.20' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.spring' version '2.0.20' apply false
|
||||
id 'org.springframework.boot' version '3.3.4' apply false
|
||||
id 'io.spring.dependency-management' version '1.1.6' apply false
|
||||
id 'org.jetbrains.kotlin.jvm' version '2.4.10' apply false
|
||||
id 'org.jetbrains.kotlin.plugin.spring' version '2.4.10' apply false
|
||||
id 'org.jetbrains.kotlin.kapt' version '2.4.10' apply false
|
||||
id 'org.springframework.boot' version '4.1.0' apply false
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply plugin: 'org.jetbrains.kotlin.jvm'
|
||||
apply plugin: 'org.jetbrains.kotlin.plugin.spring'
|
||||
apply plugin: 'io.spring.dependency-management'
|
||||
|
||||
group = 'com.continental.retailapp'
|
||||
version = '0.1.0-SNAPSHOT'
|
||||
|
||||
// 用 toolchain 统一 Java 版本:Kotlin 插件会自动把 jvmTarget 对齐到同一个版本。
|
||||
// 只写 sourceCompatibility 是不够的——Kotlin 的 jvmTarget 默认值和它无关,
|
||||
// 两边不一致时 Gradle 会直接报 "Inconsistent JVM-target compatibility" 构建失败。
|
||||
java {
|
||||
sourceCompatibility = JavaVersion.VERSION_21
|
||||
}
|
||||
|
||||
dependencyManagement {
|
||||
imports {
|
||||
mavenBom "org.springframework.boot:spring-boot-dependencies:3.3.4"
|
||||
toolchain {
|
||||
languageVersion = JavaLanguageVersion.of(21)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// 用 Gradle 原生的 platform() 做版本对齐,不再引入 io.spring.dependency-management 插件:
|
||||
// 少一个需要跟着 Boot 一起升级的插件,行为也更符合 Gradle 自身的依赖解析语义。
|
||||
// testImplementation 继承自 implementation,所以测试依赖同样受这两个 BOM 约束。
|
||||
implementation platform('org.springframework.boot:spring-boot-dependencies:4.1.0')
|
||||
implementation platform('org.springframework.cloud:spring-cloud-dependencies:2025.1.2')
|
||||
|
||||
implementation 'org.jetbrains.kotlin:kotlin-reflect'
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
}
|
||||
@@ -99,43 +173,56 @@ subprojects {
|
||||
rootProject.name = 'conti-backend'
|
||||
|
||||
include 'bootstrap'
|
||||
include 'architecture-test'
|
||||
|
||||
include 'platform:platform-web'
|
||||
include 'platform:platform-security'
|
||||
include 'platform:platform-persistence'
|
||||
include 'platform:platform-observability'
|
||||
include 'platform:platform-integration'
|
||||
|
||||
include 'domains:identity-store'
|
||||
include 'domains:identity-store-contract'
|
||||
include 'domains:bff-orchestration'
|
||||
include 'domains:workbench'
|
||||
include 'domains:webview-ticket'
|
||||
include 'domains:f6-integration'
|
||||
include 'domains:mini-clients'
|
||||
|
||||
include 'integration:f6-adapter'
|
||||
include 'integration:mini-clients'
|
||||
```
|
||||
|
||||
### `domains/workbench/build.gradle` 示例(体现依赖规则)
|
||||
|
||||
```groovy
|
||||
apply plugin: 'org.springframework.boot' // 只用来获得 bootJar 之外的 starter 依赖管理,不产出可执行 jar
|
||||
|
||||
bootJar {
|
||||
enabled = false // 非 bootstrap 模块不产出可执行 jar
|
||||
}
|
||||
jar {
|
||||
enabled = true
|
||||
}
|
||||
// 注意:库模块不 apply 'org.springframework.boot' 插件。
|
||||
// 版本对齐已经由根工程的 platform() BOM 统一处理,库模块 apply Boot 插件唯一的作用
|
||||
// 就是产出一个我们并不需要的 bootJar,然后再手动把它关掉——多余的一步。
|
||||
// 只有 bootstrap 需要 Boot 插件。
|
||||
|
||||
dependencies {
|
||||
implementation project(':platform:platform-web')
|
||||
implementation project(':platform:platform-persistence')
|
||||
implementation project(':platform:platform-integration')
|
||||
implementation project(':domains:mini-clients') // 允许:workbench 依赖只读客户端封装
|
||||
|
||||
// 不允许出现 implementation project(':domains:identity-store') 这种同级 domain 依赖
|
||||
implementation project(':integration:mini-clients') // 允许:domain 可以依赖 integration
|
||||
implementation project(':domains:identity-store-contract') // 允许:只依赖契约模块
|
||||
|
||||
// 不允许:implementation project(':domains:identity-store') // 同级 domain 的实现模块
|
||||
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
}
|
||||
```
|
||||
|
||||
### `domains/identity-store-contract/build.gradle` 示例
|
||||
|
||||
```groovy
|
||||
dependencies {
|
||||
// 契约模块保持极简:不依赖 Spring Web / JPA / 其他 domain,
|
||||
// 这样任何 domain 依赖它都不会顺带把实现细节拉进来。
|
||||
}
|
||||
```
|
||||
|
||||
### `bootstrap/build.gradle` 示例
|
||||
|
||||
```groovy
|
||||
@@ -147,12 +234,15 @@ dependencies {
|
||||
implementation project(':platform:platform-persistence')
|
||||
implementation project(':platform:platform-observability')
|
||||
implementation project(':platform:platform-integration')
|
||||
|
||||
implementation project(':domains:identity-store')
|
||||
implementation project(':domains:identity-store-contract')
|
||||
implementation project(':domains:bff-orchestration')
|
||||
implementation project(':domains:workbench')
|
||||
implementation project(':domains:webview-ticket')
|
||||
implementation project(':domains:f6-integration')
|
||||
implementation project(':domains:mini-clients')
|
||||
|
||||
implementation project(':integration:f6-adapter')
|
||||
implementation project(':integration:mini-clients')
|
||||
}
|
||||
```
|
||||
|
||||
@@ -170,18 +260,18 @@ fun main(args: Array<String>) {
|
||||
|
||||
```
|
||||
domains/xxx/
|
||||
build.gradle # 依赖需要的 platform-* 模块,bootJar 禁用
|
||||
src/main/kotlin/com/continental/retailapp/xxx/
|
||||
build.gradle # 依赖需要的 platform-* / integration-* / 其他 domain 的 -contract
|
||||
src/main/kotlin/com/continental/retailapp/xxx/ # xxx = 去掉连字符的模块名,见上方对应表
|
||||
api/ # Controller、请求/响应 DTO
|
||||
application/ # Service,编排用例
|
||||
application/ # Service、定时任务;Entity → Response 的 mapper 也在这层
|
||||
domain/ # 可选,见 02-layering.md
|
||||
infrastructure/ # repository 实现、外部 client 实现
|
||||
infrastructure/ # repository 实现、Entity、外部 client 实现、模块内的 @Configuration
|
||||
src/main/resources/db/migration/xxx/
|
||||
V1__init.sql
|
||||
src/test/kotlin/...
|
||||
```
|
||||
|
||||
新增模块后记得在 `settings.gradle` 里 `include`,并在 `bootstrap/build.gradle` 里加上依赖——这两步是唯一需要"手动接线"的地方,其余边界规则都由各模块自己的 `build.gradle` 保证。
|
||||
新增模块后需要手动接线的地方只有三处:`settings.gradle` 里 `include`、`bootstrap/build.gradle` 里加依赖、[03-persistence.md](./03-persistence.md) 里给这个 domain 注册一个 Flyway 实例(因为每个 domain 有自己独立的 database 和迁移版本序列)。其余边界规则都由各模块自己的 `build.gradle` 保证。
|
||||
|
||||
## 附录:为什么用 Gradle 多模块,而不是单模块 + 包分层
|
||||
|
||||
@@ -191,7 +281,9 @@ domains/xxx/
|
||||
|
||||
如果整个后端只是一个 Gradle 模块、内部靠 `com.xxx.identity` / `com.xxx.workbench` 这样的包名分层——短期能跑,但 Java/Kotlin 的包(package)**不是编译期边界**,`workbench` 包里的类可以随手 `import com.xxx.identity.SomeInternalClass`,IDE 不会报错,只能靠人自觉或者 ArchUnit 这类静态检查工具事后检测。等项目大到几十人协作时,"事后检测"经常检测不过来,边界会慢慢被绕开。
|
||||
|
||||
**Gradle 多模块的本质**:把"包名上的软边界"换成"模块依赖上的硬边界"。`domains/identity-store` 这个模块的 `build.gradle` 里没有声明对 `domains/workbench` 的依赖,`workbench` 里的类就物理 import 不到 `identity-store` 内部的任何类型,哪怕两个模块在同一个 git 仓库、同一次构建、最终打进同一个 jar 里。
|
||||
**Gradle 多模块的本质**:把"包名上的软边界"换成"模块依赖上的硬边界"。`domains/workbench` 这个模块的 `build.gradle` 里没有声明对 `domains/identity-store` 的依赖,`workbench` 里的类就物理 import 不到 `identity-store` 内部的任何类型,哪怕两个模块在同一个 git 仓库、同一次构建、最终打进同一个 jar 里。
|
||||
|
||||
契约模块是这个思路的延伸:不是"要么全开放、要么全封闭",而是让被依赖方自己决定**对外承诺哪些东西**,其余一律看不见。这跟微服务里"只有 HTTP API 是公开契约、数据库表是私有实现"是同一个道理,只是这里的强制手段从网络协议换成了 Gradle 依赖图。
|
||||
|
||||
### 跟微服务的关系
|
||||
|
||||
@@ -200,11 +292,13 @@ Gradle 多模块和微服务解决的是同一类问题(业务边界隔离)
|
||||
- 微服务:边界靠网络调用强制,代价是要处理服务发现、网络失败、分布式事务/最终一致性、独立的 CI/CD 和监控。
|
||||
- 模块化单体:边界靠编译依赖强制,代价是无法针对单个模块独立扩缩容或独立发布,进程内故障会互相影响(一个模块 OOM 会拖垮整个进程)。
|
||||
|
||||
我们现在处的阶段(App Backend 内部几个模块业务强相关、团队规模有限)更适合后者;如果将来某个模块单独的流量、团队规模、发布节奏都明显跟其他模块脱节,再把它拆成独立微服务——因为模块边界已经在代码里划清楚了,拆分主要是把 `implementation project(':domains:xxx')` 换成 HTTP/消息调用,改动范围可控,不需要推倒重来。
|
||||
我们现在处的阶段(App Backend 内部几个模块业务强相关、团队规模有限)更适合后者;如果将来某个模块单独的流量、团队规模、发布节奏都明显跟其他模块脱节,再把它拆成独立微服务——因为模块边界已经在代码里划清楚了,拆分主要是把 `implementation project(':domains:xxx-contract')` 换成 HTTP/消息调用,改动范围可控,不需要推倒重来。契约模块在这里额外多给了一层好处:**要拆的那个接口清单已经现成写在 contract 模块里了**,不需要先花时间考古"到底谁在用我的什么"。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Gradle Multi-Project Builds](https://docs.gradle.org/current/userguide/multi_project_builds.html)
|
||||
- [Gradle: Platforms / BOM 支持](https://docs.gradle.org/current/userguide/platforms.html)
|
||||
- [Spring Boot Gradle Plugin](https://docs.spring.io/spring-boot/gradle-plugin/index.html)
|
||||
- [Spring Boot 4.0 Migration Guide](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-4.0-Migration-Guide)
|
||||
- [Modular Monolith: A Primer (Kamil Grzybek)](https://www.kamilgrzybek.com/blog/posts/modular-monolith-primer)
|
||||
- [ArchUnit(可选的架构规则静态检查工具)](https://www.archunit.org/)
|
||||
- [ArchUnit](https://www.archunit.org/)
|
||||
|
||||
+71
-14
@@ -8,17 +8,19 @@
|
||||
domains/xxx/
|
||||
src/main/kotlin/com/continental/retailapp/xxx/
|
||||
api/ # Controller、请求/响应 DTO
|
||||
application/ # Service,编排用例、跨 repository 协调
|
||||
application/ # Service,编排用例、跨 repository 协调;Entity/领域模型 → Response 的转换
|
||||
domain/ # 可选:领域模型、repository/client 接口、状态机/复杂业务规则
|
||||
infrastructure/ # JPA repository 实现、外部 client 实现
|
||||
```
|
||||
|
||||
`integration/*` 模块(`f6-adapter`、`mini-clients`,见 [01-project-structure.md](./01-project-structure.md))不套这四层——它们本身就是"别人的 infrastructure",内部只有 client 实现 + 对外暴露的接口和传输模型,规则见 [05-integration-layer.md](./05-integration-layer.md)。
|
||||
|
||||
## 各层职责
|
||||
|
||||
- **api**:`Controller`(只做参数校验 + 调用 `application`)、请求/响应 DTO。不写业务逻辑,不直接依赖 `infrastructure` 的具体实现类。
|
||||
- **application**:`Service`,编排用例、事务边界(`@Transactional` 一般加在这一层)。依赖 `domain` 定义的接口(有 domain 层时),或直接依赖 `infrastructure` 暴露的接口(跳过 domain 层时)。
|
||||
- **api**:`Controller`(只做参数校验 + 调用 `application`)、请求/响应 DTO。不写业务逻辑,**不 import `infrastructure` 包下的任何类型**(包括 Entity)。
|
||||
- **application**:`Service`,编排用例、事务边界(`@Transactional` 一般加在这一层)。依赖 `domain` 定义的接口(有 domain 层时),或直接依赖 `infrastructure` 暴露的接口(跳过 domain 层时)。**`Entity`/领域模型 → `Response` 的转换在这一层完成**(`application/mapper/`,见 [06-api-design.md](./06-api-design.md))。
|
||||
- **domain**(可选):领域模型(可以是纯 Kotlin data class,不一定是 JPA entity)、repository/client 接口、封装多步骤业务规则或状态机的领域服务。不依赖 Spring Web/JPA 相关类型,可以脱离容器单独做单元测试。
|
||||
- **infrastructure**:`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、`WebClient`/Feign 外部调用实现。
|
||||
- **infrastructure**:`domain`(或 `application`,跳过 domain 层时)里接口的具体实现——JPA repository 实现、基于 `RestClient` 的外部调用实现。
|
||||
|
||||
## 对象命名约定(PO / DAO / BO / DTO / VO)
|
||||
|
||||
@@ -35,14 +37,31 @@ Java 生态里这几个缩写来源不一、经常被混用,这里把我们实
|
||||
落地规则:
|
||||
|
||||
- **类名统一用 `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` 层模型(BO)不是必须的**,规则见下一节;没有 `domain` 层时,`Entity`(PO)由 `application` 层转换成 `Response`,不会凭空多出一个 BO。
|
||||
- **Entity 边界规则**(这一条在 [06-api-design.md](./06-api-design.md) 和 [10-testing.md](./10-testing.md) 的 ArchUnit 规则里用的是同一句话,三处必须一致):
|
||||
|
||||
> **`XxxEntity` 不出现在 `api` 层的任何签名或 import 里,也不跨出所在模块的边界。**
|
||||
> **`Entity`/`Domain` 模型 → `Response` 的转换发生在 `application` 层。**
|
||||
|
||||
有 `domain` 层时更严一档:Entity 到 `infrastructure` 的 repository 实现为止,`application` 拿到的已经是领域模型。
|
||||
|
||||
这句话对应 ArchUnit 的 `noClasses().that().resideInAPackage("..api..").should().dependOnClassesThat().resideInAPackage("..infrastructure..")`,能被自动检查,不靠人盯。
|
||||
|
||||
### 为什么规则不是"Entity 永不跨出 infrastructure"
|
||||
|
||||
因为跳过 domain 层的简单 CRUD 场景下,那条更严的规则会强迫我们为每个查询凭空造一个和 Entity 字段一模一样的中间模型,只为了把它从 `infrastructure` 搬到 `application`——纯粹的样板代码,没有换来任何隔离收益(`application` 转手就把它变成 `Response` 了)。
|
||||
|
||||
真正需要防的是**两件事**:Entity 泄漏到 API 契约上(数据库字段一改,APP 就崩),以及 Entity 泄漏到别的模块(别的模块从此依赖上你的表结构)。上面那条规则精确地只挡这两件事,所以它既能自动检查,也不会逼出无意义的中间类。
|
||||
|
||||
## 何时可以跳过 domain 层
|
||||
|
||||
先明确一件事:**"跳过 domain 层"跳过的是领域模型和领域服务,不是跳过接口抽象**。`application` 依赖的仍然是一个接口(只不过接口定义挪到了 `infrastructure` 包内),而不是直接 `@Autowired` 一个 `JpaRepository` 或 `EntityManager` 到处用。
|
||||
|
||||
- **可以跳过**:简单 CRUD、没有跨 repository 协调、没有状态机——`application` 直接依赖 `infrastructure` 里定义的 repository/client 接口即可(接口和实现放在同一层)。
|
||||
- **必须要有**:多步骤业务规则(如 WebView 换票的状态校验)、需要协调多个数据源(如 `workbench` 聚合多个 Mini 域)、包含状态机或需要独立于容器做单元测试的核心业务逻辑——接口定义在 `domain`,`infrastructure` 反向实现。
|
||||
|
||||
判断不确定时按"先跳过、需要时再补"处理:从"无 domain 层"补出一个 domain 层是局部重构(把规则从 `application` 提到 `domain`,加一层模型转换),成本可控;反过来为了对称给所有简单查询都套上 domain 层,则是持续付出的样板成本。
|
||||
|
||||
## 依赖方向
|
||||
|
||||
```
|
||||
@@ -53,6 +72,8 @@ infrastructure → 依赖 domain 的接口(若有),依赖 platform-persist
|
||||
|
||||
`domain` 层的类不 import `org.springframework.web.*` / `jakarta.persistence.*`,保证这一层的单元测试不需要起 Spring 容器、不需要真实数据库。
|
||||
|
||||
模块之间的依赖方向(domain 之间不互相依赖、跨域只走 `-contract` 契约模块)见 [01-project-structure.md](./01-project-structure.md),两套规则一个管模块内、一个管模块间,都由 [10-testing.md](./10-testing.md) 里的 ArchUnit 测试检查。
|
||||
|
||||
## 示例一:有 domain 层(`webview-ticket`,换票——多步骤状态校验)
|
||||
|
||||
```kotlin
|
||||
@@ -103,6 +124,7 @@ class WebviewTicketAppService(
|
||||
@Transactional
|
||||
fun issueTicket(userId: Long, storeId: Long): WebviewTicketResponse {
|
||||
val ticket = issueWebviewTicketService.issue(userId, storeId)
|
||||
// 领域模型 → Response 的转换在 application 层
|
||||
return WebviewTicketResponse(ticket.ticketId, ticket.expiresAt)
|
||||
}
|
||||
}
|
||||
@@ -112,6 +134,7 @@ class WebviewTicketAppService(
|
||||
class WebviewTicketRepositoryImpl(
|
||||
private val jpaRepository: WebviewTicketJpaRepository,
|
||||
) : WebviewTicketRepository {
|
||||
// Entity 到这里为止,不会出现在返回值里
|
||||
override fun findActiveTicket(userId: Long, storeId: Long): WebviewTicket? =
|
||||
jpaRepository.findByUserIdAndStoreIdAndStatus(userId, storeId, TicketStatus.ISSUED)?.toDomain()
|
||||
|
||||
@@ -123,38 +146,72 @@ class WebviewTicketRepositoryImpl(
|
||||
|
||||
`IssueWebviewTicketService` 的复用/过期判断规则可以直接用一个假的 `WebviewTicketRepository` 实现做单元测试,不需要起 Spring 容器或真实数据库,也不需要 mock HTTP。
|
||||
|
||||
注意 `IssueWebviewTicketService` 上**没有 `@Service` 注解**——domain 层不依赖 Spring([10-testing.md](./10-testing.md) 有一条 ArchUnit 规则盯着这件事)。它成为 bean 的方式是在 `infrastructure/config/` 里显式声明:
|
||||
|
||||
```kotlin
|
||||
// infrastructure/config/DomainServiceConfig.kt
|
||||
@Configuration
|
||||
class DomainServiceConfig {
|
||||
@Bean
|
||||
fun issueWebviewTicketService(repository: WebviewTicketRepository, clock: Clock) =
|
||||
IssueWebviewTicketService(repository, clock)
|
||||
}
|
||||
```
|
||||
|
||||
多写这几行换来的是:领域规则这一层可以脱离 Spring 单独编译和测试。domain 层类不多,这个成本是可控的。
|
||||
|
||||
## 示例二:跳过 domain 层(`identity-store`,门店列表——简单查询)
|
||||
|
||||
```kotlin
|
||||
// infrastructure/persistence/StoreView.kt
|
||||
// Spring Data 接口投影:只声明这次查询需要的字段,Hibernate 只 select 这几列。
|
||||
// 用它而不是直接返回 StoreEntity,是为了让 application 拿到的东西不带 Entity 的
|
||||
// 生命周期(游离态/懒加载)和无关字段——不需要额外写一个类,接口本身就是契约。
|
||||
interface StoreView {
|
||||
val id: Long
|
||||
val name: String
|
||||
val code: String
|
||||
}
|
||||
|
||||
// infrastructure/persistence/StoreRepository.kt
|
||||
interface StoreRepository {
|
||||
fun findStoresByUserId(userId: Long): List<StoreEntity>
|
||||
fun findStoresByUserId(userId: Long): List<StoreView>
|
||||
}
|
||||
|
||||
@Repository
|
||||
interface StoreJpaRepository : JpaRepository<StoreEntity, Long>, StoreRepository {
|
||||
@Query("select s from StoreEntity s join UserStoreEntity us on us.storeId = s.id where us.userId = :userId")
|
||||
override fun findStoresByUserId(userId: Long): List<StoreEntity>
|
||||
@Query(
|
||||
"""
|
||||
select s.id as id, s.name as name, s.code as code
|
||||
from StoreEntity s
|
||||
join UserStoreEntity us on us.storeId = s.id
|
||||
where us.userId = :userId
|
||||
""",
|
||||
)
|
||||
override fun findStoresByUserId(userId: Long): List<StoreView>
|
||||
}
|
||||
|
||||
// application/StoreAppService.kt
|
||||
@Service
|
||||
class StoreAppService(
|
||||
private val storeRepository: StoreRepository,
|
||||
private val storeMapper: StoreMapper, // application/mapper/,见 06-api-design.md
|
||||
) {
|
||||
fun listStores(userId: Long): List<StoreResponse> =
|
||||
storeRepository.findStoresByUserId(userId).map { StoreResponse(it.id, it.name) }
|
||||
fun listAccessibleStores(userId: Long): List<StoreResponse> =
|
||||
storeMapper.toResponseList(storeRepository.findStoresByUserId(userId))
|
||||
}
|
||||
```
|
||||
|
||||
没有多步骤规则、没有跨 repository 协调,接口和实现直接放在 `infrastructure`,`application` 直接依赖 `StoreRepository` 这个接口,省掉一层 `domain` 目录。
|
||||
|
||||
写操作或者确实需要整个实体的场景,`StoreRepository` 也可以返回 `StoreEntity`——那时候 Entity 进到 `application` 是允许的(见上面的边界规则),只要它不出现在 `api` 层、也不跨出这个模块就行。投影是查询场景下的优选,不是硬性要求。
|
||||
|
||||
## 附录:为什么要分层,以及依赖倒置在这里怎么体现
|
||||
|
||||
如果 `Controller` 里直接写 `EntityManager` 查询、直接 `new WebClient.create(...)` 调用 F6——短期能跑,但会导致:
|
||||
如果 `Controller` 里直接写 `EntityManager` 查询、直接 `RestClient.create(...)` 调用 F6——短期能跑,但会导致:
|
||||
|
||||
1. **业务规则没法脱离容器单独测试**:想验证"换票是否要判断过期时间",得连 Spring 容器、连数据库一起跑测试。
|
||||
2. **换底层实现要动到业务代码**:比如把 JPA 换成 jOOQ,或者把 F6 调用从 `RestTemplate` 换成 `WebClient`,如果业务代码直接依赖具体实现类,改动会散落得到处都是。
|
||||
2. **换底层实现要动到业务代码**:比如把 JPA 换成 jOOQ,或者把 F6 调用从 `RestTemplate` 换成 `RestClient`,如果业务代码直接依赖具体实现类,改动会散落得到处都是。
|
||||
|
||||
分层的关键不是"分了几层",而是**依赖方向单向流动**,`domain` 只定义接口("我需要一个能查到 `WebviewTicket` 的东西"),不关心 `infrastructure` 具体怎么实现——这是[依赖倒置原则](https://en.wikipedia.org/wiki/Dependency_inversion_principle)。我们只取这套思想里最实用的一层隔离,不套用完整的 DDD 战术模式(聚合根、值对象、领域事件那一整套),避免简单模块也被迫按重量级模板写代码。
|
||||
|
||||
@@ -162,4 +219,4 @@ class StoreAppService(
|
||||
|
||||
- [依赖倒置原则(Dependency Inversion Principle)](https://en.wikipedia.org/wiki/Dependency_inversion_principle)
|
||||
- [The Clean Architecture(Uncle Bob 原文)](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
|
||||
- [Spring 官方分层架构指南](https://docs.spring.io/spring-framework/reference/core/beans/introduction.html)
|
||||
- [Spring Data JPA: Projections](https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html)
|
||||
|
||||
+238
-36
@@ -2,7 +2,7 @@
|
||||
|
||||
## 决策
|
||||
|
||||
Spring Data JPA + Hibernate 作为默认 ORM,Flyway 做 schema 迁移。
|
||||
**MySQL 8.4 LTS**(生产用 Azure Database for MySQL Flexible Server,见 [09-build-deploy.md](./09-build-deploy.md))+ Spring Data JPA / Hibernate + Flyway 做 schema 迁移。
|
||||
|
||||
选 JPA 而不是 MyBatis-Plus / jOOQ,主要考虑:
|
||||
|
||||
@@ -10,23 +10,82 @@ Spring Data JPA + Hibernate 作为默认 ORM,Flyway 做 schema 迁移。
|
||||
- 大部分 domain 模块(`identity-store`、`webview-ticket` 等)都是常规 CRUD + 少量关联查询,JPA 默认能力够用;真的遇到复杂查询,用 `Specification` 或原生 SQL(`@Query(nativeQuery = true)`)兜底,不需要为了少数复杂查询把整个技术栈换成 jOOQ。
|
||||
- 如果某个 domain 后续查询复杂度明显上升(比如报表类需求),可以在那个模块单独引入 jOOQ 只处理复杂查询,两者不互斥。
|
||||
|
||||
MySQL 一侧需要注意的是:**MySQL 里 schema 和 database 是同一个东西**(`CREATE SCHEMA` 就是 `CREATE DATABASE` 的别名)。下文说"每个 domain 一个 schema"时,物理上就是"同一个 MySQL 实例里的一个 database"。这跟 PostgreSQL 的 "一个 database 里多个 schema" 不是一回事,很多网上的多 schema 方案不能直接照搬,包括权限模型(见后面的跨 domain 规则)。
|
||||
|
||||
## 结构约定
|
||||
|
||||
```
|
||||
platform-persistence/
|
||||
BaseEntity # 审计字段:createdAt/updatedAt/createdBy/updatedBy,各 domain entity 继承
|
||||
PageResult<T> # 统一分页返回封装
|
||||
JpaAuditingConfig # 开启 Spring Data JPA Auditing
|
||||
VersionedEntity # BaseEntity + @Version 乐观锁,有并发更新的表继承它
|
||||
PageResult<T> # 统一分页返回封装
|
||||
JpaAuditingConfig # 开启 Spring Data JPA Auditing
|
||||
DomainFlywayConfig # 按 domain database 分别建 Flyway 实例
|
||||
|
||||
domains/xxx/
|
||||
src/main/kotlin/.../xxx/infrastructure/persistence/
|
||||
XxxEntity # JPA entity
|
||||
XxxJpaRepository # : JpaRepository<XxxEntity, Long>
|
||||
src/main/resources/db/migration/xxx/
|
||||
V1__init.sql # Flyway migration,按 domain 分子目录
|
||||
V1__init.sql # Flyway migration,目录名 = database 名(下划线形式)
|
||||
```
|
||||
|
||||
## `BaseEntity` 示例
|
||||
迁移目录名统一用**下划线形式、与 database 名一致**(`identity_store`、`webview_ticket`),不用模块名的中划线形式(`identity-store`)——因为 `DomainFlywayConfig` 里是拿 database 名直接拼 `classpath:db/migration/$schema`,两边不一致会静默地一条迁移都不执行。
|
||||
|
||||
## 全局 JPA / 数据源配置
|
||||
|
||||
```yaml
|
||||
spring:
|
||||
datasource:
|
||||
url: >-
|
||||
jdbc:mysql://${DB_HOST}:3306/?sslMode=REQUIRED
|
||||
&connectionTimeZone=UTC&preserveInstants=true
|
||||
&rewriteBatchedStatements=true
|
||||
username: ${DB_USERNAME}
|
||||
password: ${DB_PASSWORD} # 来自 K8s Secret,见 07-config-governance.md
|
||||
hikari:
|
||||
maximum-pool-size: 15
|
||||
minimum-idle: 5
|
||||
connection-timeout: 3000 # 拿不到连接就快速失败,不要让请求线程堆在这里
|
||||
max-lifetime: 570000 # 略小于 MySQL 的 wait_timeout,避免用到已被服务端关闭的连接
|
||||
transaction-isolation: TRANSACTION_READ_COMMITTED
|
||||
|
||||
jpa:
|
||||
open-in-view: false # 必须显式关掉,Boot 默认是 true
|
||||
hibernate:
|
||||
ddl-auto: validate # 表结构只由 Flyway 改,Hibernate 只做校验
|
||||
properties:
|
||||
hibernate:
|
||||
jdbc:
|
||||
time_zone: UTC
|
||||
batch_size: 50
|
||||
order_inserts: true
|
||||
order_updates: true
|
||||
query:
|
||||
fail_on_pagination_over_collection_fetch: true
|
||||
|
||||
flyway:
|
||||
enabled: false # 关掉 Boot 的单实例自动配置,改由 DomainFlywayConfig 接管
|
||||
```
|
||||
|
||||
几条不显眼但会实际出事的配置:
|
||||
|
||||
- **`open-in-view: false`**:Boot 默认 `true`,意思是数据库连接会一直持有到视图渲染完(对我们来说是到 JSON 序列化完)。后果是连接被无谓占用、懒加载在 Controller 层还能"碰巧成功"从而掩盖 N+1 问题。关掉之后,`application` 层事务外访问懒加载字段会直接抛 `LazyInitializationException`——这是好事,问题会在开发期暴露而不是压测时暴露。
|
||||
- **`ddl-auto: validate`**:绝不能是 `update`。`update` 会在应用启动时按 Entity 反推 DDL 去改生产库,且它的改法不可预测、无法评审、无法回滚。表结构的唯一事实来源是 Flyway 脚本。
|
||||
- **`transaction-isolation: TRANSACTION_READ_COMMITTED`**:MySQL 默认是 REPEATABLE READ,我们显式降到 READ COMMITTED。理由:RR 下的一致性读快照在整个事务期间不变,长一点的事务会读到过时数据;RR 还会用更多的 gap lock,并发插入时更容易死锁。绝大多数 Web 业务不需要 RR 的可重复读语义,需要防并发覆盖的地方我们用乐观锁(下一节)显式处理,比依赖隔离级别更清楚。
|
||||
- **`rewriteBatchedStatements=true`**:MySQL 驱动默认**不会**把 JDBC batch 真的合成一条多值 `INSERT`,只配 `hibernate.jdbc.batch_size` 是没用的,必须在 JDBC URL 上开这个开关。
|
||||
|
||||
### 时区:全链路 UTC
|
||||
|
||||
数据库里只存 UTC,时区转换只在客户端做。三处配置必须一起生效,缺一处就会出现"写进去和读出来差几个小时":
|
||||
|
||||
1. 时间列一律用 `datetime(6)`,Kotlin 侧一律用 `Instant`(不用 `LocalDateTime`,它不带时区信息,语义上表达不了"某个时刻")。
|
||||
2. `spring.jpa.properties.hibernate.jdbc.time_zone=UTC`——Hibernate 写库时按 UTC 转换。
|
||||
3. JDBC URL 上 `connectionTimeZone=UTC&preserveInstants=true`——驱动层按 UTC 解释。
|
||||
|
||||
不用 MySQL 的 `timestamp` 类型:它会按会话时区自动转换(结果依赖服务器/连接的时区设置,是上面这类 bug 的常见来源),而且有 2038 年上限。API 层的时间格式约定见 [06-api-design.md](./06-api-design.md)。
|
||||
|
||||
## `BaseEntity` / `VersionedEntity` 示例
|
||||
|
||||
```kotlin
|
||||
// platform-persistence/src/main/kotlin/.../BaseEntity.kt
|
||||
@@ -50,19 +109,41 @@ abstract class BaseEntity {
|
||||
var updatedBy: String? = null
|
||||
}
|
||||
|
||||
// platform-persistence/src/main/kotlin/.../VersionedEntity.kt
|
||||
@MappedSuperclass
|
||||
abstract class VersionedEntity : BaseEntity() {
|
||||
@Version
|
||||
@Column(nullable = false)
|
||||
var version: Long = 0
|
||||
}
|
||||
|
||||
// platform-persistence/src/main/kotlin/.../JpaAuditingConfig.kt
|
||||
@Configuration
|
||||
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
|
||||
class JpaAuditingConfig {
|
||||
class JpaAuditingConfig(
|
||||
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
|
||||
) {
|
||||
@Bean
|
||||
fun auditorAware(): AuditorAware<String> = AuditorAware {
|
||||
Optional.ofNullable(StoreContextHolder.currentUserIdOrNull()?.toString())
|
||||
// StoreContextHolder 是 @RequestScope bean,定时任务/启动流程里没有请求上下文,
|
||||
// 这里必须容忍拿不到的情况,否则后台任务写库会直接抛 BeanCreationException。
|
||||
runCatching { storeContextHolder.`object`.userId?.toString() }
|
||||
.getOrNull()
|
||||
.let { Optional.ofNullable(it) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`auditorAware` 直接读 [04-security-auth.md](./04-security-auth.md) 里的 `StoreContextHolder`,避免每个 domain 各写一份"当前操作人是谁"的逻辑。
|
||||
|
||||
**继承 `BaseEntity` 的表,建表脚本必须带全 `created_at` / `updated_at` / `created_by` / `updated_by` 四列**,且前两列 `not null`——漏一列,第一次插入就会直接失败。这是最容易在新表上重复踩的坑,建表脚本 review 时优先看这一条。
|
||||
|
||||
### 乐观锁(`@Version`)
|
||||
|
||||
只要一条记录可能被两个请求同时改(门店信息编辑、票据状态流转、库存类数据),Entity 就继承 `VersionedEntity`,表上加一列 `version bigint not null default 0`。Hibernate 在 `update` 时自动带上 `where version = ?` 并 `version + 1`,更新影响行数为 0 时抛 `ObjectOptimisticLockingFailureException`。
|
||||
|
||||
`application` 层要显式处理这个异常,转成业务错误码返回给 APP("数据已被他人修改,请刷新后重试"),而不是让它落到 `GlobalExceptionHandler` 变成 500。并发冲突的重试策略见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
|
||||
## Entity + Repository + Migration 示例(`identity-store` 里的门店表)
|
||||
|
||||
```kotlin
|
||||
@@ -76,13 +157,13 @@ class StoreEntity(
|
||||
@Column(nullable = false, length = 128)
|
||||
var name: String,
|
||||
|
||||
@Column(name = "code", nullable = false, unique = true, length = 32)
|
||||
@Column(name = "code", nullable = false, length = 32)
|
||||
var code: String,
|
||||
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Enumerated(EnumType.STRING) // 存字符串,不存序号,见下面的说明
|
||||
@Column(nullable = false, length = 16)
|
||||
var status: StoreStatus,
|
||||
) : BaseEntity()
|
||||
) : VersionedEntity()
|
||||
|
||||
@Repository
|
||||
interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
|
||||
@@ -93,57 +174,175 @@ interface StoreJpaRepository : JpaRepository<StoreEntity, Long> {
|
||||
|
||||
```sql
|
||||
-- src/main/resources/db/migration/identity_store/V1__init.sql
|
||||
create schema if not exists identity_store;
|
||||
-- 注意:不要在迁移脚本里写 create database / use,database 由 Flyway 实例的
|
||||
-- defaultSchema 指定(见 DomainFlywayConfig),脚本里一律用不带库名的表名。
|
||||
|
||||
create table identity_store.store (
|
||||
id bigint generated always as identity primary key,
|
||||
name varchar(128) not null,
|
||||
code varchar(32) not null unique,
|
||||
status varchar(16) not null,
|
||||
created_at timestamp not null,
|
||||
updated_at timestamp not null,
|
||||
create table store (
|
||||
id bigint not null auto_increment,
|
||||
name varchar(128) not null,
|
||||
code varchar(32) not null,
|
||||
status varchar(16) not null,
|
||||
version bigint not null default 0,
|
||||
created_at datetime(6) not null,
|
||||
updated_at datetime(6) not null,
|
||||
created_by varchar(64),
|
||||
updated_by varchar(64)
|
||||
);
|
||||
updated_by varchar(64),
|
||||
primary key (id),
|
||||
unique key uk_store_code (code),
|
||||
key idx_store_status (status)
|
||||
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
|
||||
```
|
||||
|
||||
Flyway 版本号(`V1`、`V2`…)在同一个 schema 目录下按提交顺序递增,不同 domain 目录之间的版本号互相独立,互不干扰。
|
||||
约定说明:
|
||||
|
||||
- **字符集固定 `utf8mb4` + `utf8mb4_0900_ai_ci`**。MySQL 的 `utf8` 是三字节的历史遗留别名,存不了 emoji 和部分生僻汉字,一律不用。
|
||||
- **索引命名**:唯一索引 `uk_<表名>_<列名>`,普通索引 `idx_<表名>_<列名>`,多列用下划线连接(`idx_store_status_created_at`)。唯一约束写成 `unique key uk_xxx (...)` 而不是列上的 `unique`,是为了让它有个能在日志和慢查询里认出来的名字。
|
||||
- **InnoDB 单个索引键最长 3072 字节**,`utf8mb4` 下一个字符最多 4 字节,所以 `varchar(768)` 是能整列建索引的上限。要给长文本建索引时用前缀索引(`key idx_x_url (url(255))`)。
|
||||
- **枚举用 `@Enumerated(EnumType.STRING)`**,绝不用默认的 `ORDINAL`——`ORDINAL` 存的是枚举常量的下标,以后在枚举中间插入一个值,历史数据的含义会整体错位,且这种错位没有任何报错。
|
||||
- **金额用 `decimal(18, 4)`**,Kotlin 侧 `BigDecimal`,永远不用 `double`/`float`。
|
||||
- **布尔用 `tinyint(1)`**(Hibernate 对 Kotlin `Boolean` 的默认映射)。
|
||||
- **外键**:同一个 database 内部可以用物理外键;**跨 database 一律只做逻辑关联**(存 ID,不建 `foreign key` 约束),否则模块边界在数据库层就被焊死了,将来任何一个域想单独拆库都要先拆约束。
|
||||
- **软删除**:不做全局的 `@SQLDelete` + `@Where` 软删除(它会污染所有查询、和唯一索引冲突、还容易被忘记)。确实需要保留历史的表,显式加 `status` 或 `deleted_at` 列并在每个查询里显式过滤。
|
||||
|
||||
## Flyway:每个 domain database 一个独立实例
|
||||
|
||||
Boot 自动配置的 Flyway 只有一个实例、一张 `flyway_schema_history`。如果各 domain 目录各自从 `V1__init.sql` 开始编号,这个单实例扫到两个 `V1` 会直接报 `Found more than one migration with version 1`,启动失败。所以关掉自动配置,按 database 各建一个 Flyway 实例,各自维护自己那张历史表、各自的版本序列:
|
||||
|
||||
```kotlin
|
||||
// platform-persistence/.../DomainFlywayConfig.kt
|
||||
@Configuration
|
||||
class DomainFlywayConfig {
|
||||
|
||||
companion object {
|
||||
// 新增一个 domain 时,这里加一行 —— 见 01-project-structure.md 的脚手架说明
|
||||
val DOMAIN_SCHEMAS = listOf(
|
||||
"identity_store",
|
||||
"workbench",
|
||||
"webview_ticket",
|
||||
)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun domainFlywayMigrations(dataSource: DataSource): DomainFlywayMigrations {
|
||||
DOMAIN_SCHEMAS.forEach { schema ->
|
||||
Flyway.configure()
|
||||
.dataSource(dataSource)
|
||||
.schemas(schema) // MySQL 下即 database;不存在时会自动创建
|
||||
.defaultSchema(schema) // 历史表和脚本里的裸表名都落在这个 database
|
||||
.table("flyway_schema_history")
|
||||
.locations("classpath:db/migration/$schema")
|
||||
.load()
|
||||
.migrate()
|
||||
}
|
||||
return DomainFlywayMigrations
|
||||
}
|
||||
|
||||
// 必须让 EntityManagerFactory 等迁移跑完再初始化,
|
||||
// 否则 ddl-auto=validate 会在建表之前校验,启动直接失败。
|
||||
// Boot 自带的这个依赖关系只认名为 flyway/flywayInitializer 的 bean,自定义实例要自己挂。
|
||||
@Bean
|
||||
fun flywayEntityManagerFactoryDependsOn(): EntityManagerFactoryDependsOnPostProcessor =
|
||||
object : EntityManagerFactoryDependsOnPostProcessor("domainFlywayMigrations") {}
|
||||
}
|
||||
|
||||
object DomainFlywayMigrations // 只是一个用来表达依赖顺序的标记 bean
|
||||
```
|
||||
|
||||
规则:
|
||||
|
||||
- 各 domain 目录内版本号独立递增,`identity_store/V2__xxx.sql` 和 `workbench/V2__yyy.sql` 互不冲突。
|
||||
- **迁移脚本一旦合入主干就不可修改**(Flyway 会校验 checksum,改了会导致其他环境启动失败)。写错了就再加一个 `V(n+1)` 修正。
|
||||
- 迁移脚本必须**前向兼容**:滚动更新期间新旧两个版本的应用会同时连着同一个库,所以不能有"旧代码见到就会崩"的改动。加列不删列、分两个版本走 expand-contract,规则和发布流程的配合见 [09-build-deploy.md](./09-build-deploy.md)。
|
||||
- **运行账号和迁移账号分开**:Flyway 用的账号需要 DDL 权限,应用运行时只需要 DML。生产上把迁移放在部署流程里用单独的账号执行(见 09),运行时账号不给 `CREATE`/`DROP`/`ALTER`——这样即使应用被注入了 DDL,也执行不了。
|
||||
|
||||
## 跨 domain 数据访问规则
|
||||
|
||||
**每个 domain 独立 schema**:即使同一个数据库实例,各 `domains/*` 的表也归属各自 schema,不允许跨 domain 直接 `join` 表——需要数据时通过对方模块暴露的 `application` 层接口调用,保持模块边界(即使将来要拆分微服务,DB 层面也不用重新拆分)。
|
||||
**每个 domain 一个独立的 database**:`identity_store` / `workbench` / `webview_ticket` 各自建库,不允许跨 domain 直接 join 表。需要别的域的数据时,走对方 `-contract` 模块暴露的接口或领域事件(见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),不查对方的表。
|
||||
|
||||
```kotlin
|
||||
// 错误示范:workbench 直接 join identity_store 的表
|
||||
@Query("""
|
||||
select w from WorkbenchTileEntity w
|
||||
join StoreEntity s on s.id = w.storeId -- 跨 schema 直接 join,禁止
|
||||
""")
|
||||
fun findTilesWithStoreInfo(): List<WorkbenchTileEntity>
|
||||
|
||||
// 正确做法:workbench 通过 identity-store 暴露的接口获取门店信息
|
||||
// 正确做法:workbench 通过 identity-store 的契约模块获取门店信息
|
||||
@Service
|
||||
class WorkbenchAppService(
|
||||
private val storeQueryService: StoreQueryService, // identity-store 模块对外暴露的接口
|
||||
private val storeQueryService: StoreQueryService, // 来自 domains/identity-store-contract
|
||||
private val tileRepository: WorkbenchTileRepository,
|
||||
) {
|
||||
fun listTiles(userId: Long): List<TileResponse> {
|
||||
val stores = storeQueryService.listStoresByUserId(userId) // 走 application 层调用,不查表
|
||||
val stores = storeQueryService.listStoresByUserId(userId) // 走契约接口,不查表
|
||||
val tiles = tileRepository.findByUserId(userId)
|
||||
return buildTiles(tiles, stores)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 附录:为什么坚持"每个 domain 独立 schema"
|
||||
### 这条规则的强制力:哪些是硬约束、哪些不是
|
||||
|
||||
模块化单体最容易被破坏的地方就是数据库——代码层面 Gradle 依赖规则挡住了跨 domain import 类,但如果两个 domain 的表都在同一个 schema 下,写 SQL 的时候很容易"顺手 join 一下",这条规则完全不受编译器约束,只能靠约定。所以我们把 schema 拆开:`workbench` 的 `DataSource` 配置的默认 schema 是 `workbench`,即使有人手滑写了一条跨 schema 的 join,大概率会因为找不到表或者权限问题直接报错,把"容易被绕开的软约束"变成"大概率会失败的硬约束"。
|
||||
这里要说清楚,避免高估它的保护力度:
|
||||
|
||||
代价是:如果确实需要跨 domain 做一次性数据修复或报表查询,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。
|
||||
1. **JPQL 层面是硬约束**。JPQL 引用的是 Entity 类,`workbench` 想写 `join StoreEntity s` 就得 `import ...identitystore...StoreEntity`——而 [01-project-structure.md](./01-project-structure.md) 的 Gradle 依赖规则让 `workbench` 模块根本编译不到这个类。这一条编译期就挡死了,不需要靠自觉。
|
||||
|
||||
2. **原生 SQL 是软约束**。`@Query(nativeQuery = true)` 里的表名是字符串,写 `join identity_store.store s` 完全能编译通过。而且——这一点必须讲清楚——**MySQL 里跨 database join 是完全合法的**,只要连接用的账号对两个库都有权限就能执行成功。我们是模块化单体,整个进程共用一个 `DataSource` 和一个数据库账号,这个账号必然对所有 domain 的库都有权限。所以在 MySQL 下,"跨库 join 会报错"这个说法**不成立**,别指望数据库替我们拦住它。
|
||||
|
||||
> 顺带说明:网上很多"多 schema 隔离"的方案是按 PostgreSQL 写的。PG 里 schema 是 database 内部的命名空间,可以按 schema 单独 `REVOKE` 权限从而做成硬约束;MySQL 里 schema 就是 database,我们这种单账号单数据源的结构做不到同样的效果。
|
||||
|
||||
兜底手段只能是流程性的:**原生 SQL 一律在 code review 里重点看**,并且加一条测试扫描所有 `@Query(nativeQuery = true)` 的字符串里有没有出现其他 domain 的 database 名(写法见 [10-testing.md](./10-testing.md))。这道防线不完美,但配合第 1 条已经覆盖了绝大多数实际会发生的情况——正常人不会为了 join 一张表专门去写原生 SQL 绕过编译错误。
|
||||
|
||||
3. **如果将来需要把它升级成硬约束**:做法是每个 domain 一个 `DataSource` + 一个只 GRANT 本库的数据库账号,那时跨库 join 会因为权限不足而真的失败。代价是多套 `EntityManagerFactory`/`TransactionManager`、多个连接池(连接数要重新算),并且跨 domain 的本地事务彻底不可能——最后一条其实是好事,但整体复杂度明显上升。现阶段不做,等到真的出现跨域乱查的实际问题、或者某个域准备独立拆库时再上。
|
||||
|
||||
代价那一面也要认:确实需要跨 domain 做一次性数据修复或报表查询时,不能简单写 SQL join,要么走各自暴露的接口拼装,要么走专门的数据同步/报表管道——这是有意为之的摩擦,用来保护长期的模块边界。
|
||||
|
||||
## 查询规范
|
||||
|
||||
### N+1 与抓取策略
|
||||
|
||||
- **所有 `@ManyToOne` / `@OneToOne` 显式写 `fetch = FetchType.LAZY`**。JPA 规范里这两种关联默认是 `EAGER`,意味着查一个 Entity 会顺带把关联对象也查出来,列表查询时就是典型的 N+1。
|
||||
- 确实需要一次带出关联数据时,用 `@EntityGraph` 或 JPQL 的 `join fetch` 显式声明,不要靠懒加载在循环里触发。
|
||||
- **分页 + `join fetch` 集合属性会导致 Hibernate 把全表拉进内存再分页**。上面配置里的 `fail_on_pagination_over_collection_fetch: true` 让这种写法直接抛异常而不是悄悄变慢。
|
||||
- 开发环境开 `spring.jpa.properties.hibernate.generate_statistics=true` 或用 [datasource-proxy](https://github.com/jdbc-observations/datasource-proxy) 观察每个请求实际发了多少条 SQL;关键列表接口在测试里断言 SQL 条数,比事后压测发现要早得多。
|
||||
|
||||
### 分页
|
||||
|
||||
统一用 Spring Data 的 `Pageable` + 我们自己的 `PageResult<T>` 返回(字段名和 APP 侧约定见 [06-api-design.md](./06-api-design.md)),不直接把 Spring 的 `Page` 序列化给前端——`Page` 的 JSON 结构由 Spring 版本决定,升级 Boot 时会变,属于把框架内部结构写进 API 契约。
|
||||
|
||||
```kotlin
|
||||
data class PageResult<T>(
|
||||
val list: List<T>,
|
||||
val pageNum: Int,
|
||||
val pageSize: Int,
|
||||
val total: Long,
|
||||
val hasMore: Boolean,
|
||||
)
|
||||
```
|
||||
|
||||
翻很深的页(`offset` 很大)在 MySQL 上会越来越慢,因为它必须先扫过前面所有行再丢弃。App 上的列表基本都是"下拉加载更多",这种场景优先用**游标分页**(按 `id` 或 `created_at` 传 `lastId`,`where id < :lastId order by id desc limit :size`),不用 `offset`。
|
||||
|
||||
### 批量写
|
||||
|
||||
`hibernate.jdbc.batch_size` + JDBC URL 上的 `rewriteBatchedStatements=true` 两个都配上,批量 update/delete 才会真的合并。
|
||||
|
||||
但**批量 insert 有个 MySQL 特有的坑**:`@GeneratedValue(strategy = IDENTITY)` 下 Hibernate 必须逐条插入才能拿回自增主键,JDBC batch 会被直接禁用,配了也没用。所以:常规写入保持 `IDENTITY` 不变(简单、够用);真正的大批量导入场景(几千行以上)绕开 JPA,直接用 `JdbcTemplate.batchUpdate` 或一条多值 `INSERT`。不要为了让 JPA 能批量插入就把主键策略换成 `TABLE` 生成器——那会引入一张全局竞争的序列表,得不偿失。
|
||||
|
||||
## 连接池容量怎么算
|
||||
|
||||
`maximum-pool-size` 不是越大越好,要和数据库实例的连接上限对齐:
|
||||
|
||||
```
|
||||
所有 Pod 的连接总数 = maximum-pool-size × Pod 副本数(含滚动更新期间的临时多余副本)
|
||||
必须 ≤ MySQL 实例的 max_connections − 预留(运维/迁移/监控账号,留 20 左右)
|
||||
```
|
||||
|
||||
Azure Database for MySQL Flexible Server 的 `max_connections` 由 SKU 规格决定(跟内存挂钩),扩副本前要先确认这个数字。按当前 3 副本、滚动更新时最多 4 副本估算,`maximum-pool-size: 15` 对应峰值 60 个连接。
|
||||
|
||||
池子大小本身的经验值是"略大于并发执行 SQL 的线程数",不是"等于 Tomcat 线程数"——大部分请求线程在等下游 HTTP(见 [05-integration-layer.md](./05-integration-layer.md))而不是等数据库。池子配得过大反而会让数据库承受更多并发、整体延迟变差。
|
||||
|
||||
## 事务
|
||||
|
||||
- `@Transactional` 只加在 `application` 层(见 [02-layering.md](./02-layering.md)),不加在 Controller 或 repository 上。
|
||||
- **事务里不要调外部 HTTP**。一次 F6 调用可能耗时几秒,事务开着就意味着数据库连接和行锁被占几秒。把外部调用挪到事务外,或者用 `@TransactionalEventListener(AFTER_COMMIT)`。
|
||||
- 只读查询加 `@Transactional(readOnly = true)`:Hibernate 会跳过脏检查,省掉一次快照比对。
|
||||
- 详细的事务边界、传播行为、幂等与并发冲突处理见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 具体数据库选型(PostgreSQL/MySQL)和实例划分方式(同实例多 schema,还是多实例)。
|
||||
- 复杂查询是否引入 QueryDSL/jOOQ(`Specification` 不够用时再决定)。
|
||||
- 各 domain 的实际表结构,等开发到对应模块时再补。
|
||||
|
||||
@@ -152,3 +351,6 @@ class WorkbenchAppService(
|
||||
- [Spring Data JPA 官方文档](https://docs.spring.io/spring-data/jpa/reference/)
|
||||
- [Flyway 官方文档](https://documentation.red-gate.com/fd)
|
||||
- [Spring Data JPA Auditing](https://docs.spring.io/spring-data/jpa/reference/auditing.html)
|
||||
- [MySQL 8.4 参考手册:字符集与排序规则](https://dev.mysql.com/doc/refman/8.4/en/charset.html)
|
||||
- [MySQL Connector/J:时区处理](https://dev.mysql.com/doc/connector-j/en/connector-j-time-instants.html)
|
||||
- [HikariCP: About Pool Sizing](https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing)
|
||||
|
||||
+378
-106
@@ -2,79 +2,178 @@
|
||||
|
||||
## 决策
|
||||
|
||||
Spring Security + JWT,由 `identity-store` 模块统一签发和校验,对应架构图里 `Auth and Token Center`;门店/角色上下文通过统一 filter 解析后注入 `RequestScope`,供各 domain 读取,对应架构图 `Store Context` 的职责。
|
||||
Spring Security + JWT,由 `identity-store` 模块统一签发,由 `platform-security` 统一校验,对应架构图里 `Auth and Token Center`;门店/角色上下文在认证之后注入 `@RequestScope` bean,供各 domain 读取,对应架构图 `Store Context` 的职责。
|
||||
|
||||
JWT 的签发与校验**用 Spring Security 内置的 `NimbusJwtEncoder`/`NimbusJwtDecoder`,不引入 jjwt**,理由见文末附录。
|
||||
|
||||
## 结构约定
|
||||
|
||||
```
|
||||
platform-security/
|
||||
JwtTokenProvider # 签发/解析/刷新 token
|
||||
JwtAuthenticationFilter # 统一 filter:解析 JWT,写入 SecurityContext + StoreContextHolder
|
||||
StoreContextHolder # RequestScope bean,持有当前 用户+门店+角色
|
||||
SecurityConfigSupport # 各 domain 复用的 Spring Security 通用配置片段
|
||||
JwtProperties # security.jwt.* 配置绑定 + 启动期校验
|
||||
JwtEncoderConfig # NimbusJwtEncoder / NimbusJwtDecoder(HS256)
|
||||
AccessTokenIssuer # 签发 access token(claims 结构见下)
|
||||
StoreContextFilter # 认证之后执行:把 Jwt claims 写进 StoreContextHolder
|
||||
StoreContextHolder # @RequestScope bean,持有当前 用户+门店+角色
|
||||
ApiResultAuthenticationEntryPoint # 401 → ApiResult JSON
|
||||
ApiResultAccessDeniedHandler # 403 → ApiResult JSON
|
||||
SecurityConfig # 统一 SecurityFilterChain
|
||||
|
||||
domains/identity-store/
|
||||
负责登录、token 签发/刷新/失效、门店列表、菜单权限
|
||||
负责登录、refresh token 签发/轮换/撤销、门店列表与切换、菜单权限
|
||||
```
|
||||
|
||||
## `JwtTokenProvider` 示例
|
||||
## 端点契约(与客户端已实现的行为对齐)
|
||||
|
||||
以下五个端点的路径、请求体、响应体**必须**与客户端文档 [../05-networking.md](../05-networking.md)、[../11-store-context-and-session.md](../11-store-context-and-session.md) 一致,客户端的自动刷新、切店、登出、冷启动恢复流程已经按这个契约实现:
|
||||
|
||||
| 端点 | 认证 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `POST /api/v1/auth/login` | 免认证 | `{ username, password }` → `{ accessToken, refreshToken, user }` |
|
||||
| `POST /api/v1/auth/refresh` | 免认证 | `{ refreshToken }` → `{ accessToken, refreshToken }`。**必须免认证**:调用它的时候 access token 恰好已经过期 |
|
||||
| `POST /api/v1/auth/logout` | 需认证 | `{ refreshToken }` → `ApiResult<Unit>`。撤销该 refresh token;客户端 3 秒超时、失败不阻断本地登出 |
|
||||
| `GET /api/v1/auth/me` | 需认证 | → `{ user, currentStoreId }`。客户端冷启动时用本地 token 调它恢复会话(客户端 `11-store-context-and-session.md` 的启动流程)。**必须是轻量查询**,不做任何写操作——它在每次冷启动都会被调用 |
|
||||
| `POST /api/v1/stores/{id}/switch` | 需认证 | → `StoreContext`(含新的 `accessToken` + 菜单),见下面"切换门店" |
|
||||
|
||||
客户端冷启动的完整顺序是 `GET /auth/me` → `GET /api/v1/stores/accessible`(后者见 [06-api-design.md](./06-api-design.md)):先确认身份还有效,再拉可选门店列表。这两个接口在 401 时都会触发客户端的串行刷新流程,所以它们的 401 必须是标准的 `ApiResult` JSON(见下面 `ApiResultAuthenticationEntryPoint`),不能是 500。
|
||||
|
||||
其余端点的路径与响应包装规则见 [06-api-design.md](./06-api-design.md)。
|
||||
|
||||
## Token 配置与校验
|
||||
|
||||
```kotlin
|
||||
// platform-security/.../JwtTokenProvider.kt
|
||||
@Component
|
||||
class JwtTokenProvider(
|
||||
@Value("\${security.jwt.secret}") secret: String,
|
||||
@Value("\${security.jwt.access-token-ttl-minutes:30}") private val accessTokenTtlMinutes: Long,
|
||||
// platform-security/.../JwtProperties.kt
|
||||
@ConfigurationProperties(prefix = "security.jwt")
|
||||
@Validated
|
||||
data class JwtProperties(
|
||||
/** 当前签发用的密钥 ID,写进 JWT header 的 kid,用于密钥轮换,见"密钥轮换"一节。 */
|
||||
@field:NotBlank val activeKeyId: String,
|
||||
/** kid -> base64 密钥,允许同时持有多个用于校验,只有 activeKeyId 用于签发。 */
|
||||
val keys: Map<String, String> = emptyMap(),
|
||||
@field:Min(5) val accessTokenTtlMinutes: Long = 30,
|
||||
@field:Min(1) val refreshTokenTtlDays: Long = 30,
|
||||
) {
|
||||
private val key = Keys.hmacShaKeyFor(secret.toByteArray())
|
||||
|
||||
fun issueAccessToken(userId: Long, storeId: Long, roles: List<String>): String =
|
||||
Jwts.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("storeId", storeId)
|
||||
.claim("roles", roles)
|
||||
.issuedAt(Date())
|
||||
.expiration(Date.from(Instant.now().plus(accessTokenTtlMinutes, ChronoUnit.MINUTES)))
|
||||
.signWith(key)
|
||||
.compact()
|
||||
|
||||
fun parse(token: String): Jws<Claims> =
|
||||
Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
|
||||
@PostConstruct
|
||||
fun validate() {
|
||||
val key = keys[activeKeyId] ?: error("security.jwt.keys 里没有 activeKeyId=$activeKeyId 对应的密钥")
|
||||
require(Base64.getDecoder().decode(key).size >= 32) {
|
||||
"HS256 密钥长度必须 ≥ 32 字节,当前配置不满足" // 启动即失败,不允许带着弱密钥跑起来
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `JwtAuthenticationFilter` + `StoreContextHolder` 示例
|
||||
```kotlin
|
||||
// platform-security/.../JwtEncoderConfig.kt
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(JwtProperties::class)
|
||||
class JwtEncoderConfig(private val props: JwtProperties) {
|
||||
|
||||
private fun secretKey(keyId: String): SecretKey =
|
||||
SecretKeySpec(Base64.getDecoder().decode(props.keys.getValue(keyId)), "HmacSHA256")
|
||||
|
||||
@Bean
|
||||
fun jwtEncoder(): JwtEncoder {
|
||||
val jwkSet = JWKSet(
|
||||
props.keys.keys.map { kid ->
|
||||
OctetSequenceKey.Builder(secretKey(kid)).keyID(kid).algorithm(JWSAlgorithm.HS256).build()
|
||||
},
|
||||
)
|
||||
return NimbusJwtEncoder(ImmutableJWKSet(jwkSet))
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun jwtDecoder(): JwtDecoder =
|
||||
// 校验时按 header 里的 kid 选密钥,所以轮换期间新旧 token 都能验过
|
||||
NimbusJwtDecoder.withSecretKey(secretKey(props.activeKeyId))
|
||||
.macAlgorithm(MacAlgorithm.HS256)
|
||||
.build()
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// platform-security/.../AccessTokenIssuer.kt
|
||||
@Component
|
||||
class AccessTokenIssuer(
|
||||
private val jwtEncoder: JwtEncoder,
|
||||
private val props: JwtProperties,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
fun issue(userId: Long, storeId: Long, roles: List<String>): String {
|
||||
val now = clock.instant()
|
||||
val claims = JwtClaimsSet.builder()
|
||||
.subject(userId.toString())
|
||||
.claim("storeId", storeId)
|
||||
.claim("roles", roles)
|
||||
.id(UUID.randomUUID().toString()) // jti
|
||||
.issuedAt(now)
|
||||
.expiresAt(now.plus(props.accessTokenTtlMinutes, ChronoUnit.MINUTES))
|
||||
.build()
|
||||
val header = JwsHeader.with(MacAlgorithm.HS256).keyId(props.activeKeyId).build()
|
||||
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).tokenValue
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Access Token 的 claims 结构
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "1024",
|
||||
"storeId": 7,
|
||||
"roles": ["STORE_MANAGER"],
|
||||
"jti": "9f2b6e2e-2f3a-4b7a-9b0a-2c8e6f5a1d3c",
|
||||
"iat": 1735600000,
|
||||
"exp": 1735601800
|
||||
}
|
||||
```
|
||||
|
||||
`jti` 只用于**审计关联**(跟 `traceId` 一起打进日志,方便事后查"这个用户当时用的是哪个 access token"),不用于撤销判断——access token 本身依然是无状态的,服务端不会为了撤销去反查 `jti`,那样就失去了 JWT 免查库校验的意义。真正需要撤销能力的是下面的 refresh token。
|
||||
|
||||
## `StoreContextHolder` 与上下文注入
|
||||
|
||||
```kotlin
|
||||
// platform-security/.../StoreContextHolder.kt
|
||||
@Component
|
||||
@RequestScope
|
||||
@RequestScope // 默认 proxyMode = TARGET_CLASS,可以直接注入到单例 bean 里
|
||||
class StoreContextHolder {
|
||||
var userId: Long? = null
|
||||
var storeId: Long? = null
|
||||
var roles: List<String> = emptyList()
|
||||
|
||||
fun currentUserId(): Long = userId ?: throw IllegalStateException("未认证请求不应到达这里")
|
||||
fun currentStoreId(): Long = storeId ?: throw IllegalStateException("未绑定门店的请求不应到达这里")
|
||||
}
|
||||
```
|
||||
|
||||
// platform-security/.../JwtAuthenticationFilter.kt
|
||||
class JwtAuthenticationFilter(
|
||||
private val jwtTokenProvider: JwtTokenProvider,
|
||||
private val storeContextHolder: StoreContextHolder,
|
||||
Kotlin 里这个类必须是 `open` 的(CGLIB 代理要求),`kotlin-spring` 插件会因为 `@Component` 自动放开,不需要手写 `open`——但如果哪天把 `@Component` 换成了别的注册方式,这里会以一个不太好懂的报错炸掉,值得记一笔。
|
||||
|
||||
```kotlin
|
||||
// platform-security/.../StoreContextFilter.kt
|
||||
// 注意:这个 filter 只负责"把已经验过的 claims 搬进 StoreContextHolder",
|
||||
// 不做任何解析或验签——验签由 Spring Security 的 BearerTokenAuthenticationFilter 做完了。
|
||||
// 这一点很关键:token 无效时的 401 响应由标准的 AuthenticationEntryPoint 产出,
|
||||
// 不会出现"自己写的 filter 里抛异常 → @RestControllerAdvice 接不住 → 返回 500"的情况。
|
||||
class StoreContextFilter(
|
||||
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
|
||||
) : OncePerRequestFilter() {
|
||||
|
||||
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
|
||||
val token = request.getHeader("Authorization")?.removePrefix("Bearer ")
|
||||
if (token != null) {
|
||||
val claims = jwtTokenProvider.parse(token).payload
|
||||
storeContextHolder.userId = claims.subject.toLong()
|
||||
storeContextHolder.storeId = (claims["storeId"] as Number).toLong()
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
storeContextHolder.roles = claims["roles"] as List<String>
|
||||
val jwt = (SecurityContextHolder.getContext().authentication as? JwtAuthenticationToken)?.token
|
||||
if (jwt != null) {
|
||||
val ctx = storeContextHolder.`object`
|
||||
ctx.userId = jwt.subject.toLong()
|
||||
ctx.storeId = jwt.getClaim<Number>("storeId")?.toLong()
|
||||
ctx.roles = jwt.getClaimAsStringList("roles") ?: emptyList()
|
||||
|
||||
val authorities = storeContextHolder.roles.map { SimpleGrantedAuthority("ROLE_$it") }
|
||||
SecurityContextHolder.getContext().authentication =
|
||||
UsernamePasswordAuthenticationToken(storeContextHolder.userId, null, authorities)
|
||||
// 客户端会带 X-Store-Id(见 06-api-design.md 的统一请求头约定)。
|
||||
// 服务端一律以 token 里的 storeId 为准;不一致时记一条 warn 用于排查
|
||||
// (切店瞬间客户端可能还在用旧 header,直接拒绝会误伤正常请求)。
|
||||
request.getHeader("X-Store-Id")?.toLongOrNull()?.let { headerStoreId ->
|
||||
if (headerStoreId != ctx.storeId) {
|
||||
logger.warn("X-Store-Id($headerStoreId) 与 token storeId(${ctx.storeId}) 不一致,以 token 为准")
|
||||
}
|
||||
}
|
||||
}
|
||||
chain.doFilter(request, response)
|
||||
}
|
||||
@@ -91,92 +190,139 @@ class WorkbenchAppService(
|
||||
) {
|
||||
fun listTiles(): List<TileResponse> {
|
||||
val userId = storeContextHolder.currentUserId()
|
||||
val storeId = storeContextHolder.currentStoreId()
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `SecurityConfigSupport` 示例(各 domain/bootstrap 复用)
|
||||
## `SecurityConfig`
|
||||
|
||||
```kotlin
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
@EnableMethodSecurity // 开启 @PreAuthorize
|
||||
class SecurityConfig(
|
||||
private val jwtTokenProvider: JwtTokenProvider,
|
||||
private val storeContextHolder: StoreContextHolder,
|
||||
private val storeContextHolder: ObjectFactory<StoreContextHolder>,
|
||||
private val entryPoint: ApiResultAuthenticationEntryPoint,
|
||||
private val accessDeniedHandler: ApiResultAccessDeniedHandler,
|
||||
private val environment: Environment,
|
||||
) {
|
||||
@Bean
|
||||
fun filterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
val isProd = environment.acceptsProfiles(Profiles.of("prod"))
|
||||
|
||||
http
|
||||
.csrf { it.disable() } // 无状态 API,不需要 CSRF token
|
||||
.csrf { it.disable() } // 无状态 API + Bearer token,不存在 CSRF 的前提
|
||||
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
|
||||
.authorizeHttpRequests {
|
||||
it.requestMatchers("/actuator/health", "/api/v1/auth/login").permitAll()
|
||||
it.anyRequest().authenticated()
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.requestMatchers(
|
||||
"/actuator/health/**", // 存活/就绪探针,见 08-observability.md
|
||||
"/api/v1/auth/login",
|
||||
"/api/v1/auth/refresh", // 调它的时候 access token 已过期,必须免认证
|
||||
).permitAll()
|
||||
|
||||
if (!isProd) {
|
||||
// 接口文档只在非生产环境开放;生产环境走内网文档站或 CI 产物
|
||||
auth.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||
}
|
||||
|
||||
auth.anyRequest().authenticated()
|
||||
}
|
||||
.addFilterBefore(
|
||||
JwtAuthenticationFilter(jwtTokenProvider, storeContextHolder),
|
||||
UsernamePasswordAuthenticationFilter::class.java,
|
||||
.oauth2ResourceServer { oauth2 ->
|
||||
oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(rolesConverter()) }
|
||||
oauth2.authenticationEntryPoint(entryPoint) // token 缺失/过期/签名错 → 统一 401 JSON
|
||||
}
|
||||
.exceptionHandling {
|
||||
it.authenticationEntryPoint(entryPoint)
|
||||
it.accessDeniedHandler(accessDeniedHandler) // 认证过但权限不足 → 统一 403 JSON
|
||||
}
|
||||
.addFilterAfter(
|
||||
StoreContextFilter(storeContextHolder),
|
||||
BearerTokenAuthenticationFilter::class.java, // 必须在认证之后
|
||||
)
|
||||
return http.build()
|
||||
}
|
||||
|
||||
private fun rolesConverter(): Converter<Jwt, AbstractAuthenticationToken> {
|
||||
val authorities = JwtGrantedAuthoritiesConverter().apply {
|
||||
setAuthoritiesClaimName("roles")
|
||||
setAuthorityPrefix("ROLE_")
|
||||
}
|
||||
return JwtAuthenticationConverter().apply { setJwtGrantedAuthoritiesConverter(authorities) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 关键规则
|
||||
|
||||
- `API Gateway` 已做 TLS/路由,后端服务只需要校验 JWT 签名和 claims,不重复做接入层的事。
|
||||
- 门店/角色上下文在统一 filter 里解析 JWT 后写入 `StoreContextHolder`,各 domain 通过它读取当前上下文,不各自解析 token——避免"每个 domain 有一份自己的 token 解析逻辑"这种重复和不一致。
|
||||
- 切换门店会使当前上下文失效,`webview-ticket` 相关会话需要联动失效(对应架构图 Flow 2 的规则):`identity-store` 切换门店成功后,需要通知 `webview-ticket` 使当前 ticket 状态置为 `INVALIDATED`(走 `bff-orchestration` 编排或事件通知,不是 `identity-store` 直接改 `webview-ticket` 的表)。
|
||||
- `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。
|
||||
|
||||
## Access Token 的 claims 结构
|
||||
|
||||
在现有 `JwtTokenProvider.issueAccessToken` 基础上补充 `jti`(每次签发的唯一 ID),完整 claims:
|
||||
|
||||
```json
|
||||
{
|
||||
"sub": "1024",
|
||||
"storeId": 7,
|
||||
"roles": ["STORE_MANAGER"],
|
||||
"jti": "9f2b6e2e-2f3a-4b7a-9b0a-2c8e6f5a1d3c",
|
||||
"iat": 1735600000,
|
||||
"exp": 1735601800
|
||||
```kotlin
|
||||
// platform-security/.../ApiResultAuthenticationEntryPoint.kt
|
||||
@Component
|
||||
class ApiResultAuthenticationEntryPoint(
|
||||
private val objectMapper: ObjectMapper,
|
||||
) : AuthenticationEntryPoint {
|
||||
override fun commence(req: HttpServletRequest, resp: HttpServletResponse, ex: AuthenticationException) {
|
||||
resp.status = HttpStatus.UNAUTHORIZED.value() // 必须是 401:客户端只在 401 时触发刷新
|
||||
resp.contentType = MediaType.APPLICATION_JSON_VALUE
|
||||
resp.characterEncoding = Charsets.UTF_8.name()
|
||||
objectMapper.writeValue(
|
||||
resp.outputStream,
|
||||
ApiResult.error(ErrorCode.UNAUTHORIZED, "登录状态已失效,请重新登录"),
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`jti` 只用于**审计关联**(跟 `traceId` 一起打进日志,方便事后查"这个用户当时用的是哪个 access token"),不用于撤销判断——access token 本身依然是无状态的,服务端不会为了撤销去反查 `jti`,那样就失去了 JWT 免查库校验的意义。真正需要撤销能力的是下面的 refresh token。
|
||||
**为什么这两个 handler 必须存在**:Spring Security 的过滤器跑在 `DispatcherServlet` **之前**,这里抛出的异常 `@RestControllerAdvice`([06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler`)根本接不到。如果不显式处理,token 过期会返回一个空 body 的 401 或者 Spring 默认的错误页——而客户端 [../05-networking.md](../05-networking.md) 是**严格按 HTTP 401 + `ApiResult` 结构**来判断"要不要触发 token 刷新"的。这里返回错了,整条自动刷新链路就是断的,表现为用户莫名其妙被登出。
|
||||
|
||||
## Refresh Token:不用 Redis,直接用现有 Postgres
|
||||
## Refresh Token:不用 Redis,直接用现有 MySQL
|
||||
|
||||
Access token TTL 短(现有配置 30 分钟),到期后客户端用 refresh token 换新的 access token,避免频繁重新登录。Refresh token **不是 JWT**,是一个不透明的随机字符串(`SecureRandom` 生成 32 字节,base64url 编码):因为 refresh token 需要能被服务端主动吊销(登出、检测到被盗用),JWT 本身无状态、签发后没法在不额外存储的情况下撤销——既然撤销必须要有一张表来查,索性让 refresh token 本身就是这张表的查询 key,不需要再多一层"签发 JWT 又要验签"的复杂度。
|
||||
Access token TTL 短(30 分钟),到期后客户端用 refresh token 换新的 access token,避免频繁重新登录。Refresh token **不是 JWT**,是一个不透明的随机字符串(`SecureRandom` 生成 32 字节,base64url 编码):因为 refresh token 需要能被服务端主动吊销(登出、检测到被盗用),JWT 本身无状态、签发后没法在不额外存储的情况下撤销——既然撤销必须要有一张表来查,索性让 refresh token 本身就是这张表的查询 key,不需要再多一层"签发 JWT 又要验签"的复杂度。
|
||||
|
||||
```kotlin
|
||||
// domains/identity-store/infrastructure/persistence/RefreshTokenEntity.kt
|
||||
@Entity
|
||||
@Table(name = "refresh_tokens")
|
||||
@Table(name = "refresh_token", schema = "identity_store")
|
||||
class RefreshTokenEntity(
|
||||
@Id @GeneratedValue val id: Long? = null,
|
||||
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
val id: Long = 0,
|
||||
|
||||
@Column(nullable = false)
|
||||
val userId: Long,
|
||||
|
||||
@Column(name = "token_hash", nullable = false, length = 64)
|
||||
val tokenHash: String, // SHA-256(原始 token) 的 hex,不存明文
|
||||
|
||||
@Column(nullable = false)
|
||||
val expiresAt: Instant,
|
||||
|
||||
@Column
|
||||
var revokedAt: Instant? = null,
|
||||
|
||||
@Column
|
||||
var replacedByTokenId: Long? = null, // 轮换链,用于检测"已撤销的旧 token 被重放"
|
||||
) : BaseEntity()
|
||||
```
|
||||
|
||||
```sql
|
||||
-- domains/identity-store/src/main/resources/db/migration/identity-store/V2__refresh_tokens.sql
|
||||
CREATE TABLE refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||||
token_hash CHAR(64) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
revoked_at TIMESTAMPTZ,
|
||||
replaced_by_token_id BIGINT,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
-- domains/identity-store/src/main/resources/db/migration/identity_store/V2__refresh_token.sql
|
||||
create table refresh_token (
|
||||
id bigint not null auto_increment,
|
||||
user_id bigint not null,
|
||||
token_hash char(64) not null,
|
||||
expires_at datetime(6) not null,
|
||||
revoked_at datetime(6),
|
||||
replaced_by_token_id bigint,
|
||||
-- BaseEntity 的四个审计列,缺任何一个第一次插入就会失败,见 03-persistence.md
|
||||
created_at datetime(6) not null,
|
||||
updated_at datetime(6) not null,
|
||||
created_by varchar(64),
|
||||
updated_by varchar(64),
|
||||
primary key (id),
|
||||
unique key uk_refresh_token_hash (token_hash),
|
||||
key idx_refresh_token_user_id (user_id),
|
||||
key idx_refresh_token_expires_at (expires_at),
|
||||
constraint fk_refresh_token_user foreign key (user_id) references user (id)
|
||||
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
|
||||
```
|
||||
|
||||
```kotlin
|
||||
@@ -184,6 +330,7 @@ CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
@Service
|
||||
class RefreshTokenService(
|
||||
private val repository: RefreshTokenJpaRepository,
|
||||
private val props: JwtProperties,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
fun issue(userId: Long): String {
|
||||
@@ -192,64 +339,189 @@ class RefreshTokenService(
|
||||
RefreshTokenEntity(
|
||||
userId = userId,
|
||||
tokenHash = sha256Hex(rawToken),
|
||||
expiresAt = clock.instant().plus(30, ChronoUnit.DAYS),
|
||||
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
|
||||
),
|
||||
)
|
||||
return rawToken
|
||||
return rawToken // 返回明文给客户端,库里只有 hash
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun rotate(rawToken: String): String {
|
||||
fun rotate(rawToken: String): RotatedTokens {
|
||||
val existing = repository.findByTokenHash(sha256Hex(rawToken))
|
||||
?.takeIf { it.revokedAt == null && it.expiresAt.isAfter(clock.instant()) }
|
||||
?: throw InvalidRefreshTokenException() // 已过期/已撤销/被重放,一律要求重新登录
|
||||
?: throw InvalidRefreshTokenException() // 从来不存在的 token
|
||||
|
||||
// 重放检测:这个 token 存在,但已经被轮换掉了 —— 说明它很可能已泄漏,
|
||||
// 因为正常客户端拿到新 token 后不会再用旧的。撤销该用户全部 refresh token,强制重新登录。
|
||||
if (existing.revokedAt != null) {
|
||||
repository.revokeAllByUserId(existing.userId, clock.instant())
|
||||
log.warn("检测到 refresh token 重放,已撤销用户 {} 的全部 refresh token", existing.userId)
|
||||
throw InvalidRefreshTokenException()
|
||||
}
|
||||
if (existing.expiresAt.isBefore(clock.instant())) {
|
||||
throw InvalidRefreshTokenException()
|
||||
}
|
||||
|
||||
val newRawToken = generateOpaqueToken()
|
||||
val rotated = repository.save(
|
||||
RefreshTokenEntity(
|
||||
userId = existing.userId,
|
||||
tokenHash = sha256Hex(generateOpaqueToken()),
|
||||
expiresAt = clock.instant().plus(30, ChronoUnit.DAYS),
|
||||
tokenHash = sha256Hex(newRawToken), // 存 hash
|
||||
expiresAt = clock.instant().plus(props.refreshTokenTtlDays, ChronoUnit.DAYS),
|
||||
),
|
||||
)
|
||||
existing.revokedAt = clock.instant()
|
||||
existing.replacedByTokenId = rotated.id
|
||||
return rotated.tokenHash // 实际返回给客户端的是加密前的 rawToken,此处示意轮换关系
|
||||
|
||||
return RotatedTokens(userId = existing.userId, refreshToken = newRawToken) // 返回明文
|
||||
}
|
||||
|
||||
fun revokeAllByUser(userId: Long) = repository.revokeAllByUserId(userId, clock.instant()) // 登出/强制下线
|
||||
@Transactional
|
||||
fun revoke(rawToken: String) {
|
||||
repository.findByTokenHash(sha256Hex(rawToken))
|
||||
?.takeIf { it.revokedAt == null }
|
||||
?.let { it.revokedAt = clock.instant() }
|
||||
// 登出场景:token 不存在或已撤销都视为成功,不给调用方任何"这个 token 存不存在"的信号
|
||||
}
|
||||
|
||||
fun revokeAllByUser(userId: Long) = repository.revokeAllByUserId(userId, clock.instant()) // 强制下线
|
||||
}
|
||||
|
||||
data class RotatedTokens(val userId: Long, val refreshToken: String)
|
||||
```
|
||||
|
||||
- **轮换(rotation)**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`——如果旧 token 之后又被人拿来用一次,说明它可能已经泄漏被盗用,能立刻识别出这次"重放",可以顺带撤销该用户名下所有 refresh token,强制重新登录。
|
||||
- **过期清理**:不依赖 Redis 的自动 TTL,加一个简单的定时任务(`@Scheduled`)定期删掉 `expires_at < now()` 的行即可——单个用户同时存在的有效 refresh token 数量本来就很小,数据量级不构成问题。
|
||||
- **轮换(rotation)**:每次用 refresh token 换 access token,同时签发一个新的 refresh token,旧的立刻标记 `revokedAt`。旧 token 之后又被用一次,就落进上面的重放分支——**注意"从来没见过的 token"和"已撤销的 token"必须分成两个分支处理**,合在一起写会让重放检测形同虚设(客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md) 的串行刷新设计正是建立在"重放即全量撤销"这条规则上的)。
|
||||
- **过期清理**:加一个定时任务定期删掉 `expires_at < now()` 且已撤销的行。**注意多副本下这个任务会在每个 Pod 各跑一次**,处理方式见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
|
||||
### 为什么现阶段不引入 Redis
|
||||
|
||||
Redis 常被用来存 refresh token/session,图的是两点:**自动过期(TTL)**和**高并发读写性能**。这两点目前都不是硬约束:
|
||||
|
||||
- 过期:一张表 + 一个定时清理任务就够,只是没有 Redis"写入即设 TTL、到期自动消失"那么省事。
|
||||
- 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,Postgres 加个唯一索引足够支撑。
|
||||
- 性能:refresh token 校验只发生在"access token 过期后换新"这一低频动作上,不是每次请求都查,MySQL 上一个唯一索引足够支撑。
|
||||
- 额外成本:引入 Redis 意味着在 K8s 上多起一个有状态服务(持久化、备份、连接池配置、AKS 里再开一条访问路径),这跟 [09-build-deploy.md](./09-build-deploy.md)、[07-config-governance.md](./07-config-governance.md) 里"能用现有基础设施就不额外引入运维负担"的取舍一致。
|
||||
|
||||
结论:refresh token 存现有 Postgres 就够,不引入 Redis;如果以后出现 Redis 能解决、Postgres 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。
|
||||
结论:refresh token 存现有 MySQL 就够,不引入 Redis;如果以后出现 Redis 能解决、MySQL 解决不了的场景(比如跨实例分布式限流、高频缓存),到时候再评估,届时 refresh token 也可以顺带迁过去,不存在现在这张表以后没法迁移的问题。
|
||||
|
||||
## 附录:为什么用 `RequestScope` bean 而不是 `ThreadLocal`
|
||||
## 切换门店
|
||||
|
||||
```kotlin
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/stores")
|
||||
class StoreController(private val storeAppService: StoreAppService) {
|
||||
|
||||
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
|
||||
@PostMapping("/{storeId}/switch")
|
||||
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
|
||||
ApiResult.ok(storeAppService.switchStore(storeId))
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class StoreAppService(
|
||||
private val storeRepository: StoreRepository,
|
||||
private val accessTokenIssuer: AccessTokenIssuer,
|
||||
private val storeContextHolder: StoreContextHolder,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
) {
|
||||
@Transactional
|
||||
fun switchStore(targetStoreId: Long): StoreContextResponse {
|
||||
val userId = storeContextHolder.currentUserId()
|
||||
|
||||
// 1. 必须校验目标门店对当前用户可访问 —— 否则任何人改一下 URL 里的 id 就能进别人的门店
|
||||
val store = storeRepository.findAccessibleStore(userId, targetStoreId)
|
||||
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
|
||||
|
||||
val roles = storeRepository.findRoles(userId, targetStoreId)
|
||||
|
||||
// 2. 重新签发 access token —— 新 token 里的 storeId 是目标门店。
|
||||
// 这一步不能省:token 里的 storeId 决定了后续所有请求的数据范围,
|
||||
// 只改客户端本地状态不换 token,等于门店没真的切过去。
|
||||
val accessToken = accessTokenIssuer.issue(userId, targetStoreId, roles)
|
||||
|
||||
// 3. 切店会使旧门店下的 WebView 票据失效(架构图 Flow 2)。
|
||||
// 走领域事件,不由 identity-store 直接改 webview-ticket 的表,见 11-cross-domain-collaboration.md
|
||||
eventPublisher.publishEvent(StoreSwitchedEvent(userId, from = storeContextHolder.storeId, to = targetStoreId))
|
||||
|
||||
return StoreContextResponse(accessToken, store.toInfo(), roles, menuOf(roles))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
refresh token **不随切店轮换**:它绑定的是用户身份而不是门店,切店只换 access token。
|
||||
|
||||
## 越权与数据隔离规则
|
||||
|
||||
这是这套系统里最容易出、也最贵的一类漏洞(一个门店看到另一个门店的数据),单独立规则:
|
||||
|
||||
1. **所有涉及门店数据的查询,必须以 `storeContextHolder.currentStoreId()` 作为过滤条件**,而不是用请求参数里传来的 `storeId`。请求参数可以被任意篡改,token 里的不行。
|
||||
2. **确实需要按路径/参数指定 `storeId` 的接口(如切店),必须先校验该用户对目标门店的可访问性**,校验不过返回 `STORE_NOT_ACCESSIBLE`。上面的 `findAccessibleStore` 就是这个模式:把权限校验合进查询条件,而不是"先查出来再判断"——后者很容易漏判,前者查不到就是没权限。
|
||||
3. **按 ID 查单条记录时,`where id = ? and store_id = ?`**,不要只按主键查完再比对——只按主键查会让"不存在"和"没权限"走不同的代码路径,容易漏掉后者,而且响应差异本身就是一种信息泄漏。
|
||||
4. 这三条在 code review 里是必看项,新增查询方法时优先检查。
|
||||
|
||||
### 接口级权限 vs 菜单权限
|
||||
|
||||
- **菜单权限**(切店时返回的 `menu`)只决定 APP 上显示什么,是 UI 层的便利,**不构成安全边界**。客户端拿到的菜单里没有某一项,不代表它调不到对应接口。
|
||||
- **接口级权限**用 `@PreAuthorize` 在 `application` 层(或 Controller)上显式声明,这才是真正的边界:
|
||||
|
||||
```kotlin
|
||||
@PreAuthorize("hasRole('STORE_MANAGER')")
|
||||
fun approveProcurement(id: Long) { ... }
|
||||
```
|
||||
- 两者的数据来源应该是同一份角色/权限配置,但**必须两边都做**。只做菜单不做接口校验,等于没做。
|
||||
|
||||
## 密码存储与登录保护
|
||||
|
||||
- **密码用 BCrypt 存储**(`BCryptPasswordEncoder`,强度 12),存 hash,绝不存明文或可逆加密。用 `DelegatingPasswordEncoder`(Spring Security 默认)让 hash 带 `{bcrypt}` 前缀,将来换算法时新旧可以共存。
|
||||
- **登录失败限流**:`user` 表上加 `failed_attempts` / `locked_until` 两列,同一账号连续失败 5 次锁定 15 分钟。放在数据库而不是内存里,是因为多副本下内存计数各算各的,等于没限。
|
||||
- **IP 维度的限流放在 API Gateway 做**,后端不重复实现——后端看到的往往是网关的 IP,自己做也做不准。
|
||||
- **登录失败的响应不区分"用户不存在"和"密码错误"**,统一返回同一个错误码和文案,避免被用来枚举账号。
|
||||
- 密码、token、`Authorization` 头等敏感字段**不允许进日志**,脱敏规则见 [08-observability.md](./08-observability.md)。
|
||||
|
||||
## 密钥轮换
|
||||
|
||||
JWT 签名密钥放在 K8s Secret 里(见 [07-config-governance.md](./07-config-governance.md)),轮换按 `kid` 分三步走,全程不需要让用户重新登录:
|
||||
|
||||
1. **加新密钥**:在 `security.jwt.keys` 里加一个新 `kid`,但 `activeKeyId` 保持旧的。这时新旧密钥都能验签,签发仍用旧的。滚动更新完成后所有实例都认识新密钥。
|
||||
2. **切签发**:把 `activeKeyId` 改成新 `kid`。此时新签发的 token 用新密钥,还在有效期内的旧 token 仍能验过。
|
||||
3. **删旧密钥**:等超过一个 access token TTL(30 分钟)后,从 `keys` 里移除旧 `kid`。
|
||||
|
||||
三步之间必须各自完成一次滚动更新,不能合并成一次改动——合并了会出现"某些实例已经用新密钥签发,另一些实例还不认识新密钥"的窗口,表现为随机 401。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- `API Gateway` 已做 TLS/路由,后端服务只需要校验 JWT 签名和 claims,不重复做接入层的事。
|
||||
- 门店/角色上下文在统一 filter 里从已认证的 `Jwt` 写入 `StoreContextHolder`,各 domain 通过它读取,不各自解析 token。
|
||||
- 切换门店必须重新签发 access token,并联动使 `webview-ticket` 的当前票据失效(架构图 Flow 2)——走领域事件,不跨模块直接改表。
|
||||
- `webview-ticket` 用的票据是独立的短时票据机制(见 [05-integration-layer.md](./05-integration-layer.md)),不复用登录 JWT,避免票据泄漏后长期有效。
|
||||
|
||||
## 附录一:为什么用 Spring Security 内置的 Nimbus,而不是 jjwt
|
||||
|
||||
jjwt 是 Java 生态里很常见的 JWT 库,但在我们这个版本基线(Spring Boot 4 / [01-project-structure.md](./01-project-structure.md))下它有个具体问题:jjwt 的 JSON 序列化模块 `jjwt-jackson` 依赖 **Jackson 2**(`com.fasterxml.jackson`),而 Boot 4 默认用的是 **Jackson 3**(`tools.jackson`)。两者包名不同、不二进制兼容,用 jjwt 就意味着要在同一个应用里同时带两套 Jackson——能跑,但多一份依赖、多一处升级时要照顾的地方,还容易让人误以为 `ObjectMapper` 只有一个。
|
||||
|
||||
Spring Security 本身就自带基于 Nimbus JOSE + JWT 的 `JwtEncoder`/`JwtDecoder`(`spring-boot-starter-oauth2-resource-server`),版本由 Boot BOM 管,不引入第二套 JSON 库。更重要的是,用它就能顺带用上 `oauth2ResourceServer` 这套标准链路:**token 的解析和验签由框架的 filter 完成,失败时走标准的 `AuthenticationEntryPoint` 返回 401**——而自己写 filter 解析 token 时,最常见的 bug 恰恰是异常没接住导致返回 500(详见上面 handler 那一节)。选它是同时解决依赖和正确性两个问题。
|
||||
|
||||
代价:Nimbus 的 API 比 jjwt 的链式 builder 略啰嗦一点(多一个 `JwtClaimsSet`/`JwsHeader` 的概念),以及如果团队里有人熟悉 jjwt 需要适应一下。如果后续确实要换回 jjwt,注意版本要选和 Jackson 3 兼容的,或者自己实现 jjwt 的 `Serializer`/`Deserializer` 接口接到 Jackson 3 上。
|
||||
|
||||
## 附录二:为什么用 `RequestScope` bean 而不是 `ThreadLocal`
|
||||
|
||||
传统做法常见用 `ThreadLocal` 存当前用户上下文,但 `ThreadLocal` 有两个常见坑:
|
||||
|
||||
1. **忘记清理**:请求处理完不手动 `remove()`,线程池复用线程时,下一个请求可能读到上一个请求残留的上下文——在 Tomcat 这种线程池容器里是真实发生过的安全事故类型。
|
||||
2. **响应式/协程场景失效**:一旦引入 `WebClient` 的异步回调或 Kotlin 协程切换线程,`ThreadLocal` 绑定的线程和实际处理请求的线程可能不是同一个。
|
||||
2. **线程切换场景失效**:一旦把工作交给另一个线程池(比如 `workbench` 首页的并行聚合,见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)),`ThreadLocal` 绑定的线程和实际干活的线程就不是同一个了。
|
||||
|
||||
Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。当前阶段 domain 内部都是同步 Servlet 栈(Spring MVC),`RequestScope` 完全够用;如果未来某个模块换成 WebFlux(响应式),需要改用 Reactor Context 传递上下文,不能直接照搬 `RequestScope`。
|
||||
Spring 的 `@RequestScope` bean 由容器管理生命周期,请求结束自动销毁,不需要手动清理,语义上也更清楚地表达"这个对象的生命周期等于一次 HTTP 请求"。
|
||||
|
||||
需要注意的是 `@RequestScope` 也**不会自动跨线程传播**:并行聚合时子线程里拿不到它。那种场景要么把需要的值(`userId`/`storeId`)作为方法参数显式传进去(推荐,最不容易出错),要么给线程池配 `TaskDecorator` 显式搬运上下文——两种做法的取舍见 11 篇。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 权限模型细节(菜单权限 vs 接口级权限,是否需要单独的权限表)。
|
||||
- 与 K8s ConfigMap/Secret 配合的密钥轮换方式,见 [07-config-governance.md](./07-config-governance.md)。
|
||||
- 权限模型细节:角色-权限-菜单三张表怎么设计,是否需要按门店维度分配不同角色。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Spring Security 官方文档](https://docs.spring.io/spring-security/reference/index.html)
|
||||
- [jjwt(JWT 库)](https://github.com/jwtk/jjwt)
|
||||
- [Spring Security: JWT 支持(JwtEncoder / JwtDecoder)](https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/jwt.html)
|
||||
- [OWASP JWT 安全实践](https://cheatsheetseries.owasp.org/cheatsheets/JSON_Web_Token_for_Java_Cheat_Sheet.html)
|
||||
- [OWASP 认证 Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
||||
- [Auth0: Refresh Token Rotation](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-rotation)
|
||||
|
||||
+245
-61
@@ -2,130 +2,314 @@
|
||||
|
||||
## 决策
|
||||
|
||||
供应商(F6)和历史 Mini 域的调用统一收口在 `f6-integration` / `mini-clients` 模块,业务 domain 不直接持有 `WebClient` 或 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断。
|
||||
供应商(F6)和历史 Mini 域的调用统一收口在 `integration/f6-adapter` / `integration/mini-clients` 模块(模块位置见 [01-project-structure.md](./01-project-structure.md)),业务 domain 不直接持有 HTTP 客户端;用 Resilience4j 统一管理超时、重试、熔断、并发隔离。
|
||||
|
||||
HTTP 客户端用 **`RestClient`(同步)**,底层是 Apache HttpClient 5 连接池,不用 `WebClient`/`Mono`。
|
||||
|
||||
### 为什么是同步 `RestClient` 而不是 `WebClient`
|
||||
|
||||
`WebClient` 是 Spring 官方推荐的现代 HTTP 客户端,但它的返回值是 `Mono`/`Flux`,把响应式编程模型带进了整个调用链。在我们这套以 Spring MVC(同步 Servlet 栈)为主的应用里,这会造成几个实际问题:
|
||||
|
||||
- **上下文会丢**。`StoreContextHolder` 是 `@RequestScope` bean、日志的 `traceId` 在 MDC 里,这两样都绑在请求线程上。一旦回调跑在 Reactor 的 event-loop 线程上,两者都拿不到——日志断链、上下文取不到值,而且这类 bug 只在特定时序下出现,很难查。
|
||||
- **收益用不上**。响应式的价值是用少量线程扛住大量并发连接,前提是**整条链路都是非阻塞的**。我们的链路里有 JPA(阻塞 JDBC),只要还在用 JPA,中间夹一段响应式并不会减少线程占用,只是把复杂度带进来。
|
||||
- **心智成本**。`Mono` 一旦进入业务代码,测试、异常处理、事务边界都要按另一套规则写,团队要同时掌握两套模型。
|
||||
|
||||
`RestClient`(Spring 6.1+)提供的是和 `WebClient` 一样的流式 API,但返回值是普通对象、执行是同步的。等到将来整条链路真的要转非阻塞(比如换掉 JPA),再统一切换到 `WebClient` 不迟——那时是一次有明确收益的整体迁移,而不是现在这样局部引入。
|
||||
|
||||
## 结构约定
|
||||
|
||||
```
|
||||
platform-integration/
|
||||
WebClientConfig # 统一封装 WebClient(连接池、超时基线配置)
|
||||
Resilience4jDefaults # 超时/重试/熔断的公共默认配置
|
||||
RestClientConfig # 按下游系统构建 RestClient(连接池、超时、拦截器)
|
||||
IntegrationClientProperties # integration.clients.* 配置绑定
|
||||
TracePropagationInterceptor # 统一给出站请求加 X-Trace-Id
|
||||
IntegrationException # 集成层异常基类(继承 platform-web 的 BusinessException)
|
||||
|
||||
domains/f6-integration/
|
||||
负责:换票、供应商访问上下文准备、超时/重试/熔断策略、异常转换为内部标准错误码
|
||||
integration/f6-adapter/
|
||||
负责:换票、供应商访问上下文准备、超时/重试/熔断/舱壁策略、异常转换为内部标准错误码
|
||||
|
||||
domains/mini-clients/
|
||||
integration/mini-clients/
|
||||
对 O2O/Warranty/Retail Store/ROOS 的只读客户端封装,供 workbench / bff-orchestration 调用
|
||||
```
|
||||
|
||||
## `WebClient` + Resilience4j 配置示例
|
||||
`platform-integration` 依赖 `platform-web`(为了复用 `BusinessException` 和 `ErrorCode`)是本次允许的少数几个 platform 间依赖之一。
|
||||
|
||||
## `RestClient` 配置
|
||||
|
||||
```groovy
|
||||
// platform-integration/build.gradle
|
||||
dependencies {
|
||||
implementation project(':platform:platform-web')
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.apache.httpcomponents.client5:httpclient5' // 版本由 Boot BOM 管
|
||||
implementation 'io.github.resilience4j:resilience4j-spring-boot4:2.4.0'
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// platform-integration/.../RestClientConfig.kt
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(IntegrationClientProperties::class)
|
||||
class RestClientConfig(private val props: IntegrationClientProperties) {
|
||||
|
||||
@Bean
|
||||
fun f6RestClient(builder: RestClient.Builder): RestClient = build(builder, props.f6) { req, resp ->
|
||||
// 5xx / 连接类问题 → 可重试;4xx → 请求本身有问题,重试没有意义
|
||||
if (resp.statusCode.is5xxServerError) {
|
||||
throw F6ServerException("F6 返回 ${resp.statusCode}")
|
||||
}
|
||||
throw F6ClientException("F6 拒绝了请求: ${resp.statusCode}")
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun miniRestClient(builder: RestClient.Builder): RestClient = build(builder, props.mini) { _, resp ->
|
||||
throw MiniIntegrationException("Mini 域返回 ${resp.statusCode}")
|
||||
}
|
||||
|
||||
private fun build(
|
||||
builder: RestClient.Builder, // 注入 Boot 提供的 Builder,而不是 RestClient.create():
|
||||
config: ClientConfig, // 这样 Micrometer 的 observation/W3C traceparent 会自动挂上(见 08)
|
||||
statusHandler: RestClient.ResponseSpec.ErrorHandler,
|
||||
): RestClient = builder
|
||||
.baseUrl(config.baseUrl)
|
||||
.requestFactory(requestFactory(config))
|
||||
.requestInterceptor(TracePropagationInterceptor())
|
||||
.defaultStatusHandler(HttpStatusCode::isError, statusHandler)
|
||||
.build()
|
||||
|
||||
private fun requestFactory(config: ClientConfig): ClientHttpRequestFactory {
|
||||
val connectionManager = PoolingHttpClientConnectionManagerBuilder.create()
|
||||
.setMaxConnTotal(config.maxConnections)
|
||||
.setMaxConnPerRoute(config.maxConnectionsPerRoute) // 必须 ≥ 对应的舱壁并发上限,理由见下
|
||||
.setDefaultConnectionConfig(
|
||||
ConnectionConfig.custom()
|
||||
.setConnectTimeout(Timeout.ofMilliseconds(config.connectTimeoutMs))
|
||||
.setValidateAfterInactivity(TimeValue.ofSeconds(5)) // 复用前探活,避开对端已断的空闲连接
|
||||
.build(),
|
||||
)
|
||||
.build()
|
||||
|
||||
val httpClient = HttpClients.custom()
|
||||
.setConnectionManager(connectionManager)
|
||||
.setDefaultRequestConfig(
|
||||
RequestConfig.custom()
|
||||
// 从连接池拿连接的等待上限。漏配它是最隐蔽的一个坑:
|
||||
// 池子被占满时线程会无限期地等在这里,前面所有超时配置全部失效。
|
||||
.setConnectionRequestTimeout(Timeout.ofMilliseconds(config.connectionRequestTimeoutMs))
|
||||
.setResponseTimeout(Timeout.ofMilliseconds(config.readTimeoutMs))
|
||||
.build(),
|
||||
)
|
||||
.evictIdleConnections(TimeValue.ofSeconds(30))
|
||||
.build()
|
||||
|
||||
return HttpComponentsClientHttpRequestFactory(httpClient)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
integration:
|
||||
clients:
|
||||
f6:
|
||||
base-url: ${F6_BASE_URL}
|
||||
connect-timeout-ms: 1000
|
||||
read-timeout-ms: 2000
|
||||
connection-request-timeout-ms: 500
|
||||
max-connections: 60
|
||||
max-connections-per-route: 30
|
||||
mini:
|
||||
base-url: ${MINI_BASE_URL}
|
||||
connect-timeout-ms: 500
|
||||
read-timeout-ms: 1000
|
||||
connection-request-timeout-ms: 300
|
||||
max-connections: 60
|
||||
max-connections-per-route: 30
|
||||
```
|
||||
|
||||
**超时由 HTTP 客户端承担,不用 `@TimeLimiter`。** Resilience4j 的 `@TimeLimiter` 只能作用于 `CompletableFuture` 或响应式返回值——同步方法上加了不会报错,但**完全不生效**,是个非常容易误配的注解。同步栈下真正管用的是上面三个:`connectTimeout`(建连)、`responseTimeout`(等响应)、`connectionRequestTimeout`(等连接池)。
|
||||
|
||||
## Resilience4j 配置
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
resilience4j:
|
||||
timelimiter:
|
||||
instances:
|
||||
f6-api:
|
||||
timeout-duration: 2s
|
||||
mini-o2o:
|
||||
timeout-duration: 1s
|
||||
# 叠加顺序由这几个 *-aspect-order 属性决定,跟注解写在方法上的先后顺序无关(详见文末附录)。
|
||||
# 这里显式写死,避免依赖框架默认值——默认值会随版本变,而顺序变了语义就变了。
|
||||
retry:
|
||||
retry-aspect-order: 3 # 最外层
|
||||
instances:
|
||||
f6-api:
|
||||
max-attempts: 2
|
||||
wait-duration: 200ms
|
||||
exponential-backoff-multiplier: 2
|
||||
retry-exceptions:
|
||||
- java.net.SocketTimeoutException
|
||||
- org.springframework.web.reactive.function.client.WebClientRequestException
|
||||
# 同步栈下真实会抛出来的类型:
|
||||
# - ResourceAccessException 包住了 SocketTimeoutException/ConnectException 等所有 IO 异常,
|
||||
# 业务代码永远不会直接见到 SocketTimeoutException,写它是无效配置
|
||||
# - F6ServerException 是我们在 statusHandler 里对 5xx 的转换
|
||||
- org.springframework.web.client.ResourceAccessException
|
||||
- com.continental.retailapp.integration.f6.F6ServerException
|
||||
ignore-exceptions:
|
||||
# 熔断已打开时抛的异常,重试它毫无意义,只会白白多等一轮 wait-duration
|
||||
- io.github.resilience4j.circuitbreaker.CallNotPermittedException
|
||||
- com.continental.retailapp.integration.f6.F6ClientException
|
||||
circuitbreaker:
|
||||
circuit-breaker-aspect-order: 2
|
||||
instances:
|
||||
f6-api:
|
||||
sliding-window-type: COUNT_BASED
|
||||
sliding-window-size: 20
|
||||
minimum-number-of-calls: 10 # 样本太少时不做判断,避免启动后头几个请求就把熔断打开
|
||||
failure-rate-threshold: 50
|
||||
slow-call-duration-threshold: 1500ms
|
||||
slow-call-rate-threshold: 80 # 慢调用也算故障:只统计失败率的话,"每次都卡满 2 秒但最终成功"永远不会熔断
|
||||
wait-duration-in-open-state: 10s
|
||||
permitted-number-of-calls-in-half-open-state: 5
|
||||
record-exceptions:
|
||||
- org.springframework.web.client.ResourceAccessException
|
||||
- com.continental.retailapp.integration.f6.F6ServerException
|
||||
bulkhead:
|
||||
bulkhead-aspect-order: 1 # 最内层,贴着真实调用
|
||||
instances:
|
||||
f6-api:
|
||||
max-concurrent-calls: 20
|
||||
max-wait-duration: 0 # 拿不到名额立刻失败走降级,不排队 —— 排队等于把阻塞换个地方而已
|
||||
mini-o2o:
|
||||
max-concurrent-calls: 20
|
||||
max-wait-duration: 0
|
||||
```
|
||||
|
||||
## F6 客户端示例
|
||||
|
||||
```kotlin
|
||||
// domains/f6-integration/.../F6ApiClient.kt
|
||||
// integration/f6-adapter/.../F6ApiClient.kt
|
||||
@Component
|
||||
class F6ApiClient(
|
||||
private val webClient: WebClient, // 来自 platform-integration 的统一封装
|
||||
private val f6RestClient: RestClient, // 来自 platform-integration 的统一封装
|
||||
) {
|
||||
@Bulkhead(name = "f6-api") // 默认 SEMAPHORE 类型,不额外起线程
|
||||
@CircuitBreaker(name = "f6-api", fallbackMethod = "fallbackProcurementList")
|
||||
@Retry(name = "f6-api")
|
||||
@TimeLimiter(name = "f6-api")
|
||||
fun fetchProcurementList(storeId: Long): Mono<ProcurementListResponse> =
|
||||
webClient.get()
|
||||
fun fetchProcurementList(storeId: Long): ProcurementListResponse =
|
||||
f6RestClient.get()
|
||||
.uri("/f6/procurement/list?storeId={storeId}", storeId)
|
||||
.retrieve()
|
||||
.onStatus({ it.isError }) { resp ->
|
||||
resp.bodyToMono(String::class.java)
|
||||
.map { body -> F6IntegrationException("F6 采购列表调用失败: ${resp.statusCode()} $body") }
|
||||
}
|
||||
.bodyToMono(ProcurementListResponse::class.java)
|
||||
.body(ProcurementListResponse::class.java)!!
|
||||
|
||||
// Resilience4j 约定:fallback 方法签名 = 原方法参数 + Throwable,返回类型一致
|
||||
fun fallbackProcurementList(storeId: Long, ex: Throwable): Mono<ProcurementListResponse> =
|
||||
Mono.just(ProcurementListResponse.degraded())
|
||||
// Resilience4j 约定:fallback 方法签名 = 原方法参数 + Throwable,返回类型与原方法一致(同步下就是 T)
|
||||
fun fallbackProcurementList(storeId: Long, ex: Throwable): ProcurementListResponse {
|
||||
log.warn("F6 采购列表降级返回,storeId={}, cause={}", storeId, ex.toString())
|
||||
return ProcurementListResponse.degraded()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// 统一异常转换:F6IntegrationException -> 内部标准错误码,业务层不感知供应商原始协议
|
||||
class F6IntegrationException(message: String) : RuntimeException(message)
|
||||
// platform-integration/.../IntegrationException.kt
|
||||
// 继承 BusinessException(见 06-api-design.md),复用 GlobalExceptionHandler,
|
||||
// 不再单独写一个 @RestControllerAdvice —— 少一个会和全局处理器抢优先级的地方。
|
||||
open class IntegrationException(
|
||||
code: Int,
|
||||
message: String,
|
||||
httpStatus: HttpStatus = HttpStatus.BAD_GATEWAY,
|
||||
) : BusinessException(code, message, httpStatus)
|
||||
|
||||
@RestControllerAdvice
|
||||
class F6ExceptionHandler {
|
||||
@ExceptionHandler(F6IntegrationException::class)
|
||||
fun handle(ex: F6IntegrationException): ResponseEntity<ApiResult<Nothing>> =
|
||||
ResponseEntity.status(HttpStatus.BAD_GATEWAY)
|
||||
.body(ApiResult.error(code = "F6_UNAVAILABLE", message = "供应商服务暂不可用,请稍后重试"))
|
||||
}
|
||||
// integration/f6-adapter/.../F6Exceptions.kt
|
||||
class F6ServerException(message: String) :
|
||||
IntegrationException(ErrorCode.F6_UNAVAILABLE, "供应商服务暂不可用,请稍后重试")
|
||||
|
||||
class F6ClientException(message: String) :
|
||||
IntegrationException(ErrorCode.F6_BUSINESS_ERROR, "供应商请求被拒绝")
|
||||
```
|
||||
|
||||
错误码是 `Int`,落在 `3xxxx` 集成段(`ErrorCode.F6_UNAVAILABLE = 30001`),完整分段表见 [06-api-design.md](./06-api-design.md)——客户端 [../12-error-and-api-contract.md](../12-error-and-api-contract.md) 的 `ApiCode` 也是数字,两边必须一致。异常里带的原始 `message`(含 F6 的状态码和响应体)只进日志,**不进返回给 APP 的 `message`**,避免把供应商的协议细节泄漏出去。
|
||||
|
||||
## Mini 域客户端示例(内部系统,策略更宽松)
|
||||
|
||||
```kotlin
|
||||
// domains/mini-clients/.../O2OClient.kt
|
||||
// integration/mini-clients/.../O2OClient.kt
|
||||
@Component
|
||||
class O2OClient(private val webClient: WebClient) {
|
||||
class O2OClient(private val miniRestClient: RestClient) {
|
||||
|
||||
@TimeLimiter(name = "mini-o2o") // 只兜底超时,不需要熔断(内部系统,稳定性相对可控)
|
||||
fun fetchOrderSummary(storeId: Long): Mono<OrderSummary> =
|
||||
webClient.get()
|
||||
.uri("/o2o/orders/summary?storeId={storeId}", storeId)
|
||||
.retrieve()
|
||||
.bodyToMono(OrderSummary::class.java)
|
||||
.onErrorResume { Mono.just(OrderSummary.empty()) } // 局部降级,见 workbench 聚合规则
|
||||
@Bulkhead(name = "mini-o2o") // 只做超时 + 并发隔离,不加熔断(内部系统,稳定性相对可控)
|
||||
fun fetchOrderSummary(storeId: Long): OrderSummary =
|
||||
try {
|
||||
miniRestClient.get()
|
||||
.uri("/o2o/orders/summary?storeId={storeId}", storeId)
|
||||
.retrieve()
|
||||
.body(OrderSummary::class.java)!!
|
||||
} catch (ex: Exception) {
|
||||
log.warn("O2O 订单摘要降级返回,storeId={}", storeId, ex)
|
||||
OrderSummary.empty() // 局部降级:首页某个 tile 空着,好过整个首页 500
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## traceId 透传
|
||||
|
||||
```kotlin
|
||||
// platform-integration/.../TracePropagationInterceptor.kt
|
||||
class TracePropagationInterceptor : ClientHttpRequestInterceptor {
|
||||
override fun intercept(
|
||||
request: HttpRequest,
|
||||
body: ByteArray,
|
||||
execution: ClientHttpRequestExecution,
|
||||
): ClientHttpResponse {
|
||||
MDC.get("traceId")?.let { request.headers.set("X-Trace-Id", it) }
|
||||
return execution.execute(request, body)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
同步栈下拦截器和业务代码跑在同一个线程上,MDC 一定取得到,业务代码零感知。W3C 标准的 `traceparent` 头由 Micrometer Tracing 自动加(前提是用注入的 `RestClient.Builder` 构建,见上面的注释);这里额外发的 `X-Trace-Id` 是给 F6 这类只认自定义头的外部系统用的,两者并存,规则见 [08-observability.md](./08-observability.md)。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- **F6 是外部供应商域**,稳定性不可控,必须配置超时 + 重试 + 熔断,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。
|
||||
- **异常统一转换**:F6 / Mini 域返回的异常或非标准错误,在 `f6-integration` / `mini-clients` 内部转换成内部标准错误码(如 `F6_UNAVAILABLE`),业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。
|
||||
- **Mini 域调用相对可控**(内部系统),熔断策略可以比 F6 宽松(示例里只加超时兜底),但仍需要超时兜底,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"的要求。
|
||||
- 业务 domain(如 `workbench`)只依赖 `mini-clients` / `f6-integration` 暴露的接口,不自己 `new WebClient` 发请求。
|
||||
- **F6 是外部供应商域**,稳定性不可控,必须配齐超时 + 重试 + 熔断 + 舱壁,且熔断后要有降级返回(`fallbackXxx` 方法),不能让异常直接穿透到 APP。
|
||||
- **异常统一转换**:F6 / Mini 域的异常和非标准错误,在集成模块内部转换成内部标准错误码,业务 domain 和最终 API 响应都不暴露供应商侧的原始协议细节。
|
||||
- **Mini 域调用相对可控**(内部系统),熔断可以不加,但超时和舱壁不能省,避免慢查询拖垮 `workbench` 聚合——对应架构图 Flow 3 "首页失败按 tile 降级"。
|
||||
- **只对幂等调用配 `@Retry`**。GET 查询可以放心重试;有副作用的调用(下单、扣减)**默认不重试**,确实需要时接口必须带幂等 key,规则见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
- **不在数据库事务里调外部 HTTP**,理由见 [03-persistence.md](./03-persistence.md)。
|
||||
- 业务 domain(如 `workbench`)只依赖 `integration/*` 暴露的接口,不自己构造 `RestClient` 发请求。
|
||||
- `workbench` 首页把多个下游并行拉起来的写法(线程池、整体超时预算、按 tile 降级)见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)——那不属于单个客户端的职责。
|
||||
|
||||
## 附录:超时、重试、熔断分别解决什么问题
|
||||
## 附录一:超时、重试、熔断、舱壁分别解决什么问题
|
||||
|
||||
三者经常被一起提,但作用点不同,配置的时候容易搞混:
|
||||
四者经常被一起提,但作用点不同,配置的时候容易搞混:
|
||||
|
||||
- **超时(Timeout)**:解决"对方一直不回应,我方请求线程/连接被一直占着"的问题。没有超时,一个慢下游能拖垮整个调用方的线程池。这是三者里最基础、必须有的一道防线。
|
||||
- **重试(Retry)**:解决"这次失败大概率是偶发的(网络抖动、瞬时过载)"的问题。重试的前提是**幂等**——`fetchProcurementList` 这种 GET 查询可以放心重试,但如果是"扣库存""创建订单"这类有副作用的调用,重试前要先确认接口本身幂等(比如带幂等 key),否则重试可能造成重复下单这类更严重的问题。
|
||||
- **熔断(Circuit Breaker)**:解决"对方已经持续故障,继续重试只是在浪费资源、拖慢自己"的问题。熔断器统计一个滑动窗口内的失败率,超过阈值后直接短路请求(进入 `OPEN` 状态,一段时间内不再真的发请求,直接走 fallback),过一段时间放几个探测请求(`HALF_OPEN`)判断对方是否恢复。
|
||||
- **超时(Timeout)**:解决"对方一直不回应,我方线程/连接被一直占着"的问题。这是最基础、必须有的一道防线。同步栈下它由 HTTP 客户端提供,不是 Resilience4j 提供的。
|
||||
- **重试(Retry)**:解决"这次失败大概率是偶发的(网络抖动、瞬时过载)"的问题。前提是**幂等**。
|
||||
- **熔断(Circuit Breaker)**:解决"对方已经持续故障,继续请求只是在浪费资源、拖慢自己"的问题。统计滑动窗口内的失败率(和慢调用率),超阈值后直接短路走 fallback(`OPEN`),过一段时间放几个探测请求(`HALF_OPEN`)判断是否恢复。
|
||||
- **舱壁(Bulkhead)**:解决"一个慢下游把我方所有工作线程吃光"的问题——**这一道在同步栈下尤其重要**。算笔账:Tomcat 默认 200 个工作线程,F6 读超时 2 秒,如果 F6 全面卡住且没有并发限制,200 个线程会在 2 秒内全部堵在 F6 上,此时这个应用连登录、连查本地数据库的接口都不可用了,一个外部依赖直接把整个服务拖死。配上 `max-concurrent-calls: 20` 之后,最多 20 个线程能进去,剩下 180 个照常干活,超出的请求立刻走降级——**用一个功能的降级换整个服务的存活**。
|
||||
|
||||
三者组合的顺序也有讲究:一次调用先看熔断器状态(`OPEN` 直接 fallback,不发请求)→ 没熔断就真的发请求 → 超时控制这次请求最多等多久 → 超时或失败了再看要不要重试。上面 Resilience4j 的注解顺序(`@CircuitBreaker` 在最外层,`@Retry`、`@TimeLimiter`在内层)就是按这个语义叠加的。
|
||||
响应式栈下这道防线的必要性没这么强(event-loop 天生不会被阻塞占死),这也是从 `WebClient` 换到 `RestClient` 后必须补上它的原因。
|
||||
|
||||
舱壁上限和连接池要对齐:`max-connections-per-route` 必须 ≥ `max-concurrent-calls`,否则真正的瓶颈会变成"抢连接",请求会堵在 `connectionRequestTimeout` 上,而不是被舱壁干脆利落地挡掉。
|
||||
|
||||
## 附录二:叠加顺序由配置决定,不是由注解顺序决定
|
||||
|
||||
**一个常见误解是"注解写在上面的就在外层"——不是的。** Resilience4j 的各个切面都是独立的 Spring AOP Aspect,它们的嵌套顺序由 `resilience4j.<type>.<type>-aspect-order` 属性(也就是 Spring 的 `@Order` 语义)决定,跟注解在方法上的书写顺序完全无关。框架的默认顺序是 **Retry 在最外层**:
|
||||
|
||||
```
|
||||
Retry ( CircuitBreaker ( RateLimiter ( TimeLimiter ( Bulkhead ( 真实调用 ) ) ) ) )
|
||||
```
|
||||
|
||||
顺序不同,语义差别很实在:
|
||||
|
||||
- **Retry 在外(默认,我们采用)**:每一次重试都会各自经过熔断器,所以一次失败的调用会往熔断器的统计窗口里记 2 笔(`max-attempts: 2`)。好处是下游真出问题时熔断器打开得更快;代价是窗口里的样本数会被重试放大,`sliding-window-size` 要按放大后的量来估。这也是为什么必须把 `CallNotPermittedException` 加进 `ignore-exceptions`——熔断打开后抛的就是它,不排除掉的话每次请求还要白白多等一轮重试。
|
||||
- **CircuitBreaker 在外**:整组重试合起来只在熔断器上记 1 笔,统计更"干净",但下游故障时熔断打开会慢一些。
|
||||
|
||||
**建议**:像上面 yaml 那样把三个 `*-aspect-order` 显式写出来,不要依赖默认值——默认值可能随版本变化,而这个顺序一变,熔断的触发速度就跟着变了,还很难从现象上察觉。另外,各版本对"order 数值大是更外层还是更内层"的约定容易记反,配完之后**用一个必定失败的集成测试实际验证一次**叠加顺序(比如断言下游被调用了几次、熔断器记录了几笔),比查文档可靠,写法见 [10-testing.md](./10-testing.md)。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 具体超时/重试参数需要结合 F6 实际 SLA 压测后调整,示例里的数值是起点,不是最终值。
|
||||
- 熔断后降级返回的数据结构约定(`degraded()` 具体字段)。
|
||||
- F6 换票具体协议细节(对接 [webview-ticket](./04-security-auth.md) 的会话失效联动)。
|
||||
- 具体超时/重试/舱壁参数需要结合 F6 实际 SLA 压测后调整,示例里的数值是起点,不是最终值。
|
||||
- 熔断后降级返回的数据结构约定(`degraded()` 的具体字段,以及 APP 侧如何展示"这块数据是降级的")。
|
||||
- F6 换票的具体协议细节(对接 [04-security-auth.md](./04-security-auth.md) 的切店联动失效)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Resilience4j 官方文档](https://resilience4j.readme.io/docs)
|
||||
- [Spring WebFlux WebClient](https://docs.spring.io/spring-framework/reference/web/webflux-webclient.html)
|
||||
- [Spring Framework: RestClient](https://docs.spring.io/spring-framework/reference/integration/rest-clients.html#rest-restclient)
|
||||
- [Apache HttpClient 5 连接管理](https://hc.apache.org/httpcomponents-client-5.4.x/current/tutorial/html/connmgmt.html)
|
||||
- [Martin Fowler: CircuitBreaker](https://martinfowler.com/bliki/CircuitBreaker.html)
|
||||
- [Release It! 中的 Bulkhead 模式](https://learn.microsoft.com/azure/architecture/patterns/bulkhead)
|
||||
|
||||
+161
-37
@@ -10,12 +10,16 @@ REST + JSON,统一响应包装,`bff-orchestration` 负责把内部多个 dom
|
||||
platform-web/
|
||||
ApiResult<T> # { code, message, data, traceId } 统一响应包装
|
||||
GlobalExceptionHandler # 统一异常 -> ApiResult 转换
|
||||
ErrorCode # 错误码常量/枚举
|
||||
ErrorCode # 错误码常量
|
||||
BusinessException # 业务异常基类,带错误码
|
||||
|
||||
domains/xxx/api/
|
||||
XxxController # 只做参数校验 + 调用 application 层,不写业务逻辑
|
||||
request/ Xxx*Request # 请求 DTO
|
||||
response/ Xxx*Response # 响应 DTO,不直接暴露 JPA entity
|
||||
|
||||
domains/xxx/application/
|
||||
mapper/ XxxMapper # 领域模型/投影/Entity -> Response 的转换(MapStruct)
|
||||
```
|
||||
|
||||
## `ApiResult` + 全局异常处理示例
|
||||
@@ -30,10 +34,10 @@ data class ApiResult<T>(
|
||||
) {
|
||||
companion object {
|
||||
fun <T> ok(data: T): ApiResult<T> =
|
||||
ApiResult(ErrorCode.OK, "success", data, TraceIdHolder.current())
|
||||
ApiResult(ErrorCode.OK, "success", data, currentTraceId())
|
||||
|
||||
fun error(code: Int, message: String): ApiResult<Nothing> =
|
||||
ApiResult(code, message, null, TraceIdHolder.current())
|
||||
ApiResult(code, message, null, currentTraceId())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,15 +49,40 @@ object ErrorCode {
|
||||
const val INVALID_PARAM = 10001
|
||||
const val UNAUTHORIZED = 10401
|
||||
const val FORBIDDEN = 10403
|
||||
const val NOT_FOUND = 10404
|
||||
const val CONFLICT = 10409 // 乐观锁冲突等,见 03-persistence.md
|
||||
const val INTERNAL_ERROR = 10500
|
||||
|
||||
// 11xxx 认证与门店
|
||||
const val STORE_NOT_ACCESSIBLE = 11001
|
||||
const val NO_STORE_PERMISSION = 11002
|
||||
// 20xxx 采购 / 21xxx 库存 / 3xxxx F6·Mini 透传类,各 domain 在自己的段内分配
|
||||
|
||||
// 20xxx 采购 / 21xxx 库存,各 domain 在自己的段内分配
|
||||
|
||||
// 30xxx F6 集成,见 05-integration-layer.md
|
||||
const val F6_UNAVAILABLE = 30001 // 熔断/超时/连不上
|
||||
const val F6_BUSINESS_ERROR = 30002 // F6 明确拒绝了请求
|
||||
|
||||
// 31xxx Mini 域集成
|
||||
const val MINI_UNAVAILABLE = 31001
|
||||
}
|
||||
|
||||
// platform-web/.../GlobalExceptionHandler.kt
|
||||
|
||||
// platform-web/.../BusinessException.kt
|
||||
// 所有可预期的业务失败都抛它(或它的子类,如 05-integration-layer.md 的 IntegrationException)。
|
||||
// httpStatus 有默认值但可以覆盖:错误码是给客户端做分支的,HTTP 状态码是给中间层(网关、监控、
|
||||
// 客户端拦截器)做粗粒度判断的,两者职责不同,不能只留一个。
|
||||
open class BusinessException(
|
||||
val code: Int,
|
||||
override val message: String,
|
||||
val httpStatus: HttpStatus = HttpStatus.BAD_REQUEST,
|
||||
) : RuntimeException(message)
|
||||
|
||||
// 用法示例:需要客户端走"无权限"分支时,必须显式给 403,
|
||||
// 否则默认的 400 会让客户端把它当成参数错误
|
||||
throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
|
||||
|
||||
@RestControllerAdvice
|
||||
class GlobalExceptionHandler {
|
||||
|
||||
@@ -67,14 +96,22 @@ class GlobalExceptionHandler {
|
||||
fun handleBusiness(ex: BusinessException): ResponseEntity<ApiResult<Nothing>> =
|
||||
ResponseEntity.status(ex.httpStatus).body(ApiResult.error(ex.code, ex.message ?: "业务异常"))
|
||||
|
||||
@ExceptionHandler(ObjectOptimisticLockingFailureException::class)
|
||||
fun handleConcurrentUpdate(ex: ObjectOptimisticLockingFailureException): ResponseEntity<ApiResult<Nothing>> =
|
||||
ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(ApiResult.error(ErrorCode.CONFLICT, "数据已被他人修改,请刷新后重试"))
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
fun handleUnexpected(ex: Exception): ResponseEntity<ApiResult<Nothing>> {
|
||||
// 未预期异常统一兜底,避免堆栈信息泄漏给前端,详细堆栈走日志(见 08-observability.md)
|
||||
log.error("未处理异常", ex)
|
||||
return ResponseEntity.internalServerError().body(ApiResult.error(ErrorCode.INTERNAL_ERROR, "系统繁忙,请稍后重试"))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`GlobalExceptionHandler` 接不到 Spring Security 过滤器里抛的异常**(它们在 `DispatcherServlet` 之前),401/403 由 [04-security-auth.md](./04-security-auth.md) 里的 `AuthenticationEntryPoint`/`AccessDeniedHandler` 产出同样结构的 JSON。两处必须返回同一套结构,客户端才只需要一套解析逻辑。
|
||||
|
||||
## Controller + DTO 示例
|
||||
|
||||
```kotlin
|
||||
@@ -85,39 +122,34 @@ class StoreController(
|
||||
private val storeAppService: StoreAppService,
|
||||
) {
|
||||
@Operation(summary = "查询当前用户可访问的门店列表")
|
||||
@GetMapping
|
||||
fun listStores(): ApiResult<List<StoreResponse>> =
|
||||
ApiResult.ok(storeAppService.listStores())
|
||||
@GetMapping("/accessible")
|
||||
fun listAccessibleStores(): ApiResult<List<StoreResponse>> =
|
||||
ApiResult.ok(storeAppService.listAccessibleStores())
|
||||
|
||||
@Operation(summary = "切换当前门店")
|
||||
@PostMapping("/switch")
|
||||
fun switchStore(@Valid @RequestBody request: SwitchStoreRequest): ApiResult<Unit> {
|
||||
storeAppService.switchStore(request.storeId)
|
||||
return ApiResult.ok(Unit)
|
||||
}
|
||||
@Operation(summary = "切换当前门店,返回新的门店上下文与重新签发的 access token")
|
||||
@PostMapping("/{storeId}/switch")
|
||||
fun switchStore(@PathVariable storeId: Long): ApiResult<StoreContextResponse> =
|
||||
ApiResult.ok(storeAppService.switchStore(storeId))
|
||||
}
|
||||
|
||||
// api/request/SwitchStoreRequest.kt
|
||||
data class SwitchStoreRequest(
|
||||
@field:NotNull(message = "storeId 不能为空")
|
||||
val storeId: Long?,
|
||||
)
|
||||
|
||||
// api/response/StoreResponse.kt
|
||||
data class StoreResponse(
|
||||
val id: Long,
|
||||
val name: String,
|
||||
val code: String,
|
||||
)
|
||||
```
|
||||
|
||||
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段(比如某个只有 `infrastructure` 层需要的标记位)被不小心带出去。
|
||||
路径和响应体是照着客户端 [../11-store-context-and-session.md](../11-store-context-and-session.md)、[../05-networking.md](../05-networking.md) 写的——**这两个端点客户端已经实现了,后端对齐客户端,不是反过来**。切店返回的是含新 `accessToken` 和菜单的完整上下文,不是空 body,理由见 04。
|
||||
|
||||
## DTO 与 entity 的转换:用 MapStruct
|
||||
Controller 不直接返回 `StoreEntity`,而是转换成 `StoreResponse`——即使当前字段一模一样,也统一走这层转换,避免以后 entity 加了内部字段被不小心带出去。
|
||||
|
||||
转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写。字段名一致的直接映射,不一致的用 `@Mapping` 指定,编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
|
||||
## DTO 转换:MapStruct,放在 application 层
|
||||
|
||||
转换代码用 [MapStruct](https://mapstruct.org/) 自动生成,不手写:编译期生成实现类,没有反射开销,字段漏映射编译期就能发现。
|
||||
|
||||
```groovy
|
||||
// build.gradle(Kotlin 项目用 kapt 做注解处理)
|
||||
// build.gradle(Kotlin 项目用 kapt 做注解处理;MapStruct 目前仍不支持 KSP)
|
||||
plugins {
|
||||
id 'org.jetbrains.kotlin.kapt'
|
||||
}
|
||||
@@ -129,29 +161,121 @@ dependencies {
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// api/mapper/StoreMapper.kt
|
||||
// application/mapper/StoreMapper.kt ← 注意是 application 层,不是 api 层
|
||||
@Mapper(componentModel = "spring")
|
||||
interface StoreMapper {
|
||||
fun toResponse(entity: StoreEntity): StoreResponse
|
||||
fun toResponse(view: StoreView): StoreResponse
|
||||
|
||||
@Mapping(target = "displayName", source = "name")
|
||||
fun toSummary(entity: StoreEntity): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定
|
||||
fun toSummary(view: StoreView): StoreSummaryResponse // 字段名不一致时用 @Mapping 指定
|
||||
|
||||
fun toResponseList(entities: List<StoreEntity>): List<StoreResponse>
|
||||
fun toResponseList(views: List<StoreView>): List<StoreResponse>
|
||||
}
|
||||
```
|
||||
|
||||
`componentModel = "spring"` 让 MapStruct 生成的实现类自动注册成 Spring bean,直接在 `application` 层注入 `StoreMapper` 使用,不用手动 `new`。
|
||||
**mapper 必须放在 `application` 层,不能放在 `api/mapper/`**:它的入参是 `Entity` 或投影(`infrastructure` 里的类型),放在 `api` 层就等于让 `api` 依赖 `infrastructure`,会被 [10-testing.md](./10-testing.md) 里的 ArchUnit 规则判红。对应 [02-layering.md](./02-layering.md) 的那句边界规则:
|
||||
|
||||
## 关键规则
|
||||
> **`XxxEntity` 不出现在 `api` 层的任何签名或 import 里,也不跨出所在模块的边界。**
|
||||
> **`Entity`/领域模型 → `Response` 的转换发生在 `application` 层。**
|
||||
|
||||
`componentModel = "spring"` 让生成的实现类自动注册成 Spring bean,`application` 层直接注入使用。
|
||||
|
||||
## 统一请求头约定
|
||||
|
||||
客户端每个请求固定携带以下头(见 [../05-networking.md](../05-networking.md)),后端的处理规则:
|
||||
|
||||
| 请求头 | 必带 | 后端处理 |
|
||||
| --- | --- | --- |
|
||||
| `Authorization: Bearer <accessToken>` | 除免认证端点外 | 见 [04-security-auth.md](./04-security-auth.md) |
|
||||
| `X-Trace-Id` | 是 | **优先复用**客户端传来的值作为本次请求的 traceId,格式非法时丢弃并自行生成,见 [08-observability.md](./08-observability.md) |
|
||||
| `X-Store-Id` | 是 | **仅用于日志与排查**。数据范围一律以 token 里的 `storeId` 为准;不一致时记 warn,不拒绝请求 |
|
||||
| `X-App-Version` | 是 | 用于版本兼容判断(见下)与埋点维度 |
|
||||
| `X-Device-Id` | 是 | 用于日志关联和风控,不作为身份凭证 |
|
||||
|
||||
**关键规则:请求头里的任何值都不构成身份或权限依据。** `X-Store-Id`、`X-Device-Id` 都是客户端可以随手改的,只有 `Authorization` 里签过名的 claims 才算数。
|
||||
|
||||
## 数据格式约定
|
||||
|
||||
这一节的每一条都要求前后端一字不差地对齐,客户端侧对应 [../12-error-and-api-contract.md](../12-error-and-api-contract.md)。
|
||||
|
||||
- **时间**:一律 ISO-8601 UTC 字符串,带毫秒和 `Z` 后缀——`"2026-08-14T03:21:45.123Z"`。Kotlin 侧类型是 `Instant`。**不传时间戳数字**(数字看不出单位是秒还是毫秒,出过太多次事),**不传本地时间**(不带时区的时间在跨时区场景下无解)。库里存的也是 UTC,见 [03-persistence.md](./03-persistence.md)。
|
||||
- **金额**:Kotlin 侧 `BigDecimal`,序列化成**字符串**(`"1234.56"`)而不是 JSON number。JSON number 在很多客户端会被解析成双精度浮点,`0.1 + 0.2` 那一类精度问题会直接变成对不上账。单位统一为元,小数位固定两位。
|
||||
- **枚举**:序列化成大写下划线字符串(`"STORE_MANAGER"`),不传序号。**客户端遇到未知枚举值必须能容错**(降级成"未知"而不是崩溃),否则后端加一个枚举值就得等所有用户升级 APP。
|
||||
- **布尔**:真正的 `true`/`false`,不用 `0`/`1`,不用 `"Y"`/`"N"`。
|
||||
- **ID**:`Long`,序列化成 JSON number。当前量级不会超过 JS 安全整数范围(2^53),如果将来引入雪花 ID 之类的大数字,必须改成字符串——这一条到时候是 breaking change,需要走版本升级。
|
||||
- **null 策略**:**不做全局的 null 字段剔除**(不配 `NON_NULL`)。响应里保留 `"field": null`,让客户端能区分"这个字段服务端明确说了是空"和"服务端根本没返回这个字段"。集合类型永远返回 `[]` 而不是 `null`,客户端就不用到处判空。
|
||||
- **字段命名**:JSON 用小驼峰(`storeId`、`createdAt`),与 Kotlin 属性名一致,不做下划线转换。
|
||||
|
||||
## 分页与排序约定
|
||||
|
||||
(这条同时解决客户端 [../05-networking.md](../05-networking.md) 里挂着的"分页字段名待定"。)
|
||||
|
||||
**请求参数**:
|
||||
|
||||
| 参数 | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `pageNum` | Int | 1 | **从 1 开始**。注意 Spring Data 的 `Pageable` 是从 0 开始的,转换在 Controller 层做完,不要把这个差异漏给客户端 |
|
||||
| `pageSize` | Int | 20 | 上限 100,超过按 100 处理,防止被一次拉全表 |
|
||||
| `sort` | String | 各接口自定 | `字段名,asc|desc`,如 `createdAt,desc`。**允许排序的字段必须是白名单**,不能把参数直接拼进 SQL/JPQL |
|
||||
|
||||
**响应结构**(对应 [03-persistence.md](./03-persistence.md) 的 `PageResult<T>`):
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"traceId": "...",
|
||||
"data": {
|
||||
"list": [],
|
||||
"pageNum": 1,
|
||||
"pageSize": 20,
|
||||
"total": 134,
|
||||
"hasMore": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
不直接把 Spring Data 的 `Page` 序列化出去——它的 JSON 结构由 Spring 版本决定(Boot 3.3 起还会为此打警告并推荐 `PagedModel`),升级框架就可能悄悄改掉 API 契约。
|
||||
|
||||
**游标分页**:数据量大或要求"下拉加载不重不漏"的列表,用 `lastId` + `pageSize`,响应里返回 `nextCursor`。理由和写法见 [03-persistence.md](./03-persistence.md) 的分页一节。哪些接口用哪种,在接口文档里写清楚。
|
||||
|
||||
## 错误码规则
|
||||
|
||||
- **错误码是数字,`0` 表示成功**,按 domain 分段:
|
||||
|
||||
| 段 | 归属 |
|
||||
| --- | --- |
|
||||
| `10xxx` | 平台通用(参数、认证、鉴权、系统错误) |
|
||||
| `11xxx` | 认证与门店 |
|
||||
| `20xxx` / `21xxx` | 采购 / 库存 |
|
||||
| `30xxx` | F6 集成 |
|
||||
| `31xxx` | Mini 域集成 |
|
||||
|
||||
分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
|
||||
|
||||
- Controller 不直接返回 entity,统一走 `Xxx*Response` DTO,避免持久层字段变更影响 API 契约。
|
||||
- 路径版本化:`/api/v1/...`,未来 breaking change 走 `/api/v2/...`,不在原路径上做不兼容修改。
|
||||
- 用 [springdoc-openapi](https://springdoc.org/) 自动生成接口文档,Controller 上写清楚的 `@Operation` 描述;`build.gradle` 加 `implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0'` 即可在 `/swagger-ui.html` 看到文档。
|
||||
- `traceId` 贯穿请求全链路(对应架构图 `Observability` 的要求),从入口 filter 生成,写入 `ApiResult` 和日志,详见 [08-observability.md](./08-observability.md)。
|
||||
- **错误码是数字,`0` 表示成功**,按 domain 分段(`10xxx` 平台通用 / `11xxx` 认证与门店 / `20xxx` 采购 / `21xxx` 库存 / `3xxxx` F6·Mini 透传类)。分段的价值是看到前两位就知道该找哪个域;全局连续编号在多域并行开发时必然撞号。
|
||||
- **不允许在业务代码里写裸数字**,一律走 `ErrorCode` 常量。数字码在监控里聚合方便(可以直接 `group by code`),代价是不自解释——所以 `message` 必须始终是给人看的,日志里 `code` 和 `message` 一起打。
|
||||
- 客户端侧的对应契约见 [../12-error-and-api-contract.md](../12-error-and-api-contract.md),两边的分段方案必须保持一致。
|
||||
- **`message` 是给用户看的**,不放技术细节(SQL、堆栈、下游状态码、内部服务名)。技术细节进日志,用 `traceId` 关联。
|
||||
- **HTTP status code 仍然要用对**:成功 200,参数错 400,未认证 401,无权限 403,不存在 404,并发冲突 409,下游不可用 502。客户端主要看 `code`,但 401 是例外——它触发自动刷新逻辑,必须准确(见 04)。网关、监控、日志分析也都依赖 status code。
|
||||
- 新增错误码时**同步更新客户端的 `ApiCode`**([../12-error-and-api-contract.md](../12-error-and-api-contract.md)),两边分段方案必须一致。
|
||||
|
||||
## 版本与兼容
|
||||
|
||||
- **路径版本化**:`/api/v1/...`。**只有 breaking change 才升 `/v2`**,且 v1 必须保留到监控数据显示旧版本 APP 的活跃量足够低为止——APP 不像网页能强制刷新,用户手机上永远会有旧版本。
|
||||
- **兼容性判定**(这是 API 改动 review 的检查表):
|
||||
|
||||
| 改动 | 兼容? |
|
||||
| --- | --- |
|
||||
| 响应里加字段 | ✅ 兼容 |
|
||||
| 请求里加**可选**参数 | ✅ 兼容 |
|
||||
| 放宽校验规则 | ✅ 兼容 |
|
||||
| 删字段 / 改字段名 / 改字段类型 | ❌ 破坏性 |
|
||||
| 加必填参数 / 收紧校验 | ❌ 破坏性 |
|
||||
| 改字段语义(值域、单位、时区) | ❌ 破坏性,且**最危险**——编译不报错,测试可能也过,只有线上数据是错的 |
|
||||
|
||||
- **字段废弃流程**:① 新字段上线、旧字段继续双写,OpenAPI 上给旧字段标 `@Schema(deprecated = true)`;② 观察埋点,确认使用旧字段的 APP 版本占比降到可接受;③ 下一个版本移除。整个过程至少跨两个 APP 发版周期,不要图快跳步。
|
||||
- **最低版本控制**:确实需要强制升级时,由后端根据 `X-App-Version` 返回一个专门的错误码,APP 弹强制升级引导。这个能力要提前留出来(哪怕暂时不用),否则真需要的时候一点办法都没有。
|
||||
- 接口文档用 [springdoc-openapi](https://springdoc.org/) 自动生成,`implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3'`(Boot 4 对应 springdoc 3.x),Controller 上写清楚 `@Operation` 描述。**文档 UI 只在非生产环境开放**,见 [04-security-auth.md](./04-security-auth.md)。
|
||||
- `traceId` 贯穿请求全链路(对应架构图 `Observability` 的要求),写入 `ApiResult` 和日志,详见 [08-observability.md](./08-observability.md)。
|
||||
|
||||
## 附录:为什么要统一响应包装,而不是直接返回业务对象
|
||||
|
||||
@@ -161,12 +285,12 @@ interface StoreMapper {
|
||||
- `traceId` 无论成功失败都会带上,用户反馈问题时报个 `traceId`,就能在日志里定位到具体这一次请求(见 [08-observability.md](./08-observability.md)),不需要靠时间戳模糊查找。
|
||||
- 新增一种失败场景时,只需要新增一个 `code`,不需要前端为每种 HTTP status code 单独写处理分支。
|
||||
|
||||
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。
|
||||
代价是:这不是纯粹的 RESTful 风格(标准 REST 提倡用 HTTP status code 表达成功/失败),但对于一个统一给自家 APP 消费的 BFF 层来说,"前端处理简单、错误信息结构统一"比"严格遵循 REST 语义"更重要。我们的折中是**两个都给对**:`code` 给客户端用,HTTP status code 给网关/监控/日志用。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 分页/排序参数的统一约定。
|
||||
- **完整错误码表**:分段方案已定(见上),但各 domain 段内的具体码值还没分配,需要各 domain 负责人一起填,并与客户端的 `ApiCode`([../12-error-and-api-contract.md](../12-error-and-api-contract.md))保持同步。
|
||||
- 强制升级用的错误码码值,以及触发它的版本判断规则(放在网关还是应用里)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
|
||||
@@ -70,7 +70,8 @@ spec:
|
||||
## 启用 Spring Cloud Kubernetes 配置热更新
|
||||
|
||||
```groovy
|
||||
// bootstrap/build.gradle
|
||||
// bootstrap/build.gradle —— 版本由根工程的 spring-cloud-dependencies BOM(2025.1.2)统一管理,
|
||||
// 见 01-project-structure.md 的版本基线表
|
||||
dependencies {
|
||||
implementation 'org.springframework.cloud:spring-cloud-starter-kubernetes-client-config'
|
||||
}
|
||||
@@ -87,7 +88,8 @@ spring:
|
||||
- name: conti-backend-config
|
||||
reload:
|
||||
enabled: true
|
||||
mode: polling # 定期轮询 ConfigMap 变化,无需重启 Pod
|
||||
mode: polling # 怎么发现变化:定期轮询 ConfigMap(另一个选项 event 需要 watch 权限)
|
||||
strategy: refresh # 发现变化后做什么:只刷新 @RefreshScope bean,不重启容器
|
||||
period: 15s
|
||||
```
|
||||
|
||||
@@ -101,6 +103,20 @@ class WorkbenchProperties {
|
||||
}
|
||||
```
|
||||
|
||||
**`mode` 和 `strategy` 是两件事,别混**:`mode` 决定怎么感知 ConfigMap 变化,`strategy` 决定感知到之后做什么。只配 `mode` 而漏掉 `strategy`,行为会依赖框架默认值。
|
||||
|
||||
**并不是所有配置都能热更新**,这一点要在改配置之前想清楚,否则会出现"改了 ConfigMap、观察半天没生效"的困惑:
|
||||
|
||||
| 配置 | 能否热更新 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 自定义的 `@RefreshScope` + `@ConfigurationProperties`(如 `workbench.*`) | ✅ | 这是热更新真正的适用范围 |
|
||||
| 日志级别 | ✅ | 也可以直接用 `/actuator/loggers` 端点改,更直接 |
|
||||
| `resilience4j.*` 的熔断/重试参数 | ⚠️ | 这些实例在启动时创建,普通 `refresh` 刷不到。真要动,用 `strategy: restart_context`(会重建应用上下文,等于一次内部重启)或者干脆走发布流程 |
|
||||
| 数据源、连接池、JPA 相关 | ❌ | 走发布流程 |
|
||||
| Secret 里的值 | ❌ | 见下面"关键规则",Secret 变更需要重启 Pod |
|
||||
|
||||
上面 ConfigMap 示例里同时放了 `resilience4j` 和 `workbench` 两段,正是为了说明这个差别:`workbench.degradeMessage` 改完 15 秒内生效,`failure-rate-threshold` 不会。
|
||||
|
||||
## 本地开发怎么跑(不需要真的连 K8s)
|
||||
|
||||
`spring-cloud-starter-kubernetes-client-config` 启动时默认会尝试读 `~/.kube/config` 或 in-cluster 凭证去调 K8s API 拿 ConfigMap,本地电脑上没有这些东西的话,启动会报错或者卡住去连一个不存在的 API Server。本地开发不需要也不应该依赖真实 K8s,两种可选方案:
|
||||
@@ -117,23 +133,26 @@ spring:
|
||||
reload:
|
||||
enabled: false
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/conti_backend
|
||||
url: jdbc:mysql://localhost:3306/?connectionTimeZone=UTC&preserveInstants=true&rewriteBatchedStatements=true
|
||||
username: conti
|
||||
password: conti_local_password # 仅本地开发用,不是真实密钥
|
||||
security:
|
||||
jwt:
|
||||
secret: local-dev-only-secret-not-for-real-use
|
||||
active-key-id: local
|
||||
keys:
|
||||
# 仅本地开发用的假密钥;HS256 要求 base64 解码后 ≥ 32 字节,见 04-security-auth.md
|
||||
local: bG9jYWwtZGV2LW9ubHktc2VjcmV0LW5vdC1mb3ItcmVhbC11c2UtMzJi
|
||||
```
|
||||
|
||||
```bash
|
||||
# 本地起依赖(DB 等),配合 docker-compose 用
|
||||
docker compose up -d postgres
|
||||
docker compose up -d mysql
|
||||
|
||||
# 用 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 还是本地文件。
|
||||
`application-local.yml` 不提交敏感真实值(本来也没有,本地密码本身就是假的),可以放进代码库方便新人直接跑起来;`local` profile 和 dev/uat/prod 的关键区别就是 `spring.cloud.kubernetes.config.enabled=false`,其余代码逻辑完全一致——这也正是**配置外置**的价值所在:业务代码不知道、也不需要知道配置到底来自 K8s ConfigMap 还是本地文件,换配置来源不需要改一行 Kotlin。
|
||||
|
||||
这是**个人本机调试**用的,跟团队共享的 **Dev 环境**不是一回事——Dev 环境跑在公司内网一台 Ubuntu 服务器的 k3s 集群上,走真实的 K8s ConfigMap/Secret(跟下面"方案二"是同一套思路,只是长期跑着给团队用,而不是临时验证),团队通过公司 VPN 访问,具体见 [09-build-deploy.md](./09-build-deploy.md#环境层级local个人本机vs-dev内网-ubuntu-k3s-集群vs-uatprodazure-aks)。
|
||||
|
||||
@@ -232,10 +251,53 @@ spec:
|
||||
|
||||
两种方式**不需要同时维护**——选一个用,文档里保留方式二只是留个参考路径,不是说两者要并存。
|
||||
|
||||
## 配置绑定与启动期校验
|
||||
|
||||
**所有配置一律绑定到 `@ConfigurationProperties` 类,不散着写 `@Value`。** `@Value` 散落在各处时,没人说得清这个应用到底需要哪些配置项,漏配一个只能等到运行到那行代码才报错。
|
||||
|
||||
```kotlin
|
||||
@ConfigurationProperties(prefix = "integration.clients.f6")
|
||||
@Validated
|
||||
data class F6ClientProperties(
|
||||
@field:NotBlank val baseUrl: String,
|
||||
@field:Min(100) @field:Max(10_000) val readTimeoutMs: Long = 2000,
|
||||
@field:Min(1) val maxConcurrentCalls: Int = 20,
|
||||
)
|
||||
```
|
||||
|
||||
配上 `@Validated` 之后,配置缺失或越界会在**启动时**直接失败,Pod 起不来,K8s 的就绪探针不通过,滚动更新会自动停住并保留旧版本(见 [09-build-deploy.md](./09-build-deploy.md))。这比"启动成功了,但半夜某个接口因为超时配成 0 而全线失败"要好得多——**配错了就别起来**是这里的核心原则。
|
||||
|
||||
同理,[04-security-auth.md](./04-security-auth.md) 里 JWT 密钥长度的校验也放在启动期,绝不允许带着弱密钥跑起来。
|
||||
|
||||
## 命名规范
|
||||
|
||||
| 对象 | 规范 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| namespace | `retailapp-<env>` | `retailapp-uat` |
|
||||
| ConfigMap | `conti-backend-config` | 每个环境一份,同名不同 namespace |
|
||||
| Secret | `conti-backend-secret` | 同上 |
|
||||
| ConfigMap 里的 key | `application-<profile>.yml` | `application-uat.yml` |
|
||||
| Secret 里的 key | 全大写下划线,与 Spring 的 relaxed binding 对齐 | `DB_PASSWORD` → `spring.datasource.password`(配合 `${DB_PASSWORD}` 引用) |
|
||||
| Key Vault secret 名 | 中划线小写,与配置路径对应 | `security-jwt-secret` |
|
||||
| 自定义配置前缀 | 模块名小驼峰 | `workbench.*`、`integration.clients.*` |
|
||||
|
||||
**同名不同 namespace** 这一点是有意的:环境差异全部体现在 namespace 和 ConfigMap 内容上,Deployment yaml 在各环境之间只差镜像 tag 和 namespace,减少"UAT 好好的、生产漏配了一项"这类问题。
|
||||
|
||||
## 数据库与外部依赖的实例形态
|
||||
|
||||
| 依赖 | dev(内网 k3s) | UAT / Prod(Azure AKS) |
|
||||
| --- | --- | --- |
|
||||
| MySQL | 集群内一个 MySQL 容器(数据可丢,随时重建) | **Azure Database for MySQL Flexible Server**,通过 **Private Endpoint** 接入 AKS 所在 VNet,不开公网访问 |
|
||||
| 密钥 | k3s Secret,值由内网 CI 注入 | Azure Key Vault + Private Endpoint(见下一节) |
|
||||
| 缓存 | 无(不引入 Redis,理由见 [04-security-auth.md](./04-security-auth.md)) | 同左 |
|
||||
|
||||
生产 MySQL 的连接信息(host/账号)走 ConfigMap + Secret 注入,应用侧配置见 [03-persistence.md](./03-persistence.md)。**连接串里必须带 `sslMode=REQUIRED`**——Flexible Server 默认要求 TLS,漏了会连不上;同时也别为了图省事把它降级成 `DISABLED`。
|
||||
|
||||
数据库账号分两个:Flyway 迁移用的账号有 DDL 权限(只在部署流程中使用),应用运行时账号只有 DML 权限,理由见 03。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 具体 ConfigMap/Secret 命名规范和 namespace 划分细节。
|
||||
- 多环境 profile 的详细参数列表。
|
||||
- 多环境 profile 的完整参数清单(等各 domain 的配置项定下来后汇总成一张表)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
|
||||
+202
-59
@@ -4,49 +4,103 @@
|
||||
|
||||
统一 Trace ID + 结构化(JSON)日志 + Micrometer 指标,对应架构图 `Cross-Cutting` 里的 `Observability` 要求;关键行为单独走审计日志通道,对应 `Audit / Security`。
|
||||
|
||||
traceId **用 Spring Boot 自带的 Micrometer Tracing 生成和传播,不自己写 `TraceIdFilter`**——理由见下一节。
|
||||
|
||||
## 结构约定
|
||||
|
||||
```
|
||||
platform-observability/
|
||||
TraceIdFilter # 入口生成/透传 traceId,写入 MDC
|
||||
logback-spring.xml # 结构化日志格式配置
|
||||
MetricsConfig # Micrometer 基础配置,暴露 /actuator/prometheus
|
||||
AuditLogAspect # AOP 切面,标注 @Audited 的方法自动记录审计日志
|
||||
ClientTraceIdBridgeFilter # 把客户端的 X-Trace-Id 接进 Micrometer 的 trace 上下文
|
||||
TraceResponseFilter # 把最终生效的 traceId 写回响应头
|
||||
logback-spring.xml # 结构化日志格式 + 脱敏配置
|
||||
MetricsConfig # Micrometer 基础配置,暴露 /actuator/prometheus
|
||||
AuditLogAspect # AOP 切面,标注 @Audited 的方法自动记录审计日志
|
||||
```
|
||||
|
||||
## `TraceIdFilter` 示例
|
||||
## traceId:用 Micrometer Tracing
|
||||
|
||||
```groovy
|
||||
// build.gradle
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'io.micrometer:micrometer-registry-prometheus'
|
||||
implementation 'io.micrometer:micrometer-tracing-bridge-otel' // 版本由 Boot BOM 管
|
||||
```
|
||||
|
||||
```yaml
|
||||
management:
|
||||
tracing:
|
||||
enabled: true # 开着:这是 traceId 进 MDC 的前提,关掉连日志里的 traceId 都没有了
|
||||
export:
|
||||
enabled: false # 但不往任何后端上报 span —— 我们现在只要日志关联,不建全链路追踪系统
|
||||
sampling:
|
||||
probability: 1.0 # 不上报就没有采样成本,全采即可,避免部分请求日志里没有 traceId
|
||||
```
|
||||
|
||||
加上依赖之后,Spring Boot 会自动:
|
||||
|
||||
- 为每个进来的 HTTP 请求创建一个 span,把 `traceId` / `spanId` **自动放进 MDC**;
|
||||
- 出站的 `RestClient` 调用自动带上 W3C 标准的 `traceparent` 头(前提是用注入的 `RestClient.Builder` 构建,见 [05-integration-layer.md](./05-integration-layer.md));
|
||||
- 线程池、`@Async`、`@Scheduled` 等场景由 Micrometer 的上下文传播机制接管,不需要自己搬运 MDC。
|
||||
|
||||
**为什么不自己写 `TraceIdFilter`**:自己写的版本只覆盖"HTTP 入口 + 手工加请求头"这两个点,一旦出现线程切换(并行聚合、异步任务)或者需要跟别的系统按标准协议对接,就要自己一点点补;而这些正是最容易漏、漏了又最难查的地方(日志断链时你只会觉得"这个请求怎么没日志")。Micrometer Tracing 是 Boot 的一等公民,这些点框架都已经处理好,而且将来真要接 APM(Jaeger/Zipkin/Application Insights)时,只需要加一个 exporter 依赖、把 `export.enabled` 打开,代码一行不用改。
|
||||
|
||||
### 与客户端 `X-Trace-Id` 的对接契约
|
||||
|
||||
客户端每个请求都会带一个自己生成的 `X-Trace-Id`(见 [../05-networking.md](../05-networking.md)),要求"后端复用它",这样一次用户操作在 APP 日志和服务端日志里是同一个 ID。但 Micrometer 认的是 W3C 的 `traceparent` 头,所以中间需要一层桥接:
|
||||
|
||||
**契约(同时解决客户端文档里挂着的那条待确认项)**:
|
||||
|
||||
1. 客户端生成的 `X-Trace-Id` **必须是 32 位小写十六进制字符**(UUID 去掉四个横线正好 32 位 hex,直接用即可),且不能全为 `0`。
|
||||
2. 后端校验通过则用它作为本次请求的 `traceId`;**校验不通过就忽略它,自行生成**——绝不把一个未经校验的请求头值直接当 ID 用。
|
||||
3. 后端在响应头里回写最终生效的 `X-Trace-Id`,客户端以响应头为准(这样客户端能发现自己的值被丢弃了)。
|
||||
4. `ApiResult.traceId` 返回的也是这个最终生效的值。
|
||||
|
||||
```kotlin
|
||||
// platform-observability/.../TraceIdFilter.kt
|
||||
class TraceIdFilter : OncePerRequestFilter() {
|
||||
// platform-observability/.../ClientTraceIdBridgeFilter.kt
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE) // 必须排在 Micrometer 的 observation filter 之前
|
||||
class ClientTraceIdBridgeFilter : OncePerRequestFilter() {
|
||||
|
||||
companion object {
|
||||
// 只接受 W3C trace-id 格式。这条正则同时也是安全边界:
|
||||
// 请求头的值会进日志,不校验就等于允许任何人往日志里注入内容(换行伪造日志行、
|
||||
// 塞进超长字符串撑爆日志存储、塞进控制字符干扰下游日志解析)。
|
||||
private val TRACE_ID = Regex("^[0-9a-f]{32}$")
|
||||
private const val INVALID = "00000000000000000000000000000000"
|
||||
}
|
||||
|
||||
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
|
||||
val traceId = request.getHeader("X-Trace-Id") ?: UUID.randomUUID().toString()
|
||||
MDC.put("traceId", traceId)
|
||||
response.setHeader("X-Trace-Id", traceId)
|
||||
try {
|
||||
chain.doFilter(request, response)
|
||||
} finally {
|
||||
MDC.clear() // 必须清理,否则线程池复用线程会带出上一个请求的 traceId
|
||||
val clientTraceId = request.getHeader("X-Trace-Id")
|
||||
if (clientTraceId == null || !TRACE_ID.matches(clientTraceId) || clientTraceId == INVALID) {
|
||||
chain.doFilter(request, response) // 不合法就当没传,让 Micrometer 自己生成
|
||||
return
|
||||
}
|
||||
|
||||
// 合成一个 W3C traceparent,让 Micrometer 把它当作父上下文接上,
|
||||
// 于是服务端这次请求的 traceId 就等于客户端传来的值。
|
||||
val traceparent = "00-$clientTraceId-${randomSpanId()}-01"
|
||||
chain.doFilter(TraceparentRequestWrapper(request, traceparent), response)
|
||||
}
|
||||
}
|
||||
|
||||
object TraceIdHolder {
|
||||
fun current(): String = MDC.get("traceId") ?: "unknown"
|
||||
// platform-observability/.../TraceResponseFilter.kt —— 排在 observation filter 之后,此时 MDC 已有 traceId
|
||||
@Component
|
||||
class TraceResponseFilter : OncePerRequestFilter() {
|
||||
override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) {
|
||||
MDC.get("traceId")?.let { response.setHeader("X-Trace-Id", it) }
|
||||
chain.doFilter(request, response)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
调用 F6/Mini 域时,把当前 `traceId` 透传到下游请求头,方便跨系统关联日志:
|
||||
|
||||
```kotlin
|
||||
webClient.get()
|
||||
.uri("/f6/procurement/list")
|
||||
.header("X-Trace-Id", TraceIdHolder.current())
|
||||
.retrieve()
|
||||
// ...
|
||||
// platform-web/.../TraceIdSupport.kt —— 06-api-design.md 里 ApiResult 用的就是它
|
||||
fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
|
||||
```
|
||||
|
||||
## 结构化日志配置示例
|
||||
出站调用侧,除了 Micrometer 自动加的 `traceparent`,还会额外发一个 `X-Trace-Id` 给 F6 这类只认自定义头的外部系统,见 [05-integration-layer.md](./05-integration-layer.md) 的 `TracePropagationInterceptor`。
|
||||
|
||||
## 结构化日志
|
||||
|
||||
```xml
|
||||
<!-- logback-spring.xml -->
|
||||
@@ -54,7 +108,17 @@ webClient.get()
|
||||
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
|
||||
<includeMdcKeyName>traceId</includeMdcKeyName>
|
||||
<customFields>{"app":"conti-backend"}</customFields>
|
||||
<includeMdcKeyName>spanId</includeMdcKeyName>
|
||||
<customFields>{"app":"conti-backend","env":"${SPRING_PROFILES_ACTIVE:-local}"}</customFields>
|
||||
|
||||
<!-- 兜底脱敏:即使有人不小心把整个对象打进日志,这些字段的值也会被替换掉。
|
||||
它是最后一道防线,不是免死金牌 —— 主要靠下面"日志规范"里的规则。 -->
|
||||
<jsonGeneratorDecorator class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
|
||||
<path>password</path>
|
||||
<path>accessToken</path>
|
||||
<path>refreshToken</path>
|
||||
<path>authorization</path>
|
||||
</jsonGeneratorDecorator>
|
||||
</encoder>
|
||||
</appender>
|
||||
<root level="INFO">
|
||||
@@ -65,15 +129,44 @@ webClient.get()
|
||||
|
||||
```groovy
|
||||
// build.gradle
|
||||
implementation 'net.logstash.logback:logstash-logback-encoder:7.4'
|
||||
implementation 'net.logstash.logback:logstash-logback-encoder:9.0' // 9.0 起用 Jackson 3,匹配 Boot 4
|
||||
```
|
||||
|
||||
输出的每条日志会带上 `traceId` 字段,直接对接现有 ELK 方案(见 `Architecture-Diagram/ODP ELK Logging Solution Project - Overview.pdf`)时可以直接按 `traceId` 过滤出一次请求的完整链路日志。
|
||||
输出的每条日志会带上 `traceId` 字段,直接对接现有 ELK 方案(见 `Architecture-Diagram/ODP ELK Logging Solution Project - Overview.pdf`)时可以按 `traceId` 过滤出一次请求的完整链路日志。
|
||||
|
||||
### 日志级别规范
|
||||
|
||||
统一标准,避免"所有人都打 INFO"导致真正重要的信息被淹没:
|
||||
|
||||
| 级别 | 用在什么地方 | 是否告警 |
|
||||
| --- | --- | --- |
|
||||
| `ERROR` | 需要人介入处理的问题:未预期异常、数据不一致、下游持续不可用 | 是 |
|
||||
| `WARN` | 系统自己处理掉了但值得关注:降级返回、重试成功、乐观锁冲突、参数校验失败率异常 | 聚合后看趋势 |
|
||||
| `INFO` | 关键业务节点:登录、切店、换票、外部系统调用的结果 | 否 |
|
||||
| `DEBUG` | 排查用的中间状态 | 生产环境默认关闭 |
|
||||
| `TRACE` | 不在生产环境使用 | — |
|
||||
|
||||
- **用户输入错误不是 ERROR**。参数校验失败、token 过期、无权限属于正常业务流,打 `WARN` 或 `INFO`,打成 ERROR 会让告警彻底失去意义。
|
||||
- **catch 住并处理掉的异常不打 ERROR**,打 `WARN` 并说明降级动作。
|
||||
- **异常要把 `Throwable` 作为最后一个参数传进去**(`log.error("xxx 失败", ex)`),不要 `log.error("xxx 失败: " + ex.message)`——后者丢掉堆栈,等于放弃了排查的主要线索。
|
||||
- 生产环境日志级别可以通过 `/actuator/loggers` 端点临时调整(该端点仅内网可达),排查完记得调回来。
|
||||
|
||||
### 敏感信息脱敏
|
||||
|
||||
**绝不进日志**:密码、`Authorization` 头及其中的 token、refresh token、JWT 签名密钥、数据库密码、F6 API key。
|
||||
|
||||
**需要脱敏后才能进日志**:手机号(`138****8000`)、姓名、身份证号、车牌号、VIN、详细地址、银行卡号。这套系统涉及车主和车辆信息,车牌和 VIN 是能定位到具体个人的,按 PII 对待。
|
||||
|
||||
落地规则:
|
||||
|
||||
1. **禁止把整个请求体/Entity 对象直接打进日志**(`log.info("req={}", request)`)。今天这个对象里没有敏感字段,不代表明天加一个字段之后还没有——而加字段的人不会想起来去检查有哪些地方打过这个对象的日志。要打就显式列出需要的字段。
|
||||
2. 确实要打的敏感字段走统一的脱敏工具方法(`Mask.phone(...)`、`Mask.plateNo(...)`),不各写各的。
|
||||
3. 上面 logback 里的 `MaskingJsonGeneratorDecorator` 是兜底,防的是"不小心写漏了",不是"有它就可以随便打"——它只按字段名匹配 JSON 结构,拼在字符串里的敏感值它一个都拦不住。
|
||||
4. **异常堆栈也可能带出敏感信息**(比如 SQL 参数、请求 URL 上的查询串),所以 URL 上不放敏感参数——需要传就放请求体。
|
||||
|
||||
## Micrometer / Actuator 配置
|
||||
|
||||
```yaml
|
||||
# application.yml
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
@@ -82,13 +175,13 @@ management:
|
||||
endpoint:
|
||||
health:
|
||||
probes:
|
||||
enabled: true # 暴露 /actuator/health/liveness、/readiness,供 K8s 探针使用
|
||||
enabled: true # 暴露 /actuator/health/liveness、/readiness,供 K8s 探针使用
|
||||
metrics:
|
||||
tags:
|
||||
application: conti-backend
|
||||
```
|
||||
|
||||
```groovy
|
||||
implementation 'org.springframework.boot:spring-boot-starter-actuator'
|
||||
implementation 'io.micrometer:micrometer-registry-prometheus'
|
||||
```
|
||||
`/actuator/prometheus` 和 `/actuator/loggers` **不对公网暴露**:只在集群内可达(`SecurityConfig` 里只 permitAll 了 `/actuator/health/**`,见 [04-security-auth.md](./04-security-auth.md)),Prometheus 从集群内抓取。
|
||||
|
||||
## K8s 探针配置(liveness / readiness)
|
||||
|
||||
@@ -99,20 +192,26 @@ implementation 'io.micrometer:micrometer-registry-prometheus'
|
||||
spec:
|
||||
containers:
|
||||
- name: conti-backend
|
||||
startupProbe: # 启动阶段专用,跑通之前 liveness/readiness 都不生效
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: 8080
|
||||
periodSeconds: 5
|
||||
failureThreshold: 30 # 最多给 150 秒完成 JVM 启动 + Flyway 迁移
|
||||
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
|
||||
```
|
||||
|
||||
用 `startupProbe` 而不是给 liveness 配一个很大的 `initialDelaySeconds`:后者是"所有情况下都固定等这么久",启动快的时候白等,启动慢的时候(比如某次迁移脚本比较大)仍然会被误杀;`startupProbe` 是"给足上限、就绪即结束",两头都照顾到。
|
||||
|
||||
两者失败后的处理完全不同,容易搞混:
|
||||
|
||||
- **`livenessProbe` 失败** → K8s 认为这个 Pod 已经"死掉"(比如死锁、内存泄漏导致完全无响应),直接**重启**这个 Pod。
|
||||
@@ -120,24 +219,64 @@ spec:
|
||||
|
||||
`readiness` group 默认会包含数据库连接(`DataSourceHealthIndicator`)等下游依赖检查,`liveness` group 默认只检查应用自身状态(不含外部依赖)——这个区分本身也是为了避免"F6 挂了导致 liveness 失败、Pod 被不断重启"这种误杀,外部依赖异常应该走 [05-integration-layer.md](./05-integration-layer.md) 的熔断降级,而不是拖累 K8s 探针。
|
||||
|
||||
**不要把 F6 之类的外部依赖加进 readiness**,理由同上:F6 抖一下不应该让我们所有 Pod 同时被摘出负载均衡,那是自己把自己搞挂。
|
||||
|
||||
## Resilience4j 指标接入 Micrometer
|
||||
|
||||
[05-integration-layer.md](./05-integration-layer.md) 里给 F6/Mini 调用配置的超时、重试、熔断器,本身的运行状态(比如熔断器当前是 `CLOSED`/`OPEN`/`HALF_OPEN`,重试了多少次)也应该能在监控里看到,不然只能等到线上报错才知道降级生效了:
|
||||
[05-integration-layer.md](./05-integration-layer.md) 里给 F6/Mini 调用配置的熔断、重试、舱壁,本身的运行状态也应该能在监控里看到,不然只能等到线上报错才知道降级生效了:
|
||||
|
||||
```groovy
|
||||
// build.gradle
|
||||
implementation 'io.github.resilience4j:resilience4j-micrometer:2.2.0'
|
||||
implementation 'io.github.resilience4j:resilience4j-micrometer:2.4.0'
|
||||
```
|
||||
|
||||
加上这个依赖后,`CircuitBreakerRegistry`/`RetryRegistry`/`TimeLimiterRegistry` 会自动把状态注册成 Micrometer meter,不需要手写埋点代码,跟着现有的 `/actuator/prometheus` 一起暴露出去,常用的几个:
|
||||
加上这个依赖后,各个 registry 会自动把状态注册成 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` 状态时直接告警,而不是等用户反馈"下单功能卡住了"才发现。
|
||||
指标分三类看,缺一类都会有盲区:
|
||||
|
||||
## 审计日志示例
|
||||
**① 系统健康**
|
||||
|
||||
| 指标 | 告警起点 |
|
||||
| --- | --- |
|
||||
| `http_server_requests_seconds_count{status=~"5.."}` 占比 | 5 分钟内 > 1% |
|
||||
| `http_server_requests_seconds` P99 | > 2s 持续 5 分钟 |
|
||||
| `jvm_memory_used_bytes{area="heap"}` / max | > 85% 持续 10 分钟 |
|
||||
| `hikaricp_connections_pending` | > 0 持续 1 分钟(有线程在排队等数据库连接,见 [03-persistence.md](./03-persistence.md) 的池子容量算法) |
|
||||
| Pod 重启次数 | 10 分钟内 ≥ 2 次 |
|
||||
|
||||
**② 依赖健康**
|
||||
|
||||
| 指标 | 告警起点 |
|
||||
| --- | --- |
|
||||
| `resilience4j_circuitbreaker_state{state="open"}` | 出现即告警——熔断器跳闸说明下游已经持续故障 |
|
||||
| `resilience4j_bulkhead_available_concurrent_calls` | 降到 0 持续 1 分钟(并发名额被打满,正在丢请求) |
|
||||
| `resilience4j_retry_calls{kind="failed_with_retry"}` 速率 | 明显抬升即关注 |
|
||||
|
||||
**③ 业务健康**(这一类最容易被忘,但恰恰是"系统全绿、用户用不了"时唯一能发现问题的指标)
|
||||
|
||||
| 指标 | 埋点方式 | 告警起点 |
|
||||
| --- | --- | --- |
|
||||
| 登录成功率 | `Counter` 按 `result` 打标签 | 5 分钟内 < 90% |
|
||||
| 切店失败数 | 同上 | 突增 |
|
||||
| WebView 换票失败率 | 同上 | 5 分钟内 > 5% |
|
||||
| 首页聚合降级 tile 数 | `Counter` 按 `tile` 打标签 | 单个 tile 降级率 > 20% |
|
||||
|
||||
```kotlin
|
||||
// 业务埋点示例:不要自己维护计数器,用 MeterRegistry
|
||||
@Service
|
||||
class LoginService(private val meterRegistry: MeterRegistry) {
|
||||
fun login(...): LoginResult {
|
||||
val result = doLogin(...)
|
||||
meterRegistry.counter("business.login", "result", if (result.success) "success" else "failure").increment()
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
告警阈值都是**起点,不是最终值**——上线后按实际曲线调整。阈值定得过敏感导致告警疲劳,比没有告警更糟糕,因为团队会开始习惯性忽略它。
|
||||
|
||||
## 审计日志
|
||||
|
||||
```kotlin
|
||||
// platform-observability/.../Audited.kt
|
||||
@@ -148,18 +287,18 @@ annotation class Audited(val action: String)
|
||||
// platform-observability/.../AuditLogAspect.kt
|
||||
@Aspect
|
||||
@Component
|
||||
class AuditLogAspect(private val storeContextHolder: StoreContextHolder) {
|
||||
class AuditLogAspect(private val storeContextHolder: ObjectFactory<StoreContextHolder>) {
|
||||
private val auditLog = LoggerFactory.getLogger("AUDIT")
|
||||
|
||||
@Around("@annotation(audited)")
|
||||
fun logAudit(joinPoint: ProceedingJoinPoint, audited: Audited): Any? {
|
||||
val result = runCatching { joinPoint.proceed() }
|
||||
val ctx = runCatching { storeContextHolder.`object` }.getOrNull()
|
||||
auditLog.info(
|
||||
"action={} userId={} storeId={} traceId={} success={}",
|
||||
audited.action, storeContextHolder.userId, storeContextHolder.storeId,
|
||||
TraceIdHolder.current(), result.isSuccess,
|
||||
audited.action, ctx?.userId, ctx?.storeId, currentTraceId(), result.isSuccess,
|
||||
)
|
||||
return result.getOrThrow()
|
||||
return result.getOrThrow() // 审计失败不能影响业务;反过来业务异常也照常抛出
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,29 +307,33 @@ class AuditLogAspect(private val storeContextHolder: StoreContextHolder) {
|
||||
fun issueTicket(userId: Long, storeId: Long): WebviewTicket { ... }
|
||||
```
|
||||
|
||||
审计日志走独立 logger(`AUDIT`),在 `logback-spring.xml` 里单独配置一个 appender 写到专门的审计日志文件/索引,不和普通业务日志混在一起,方便设置更长的保留期和更严格的访问权限。
|
||||
审计日志走独立 logger(`AUDIT`),在 `logback-spring.xml` 里单独配置一个 appender 写到专门的审计索引,不和普通业务日志混在一起,方便设置更长的保留期和更严格的访问权限。
|
||||
|
||||
需要审计的行为:登录/登出、切换门店、WebView 换票、权限变更、任何写操作失败、外部系统调用失败。审计日志**只记"谁在什么时候对什么做了什么、成功与否",不记业务数据内容**——记内容就要面对上面那一整套脱敏问题。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- `traceId` 从入口 filter 生成,贯穿到 `f6-integration` / `mini-clients` 调用外部系统,失败时把 `traceId` 一起返回给前端(已经在 [06-api-design.md](./06-api-design.md) 的 `ApiResult` 里),方便排障(对应架构图 Flow 2 的"失败可支持排障"要求)。
|
||||
- 审计相关的关键行为(登录、换票、供应商调用失败)走单独的审计日志通道,不和普通业务日志混在一起。
|
||||
- 日志/指标最终对接现有 ELK 方案,具体接入方式(Filebeat 采集 stdout,还是直接推 Logstash)待确认。
|
||||
- `traceId` 由 Micrometer Tracing 生成(或复用客户端合法的 `X-Trace-Id`),贯穿到 `integration/*` 调用外部系统,并随 `ApiResult` 返回给前端(见 [06-api-design.md](./06-api-design.md)),方便排障(对应架构图 Flow 2 的"失败可支持排障"要求)。
|
||||
- 审计相关的关键行为走单独的审计日志通道。
|
||||
- 日志/指标最终对接现有 ELK 方案,具体接入方式(Filebeat 采集 stdout,还是直接推 Logstash)待确认;无论哪种,**应用只往 stdout 打日志、不自己写文件**——容器里写文件意味着要处理轮转、磁盘占用和 Pod 销毁后日志丢失。
|
||||
|
||||
## 附录:为什么要在 MDC 里放 traceId,而不是每条日志手动传参
|
||||
## 附录:为什么 traceId 走 MDC,而不是每条日志手动传参
|
||||
|
||||
不用 `MDC` 的话,每个方法打日志都要显式传 `traceId` 参数:`log.info("traceId={} 门店切换成功", traceId)`,深层调用链里每一层都要多加一个参数,代码侵入性很强,还容易漏传。`MDC`(Mapped Diagnostic Context)是日志框架提供的"线程内隐式上下文",在 filter 里设置一次,同一线程内后续所有日志调用(不管调用链多深)都会自动带上这个字段,日志格式配置里声明 `includeMdcKeyName` 即可,业务代码完全不需要感知 `traceId` 的传递。
|
||||
不用 `MDC` 的话,每个方法打日志都要显式传 `traceId` 参数:`log.info("traceId={} 门店切换成功", traceId)`,深层调用链里每一层都要多加一个参数,代码侵入性很强,还容易漏传。`MDC`(Mapped Diagnostic Context)是日志框架提供的"线程内隐式上下文",设置一次,同一线程内后续所有日志调用(不管调用链多深)都会自动带上这个字段,日志格式配置里声明 `includeMdcKeyName` 即可。
|
||||
|
||||
代价和 [04-security-auth.md](./04-security-auth.md) 里提到的 `ThreadLocal` 类似:`MDC` 底层也是 `ThreadLocal` 实现的,异步线程池、协程切换线程的场景需要手动透传(`MDC.getCopyOfContextMap()` 传给子线程),我们当前同步 Servlet 栈下不需要特殊处理,但如果某个模块引入异步处理要注意这一点。
|
||||
代价和 [04-security-auth.md](./04-security-auth.md) 里提到的 `ThreadLocal` 类似:`MDC` 底层就是 `ThreadLocal`,**换了线程就丢**。这一点在同步 Servlet 栈下大部分时候不用操心,但**有一个真实的例外**:`workbench` 首页把多个下游并行拉起来时会用到自己的线程池,子线程里默认拿不到 `traceId`,那部分日志会断链。Micrometer Tracing 提供了 `ContextPropagatingTaskDecorator`(或 `ContextSnapshot`)来搬运上下文,配置线程池时必须带上——具体写法见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md)。这也是选 Micrometer 而不是手写 filter 的又一个理由:这套搬运机制是现成的。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 具体接入现有 ELK / APM 的方式和字段规范。
|
||||
- 审计日志的存储和保留策略。
|
||||
- 审计日志的存储位置和保留期限(需要和安全/合规确认)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Spring Boot: Tracing](https://docs.spring.io/spring-boot/reference/actuator/tracing.html)
|
||||
- [Micrometer Tracing 官方文档](https://docs.micrometer.io/tracing/reference/)
|
||||
- [Micrometer: Context Propagation](https://docs.micrometer.io/context-propagation/reference/)
|
||||
- [W3C Trace Context 规范](https://www.w3.org/TR/trace-context/)
|
||||
- [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)
|
||||
- [logstash-logback-encoder: Masking](https://github.com/logfellow/logstash-logback-encoder#masking)
|
||||
|
||||
+210
-18
@@ -7,29 +7,129 @@ Gradle 多模块统一构建,`bootstrap` 产出单一 jar/Docker 镜像;三
|
||||
## 结构约定
|
||||
|
||||
```
|
||||
settings.gradle # include 所有 platform-* / domains/*(见 01-project-structure.md)
|
||||
settings.gradle # include 所有 platform-* / domains/* / integration/*(见 01-project-structure.md)
|
||||
build.gradle # 根工程统一 Kotlin/Spring Boot 插件版本、依赖约束
|
||||
bootstrap/build.gradle # bootJar,构建出可执行 jar
|
||||
Dockerfile # 基于 bootstrap 的 jar 打镜像
|
||||
.gitlab-ci.yml # build -> test -> docker build/push -> deploy
|
||||
Dockerfile # 基于 CI 已构建好的 jar 打镜像(不在镜像里重新编译)
|
||||
k8s/ # Deployment / ConfigMap / PodDisruptionBudget 等清单
|
||||
.gitlab-ci.yml # validate -> package -> release -> deploy-uat -> deploy-prod
|
||||
```
|
||||
|
||||
## Dockerfile 示例(多阶段构建)
|
||||
## Dockerfile:复用 CI 产物 + 分层解包
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
FROM eclipse-temurin:21-jdk AS build
|
||||
WORKDIR /workspace
|
||||
COPY . .
|
||||
RUN ./gradlew :bootstrap:bootJar --no-daemon
|
||||
# 前提:CI 的 validate 阶段已经跑过 ./gradlew :bootstrap:bootJar,
|
||||
# 产物通过 GitLab artifacts 传递到 package 阶段,这里直接用,不重新编译。
|
||||
FROM eclipse-temurin:21-jre AS layers
|
||||
WORKDIR /layers
|
||||
COPY bootstrap/build/libs/*.jar app.jar
|
||||
RUN java -Djarmode=tools -jar app.jar extract --layers --launcher --destination .
|
||||
|
||||
FROM eclipse-temurin:21-jre
|
||||
WORKDIR /app
|
||||
COPY --from=build /workspace/bootstrap/build/libs/*.jar app.jar
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
|
||||
# 非 root 运行:容器内一旦被攻破,攻击者拿到的也只是一个无特权用户
|
||||
RUN useradd --system --uid 10001 --create-home appuser
|
||||
USER 10001
|
||||
|
||||
# 按变更频率从低到高逐层 COPY,前三层几乎不变,可以吃满 Docker layer 缓存,
|
||||
# 每次发版真正推送到 ACR 的通常只有最后一层(几百 KB 的业务代码)
|
||||
COPY --from=layers --chown=10001:10001 /layers/dependencies/ ./
|
||||
COPY --from=layers --chown=10001:10001 /layers/spring-boot-loader/ ./
|
||||
COPY --from=layers --chown=10001:10001 /layers/snapshot-dependencies/ ./
|
||||
COPY --from=layers --chown=10001:10001 /layers/application/ ./
|
||||
|
||||
ENTRYPOINT ["java", \
|
||||
"-XX:MaxRAMPercentage=75.0", \
|
||||
"-XX:+ExitOnOutOfMemoryError", \
|
||||
"org.springframework.boot.loader.launch.JarLauncher"]
|
||||
```
|
||||
|
||||
多阶段构建的好处:最终镜像只包含 JRE + 一个 jar,不带 Gradle 缓存、源码、编译工具链,镜像体积和攻击面都更小。
|
||||
三个容易被忽略但都会真正咬人的点:
|
||||
|
||||
1. **不在镜像里重新构建**。上一版 Dockerfile 是 `COPY . . && ./gradlew bootJar`,等于 CI 已经编译测试过一遍,打镜像时又原样编译一遍——既浪费流水线时间,又违背本篇自己的"Build once"原则(这次构建的产物和 CI 里验证过的产物严格来说不是同一个)。改为直接消费 CI 产物,`docker-build-push` job 用 `needs:` 声明依赖 `build-package` 的 artifacts。
|
||||
2. **`-XX:MaxRAMPercentage`**。JVM 在容器里会读 cgroup 限制推算堆大小,但默认上限只有可用内存的 **25%**——给 Pod 配 2Gi,堆只用 512Mi,剩下的白白浪费,然后在流量高峰时莫名其妙地 OOM 或频繁 Full GC。配 75% 把剩余空间留给 metaspace、线程栈和堆外内存。配套的 `ExitOnOutOfMemoryError` 让 OOM 直接结束进程,交给 K8s 重启,而不是留一个半死不活、探针还返回健康的 Pod。
|
||||
3. **非 root + 数字 UID**。`USER 10001` 写数字而不是 `appuser`,是为了让 K8s 的 `runAsNonRoot: true` 能在启动前静态校验通过(K8s 无法解析镜像里的用户名,只认数字 UID)。
|
||||
|
||||
## Deployment 清单:资源、优雅停机与滚动更新
|
||||
|
||||
```yaml
|
||||
# k8s/deployment-uat.yaml(节选。探针配置见 08-observability.md「K8s 探针配置」一节,合并到同一份清单里)
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: conti-backend
|
||||
spec:
|
||||
replicas: 2
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxUnavailable: 0 # 更新期间不允许可用副本数低于 replicas,先起新的再停旧的
|
||||
maxSurge: 1
|
||||
template:
|
||||
spec:
|
||||
terminationGracePeriodSeconds: 45 # 必须 > preStop 等待 + 应用 graceful 超时
|
||||
securityContext:
|
||||
runAsNonRoot: true
|
||||
seccompProfile:
|
||||
type: RuntimeDefault
|
||||
containers:
|
||||
- name: conti-backend
|
||||
securityContext:
|
||||
allowPrivilegeEscalation: false
|
||||
readOnlyRootFilesystem: true
|
||||
capabilities:
|
||||
drop: ["ALL"]
|
||||
resources:
|
||||
requests:
|
||||
cpu: "500m"
|
||||
memory: "1Gi"
|
||||
limits:
|
||||
memory: "2Gi" # 只限内存,不限 CPU —— 理由见下
|
||||
lifecycle:
|
||||
preStop:
|
||||
exec:
|
||||
command: ["sh", "-c", "sleep 10"]
|
||||
volumeMounts:
|
||||
- name: tmp
|
||||
mountPath: /tmp # readOnlyRootFilesystem 的必需配套:内嵌 Tomcat 要可写的临时目录
|
||||
volumes:
|
||||
- name: tmp
|
||||
emptyDir: {}
|
||||
---
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: conti-backend-pdb
|
||||
spec:
|
||||
minAvailable: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: conti-backend
|
||||
```
|
||||
|
||||
配套的应用侧配置:
|
||||
|
||||
```yaml
|
||||
server:
|
||||
shutdown: graceful # Boot 默认是 immediate,收到 SIGTERM 直接掐断在途请求
|
||||
spring:
|
||||
lifecycle:
|
||||
timeout-per-shutdown-phase: 25s
|
||||
```
|
||||
|
||||
### 为什么 preStop 要 `sleep 10`
|
||||
|
||||
这是滚动更新期间最常见的"零星 502"的根因。Pod 进入 Terminating 时,K8s 会**并行**做两件事:给容器发 SIGTERM,以及把 Pod 从 Service Endpoints 里摘掉。后者要经过 kube-proxy/Ingress 逐节点更新转发规则,**不是瞬时的**。如果应用收到 SIGTERM 立刻开始停机,这几百毫秒到几秒的窗口里仍然会有新请求被转发进来,而它已经不接了。
|
||||
|
||||
`preStop` 的 `sleep 10` 把 SIGTERM 推迟 10 秒,这段时间里应用照常服务,而 Endpoints 摘除早已完成——等真正开始停机时,已经没有新流量进来了。然后 `server.shutdown: graceful` 负责把已经在处理的请求跑完(最多 25s)。三个数字的关系必须是:`terminationGracePeriodSeconds (45) > preStop (10) + timeout-per-shutdown-phase (25)`,否则超时后 K8s 直接 SIGKILL,优雅停机等于白配。
|
||||
|
||||
### 为什么只限内存、不限 CPU
|
||||
|
||||
内存超限的后果是 Pod 被 OOMKilled,必须设 limit 防止一个 Pod 拖垮整个节点。CPU 则不同:Linux 的 CPU limit 通过 cfs quota 实现,一旦触及就**限流(throttling)**——表现为请求延迟毫无规律地抖动,而监控上 CPU 使用率看起来还很健康,极难排查。JVM 启动阶段(JIT 编译)尤其吃 CPU,配了 limit 会显著拉长启动时间甚至拖垮 startupProbe。设好 `requests` 保证调度到有余量的节点即可;节点整体过载靠 `ResourceQuota` 和扩容解决,不靠 per-Pod 限流。
|
||||
|
||||
`PodDisruptionBudget` 保证节点维护、集群升级这类**自愿中断**时至少留一个副本在跑——没有它,AKS 节点池升级可能把两个副本同时驱逐,造成一次没人预料到的短暂全站不可用。
|
||||
|
||||
## 环境层级:local(个人本机)vs Dev(内网 Ubuntu k3s 集群)vs UAT/Prod(Azure AKS)
|
||||
|
||||
@@ -55,7 +155,7 @@ 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=SECURITY_JWT_KEYS_V1="dev-only-fake-secret-at-least-32-bytes-long" \
|
||||
--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
|
||||
@@ -115,20 +215,50 @@ build-package:
|
||||
stage: validate
|
||||
script:
|
||||
- ./gradlew :bootstrap:bootJar --no-daemon # Build/Package
|
||||
artifacts:
|
||||
paths:
|
||||
- bootstrap/build/libs/*.jar # 传给 package 阶段的 Dockerfile 直接消费
|
||||
expire_in: 1 week
|
||||
|
||||
security-scan:
|
||||
stage: validate
|
||||
script:
|
||||
- ./gradlew dependencyCheckAnalyze # Security/Quality Scan(依赖漏洞扫描)
|
||||
# 依赖漏洞扫描。注意:从 2023 年起 NVD API 对匿名调用限流极严,
|
||||
# 不配 API Key 会卡在 "Updating the NVD CVE data" 几十分钟甚至直接超时失败。
|
||||
# NVD_API_KEY 需去 https://nvd.nist.gov/developers/request-an-api-key 免费申请,存为 CI masked variable。
|
||||
- ./gradlew dependencyCheckAnalyze -Dnvd.api.key=$NVD_API_KEY --no-daemon
|
||||
cache:
|
||||
key: nvd-db # 缓存漏洞库,避免每次流水线重新拉全量数据
|
||||
paths:
|
||||
- build/dependency-check-data
|
||||
```
|
||||
|
||||
这四个 job 对应架构图里 CI Validation 阶段的四项检查,都跑在 GitLab Runner 上,任意一项失败流水线即中止——这一步只验证代码质量,**不产出会被部署的镜像**,MR 流水线到这里就结束。
|
||||
|
||||
`dependencyCheckAnalyze` 只覆盖**我们自己声明的依赖**,管不到基础镜像里的 OS 包(glibc、openssl 这类),而那恰恰是镜像 CVE 的大头。所以镜像层面要单独扫,并同时产出 SBOM:
|
||||
|
||||
```yaml
|
||||
image-scan:
|
||||
stage: package
|
||||
needs: [docker-build-push]
|
||||
script:
|
||||
- trivy image --exit-code 1 --severity HIGH,CRITICAL --ignore-unfixed
|
||||
$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA
|
||||
# SBOM:记录这个镜像里到底装了什么。将来爆出新 CVE 时,能直接查"我们哪些线上版本受影响",
|
||||
# 而不是挨个把历史镜像拉下来重新扫一遍。
|
||||
- syft $CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA -o cyclonedx-json > sbom.json
|
||||
artifacts:
|
||||
paths: [sbom.json]
|
||||
```
|
||||
|
||||
`--ignore-unfixed` 是刻意的:上游还没发补丁的 CVE 报出来也无法处理,让它阻断流水线只会训练团队去无脑加白名单,最后所有告警一起失效。基础镜像的 tag 建议钉到 digest,并定期(比如每月)主动升一次,而不是长期用 `21-jre` 这个内容会漂移的浮动 tag。
|
||||
|
||||
### 阶段三:Artifact & Release Controls(制品与发布控制)
|
||||
|
||||
```yaml
|
||||
docker-build-push:
|
||||
stage: package
|
||||
needs: [build-package] # 直接消费 validate 阶段的 jar artifact,镜像里不再重新编译
|
||||
rules:
|
||||
- if: '$CI_COMMIT_BRANCH == "main"'
|
||||
- if: '$CI_COMMIT_TAG'
|
||||
@@ -167,10 +297,10 @@ deploy-uat:
|
||||
- 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)
|
||||
- JWT_KEY=$(az keyvault secret show --vault-name conti-backend-kv --name security-jwt-key-v1 --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=SECURITY_JWT_KEYS_V1="$JWT_KEY"
|
||||
--from-literal=DB_PASSWORD="$DB_PASSWORD"
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
- kubectl apply -f k8s/configmap-uat.yaml -n retailapp-uat
|
||||
@@ -204,7 +334,13 @@ rollback-prod:
|
||||
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
|
||||
# ROLLBACK_TAG 不是自动推导出来的,而是触发这个 job 时由操作人手工填入的变量
|
||||
#(GitLab 手动 job 支持在点击时输入变量值)。
|
||||
# 取值来源:GitLab Environment "production" 的部署历史里,当前版本之前的那个 tag。
|
||||
# 刻意不做成自动取"上一个"——回滚目标必须是人明确确认过的版本,
|
||||
# 不能出现"上一个版本本身就是有问题的、结果自动回滚到它"这种情况。
|
||||
- '[ -n "$ROLLBACK_TAG" ] || { echo "必须指定 ROLLBACK_TAG"; exit 1; }'
|
||||
- kubectl set image deployment/conti-backend conti-backend=$CI_REGISTRY_IMAGE:$ROLLBACK_TAG -n retailapp-prod
|
||||
- kubectl rollout status deployment/conti-backend -n retailapp-prod --timeout=180s
|
||||
```
|
||||
|
||||
@@ -215,12 +351,58 @@ rollback-prod:
|
||||
- **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。
|
||||
- **回滚**不重新跑构建流水线,只是把 `ROLLBACK_TAG`(操作人从 GitLab Environment 部署历史里选定的、上一个已在 Prod 正常跑过的 release tag)重新 `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 集群上,见前面"环境层级"一节。
|
||||
|
||||
数据库不在集群里:UAT/Prod 用 **Azure Database for MySQL Flexible Server**,通过 Private Endpoint 接入 AKS 所在 VNet(见 [07-config-governance.md](./07-config-governance.md)),不对公网开放。UAT 和 Prod 是**两个独立的 server 实例**,不是同一个实例上的两个 database——共用实例意味着 UAT 的一次压测或一条慢查询能直接影响生产。
|
||||
|
||||
## 数据库迁移与回滚的协同(最容易翻车的一环)
|
||||
|
||||
镜像可以秒回滚,**数据库不能**。Flyway 社区版没有 `undo`(那是商业版功能),而且即使有,`drop column` 之后的数据也回不来。再叠加滚动更新的机制——`maxUnavailable: 0` 意味着更新期间**新旧两个版本的 Pod 同时在线,连的是同一个数据库**——就得出一条硬约束:
|
||||
|
||||
> **每一个迁移脚本都必须同时兼容"上一个版本的代码"和"这个版本的代码"。**
|
||||
|
||||
不满足这条,滚动更新的中间态就会直接报错(旧 Pod 查一个已经被删掉的列),而且此时想回滚镜像也救不了,因为库已经改了。
|
||||
|
||||
### expand-contract:把破坏性变更拆成两次发布
|
||||
|
||||
以"把 `user.phone` 改名为 `user.mobile`"为例,一次改完必然出事,正确做法是拆成两个 release:
|
||||
|
||||
| 阶段 | 迁移脚本 | 代码 | 中间态是否安全 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Expand**(v1.4.0) | `add column mobile`,回填历史数据,加触发器/双写保持两列同步 | 读 `mobile`,同时写 `phone` 和 `mobile` | 安全:旧 Pod 读写 `phone` 照常 |
|
||||
| (观察期,至少一个发布周期) | — | — | 此时回滚到 v1.3.0 完全安全 |
|
||||
| **Contract**(v1.5.0) | `drop column phone` | 只读写 `mobile` | 安全:线上已无代码引用 `phone` |
|
||||
|
||||
对应到常见变更类型:
|
||||
|
||||
| 变更 | 能否一次做完 | 做法 |
|
||||
| --- | --- | --- |
|
||||
| 加表、加可空列、加索引 | 可以 | 直接加。旧代码看不见它,不受影响 |
|
||||
| 加**非空**列 | 不可以 | 先加可空列 + 默认值 → 回填 → 下个版本再加 `not null` |
|
||||
| 删列、删表 | 不可以 | 先发一个版本让代码不再引用它,下个版本再删 |
|
||||
| 改列名、改类型 | 不可以 | 按上表的 expand-contract 走 |
|
||||
| 加唯一约束 | 谨慎 | 先查历史数据有没有重复,有重复会导致迁移失败、Pod 起不来 |
|
||||
|
||||
### 已发布的迁移脚本不可修改
|
||||
|
||||
Flyway 会校验每个脚本的 checksum。改一个已经在任何环境执行过的 `V*.sql`,下次启动会直接 `Validate failed`,应用起不来。要改就新写一个版本号更大的脚本。**这一条对 Dev 环境也适用**——Dev 上随手改了脚本,等到 UAT 部署时才炸,那时候已经不知道当初改了什么。
|
||||
|
||||
### 迁移在哪跑
|
||||
|
||||
沿用 [03-persistence.md](./03-persistence.md) 的方案:迁移由应用启动时执行(`DomainFlywayConfig` 保证在 JPA `validate` 之前跑完)。`maxSurge: 1` 保证同时只有一个新 Pod 启动,加上 Flyway 自身的表级锁,不会出现多个副本并发迁移。
|
||||
|
||||
代价是:**迁移失败 = Pod 起不来 = 部署卡住但线上服务不受影响**(旧 Pod 还在跑,因为 `maxUnavailable: 0`)。这个失败模式是可接受的——比"迁移半途成功、服务带着不一致的 schema 上线"要好得多。
|
||||
|
||||
大表变更(几百万行以上加索引/改列)是这个方案的例外:它会让启动探针超时、Pod 被反复重启,同时还可能长时间锁表。这类变更走单独的 K8s `Job` 在业务低峰期执行,执行完再发应用版本,不要塞进启动流程。
|
||||
|
||||
### 迁移脚本的数据库账号
|
||||
|
||||
迁移用的账号需要 DDL 权限,运行时账号只需要 DML 权限,两者必须分开(见 [07-config-governance.md](./07-config-governance.md))——运行时账号如果有 `drop table` 权限,一个 SQL 注入的破坏半径就完全不一样了。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- UAT/Prod 通过 K8s namespace + ConfigMap/Secret 区分(见 [07-config-governance.md](./07-config-governance.md)),**镜像本身不区分环境**,同一个镜像跨环境部署,只是挂载的 ConfigMap/Secret 和 `SPRING_PROFILES_ACTIVE` 不同——避免"UAT 验证过的镜像和 Prod 部署的镜像不是同一个产物"这种环境不一致风险。
|
||||
@@ -228,6 +410,10 @@ UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespace(
|
||||
- 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`。
|
||||
- **镜像里不编译代码**:Dockerfile 消费 CI `validate` 阶段产出的 jar artifact,保证部署的字节就是被测试验证过的字节。
|
||||
- **每个迁移脚本必须前向兼容**(旧版本代码在新 schema 上能正常跑),破坏性变更一律走 expand-contract 两次发布。已经执行过的迁移脚本不可修改。
|
||||
- 容器以非 root(UID 10001)运行,`readOnlyRootFilesystem` + `drop ALL capabilities`,堆内存用 `-XX:MaxRAMPercentage` 而不是写死 `-Xmx`。
|
||||
- 优雅停机三件套必须同时配齐且数值满足 `terminationGracePeriodSeconds > preStop sleep + timeout-per-shutdown-phase`,缺一个滚动更新期间就会掉请求。
|
||||
|
||||
## 附录:为什么坚持"一个镜像走所有环境"
|
||||
|
||||
@@ -240,10 +426,16 @@ UAT/Production 对应同一个 Private AKS 集群里两个独立的 namespace(
|
||||
- 灰度发布(金丝雀/蓝绿)方案——目前只有整体切流的滚动更新,还没有按流量比例灰度的方案。
|
||||
- Gradle 构建缓存/并行构建的 CI 加速配置。
|
||||
- self-hosted Runner 本身的高可用和运维(比如 Runner 所在 VM/VMSS 的扩缩容、镜像更新)。
|
||||
- HPA(水平自动扩缩)的指标与阈值——目前 `replicas` 是写死的。
|
||||
- 数据库备份与恢复演练周期(Azure Flexible Server 自带 PITR,但"能恢复"和"演练过能恢复"是两回事)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [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)
|
||||
- [Spring Boot: Graceful Shutdown](https://docs.spring.io/spring-boot/reference/web/graceful-shutdown.html)
|
||||
- [Kubernetes: Pod 生命周期与终止流程](https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/pod-lifecycle/#pod-termination)
|
||||
- [Kubernetes: PodDisruptionBudget](https://kubernetes.io/zh-cn/docs/concepts/workloads/pods/disruptions/)
|
||||
- [Flyway: 零停机迁移与 expand-contract](https://documentation.red-gate.com/fd/zero-downtime-deployments-268173154.html)
|
||||
- [OWASP: Kubernetes Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Kubernetes_Security_Cheat_Sheet.html)
|
||||
- [k3s 官方文档](https://docs.k3s.io/)
|
||||
|
||||
+351
-74
@@ -2,15 +2,38 @@
|
||||
|
||||
## 决策
|
||||
|
||||
JUnit 5 + MockK 做单元测试,Testcontainers 做集成测试,WireMock 做外部依赖打桩,分层对应 [02-layering.md](./02-layering.md)。
|
||||
JUnit 5 + MockK 做单元测试,Testcontainers(MySQL)做集成测试,WireMock 做外部依赖打桩,ArchUnit 把架构规则变成可执行测试。分层对应 [02-layering.md](./02-layering.md)。
|
||||
|
||||
## 测试依赖基线
|
||||
|
||||
```groovy
|
||||
// 各模块 build.gradle。版本能由 BOM 管的一律不写死(根工程已引入 Spring Boot BOM,见 01-project-structure.md)
|
||||
dependencies {
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test' // JUnit 5 + AssertJ + Mockito
|
||||
testImplementation 'io.mockk:mockk' // Kotlin 友好的 mock 库
|
||||
testImplementation 'com.ninja-squad:springmockk:5.0.1' // 提供 @MockkBean / @MockkSpyBean
|
||||
|
||||
// Testcontainers:版本由 Spring Boot BOM 统一管理,不要手动 pin。
|
||||
// 如果确实要覆盖版本,注意跨大版本时坐标和包路径可能变动,改完先跑一遍再提交。
|
||||
testImplementation 'org.springframework.boot:spring-boot-testcontainers'
|
||||
testImplementation 'org.testcontainers:junit-jupiter'
|
||||
testImplementation 'org.testcontainers:mysql'
|
||||
testImplementation 'org.wiremock:wiremock-standalone:3.9.1'
|
||||
}
|
||||
```
|
||||
|
||||
`@MockkBean` **不是 Spring 自带的**,它来自 `springmockk`——这个依赖漏了的话,注解根本不存在,编译期就红。Spring 自带的是 `@MockitoBean`(Boot 3.4+ 取代了 `@MockBean`),但 Mockito 对 Kotlin 的 final class 和协程支持不如 MockK,这套代码库统一用 MockK 一套到底,避免两套 mock 语法混着写。
|
||||
|
||||
## 分层测试策略
|
||||
|
||||
- **domain 层**(有的话):纯单元测试,不起 Spring 容器,mock 掉 repository/client 接口,验证业务规则本身。
|
||||
- **application 层**:`@SpringBootTest` 或轻量 slice test,mock 掉 infrastructure 层。
|
||||
- **infrastructure 层**:用 Testcontainers 起真实数据库跑 repository 测试,避免 H2 和生产数据库行为差异导致的假通过。
|
||||
- **api 层**:`@WebMvcTest` 验证参数校验、异常处理、响应结构是否符合 [06-api-design.md](./06-api-design.md) 的约定。
|
||||
- **f6-integration / mini-clients**:对外部依赖用 WireMock 打桩,覆盖超时/重试/熔断路径。
|
||||
| 层 | 手段 | 起 Spring 容器 | 数量 |
|
||||
| --- | --- | --- | --- |
|
||||
| `domain` | 纯 JUnit + MockK,mock 掉 repository/client 接口 | 否 | 最多 |
|
||||
| `application` | slice test 或 `@SpringBootTest`,mock 掉 infrastructure | 轻量 | 多 |
|
||||
| `infrastructure` | Testcontainers 起真实 MySQL 跑 repository 测试 | 是(`@DataJpaTest`) | 中 |
|
||||
| `api` | `@WebMvcTest` 验证参数校验、异常处理、响应结构(见 [06-api-design.md](./06-api-design.md)) | 是(web slice) | 中 |
|
||||
| `integration/*` | WireMock 打桩,覆盖超时/重试/熔断/舱壁路径 | 视用例 | 少 |
|
||||
| 架构规则 | ArchUnit,全代码库扫描 | 否 | 一组 |
|
||||
|
||||
## `domain` 层单元测试示例(对应 02 里的换票场景)
|
||||
|
||||
@@ -48,26 +71,24 @@ class IssueWebviewTicketServiceTest {
|
||||
|
||||
不起 Spring 容器、不连数据库,纯 JVM 内存跑完,这类测试应该是数量最多、跑得最快的一层。
|
||||
|
||||
## `infrastructure` 层集成测试示例(Testcontainers)
|
||||
**注意 `Clock` 是构造参数注入的,不是 `Instant.now()` 硬编码**。所有涉及时间的业务代码都必须注入 `Clock`(生产环境 `Clock.systemUTC()` 由 `platform-*` 提供一个 `@Bean`),否则"过期判断"这类逻辑根本没法稳定测试,只能靠 `Thread.sleep` 硬等——那是慢测试和随机失败的主要来源。
|
||||
|
||||
## `infrastructure` 层集成测试示例(Testcontainers + MySQL)
|
||||
|
||||
```kotlin
|
||||
// domains/identity-store/src/test/kotlin/.../StoreJpaRepositoryTest.kt
|
||||
@DataJpaTest
|
||||
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
|
||||
@Testcontainers
|
||||
class StoreJpaRepositoryTest {
|
||||
|
||||
companion object {
|
||||
@Container
|
||||
@ServiceConnection // Boot 自动把容器的 url/user/password 注入 DataSource
|
||||
@JvmStatic
|
||||
val postgres = PostgreSQLContainer("postgres:16")
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun props(registry: DynamicPropertyRegistry) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl)
|
||||
registry.add("spring.datasource.username", postgres::getUsername)
|
||||
registry.add("spring.datasource.password", postgres::getPassword)
|
||||
}
|
||||
val mysql = MySQLContainer("mysql:8.4")
|
||||
.withUrlParam("connectionTimeZone", "UTC") // 与生产一致,见 03-persistence.md
|
||||
.withUrlParam("preserveInstants", "true")
|
||||
}
|
||||
|
||||
@Autowired lateinit var repository: StoreJpaRepository
|
||||
@@ -84,67 +105,175 @@ class StoreJpaRepositoryTest {
|
||||
}
|
||||
```
|
||||
|
||||
用真实 PostgreSQL(而不是 H2)跑测试,是因为 Flyway migration 里的 SQL 方言、JPA 对特定数据库函数的行为,H2 不一定能完全模拟,容易出现"H2 测试通过、生产环境报错"的假阳性。
|
||||
两个注解缺一不可,而且都是"不加就静默走错路"的类型:
|
||||
|
||||
- **`@ServiceConnection`**:Boot 3.1+ 提供,自动从容器推导连接信息。没有它就得手写一堆 `@DynamicPropertySource`,容易漏配(比如漏了时区参数,测试全绿但生产时间差 8 小时)。
|
||||
- **`@AutoConfigureTestDatabase(replace = NONE)`**:`@DataJpaTest` **默认会把 DataSource 替换成嵌入式内存库**。如果 classpath 上恰好有 H2,容器白起了,测试实际跑在 H2 上——而且不会有任何报错,只会在某天遇到 MySQL 特有行为时才暴露。这是本篇最值得单独强调的一个坑。
|
||||
|
||||
用真实 MySQL 而不是 H2,是因为要验证的恰恰是对 MySQL 的假设:Flyway 迁移脚本里的 DDL 方言、`utf8mb4_0900_ai_ci` 排序规则下的中文比较、`datetime(6)` 的精度截断、唯一索引在 3072 字节限制下能不能建起来(见 [03-persistence.md](./03-persistence.md))。这些 H2 一个都模拟不了。
|
||||
|
||||
**容器复用**:默认每个测试类起一个新容器,模块一多启动开销很可观。开发本机可以在 `~/.testcontainers.properties` 里设 `testcontainers.reuse.enable=true` 并给容器加 `.withReuse(true)`;**CI 上不要开**——CI 需要的是每次都干净的环境。
|
||||
|
||||
## `api` 层测试示例(`@WebMvcTest`)
|
||||
|
||||
```kotlin
|
||||
@WebMvcTest(StoreController::class)
|
||||
@Import(GlobalExceptionHandler::class) // slice test 默认不装配 platform-web 里的 advice,要显式引入
|
||||
class StoreControllerTest {
|
||||
@Autowired lateinit var mockMvc: MockMvc
|
||||
@MockkBean lateinit var storeAppService: StoreAppService
|
||||
|
||||
@Test
|
||||
fun `切换门店参数为空时返回参数校验错误`() {
|
||||
mockMvc.post("/api/v1/stores/switch") {
|
||||
contentType = MediaType.APPLICATION_JSON
|
||||
content = """{}"""
|
||||
}.andExpect {
|
||||
status { isBadRequest() }
|
||||
jsonPath("$.code") { value(ErrorCode.INVALID_PARAM) } // 对应 06-api-design.md 的 ApiResult 结构
|
||||
}
|
||||
@WithMockUser
|
||||
fun `切换到无权访问的门店返回 10403`() {
|
||||
every { storeAppService.switchStore(any(), 999) } throws StoreNotAccessibleException()
|
||||
|
||||
mockMvc.post("/api/v1/stores/999/switch") // 路径与 06-api-design.md、客户端保持一致
|
||||
.andExpect {
|
||||
status { isForbidden() }
|
||||
jsonPath("$.code") { value(ErrorCode.STORE_NOT_ACCESSIBLE) } // Int,见 06-api-design.md
|
||||
jsonPath("$.traceId") { exists() }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 外部依赖打桩示例(WireMock,覆盖 F6 超时/熔断路径)
|
||||
`@WebMvcTest` 只装配 web 层(Controller、`@ControllerAdvice`、消息转换器、参数校验),不装配 Service/Repository——所以 `StoreAppService` 必须 mock 掉,否则容器起不来。这一层测的是**协议**:状态码对不对、错误码对不对、字段名和 JSON 结构对不对,而不是业务逻辑。
|
||||
|
||||
## 外部依赖打桩示例(WireMock,覆盖 F6 降级路径)
|
||||
|
||||
```kotlin
|
||||
@Testcontainers
|
||||
// integration/f6-adapter/src/test/kotlin/.../F6ApiClientResilienceTest.kt
|
||||
@SpringBootTest
|
||||
class F6ApiClientResilienceTest {
|
||||
|
||||
companion object {
|
||||
@Container
|
||||
@JvmStatic
|
||||
val wireMock = WireMockContainer("wiremock/wiremock:3.9.1")
|
||||
val wireMock = WireMockServer(options().dynamicPort()).apply { start() }
|
||||
|
||||
@JvmStatic
|
||||
@DynamicPropertySource
|
||||
fun props(registry: DynamicPropertyRegistry) {
|
||||
registry.add("integration.f6.base-url") { wireMock.baseUrl() }
|
||||
registry.add("resilience4j.circuitbreaker.instances.f6.minimum-number-of-calls") { 3 }
|
||||
}
|
||||
}
|
||||
|
||||
@Autowired lateinit var f6ApiClient: F6ApiClient
|
||||
|
||||
@Test
|
||||
fun `F6 响应超时后走 fallback 返回降级数据`() {
|
||||
wireMock.stubFor(
|
||||
get(urlPathEqualTo("/f6/procurement/list"))
|
||||
.willReturn(aResponse().withFixedDelay(5000).withStatus(200)) // 模拟超过 timeout 配置的慢响应
|
||||
.willReturn(aResponse().withFixedDelay(5_000).withStatus(200)), // 超过 responseTimeout 配置
|
||||
)
|
||||
|
||||
val result = f6ApiClient.fetchProcurementList(storeId = 1).block()
|
||||
// 同步 RestClient,直接拿返回值,没有 .block()(见 05-integration-layer.md)
|
||||
val result = f6ApiClient.fetchProcurementList(storeId = 1)
|
||||
|
||||
assertTrue(result!!.degraded) // 验证 05-integration-layer.md 里配置的 2s 超时 + fallback 生效
|
||||
assertTrue(result.degraded) // 验证超时后 @Retry 耗尽 → fallback 生效
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `连续失败后熔断器打开并直接走 fallback`() {
|
||||
wireMock.stubFor(
|
||||
get(urlPathEqualTo("/f6/procurement/list"))
|
||||
.willReturn(aResponse().withStatus(503)),
|
||||
)
|
||||
|
||||
repeat(5) { f6ApiClient.fetchProcurementList(storeId = 1) }
|
||||
|
||||
// 熔断打开后不应该再发出真实请求 —— 这是"熔断真的生效了"的唯一硬证据,
|
||||
// 只断言返回降级数据是不够的(重试耗尽也会返回降级数据,两者分不开)
|
||||
val callsBefore = wireMock.findAll(getRequestedFor(urlPathEqualTo("/f6/procurement/list"))).size
|
||||
f6ApiClient.fetchProcurementList(storeId = 1)
|
||||
assertEquals(callsBefore, wireMock.findAll(getRequestedFor(urlPathEqualTo("/f6/procurement/list"))).size)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## `ArchUnit`:把分层规则变成可执行的测试
|
||||
熔断相关的测试**必须在测试里把 `minimum-number-of-calls` 调小**(生产配置通常是 20~50),否则要打满几十次请求才会触发,测试又慢又难读。
|
||||
|
||||
[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/) 把这些规则写成测试,每次构建自动检查:
|
||||
## `ArchUnit`:把架构规则变成可执行的测试
|
||||
|
||||
[01-project-structure.md](./01-project-structure.md) 和 [02-layering.md](./02-layering.md) 里定的规则,光靠 code review 肉眼盯着一定会漏。全部写成 ArchUnit 测试,放在专门的 `architecture-test` 模块里(它依赖所有其他模块,是唯一能看到全代码库的地方):
|
||||
|
||||
```groovy
|
||||
// build.gradle(专门放架构测试的模块,或加进 bootstrap 的 test 依赖)
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.3.0'
|
||||
// architecture-test/build.gradle
|
||||
dependencies {
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
|
||||
// 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码
|
||||
testImplementation project(':bootstrap')
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// architecture-test/src/test/kotlin/.../LayeringRulesTest.kt
|
||||
class LayeringRulesTest {
|
||||
private val classes = ClassFileImporter().importPackages("com.continental.retailapp")
|
||||
// architecture-test/src/test/kotlin/.../ArchitectureRulesTest.kt
|
||||
class ArchitectureRulesTest {
|
||||
|
||||
private val classes = ClassFileImporter()
|
||||
.withImportOption(ImportOption.DoNotIncludeTests())
|
||||
.importPackages("com.continental.retailapp")
|
||||
|
||||
private val domains = listOf("identitystore", "bff", "workbench", "webviewticket")
|
||||
|
||||
// —— 规则组一:模块边界(01-project-structure.md)——
|
||||
|
||||
@Test
|
||||
fun `domain 之间只能通过 contract 包互相依赖`() {
|
||||
domains.forEach { source ->
|
||||
domains.filter { it != source }.forEach { target ->
|
||||
noClasses()
|
||||
.that().resideInAPackage("..retailapp.$source..")
|
||||
.should().dependOnClassesThat(
|
||||
JavaClass.Predicates.resideInAPackage("..retailapp.$target..")
|
||||
.and(DescribedPredicate.not(
|
||||
JavaClass.Predicates.resideInAPackage("..retailapp.$target.contract..")))
|
||||
)
|
||||
.because("跨 domain 只能走 -contract 模块发布的接口/传输模型/事件")
|
||||
.check(classes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `contract 模块不能依赖 Spring Web 或 JPA`() {
|
||||
noClasses()
|
||||
.that().resideInAPackage("..retailapp.*.contract..")
|
||||
.should().dependOnClassesThat()
|
||||
.resideInAnyPackage("org.springframework.web..", "jakarta.persistence..", "..infrastructure..")
|
||||
.because("契约模块只放接口、传输模型和事件,不携带任何技术栈")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
// —— 规则组二:层内方向(02-layering.md)——
|
||||
|
||||
@Test
|
||||
fun `层依赖方向`() {
|
||||
layeredArchitecture().consideringOnlyDependenciesInLayers()
|
||||
.layer("api").definedBy("..retailapp.*.api..")
|
||||
.layer("application").definedBy("..retailapp.*.application..")
|
||||
.layer("domain").definedBy("..retailapp.*.domain..")
|
||||
.layer("infrastructure").definedBy("..retailapp.*.infrastructure..")
|
||||
.whereLayer("api").mayNotBeAccessedByAnyLayer()
|
||||
.whereLayer("application").mayOnlyBeAccessedByLayers("api")
|
||||
.whereLayer("domain").mayOnlyBeAccessedByLayers("application", "infrastructure")
|
||||
// 注意这里是 application 而不是"谁都不能访问":跳过 domain 层的简单 CRUD 场景下,
|
||||
// application 直接依赖 infrastructure 里定义的 repository 接口是 02-layering.md 明确允许的。
|
||||
// 写成 mayNotBeAccessedByAnyLayer() 会把 02 自己的示例判红 —— 规则必须和文档一致,
|
||||
// 真正的红线是下面那条"api 不能碰 infrastructure"。
|
||||
.whereLayer("infrastructure").mayOnlyBeAccessedByLayers("application")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `api 层不能依赖 infrastructure 层`() {
|
||||
noClasses()
|
||||
.that().resideInAPackage("..api..")
|
||||
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
|
||||
.because("02-layering.md:api 层不 import infrastructure 包下的任何类型(包括 Entity)")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `domain 层不能依赖 Spring 或 JPA`() {
|
||||
@@ -154,33 +283,133 @@ class LayeringRulesTest {
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
// —— 规则组三:Entity 边界(02-layering.md / 06-api-design.md 里逐字相同的那句话)——
|
||||
// 「XxxEntity 不出现在 api 层的任何签名或 import 里,也不跨出所在模块的边界。」
|
||||
|
||||
@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
|
||||
fun `Entity 只能待在 infrastructure 包里`() {
|
||||
classes()
|
||||
.that().haveSimpleNameEndingWith("Entity")
|
||||
// platform-* 模块不按四层划分(见 01-project-structure.md 的包名对应表),
|
||||
// 里面的 BaseEntity / VersionedEntity 和幂等记录表按模块自身结构组织,整体排除。
|
||||
// 这条规则约束的是各业务域的 Entity 不许爬出 infrastructure。
|
||||
.and().resideOutsideOfPackage("..retailapp.platform..")
|
||||
.should().resideInAPackage("..infrastructure..")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `api 层不能直接依赖 infrastructure 层`() {
|
||||
fun `api 层不能触碰 Entity`() {
|
||||
noClasses()
|
||||
.that().resideInAPackage("..api..")
|
||||
.should().dependOnClassesThat().resideInAPackage("..infrastructure..")
|
||||
.should().dependOnClassesThat().haveSimpleNameEndingWith("Entity")
|
||||
.because("Entity → Response 的转换发生在 application 层,mapper 放在 application/mapper/")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
// —— 规则组四:编码约定 ——
|
||||
|
||||
@Test
|
||||
fun `Controller 的端点方法必须返回 ApiResult`() {
|
||||
methods()
|
||||
.that().areDeclaredInClassesThat().areAnnotatedWith(RestController::class.java)
|
||||
// 用 metaAnnotatedWith 而不是 arePublic:@GetMapping/@PostMapping 都是 @RequestMapping 的
|
||||
// 元注解派生,这样只圈住真正的端点方法,不会误伤 Controller 里的 public 辅助方法
|
||||
.and().areMetaAnnotatedWith(RequestMapping::class.java)
|
||||
.should().haveRawReturnType(ApiResult::class.java)
|
||||
.because("统一响应结构,见 06-api-design.md")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `禁止使用 java 时间类型的老 API`() {
|
||||
noClasses()
|
||||
.should().dependOnClassesThat()
|
||||
.belongToAnyOf(java.util.Date::class.java, java.util.Calendar::class.java)
|
||||
.because("统一用 Instant,UTC 存储,见 03-persistence.md")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `禁止字段注入`() {
|
||||
noFields().should().beAnnotatedWith(Autowired::class.java)
|
||||
.because("统一用构造器注入,可测试且不可变")
|
||||
.check(classes)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这类测试一次写好,覆盖的是全代码库范围的结构性规则,跑在 CI Validation 阶段(见 [09-build-deploy.md](./09-build-deploy.md)),比"等 code review 时人工发现某个 domain 偷偷 import 了另一个 domain 的 entity"要可靠得多,而且不区分改动大小——哪怕只加了一行 `import`,只要违反规则就会立刻挂红。
|
||||
两个使用上的注意点:
|
||||
|
||||
1. **`ImportOption.DoNotIncludeTests()` 不能省**。测试代码里 mock、构造 Entity、跨层引用都是正常的,不排除掉会误报一片。
|
||||
2. **匹配不到任何类的规则默认会失败**(ArchUnit 1.x 的 `allowEmptyShould` 行为)。比如某个 domain 还没建 contract 包,规则就会空转失败。要么用 `.allowEmptyShould(true)`,要么等这个模块真的建起来再加规则——不要因为这个把整条规则删掉。
|
||||
|
||||
### 原生 SQL 跨库扫描测试([03-persistence.md](./03-persistence.md) 承诺的那条)
|
||||
|
||||
MySQL 里 schema ≡ database,**跨 database join 只要账号有权限就是合法的**,编译器和 ArchUnit 都拦不住。所以额外加一条扫描测试作为软约束的第二道:
|
||||
|
||||
```kotlin
|
||||
// architecture-test/src/test/kotlin/.../NativeQueryScanTest.kt
|
||||
class NativeQueryScanTest {
|
||||
|
||||
// 模块目录 -> 它自己的 database 名。没有自己数据库的模块(bff-orchestration)不在表里,
|
||||
// 它一个 database 名都不该出现,所以 owner 传 null 即可。
|
||||
private val ownerByModule = mapOf(
|
||||
"identity-store" to "identity_store",
|
||||
"workbench" to "workbench",
|
||||
"webview-ticket" to "webview_ticket",
|
||||
)
|
||||
private val allDatabases = ownerByModule.values.toSet()
|
||||
|
||||
@Test
|
||||
fun `原生 SQL 里不能出现其他 domain 的库名`() {
|
||||
val violations = Files.walk(Path.of("../domains"))
|
||||
.filter { it.toString().endsWith(".kt") }
|
||||
.toList()
|
||||
.flatMap { file ->
|
||||
val path = file.toString().replace('\\', '/')
|
||||
val owner = ownerByModule.entries.firstOrNull { path.contains("/${it.key}/") }?.value
|
||||
val text = Files.readString(file)
|
||||
(allDatabases - setOfNotNull(owner))
|
||||
.filter { text.contains("$it.") }
|
||||
.map { "$path 引用了 $it" }
|
||||
}
|
||||
|
||||
assertTrue(violations.isEmpty()) { "跨 database 访问:\n${violations.joinToString("\n")}" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这是**近似检查、不是严密证明**(它只做字符串匹配,绕过它很容易)。它的价值在于把"不小心写了个跨库 join"这种最常见的情况挡在 CI 上;真正的硬约束是生产账号只 GRANT 本域 database 的权限。这一点要如实认识,不要因为有这个测试就以为边界被守住了。
|
||||
|
||||
### Resilience4j 切面顺序验证测试([05-integration-layer.md](./05-integration-layer.md) 承诺的那条)
|
||||
|
||||
05 里说了切面叠加顺序由 `*-aspect-order` 属性决定、而且属性值的方向容易记反,所以配完必须实测一次,别靠记忆:
|
||||
|
||||
```kotlin
|
||||
@SpringBootTest
|
||||
class ResilienceAspectOrderTest {
|
||||
// WireMock 的搭建同上一节的 F6ApiClientResilienceTest,这里只列关键断言
|
||||
@Autowired lateinit var f6ApiClient: F6ApiClient
|
||||
@Autowired lateinit var circuitBreakerRegistry: CircuitBreakerRegistry
|
||||
|
||||
@Test
|
||||
fun `Retry 在外层时每次重试都计入熔断统计`() {
|
||||
wireMock.stubFor(get(anyUrl()).willReturn(aResponse().withStatus(503)))
|
||||
val cb = circuitBreakerRegistry.circuitBreaker("f6")
|
||||
|
||||
f6ApiClient.fetchProcurementList(storeId = 1) // 1 次调用 + 2 次重试
|
||||
|
||||
// Retry 在外 → 熔断器看到 3 次失败;Retry 在内 → 熔断器只看到 1 次。
|
||||
// 这个断言就是"配置到底生效成什么样"的答案,改配置后它会立刻告诉你方向反没反。
|
||||
assertEquals(3, cb.metrics.numberOfFailedCalls)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CI 里跑 Testcontainers 的前提条件
|
||||
|
||||
`infrastructure` 层的集成测试(前面 `StoreJpaRepositoryTest` 那个例子)依赖 Testcontainers 起真实容器,这要求执行 `./gradlew test` 的 GitLab Runner 本身**能起 Docker 容器**,不是随便一个 Runner 都能跑,两种常见配置:
|
||||
集成测试依赖 Testcontainers 起真实容器,这要求执行 `./gradlew test` 的 GitLab Runner 本身**能起 Docker 容器**,两种常见配置:
|
||||
|
||||
**方式一:Docker-in-Docker(dind),托管 Runner 的默认选择**
|
||||
|
||||
@@ -210,64 +439,112 @@ unit-integration-test:
|
||||
|
||||
方式二没有 dind 的嵌套虚拟化开销,跑起来更快,但要求 Runner 能访问宿主机的 Docker socket(等价于 Runner 对宿主机有较高权限),只适合放在我们自己管控的 self-hosted Runner 上;如果以后接入公共共享 Runner 跑这一类测试,只能用方式一,不应该在共享 Runner 上开 Docker socket 权限。
|
||||
|
||||
## 覆盖率门禁(Jacoco)
|
||||
## 覆盖率门禁(Jacoco 多模块聚合)
|
||||
|
||||
**先说清楚为什么必须聚合**:多模块工程里如果每个模块各算各的覆盖率,会出现两个问题——① 根工程自己没有测试,在根上跑 `jacocoTestCoverageVerification` 直接就是 0/0 通过,门禁形同虚设;② `architecture-test` 模块跑的测试覆盖到的是**其他模块**的代码,按模块统计时这部分贡献会被完全丢掉。用 Gradle 自带的 `jacoco-report-aggregation` 插件把所有模块的执行数据合并成一份报告:
|
||||
|
||||
```groovy
|
||||
// build.gradle
|
||||
// 根 build.gradle
|
||||
plugins {
|
||||
id 'jacoco'
|
||||
id 'jacoco-report-aggregation'
|
||||
}
|
||||
|
||||
jacocoTestReport {
|
||||
dependsOn test
|
||||
dependencies {
|
||||
// 依赖 bootstrap 即可 —— 它传递性地依赖了所有 platform-* / domains/* / integration/*
|
||||
jacocoAggregation project(':bootstrap')
|
||||
jacocoAggregation project(':architecture-test')
|
||||
}
|
||||
|
||||
subprojects {
|
||||
apply plugin: 'jacoco'
|
||||
}
|
||||
|
||||
// 聚合报告的具体配置属性名在 Gradle 各版本间有过调整,
|
||||
// 升级 Gradle 后先跑一次 ./gradlew testCodeCoverageReport 确认任务还在、报告路径没变。
|
||||
reporting {
|
||||
reports {
|
||||
xml.required = true // CI 里给 GitLab 覆盖率可视化用
|
||||
}
|
||||
}
|
||||
|
||||
jacocoTestCoverageVerification {
|
||||
violationRules {
|
||||
rule {
|
||||
limit {
|
||||
minimum = 0.70
|
||||
}
|
||||
testCodeCoverageReport(JacocoCoverageReport) {
|
||||
testSuiteName = 'test'
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
check.dependsOn jacocoTestCoverageVerification // 覆盖率不达标,./gradlew check 直接失败
|
||||
排除项——这些代码算进分母只会让指标失真,逼着团队去写毫无意义的测试:
|
||||
|
||||
```groovy
|
||||
subprojects {
|
||||
tasks.withType(JacocoReport).configureEach {
|
||||
classDirectories.setFrom(files(classDirectories.files.collect {
|
||||
fileTree(dir: it, exclude: [
|
||||
'**/*MapperImpl*', // MapStruct 生成的代码
|
||||
'**/*Request*', '**/*Response*', '**/*Dto*', // 纯数据类,没有逻辑
|
||||
'**/*Entity*', // 同上
|
||||
'**/*Config*', '**/*Properties*',
|
||||
'**/BootstrapApplication*',
|
||||
])
|
||||
}))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```yaml
|
||||
# .gitlab-ci.yml,CI Validation 阶段追加覆盖率门禁
|
||||
# .gitlab-ci.yml
|
||||
unit-integration-test:
|
||||
stage: validate
|
||||
script:
|
||||
- ./gradlew test jacocoTestCoverageVerification --no-daemon
|
||||
- ./gradlew test testCodeCoverageReport --no-daemon
|
||||
artifacts:
|
||||
reports:
|
||||
junit: '**/build/test-results/test/TEST-*.xml'
|
||||
coverage_report:
|
||||
coverage_format: cobertura
|
||||
path: '**/build/reports/jacoco/test/jacocoTestReport.xml'
|
||||
path: 'build/reports/jacoco/testCodeCoverageReport/testCodeCoverageReport.xml'
|
||||
```
|
||||
|
||||
阈值定在 70% 而不是追求 90%+:`domain` 层因为纯逻辑、mock 成本低,覆盖率天然会很高;`infrastructure`/`api` 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖",统一定一个较低的整体阈值,作用是**拦住完全没写测试就合入的代码**,而不是逼着每个模块都卷到很高的数字——那样容易导致为了凑覆盖率写没有意义的测试。
|
||||
阈值定在 **70%** 而不是追求 90%+:`domain` 层因为纯逻辑、mock 成本低,覆盖率天然会很高;`infrastructure`/`api` 层的诉求是"关键路径别漏测"而不是"每一行都要覆盖"。这个门禁的作用是**拦住完全没写测试就合入的代码**,而不是逼着每个模块都卷到很高的数字——那样只会催生出一堆 `assertNotNull(result)` 式的假测试,覆盖率好看了,实际保护力反而下降。
|
||||
|
||||
## 测试数据构造约定
|
||||
|
||||
集成测试里最容易失控的地方是"每个测试自己 new 一个有 15 个字段的 Entity",字段一改,几十个测试同时爆掉。统一用**测试数据构建器**,只显式写这个测试真正关心的字段:
|
||||
|
||||
```kotlin
|
||||
// src/test/kotlin/.../fixtures/StoreFixtures.kt
|
||||
fun aStore(
|
||||
name: String = "示例门店",
|
||||
code: String = "S001",
|
||||
status: StoreStatus = StoreStatus.ACTIVE,
|
||||
) = StoreEntity(name = name, code = code, status = status)
|
||||
|
||||
// 用法:读者一眼就知道这个测试关心的只有 status
|
||||
val inactive = aStore(status = StoreStatus.INACTIVE)
|
||||
```
|
||||
|
||||
其他几条:
|
||||
|
||||
- **测试之间不共享状态**。`@DataJpaTest` 默认每个测试方法跑在会回滚的事务里;用 `@SpringBootTest` 时事务不会自动回滚,需要显式 `@Transactional` 或在 `@AfterEach` 里清理。
|
||||
- **不依赖测试执行顺序**。JUnit 5 的默认顺序是确定但不保证的,任何"必须先跑 A 再跑 B"的设计都要拆掉。
|
||||
- **断言要具体**。`assertTrue(list.isNotEmpty())` 在回归时几乎抓不到问题,`assertEquals(listOf("S001"), list.map { it.code })` 才有价值。
|
||||
- **测试名用反引号中文描述行为**(如上面示例),不要 `test1`、`testSwitchStore2`——失败时 CI 报告上那一行就是问题描述本身。
|
||||
|
||||
## 附录:为什么 domain 层用 mock、infrastructure 层坚持用真实依赖
|
||||
|
||||
这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实数据库、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。
|
||||
这是测试金字塔的实际落地取舍:越往下层(domain)测试数量应该越多、跑得越快,因为业务规则的分支组合往往很多(各种边界条件),用 mock 把依赖都隔离掉才能便宜地把每个分支都测到;越往上/往基础设施层,测试数量应该越少但真实度要求越高,因为这一层要验证的恰恰是"我们对某个具体技术(JPA、真实 MySQL、真实 HTTP 依赖)的假设是否成立"——如果这一层也用 mock,等于假设了"这个假设是对的",那测试就失去了意义。
|
||||
|
||||
Testcontainers 和 WireMock 的共同点是:它们让"跑得慢、需要真实环境"的测试仍然可以在 CI 里可重复地跑起来(每次测试起一个全新的容器,跑完销毁,不依赖某个共享的、状态可能被污染的测试环境)。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 是否需要和 APP 端做端到端契约测试(比如引入 Pact)。
|
||||
- 性能/压测基线(至少要有一条:首页聚合接口在 N 并发下的 P99)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [MockK 官方文档](https://mockk.io/)
|
||||
- [springmockk(`@MockkBean`)](https://github.com/Ninja-Squad/springmockk)
|
||||
- [Testcontainers 官方文档](https://testcontainers.com/)
|
||||
- [Spring Boot: Testcontainers 与 @ServiceConnection](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html)
|
||||
- [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)
|
||||
- [Gradle: JaCoCo Report Aggregation Plugin](https://docs.gradle.org/current/userguide/jacoco_report_aggregation_plugin.html)
|
||||
- [Martin Fowler: Test Pyramid](https://martinfowler.com/bliki/TestPyramid.html)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# 11. 跨域协作与聚合
|
||||
|
||||
## 决策
|
||||
|
||||
跨 domain 的**读**走契约模块(`-contract`)里的接口,跨 domain 的**写/状态联动**走 Spring `ApplicationEvent` + `@TransactionalEventListener`,暂不引入消息中间件。`bff-orchestration` / `workbench` 的并行聚合用一个专用的有界线程池,带上下文传播和整体超时预算,按 tile 局部降级。
|
||||
|
||||
这一篇填的是原来整套文档最大的一个空白:[04-security-auth.md](./04-security-auth.md) 里写了"切店 → 通知 webview-ticket 失效"走事件,但事件机制本身从来没有被定义过。
|
||||
|
||||
## 一、跨域读:契约模块
|
||||
|
||||
模块结构和约束见 [01-project-structure.md](./01-project-structure.md),这里只讲使用规则。
|
||||
|
||||
```kotlin
|
||||
// domains/identity-store-contract/src/main/kotlin/.../identitystore/contract/StoreQueryService.kt
|
||||
interface StoreQueryService {
|
||||
fun listStoresByUserId(userId: Long): List<StoreInfo>
|
||||
fun findStore(storeId: Long): StoreInfo?
|
||||
}
|
||||
|
||||
data class StoreInfo(
|
||||
val storeId: Long,
|
||||
val name: String,
|
||||
val code: String,
|
||||
)
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// domains/identity-store/src/main/kotlin/.../identitystore/application/StoreQueryServiceImpl.kt
|
||||
@Service
|
||||
class StoreQueryServiceImpl(private val storeRepository: StoreRepository) : StoreQueryService {
|
||||
override fun listStoresByUserId(userId: Long): List<StoreInfo> =
|
||||
storeRepository.findStoresByUserId(userId).map { StoreInfo(it.id, it.name, it.code) }
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// domains/workbench/src/main/kotlin/.../workbench/application/WorkbenchAppService.kt
|
||||
@Service
|
||||
class WorkbenchAppService(
|
||||
private val storeQueryService: StoreQueryService, // 注入的是契约里的接口,不是 identity-store 的实现类
|
||||
) { ... }
|
||||
```
|
||||
|
||||
### 契约设计的四条规则
|
||||
|
||||
1. **契约模型是独立的数据结构,不是 Entity 的别名**。`StoreInfo` 只含调用方真正需要的字段。让它跟着 `StoreEntity` 一起长,等于把内部表结构变成了对外承诺,改一个列名就要动三个 domain。
|
||||
2. **契约只承诺调用方需要的最小能力**。不要一上来就写 `findAll()`、`update()`——契约里出现的每个方法都是未来的约束。
|
||||
3. **契约变更要向后兼容**。加方法、加可空字段没问题;删方法、改语义要先确认所有调用方,跟对客户端的 API 一个待遇。
|
||||
4. **契约模块不含实现、不依赖 Spring Web/JPA**(ArchUnit 会检查,见 [10-testing.md](./10-testing.md))。
|
||||
|
||||
### 什么时候不该用契约,而应该重新划边界
|
||||
|
||||
如果 `workbench` 要用 `identity-store` 的契约方法超过五六个,甚至开始要求对方加"给我拼好这个结构"的定制方法,那说明**边界划错了**——这块逻辑本来就该在一边,或者本来就该单独成域。契约模块变厚是个明确的设计告警,不要靠往里加方法来消化它。
|
||||
|
||||
## 二、跨域写:领域事件
|
||||
|
||||
### 为什么写操作不走契约接口
|
||||
|
||||
`workbench` 直接调 `identityStore.doSomething()` 意味着:workbench 要知道 identity-store 内部该做什么,identity-store 的事务边界被外部方法调用拉长,而且以后每多一个关心"切店"的域,就要在切店逻辑里多加一行调用——切店代码变成一个不断膨胀的通知中心。
|
||||
|
||||
事件反转了这个依赖方向:**发布方不知道谁在听**。切店只管发一个"门店切换了"的事实,谁关心谁自己订阅。
|
||||
|
||||
### 事件定义放在契约模块
|
||||
|
||||
```kotlin
|
||||
// domains/identity-store-contract/src/main/kotlin/.../identitystore/contract/StoreSwitchedEvent.kt
|
||||
data class StoreSwitchedEvent(
|
||||
val userId: Long,
|
||||
val fromStoreId: Long?,
|
||||
val toStoreId: Long,
|
||||
val occurredAt: Instant,
|
||||
)
|
||||
```
|
||||
|
||||
事件类型必须放在契约模块,否则订阅方要 import 发布方的内部类型,边界又破了。
|
||||
|
||||
### 发布:在事务内发布
|
||||
|
||||
```kotlin
|
||||
// domains/identity-store/src/main/kotlin/.../identitystore/application/StoreSwitchAppService.kt
|
||||
@Service
|
||||
class StoreSwitchAppService(
|
||||
private val events: ApplicationEventPublisher,
|
||||
private val clock: Clock,
|
||||
) {
|
||||
@Transactional
|
||||
fun switchStore(userId: Long, targetStoreId: Long): StoreContext {
|
||||
val store = findAccessibleStore(userId, targetStoreId)
|
||||
?: throw BusinessException(ErrorCode.STORE_NOT_ACCESSIBLE, "无权访问该门店", HttpStatus.FORBIDDEN)
|
||||
// ... 更新当前门店、重新签发 access token(见 04-security-auth.md)
|
||||
|
||||
events.publishEvent(StoreSwitchedEvent(userId, currentStoreId, targetStoreId, clock.instant()))
|
||||
return context
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 订阅:`@TransactionalEventListener`
|
||||
|
||||
```kotlin
|
||||
// domains/webview-ticket/src/main/kotlin/.../webviewticket/application/StoreSwitchedListener.kt
|
||||
@Component
|
||||
class StoreSwitchedListener(private val ticketRepository: WebviewTicketRepository) {
|
||||
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
fun onStoreSwitched(event: StoreSwitchedEvent) {
|
||||
runCatching { ticketRepository.revokeActiveTickets(event.userId) }
|
||||
.onFailure { log.error("切店后作废 webview 票据失败 userId={}", event.userId, it) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这段代码里每个注解和写法都在解决一个具体问题:
|
||||
|
||||
| 写法 | 解决什么 |
|
||||
| --- | --- |
|
||||
| `@TransactionalEventListener(AFTER_COMMIT)` | 普通 `@EventListener` 是**同步、在同一事务内**执行的。用它意味着"切店事务回滚了,但票据已经作废"这种不一致;`AFTER_COMMIT` 保证只在主事务真正提交后才触发 |
|
||||
| `@Transactional(REQUIRES_NEW)` | `AFTER_COMMIT` 阶段原事务已经提交,此时**没有活跃事务**。不开新事务的话,监听器里的写操作要么报错要么自动提交,行为不可控 |
|
||||
| `runCatching` + 记日志 | 监听器抛异常**不会**回滚主事务(主事务已提交),只会让这次副作用静默丢失。必须显式捕获并留下可排查的日志 |
|
||||
|
||||
### 同步还是异步
|
||||
|
||||
**默认同步**(`AFTER_COMMIT` 但仍在同一线程)。理由:同步下 traceId、`@RequestScope` 的门店上下文都还在,出问题能直接顺着日志查下去;异步则要额外处理上下文传播,而目前这些副作用(作废票据、清缓存)都很轻,没必要付这个复杂度。
|
||||
|
||||
只有当某个监听器确实耗时(比如要调外部系统)时才加 `@Async`,并且必须:指定专用线程池(不要用默认的 `SimpleAsyncTaskExecutor`,它每次新建线程且无上界)、加上下文传播的 `TaskDecorator`(见下文第三节)、想清楚失败后怎么办。
|
||||
|
||||
### 事件的可靠性边界(必须如实认识)
|
||||
|
||||
`ApplicationEvent` 是**进程内、内存中**的:应用在事件发出后、监听器执行前崩溃,这个事件就永久丢了,没有重试、没有补偿。
|
||||
|
||||
所以这套机制**只能用于"丢了不致命"的副作用**——作废一张票据(下次换票时本来也会重新校验)、清一个缓存、记一条审计日志。**不能用于**:扣款、发货、任何丢了会造成数据不一致且无法自愈的操作。
|
||||
|
||||
在当前这套系统里,跨域事件的用途就是切店后作废票据这一类,符合这个边界。
|
||||
|
||||
### 什么时候升级到消息中间件
|
||||
|
||||
出现下面任意一条,就该引入 RabbitMQ/Kafka + 事务性发件箱(transactional outbox),而不是继续给 `ApplicationEvent` 打补丁:
|
||||
|
||||
- 事件消费失败需要**自动重试**,或者需要死信队列;
|
||||
- 事件丢失会造成**业务上的资金/库存不一致**;
|
||||
- 消费方被拆成了**独立进程**(模块化单体拆微服务时的必然结果);
|
||||
- 需要一个事件被**多个消费组各自独立消费**,且各自有独立的消费进度。
|
||||
|
||||
事务性发件箱的做法(记在这里备查,现在不实施):在业务事务里往 `outbox` 表插一条记录(与业务写在同一个事务,天然原子),另有一个轮询任务把 outbox 里的记录投递到消息中间件并标记已发送。它解决的是"写库成功但发消息失败"这个用 `AFTER_COMMIT` 无论如何都消除不掉的窗口。
|
||||
|
||||
现在不做的理由很直接:引入中间件意味着多一套需要部署、监控、排障的基础设施,而当前唯一的跨域事件场景丢了也不致命。**等到有第一个"丢了会出事"的事件时再做**,那时候需求也更清楚。
|
||||
|
||||
## 三、聚合:`bff-orchestration` 与 `workbench` 的并行 fan-out
|
||||
|
||||
首页要同时拉采购、保修、门店信息等多个 tile(架构图 Flow 3),串行调用意味着总耗时是各下游耗时之和。同步栈下必须显式用线程池做并行。
|
||||
|
||||
### 专用线程池
|
||||
|
||||
```kotlin
|
||||
// domains/workbench/src/main/kotlin/.../workbench/infrastructure/config/WorkbenchExecutorConfig.kt
|
||||
// 模块内的 @Configuration 一律放 infrastructure/config/(见 01-project-structure.md 的脚手架),
|
||||
// 不要散在模块根包下——那样它既不属于任何一层,ArchUnit 的分层规则也管不到它。
|
||||
@Configuration
|
||||
class WorkbenchExecutorConfig {
|
||||
|
||||
@Bean("workbenchExecutor")
|
||||
fun workbenchExecutor(): ThreadPoolTaskExecutor = ThreadPoolTaskExecutor().apply {
|
||||
corePoolSize = 8
|
||||
maxPoolSize = 16
|
||||
queueCapacity = 32 // 有界!无界队列会让 maxPoolSize 永远不生效
|
||||
setThreadNamePrefix("workbench-")
|
||||
setRejectedExecutionHandler(ThreadPoolExecutor.CallerRunsPolicy())
|
||||
// 关键:把 MDC(traceId)和 RequestContext(门店上下文)带到子线程
|
||||
setTaskDecorator(ContextPropagatingTaskDecorator())
|
||||
setWaitForTasksToCompleteOnShutdown(true)
|
||||
setAwaitTerminationSeconds(20) // 配合 09 的优雅停机
|
||||
initialize()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
四个必须这么写的点:
|
||||
|
||||
1. **必须是专用池,不能用公共 `@Async` 默认池**。聚合任务和别的异步任务共用一个池,一个下游变慢就会把池占满,波及所有异步任务——这正是舱壁模式要防的事(见 [05-integration-layer.md](./05-integration-layer.md))。
|
||||
2. **队列必须有界**。`ThreadPoolTaskExecutor` 的 `queueCapacity` 默认是 `Integer.MAX_VALUE`,即无界——后果是任务全部堆进队列,线程数永远不会从 core 涨到 max,然后在某次流量高峰把堆内存吃光。
|
||||
3. **`CallerRunsPolicy`**:池满时任务退回调用线程(Tomcat 线程)自己执行。这是一种天然的背压——聚合变慢了,但不会丢请求、不会抛 `RejectedExecutionException`。
|
||||
4. **`ContextPropagatingTaskDecorator`**:Micrometer 提供的上下文传播装饰器,把 MDC 里的 traceId、`@RequestScope` 的门店上下文搬到子线程。**没有它,子线程里的日志全部断链,且 `StoreContextHolder` 直接取不到值——这是同步栈下并行聚合最典型的翻车方式**([08-observability.md](./08-observability.md) 附录也点了这一处)。
|
||||
|
||||
### 整体超时预算
|
||||
|
||||
```kotlin
|
||||
// domains/workbench/src/main/kotlin/.../workbench/application/WorkbenchAppService.kt
|
||||
@Service
|
||||
class WorkbenchAppService(
|
||||
@Qualifier("workbenchExecutor") private val executor: Executor,
|
||||
private val f6ApiClient: F6ApiClient,
|
||||
private val o2oClient: O2OClient,
|
||||
private val storeQueryService: StoreQueryService,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
companion object {
|
||||
private val TOTAL_BUDGET = Duration.ofSeconds(3) // 整个首页接口的总预算
|
||||
}
|
||||
|
||||
fun loadHomepage(userId: Long, storeId: Long): HomepageResponse {
|
||||
val deadline = System.nanoTime() + TOTAL_BUDGET.toNanos()
|
||||
|
||||
val procurement = supply("procurement") { f6ApiClient.fetchProcurementList(storeId) }
|
||||
val warranty = supply("warranty") { o2oClient.fetchWarrantySummary(storeId) }
|
||||
val store = supply("store") { storeQueryService.findStore(storeId) }
|
||||
|
||||
return HomepageResponse(
|
||||
procurement = await(procurement, deadline, "procurement"),
|
||||
warranty = await(warranty, deadline, "warranty"),
|
||||
store = await(store, deadline, "store"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun <T> supply(tile: String, block: () -> T): CompletableFuture<Tile<T>> =
|
||||
CompletableFuture.supplyAsync({
|
||||
runCatching(block)
|
||||
.map { Tile.ok(it) }
|
||||
.getOrElse { log.warn("tile={} 加载失败,降级", tile, it); Tile.degraded() }
|
||||
}, executor)
|
||||
|
||||
private fun <T> await(future: CompletableFuture<Tile<T>>, deadlineNanos: Long, tile: String): Tile<T> {
|
||||
val remaining = deadlineNanos - System.nanoTime()
|
||||
if (remaining <= 0) return Tile.degraded()
|
||||
return runCatching { future.get(remaining, TimeUnit.NANOSECONDS) }
|
||||
.getOrElse { log.warn("tile={} 超出总预算,降级", tile); Tile.degraded() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**总预算不等于各下游超时之和**。三个下游各配 2 秒超时,串行最坏 6 秒、并行最坏 2 秒——但这算的是单次调用,叠上 `@Retry`(3 次)之后单个 tile 最坏可能到 6 秒。所以必须有一个独立于下游配置的**接口级总预算**(这里 3 秒),到点就把还没回来的 tile 全部降级返回。没有这道闸,首页接口的最坏耗时是由下游配置的乘积决定的,不可控。
|
||||
|
||||
### 局部降级的响应约定
|
||||
|
||||
降级必须**对客户端可见**,不能悄悄返回空数据——客户端要能区分"这块真的没数据"和"这块没拉到",才能决定是显示空态还是显示"加载失败,点击重试"。
|
||||
|
||||
```kotlin
|
||||
data class Tile<T>(
|
||||
val data: T?,
|
||||
val status: TileStatus, // OK / DEGRADED
|
||||
) {
|
||||
companion object {
|
||||
fun <T> ok(data: T) = Tile(data, TileStatus.OK)
|
||||
fun <T> degraded() = Tile<T>(null, TileStatus.DEGRADED)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对应的响应体:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"procurement": { "status": "OK", "data": { "pendingCount": 12 } },
|
||||
"warranty": { "status": "DEGRADED", "data": null },
|
||||
"store": { "status": "OK", "data": { "storeId": 1, "name": "示例门店" } }
|
||||
},
|
||||
"traceId": "..."
|
||||
}
|
||||
```
|
||||
|
||||
规则:**只要主数据(门店上下文)拿到了,首页接口就返回 `code: 0`**,个别 tile 降级不会让整个接口失败。这正是架构图 Flow 3 里"局部降级"的含义——一个外部系统抖动不应该让用户连首页都打不开。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 跨 domain 读走 `-contract` 接口,跨 domain 写/状态联动走领域事件;两者都不允许直接 import 对方的内部类型。
|
||||
- 事件类型定义在契约模块;监听器一律 `@TransactionalEventListener(AFTER_COMMIT)` + `@Transactional(REQUIRES_NEW)` + 自己兜住异常。
|
||||
- `ApplicationEvent` 只用于"丢了不致命"的副作用;出现需要重试/不能丢的场景,升级到消息中间件 + 事务性发件箱,不要给现有机制打补丁。
|
||||
- 并行聚合必须用专用**有界**线程池 + `ContextPropagatingTaskDecorator` + `CallerRunsPolicy`。
|
||||
- 聚合接口必须有独立于下游配置的**总超时预算**,超时的 tile 降级返回而不是整体失败。
|
||||
- 降级状态必须在响应里显式表达(`status: DEGRADED`),不能用空数据冒充。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 各 tile 的具体超时预算分配(需要先有真实的下游耗时数据)。
|
||||
- 首页聚合结果的本地缓存策略——见 [12-concurrency-and-scheduling.md](./12-concurrency-and-scheduling.md)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Spring: Application Events](https://docs.spring.io/spring-framework/reference/core/beans/context-introduction.html#context-functionality-events)
|
||||
- [Spring: `@TransactionalEventListener`](https://docs.spring.io/spring-framework/reference/data-access/transaction/event.html)
|
||||
- [Micrometer: Context Propagation](https://docs.micrometer.io/context-propagation/reference/)
|
||||
- [microservices.io: Transactional Outbox](https://microservices.io/patterns/data/transactional-outbox.html)
|
||||
- [Simon Brown: Modular Monoliths](https://www.youtube.com/watch?v=5OjqD-ow8GE)
|
||||
@@ -0,0 +1,306 @@
|
||||
# 12. 并发、事务与定时任务
|
||||
|
||||
## 决策
|
||||
|
||||
事务边界统一放在 application 层;写接口用幂等键防重复提交;并发冲突用 `@Version` 乐观锁 + 有限重试;定时任务在多副本下用 ShedLock 保证只跑一次;缓存暂不引入 Redis,只用 Caffeine 本地缓存,且严格限定在"能容忍副本间不一致"的数据上。
|
||||
|
||||
## 一、事务边界
|
||||
|
||||
### 规则:事务开在 application 层的用例方法上
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class StoreSwitchAppService(...) {
|
||||
|
||||
@Transactional // ← 事务边界在这里
|
||||
fun switchStore(userId: Long, targetStoreId: Long): StoreContext { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- **不在 Controller 上开事务**:Controller 属于 api 层,开事务等于把 HTTP 序列化过程也圈进事务里,事务被无谓拉长。
|
||||
- **不在 Repository 方法上开事务**:一个用例往往包含多次写,各自开事务就没有原子性可言了。
|
||||
- **只读查询加 `@Transactional(readOnly = true)`**:Hibernate 会跳过脏检查(dirty checking),减少一次全量快照比对;对只读为主的查询接口是免费的性能收益。
|
||||
|
||||
### 事务里绝对不能做的三件事
|
||||
|
||||
1. **调外部 HTTP 接口**。F6 慢一点,数据库连接和行锁就被一起占着不放,几个请求就能把连接池打满(见 [03-persistence.md](./03-persistence.md) 的池子容量算法)。外部调用要么放在事务开始前,要么放在提交之后(`AFTER_COMMIT` 事件,见 [11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md))。
|
||||
2. **`Thread.sleep` / 等待用户输入 / 等锁**。同上。
|
||||
3. **catch 掉异常却继续用同一个事务**。Spring 默认在 `RuntimeException` 时把事务标记为 rollback-only,这之后再做任何写操作,最终提交时都会抛 `UnexpectedRollbackException`——而且报错点离真正的错误现场很远,非常难查。
|
||||
|
||||
### 自调用失效:Spring 事务最经典的坑
|
||||
|
||||
```kotlin
|
||||
@Service
|
||||
class OrderAppService {
|
||||
fun createBatch(items: List<Item>) {
|
||||
items.forEach { create(it) } // ← 这里的 @Transactional 完全不生效
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun create(item: Item) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
`@Transactional` 靠 AOP 代理实现,**同一个类内部的方法调用不经过代理**,注解形同虚设。这一条对 `@Async`、`@Cacheable`、Resilience4j 的注解全部适用。解法是把被调方法挪到另一个 bean 里,让调用真的穿过代理。
|
||||
|
||||
### 传播行为:只用这两个
|
||||
|
||||
| 传播行为 | 什么时候用 |
|
||||
| --- | --- |
|
||||
| `REQUIRED`(默认) | 绝大多数情况。有事务就加入,没有就新建 |
|
||||
| `REQUIRES_NEW` | 必须独立提交/回滚的场景:`AFTER_COMMIT` 事件监听器、审计记录、失败次数累加(登录失败计数必须在认证失败回滚后仍然保留) |
|
||||
|
||||
其余传播行为(`NESTED`、`SUPPORTS`、`MANDATORY`…)在这套系统里没有需要它们的场景,用了只会增加理解成本。真遇到时先怀疑是不是边界划错了。
|
||||
|
||||
### 隔离级别
|
||||
|
||||
统一 `READ COMMITTED`(在 [03-persistence.md](./03-persistence.md) 里通过 `transaction-isolation` 全局配置,覆盖 MySQL 默认的 `REPEATABLE READ`),不在方法上单独指定。需要更强一致性的地方用显式锁(`SELECT ... FOR UPDATE`,JPA 的 `@Lock(PESSIMISTIC_WRITE)`)或乐观锁,而不是靠调隔离级别——后者影响面是整个连接,副作用难以预料。
|
||||
|
||||
## 二、幂等
|
||||
|
||||
### 哪些接口需要
|
||||
|
||||
客户端在弱网下会重试(见客户端 `05-networking.md`),用户也会连点两次。**所有会产生副作用且重复执行会出问题的写接口**都需要幂等保护:换票、切店、任何创建类操作。
|
||||
|
||||
天然幂等的不需要额外处理:`GET`、把状态设为某个确定值的更新(`status = INACTIVE`)、按主键的删除。
|
||||
|
||||
### 方案:客户端生成幂等键
|
||||
|
||||
```
|
||||
POST /api/v1/webview/tickets
|
||||
Idempotency-Key: 7f3a9c1e-... # 客户端生成的 UUID,重试时复用同一个值
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// platform/platform-web/src/main/kotlin/.../platform/web/idempotency/IdempotencyGuard.kt
|
||||
// 放 platform 而不是某个 domain:换票、切店、创建类操作分散在多个 domain,
|
||||
// 而 domain 之间不能互相依赖——公共能力只能住在 platform-*(见 01-project-structure.md)。
|
||||
@Service
|
||||
class IdempotencyGuard(private val recordRepository: IdempotencyRecordRepository) {
|
||||
|
||||
/**
|
||||
* 同一个 key 在有效期内只会真正执行一次;重复请求直接返回首次的结果。
|
||||
*/
|
||||
@Transactional
|
||||
fun <T> execute(key: String, userId: Long, block: () -> T): T {
|
||||
val existing = recordRepository.findByKeyAndUserId(key, userId)
|
||||
if (existing != null) return deserialize(existing.response)
|
||||
|
||||
val result = block()
|
||||
// 唯一索引兜底:两个并发请求同时走到这里,第二个会因为 uk_idem_key_user 冲突而失败
|
||||
recordRepository.save(IdempotencyRecordEntity(key, userId, serialize(result)))
|
||||
return result
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 建在 platform 共用的库里(和下面的 shedlock 表同库),不属于任何 domain
|
||||
create table idempotency_record (
|
||||
id bigint not null auto_increment,
|
||||
idem_key varchar(64) not null,
|
||||
user_id bigint not null,
|
||||
response text not null,
|
||||
created_at datetime(6) not null,
|
||||
primary key (id),
|
||||
unique key uk_idem_key_user (idem_key, user_id)
|
||||
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
|
||||
```
|
||||
|
||||
三个要点:
|
||||
|
||||
- **唯一索引是真正的保证,代码里的"先查再写"不是**。两个并发请求会同时查到"没有记录"然后同时执行——只有数据库的唯一约束能挡住。捕获 `DataIntegrityViolationException` 后重新读一次已有结果即可。
|
||||
- **幂等键要带 `userId`**:只用 key 做唯一索引意味着不同用户的 key 会互相冲撞,而 key 是客户端生成的,我们不控制它的全局唯一性。
|
||||
- **记录要定期清理**(见下面定时任务一节),保留期取"客户端最长可能重试的窗口",比如 24 小时,不需要永久保存。
|
||||
|
||||
## 三、并发冲突:乐观锁
|
||||
|
||||
### 用 `@Version`,不用悲观锁
|
||||
|
||||
```kotlin
|
||||
// domains/identity-store/src/main/kotlin/.../identitystore/infrastructure/persistence/StoreEntity.kt
|
||||
@Entity
|
||||
class StoreEntity : VersionedEntity() { // VersionedEntity 带 @Version,见 03-persistence.md
|
||||
var name: String = ""
|
||||
}
|
||||
```
|
||||
|
||||
更新时如果 version 已经被别人改过,Hibernate 抛 `ObjectOptimisticLockingFailureException`,[06-api-design.md](./06-api-design.md) 的 `GlobalExceptionHandler` 会把它转成 `10409 CONFLICT`。
|
||||
|
||||
选乐观锁而不是 `SELECT ... FOR UPDATE`:这套系统是典型的低冲突场景(同一门店的同一条记录被两个人同时改的概率很低),悲观锁的代价是每次读都要持锁,把并发度砍掉换一个几乎用不上的保证。
|
||||
|
||||
### 什么时候自动重试,什么时候返回 409
|
||||
|
||||
| 场景 | 处理 |
|
||||
| --- | --- |
|
||||
| 用户提交的业务更新(改门店信息) | **返回 409**,让用户看到"数据已被他人修改,请刷新后重试"。自动重试会静默覆盖别人的修改 |
|
||||
| 内部的计数器/状态推进(登录失败次数、票据状态流转) | **自动重试**,用户不需要知道内部发生了冲突 |
|
||||
|
||||
```kotlin
|
||||
@Retryable(
|
||||
retryFor = [ObjectOptimisticLockingFailureException::class],
|
||||
maxAttempts = 3,
|
||||
backoff = Backoff(delay = 50, multiplier = 2.0, random = true), // 抖动,避免两方同步重试同步再撞
|
||||
)
|
||||
@Transactional
|
||||
fun incrementFailedAttempts(userId: Long) { ... }
|
||||
```
|
||||
|
||||
**重试必须在事务外层**——`@Retryable` 要包住 `@Transactional`,因为冲突发生时事务已经标记回滚,必须开一个全新的事务重新读、重新算、重新写。在事务内部重试是无效的(而且会撞上 rollback-only)。由于两个注解在同一个方法上时代理顺序容易搞错,稳妥做法是把重试和事务拆到两个 bean 上:外层 bean 负责 `@Retryable`,内层 bean 负责 `@Transactional`。
|
||||
|
||||
## 四、定时任务在多副本下的重复执行
|
||||
|
||||
### 问题
|
||||
|
||||
[04-security-auth.md](./04-security-auth.md) 里有一个清理过期 refresh token 的 `@Scheduled` 任务,加上上面幂等记录的清理任务。**`@Scheduled` 在每个 Pod 上都会独立执行**——2 个副本就是每次跑 2 遍。清理任务跑两遍问题不大(删除是幂等的),但只要出现一个"发通知""生成对账单"式的任务,重复执行就是事故。
|
||||
|
||||
这一条在原来的文档里完全没有提到,而 [09-build-deploy.md](./09-build-deploy.md) 明确配了 `replicas: 2`——也就是说按现有文档实施,上线当天就是重复执行状态。
|
||||
|
||||
### 方案:ShedLock
|
||||
|
||||
```groovy
|
||||
implementation 'net.javacrumbs.shedlock:shedlock-spring:6.9.2'
|
||||
implementation 'net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2'
|
||||
```
|
||||
|
||||
```sql
|
||||
-- 放在 platform 共用的库里,不属于任何 domain
|
||||
create table shedlock (
|
||||
name varchar(64) not null,
|
||||
lock_until datetime(6) not null,
|
||||
locked_at datetime(6) not null,
|
||||
locked_by varchar(255) not null,
|
||||
primary key (name)
|
||||
) engine = InnoDB default charset = utf8mb4 collate = utf8mb4_0900_ai_ci;
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// platform/platform-persistence/src/main/kotlin/.../platform/persistence/scheduling/SchedulingConfig.kt
|
||||
@Configuration
|
||||
@EnableScheduling
|
||||
@EnableSchedulerLock(defaultLockAtMostFor = "PT10M")
|
||||
class SchedulingConfig {
|
||||
@Bean
|
||||
fun lockProvider(dataSource: DataSource): LockProvider = JdbcTemplateLockProvider(
|
||||
JdbcTemplateLockProvider.Configuration.builder()
|
||||
.withJdbcTemplate(JdbcTemplate(dataSource))
|
||||
.usingDbTime() // 用数据库时间而不是各 Pod 的本地时间,避免时钟漂移导致锁失效
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
// domains/identity-store/src/main/kotlin/.../identitystore/application/RefreshTokenCleanupJob.kt
|
||||
// 定时任务放 application 层:它就是一个由时钟而不是 HTTP 请求触发的用例。
|
||||
@Component
|
||||
class RefreshTokenCleanupJob(private val repository: RefreshTokenRepository, private val clock: Clock) {
|
||||
|
||||
@Scheduled(cron = "0 17 3 * * *") // 每天凌晨 3:17,见下方"为什么不用整点"
|
||||
@SchedulerLock(name = "refreshTokenCleanup", lockAtLeastFor = "PT1M", lockAtMostFor = "PT10M")
|
||||
fun cleanup() {
|
||||
val deleted = repository.deleteExpiredBefore(clock.instant())
|
||||
LoggerFactory.getLogger(javaClass).info("清理过期 refresh token,删除 {} 条", deleted)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
两个参数的含义容易混:
|
||||
|
||||
- **`lockAtMostFor`**:锁的最长持有时间,防死锁。持锁的 Pod 被 kill 掉时,锁不会自动释放(数据库里的记录还在),到这个时间才失效。**必须显著大于任务的正常执行时间**,否则任务还没跑完锁就过期了,另一个 Pod 会同时开跑。
|
||||
- **`lockAtLeastFor`**:锁的最短持有时间。防的是"任务执行极快 + 各 Pod 时钟有偏差"导致同一个调度点被跑两次。
|
||||
|
||||
**`usingDbTime()` 不能省**:不加的话 ShedLock 用各个 Pod 的本地时间写锁,Pod 之间有几秒时钟漂移就可能同时抢到锁——那这套机制就白配了。
|
||||
|
||||
### 为什么 cron 不用整点
|
||||
|
||||
`0 0 3 * * *` 这种整点时间,全世界所有系统的定时任务都挤在同一秒——数据库、外部依赖、监控在那一刻集体尖峰。错开几分钟(`0 17 3 * * *`)没有任何业务代价,但能把这个尖峰摊平。
|
||||
|
||||
### 定时任务的其他约定
|
||||
|
||||
- **必须记日志**:开始、结束、处理条数。没有日志的定时任务出问题时你连"它有没有跑"都不知道。
|
||||
- **必须限制单次处理量**:`delete from ... where expires_at < ? limit 1000` 分批删,不要一条 SQL 删几百万行——那会长时间持有行锁并撑爆 binlog。
|
||||
- **必须自己兜住异常**:`@Scheduled` 方法抛异常只会被 Spring 记一条日志,任务本身不会重试,但下次调度照常。如果失败需要告警,自己 catch 后打 `ERROR`(见 [08-observability.md](./08-observability.md) 的告警规则)。
|
||||
- **任务执行情况应该有指标**:至少一个"上次成功执行时间",用于告警"某个任务已经 3 天没成功跑过了"——这类静默失败光看错误日志是发现不了的。
|
||||
|
||||
### 替代方案:K8s CronJob
|
||||
|
||||
对于"跑一次就结束、不需要常驻"的任务(比如大表数据归档),更合适的做法是 K8s `CronJob` 起一个独立 Pod:天然只跑一份,不需要 ShedLock,也不会和在线请求抢应用的线程和连接池。代价是要额外维护一套镜像入口和清单。
|
||||
|
||||
判断标准:**任务需要用到应用内的业务逻辑 → `@Scheduled` + ShedLock;任务本质是一段独立的数据操作 → CronJob**。
|
||||
|
||||
## 五、缓存:只用 Caffeine 本地缓存
|
||||
|
||||
### 为什么暂不引入 Redis
|
||||
|
||||
引入 Redis 意味着多一个需要部署、监控、备份、排障的有状态组件,以及一整套新的失败模式(连接抖动、大 key、缓存穿透/雪崩)。当前的数据量和并发量还远没有到需要它的程度,而 [04-security-auth.md](./04-security-auth.md) 的 refresh token 已经明确落库、[11-cross-domain-collaboration.md](./11-cross-domain-collaboration.md) 的事件也不需要外部存储——没有哪个场景是非它不可的。
|
||||
|
||||
### Caffeine 的适用边界(这是重点)
|
||||
|
||||
本地缓存的本质特征是:**每个 Pod 各缓存一份,副本之间必然不一致,且无法主动失效其他副本的缓存**。所以只能缓存满足这两个条件的数据:
|
||||
|
||||
1. 变更频率极低;
|
||||
2. **短时间内读到旧值不会造成业务错误**。
|
||||
|
||||
| 数据 | 能不能用本地缓存 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 菜单/权限元数据定义 | 可以 | 几乎不变,改了之后延迟几分钟生效可接受 |
|
||||
| 门店基础信息(名称、编码) | 可以 | 同上 |
|
||||
| 字典/枚举配置 | 可以 | 同上 |
|
||||
| **用户对门店的可访问权限** | **不可以** | 收回权限后必须立即生效,这是安全边界(见 [04-security-auth.md](./04-security-auth.md)) |
|
||||
| **WebView 票据状态** | **不可以** | 作废必须立即生效 |
|
||||
| **任何用户维度的业务数据** | **不可以** | 用户在 Pod A 改的数据,请求打到 Pod B 会读到旧值 |
|
||||
|
||||
```kotlin
|
||||
// platform/platform-persistence/src/main/kotlin/.../platform/persistence/cache/CacheConfig.kt
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
class CacheConfig {
|
||||
@Bean
|
||||
fun cacheManager(): CacheManager = CaffeineCacheManager().apply {
|
||||
setCaffeine(
|
||||
Caffeine.newBuilder()
|
||||
.maximumSize(1_000) // 必须设上限,否则就是内存泄漏
|
||||
.expireAfterWrite(Duration.ofMinutes(10)), // 必须设过期,这是副本间最终一致的唯一保证
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// domains/identity-store/.../identitystore/application/StoreQueryServiceImpl.kt
|
||||
// 缓存加在 application 层的查询方法上,返回的是契约模型而不是 Entity——
|
||||
// 缓存里躺着一个游离态的 JPA Entity 是另一类难查的问题(见 02-layering.md)。
|
||||
@Cacheable(cacheNames = ["storeBasicInfo"], key = "#storeId")
|
||||
fun findStoreBasicInfo(storeId: Long): StoreInfo? { ... }
|
||||
```
|
||||
|
||||
`maximumSize` 和 `expireAfterWrite` 都是**强制**的,不是可选优化:没有 `maximumSize` 的缓存就是一个慢速内存泄漏;没有 `expireAfterWrite` 的本地缓存永远不会和其他副本收敛。
|
||||
|
||||
**用 `@Cacheable` 时同样注意自调用失效**——它和 `@Transactional` 一样走代理,同类内部调用不生效。
|
||||
|
||||
### 什么时候升级到 Redis
|
||||
|
||||
- 需要**跨副本立即失效**某个缓存;
|
||||
- 需要跨副本共享状态(分布式限流计数、在线用户数);
|
||||
- 缓存数据量大到单 Pod 内存放不下。
|
||||
|
||||
到那时 Redis 的取舍是清楚的,现在提前引入只是提前承担成本。
|
||||
|
||||
## 关键规则
|
||||
|
||||
- 事务边界在 application 层的用例方法上;事务内不做 HTTP 调用、不 sleep。
|
||||
- 注意自调用失效:`@Transactional` / `@Async` / `@Cacheable` / Resilience4j 注解在同类内部调用时全部不生效。
|
||||
- 有副作用的写接口用 `Idempotency-Key` + 唯一索引做幂等;唯一索引才是保证,"先查再写"不是。
|
||||
- 并发冲突用 `@Version` 乐观锁:用户提交的更新返回 `10409`,内部状态推进自动重试(重试包在事务外层)。
|
||||
- 所有 `@Scheduled` 任务必须加 `@SchedulerLock`,`LockProvider` 必须 `usingDbTime()`;cron 时间避开整点。
|
||||
- 只用 Caffeine 本地缓存,且只缓存"读到旧值不会出错"的数据;`maximumSize` 和 `expireAfterWrite` 强制配置。
|
||||
|
||||
## 待补充
|
||||
|
||||
- 幂等记录的具体保留期(需要和客户端确认最长重试窗口)。
|
||||
- 是否需要接口级限流(当前只有对下游的舱壁,没有对上游的限流)。
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Spring: Transaction Management](https://docs.spring.io/spring-framework/reference/data-access/transaction.html)
|
||||
- [Spring Retry](https://github.com/spring-projects/spring-retry)
|
||||
- [ShedLock](https://github.com/lukas-krecan/ShedLock)
|
||||
- [Caffeine](https://github.com/ben-manes/caffeine/wiki)
|
||||
- [Stripe: Designing robust and predictable APIs with idempotency](https://stripe.com/blog/idempotency)
|
||||
Reference in New Issue
Block a user