Files
conti-backend/docs/01-project-structure.md
2026-08-17 15:31:27 +08:00

305 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 01. 工程结构(后端)
## 技术栈
Kotlin + Spring Boot + GradleGroovy 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 48.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](../../conti-docs/Architecture-Diagram/architecture-diagram-explanation.md) 里 App Backend 的职责边界拆成多个 Gradle 子模块,用编译期依赖规则强制边界,而不是先拆成多个独立部署的微服务。
选择模块化单体而不是一开始就上微服务,原因很直接:
- 现阶段团队规模和运维能力还撑不起"多个独立部署单元 + 服务发现 + 分布式事务/一致性"的复杂度。
- App Backend 内部这几个模块(Identity/BFF/Workbench/WebView Ticket/Integration)本来就是高内聚的一套业务,拆早了只是把进程内调用换成网络调用,徒增延迟和故障点,业务上并没有获得隔离收益。
- 但如果不做任何边界约束、堆成一个大包,后期想拆也拆不动。Gradle 多模块正好卡在中间:**部署简单(一个 jar),边界物理存在(模块间依赖编译期强制)**,未来如果某个模块(比如 `f6-adapter`)流量或迭代速度明显超过其他模块,再单独拆出来部署成本也低。
## 模块结构总览
```
conti-backend/
settings.gradle
build.gradle # 根工程:统一插件版本、公共依赖约束(Spring Boot / Spring Cloud BOM
bootstrap/ # 唯一可执行模块:装配所有模块,产出单一 jar/镜像
build.gradle # 依赖所有 platform-* / domains/* / integration/* + @SpringBootApplication 启动类
platform/
platform-web/ # 统一异常处理、ApiResult 包装、参数校验、GlobalExceptionHandler
platform-security/ # Spring Security + JWT 解析、门店/角色上下文注入
platform-persistence/ # JPA 基础设施:审计字段、BaseEntity、分页封装、Flyway 多实例配置
platform-observability/ # 日志格式、Trace 透传、Micrometer 配置
platform-integration/ # RestClient/Resilience4j 基础封装(超时/重试/熔断/舱壁通用能力)
domains/
identity-store/ # Identity & Store Center:登录、token、门店上下文、菜单权限
identity-store-contract/ # ↑ 对外契约:接口 + 传输模型 + 领域事件,其他 domain 只能依赖这个
bff-orchestration/ # BFF Orchestration:面向 APP 的统一接口聚合与协议标准化
workbench/ # Workbench Aggregation:首页聚合、局部降级
webview-ticket/ # WebView Ticket CenterF6 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/*``integration/*``platform-*`;其余模块都是普通 library 模块(没有主启动类),不能单独跑成一个服务。
- **`domains/x` 不可以依赖 `domains/y`。**
- **`domains/x` 可以依赖 `domains/y-contract`**(契约模块,见下一节)。
- **`domains/*` 都可以依赖 `integration/*``platform-*`。**
- `platform-*` 不含业务逻辑;`platform-*` 之间尽量不互相依赖(`platform-security` 依赖 `platform-web` 里的异常类型是可以接受的例外)。
这三条规则的价值在于**可以被 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.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'
group = 'com.continental.retailapp'
version = '0.1.0-SNAPSHOT'
// 用 toolchain 统一 Java 版本:Kotlin 插件会自动把 jvmTarget 对齐到同一个版本。
// 只写 sourceCompatibility 是不够的——Kotlin 的 jvmTarget 默认值和它无关,
// 两边不一致时 Gradle 会直接报 "Inconsistent JVM-target compatibility" 构建失败。
java {
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'
}
kotlin {
compilerOptions {
freeCompilerArgs.add('-Xjsr305=strict')
}
}
tasks.withType(Test).configureEach {
useJUnitPlatform()
}
}
```
### `settings.gradle` 示例
```groovy
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 'integration:f6-adapter'
include 'integration:mini-clients'
```
### `domains/workbench/build.gradle` 示例(体现依赖规则)
```groovy
// 注意:库模块不 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(':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
apply plugin: 'org.springframework.boot'
dependencies {
implementation project(':platform:platform-web')
implementation project(':platform:platform-security')
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(':integration:f6-adapter')
implementation project(':integration:mini-clients')
}
```
```kotlin
// bootstrap/src/main/kotlin/com/continental/retailapp/BootstrapApplication.kt
@SpringBootApplication(scanBasePackages = ["com.continental.retailapp"])
class BootstrapApplication
fun main(args: Array<String>) {
runApplication<BootstrapApplication>(*args)
}
```
## 新增一个 domain 模块的标准脚手架
```
domains/xxx/
build.gradle # 依赖需要的 platform-* / integration-* / 其他 domain 的 -contract
src/main/kotlin/com/continental/retailapp/xxx/ # xxx = 去掉连字符的模块名,见上方对应表
api/ # Controller、请求/响应 DTO
application/ # Service、定时任务;Entity → Response 的 mapper 也在这层
domain/ # 可选,见 02-layering.md
infrastructure/ # repository 实现、Entity、外部 client 实现、模块内的 @Configuration
src/main/resources/db/migration/xxx/
V1__init.sql
src/test/kotlin/...
```
新增模块后需要手动接线的地方只有三处:`settings.gradle``include``bootstrap/build.gradle` 里加依赖、[03-persistence.md](./03-persistence.md) 里给这个 domain 注册一个 Flyway 实例(因为每个 domain 有自己独立的 database 和迁移版本序列)。其余边界规则都由各模块自己的 `build.gradle` 保证。
## 附录:为什么用 Gradle 多模块,而不是单模块 + 包分层
给还没接触过这套多模块习惯的同学看的入门说明。
### 要解决的问题
如果整个后端只是一个 Gradle 模块、内部靠 `com.xxx.identity` / `com.xxx.workbench` 这样的包名分层——短期能跑,但 Java/Kotlin 的包(package**不是编译期边界**`workbench` 包里的类可以随手 `import com.xxx.identity.SomeInternalClass`,IDE 不会报错,只能靠人自觉或者 ArchUnit 这类静态检查工具事后检测。等项目大到几十人协作时,"事后检测"经常检测不过来,边界会慢慢被绕开。
**Gradle 多模块的本质**:把"包名上的软边界"换成"模块依赖上的硬边界"。`domains/workbench` 这个模块的 `build.gradle` 里没有声明对 `domains/identity-store` 的依赖,`workbench` 里的类就物理 import 不到 `identity-store` 内部的任何类型,哪怕两个模块在同一个 git 仓库、同一次构建、最终打进同一个 jar 里。
契约模块是这个思路的延伸:不是"要么全开放、要么全封闭",而是让被依赖方自己决定**对外承诺哪些东西**,其余一律看不见。这跟微服务里"只有 HTTP API 是公开契约、数据库表是私有实现"是同一个道理,只是这里的强制手段从网络协议换成了 Gradle 依赖图。
### 跟微服务的关系
Gradle 多模块和微服务解决的是同一类问题(业务边界隔离),但选择了不同的代价:
- 微服务:边界靠网络调用强制,代价是要处理服务发现、网络失败、分布式事务/最终一致性、独立的 CI/CD 和监控。
- 模块化单体:边界靠编译依赖强制,代价是无法针对单个模块独立扩缩容或独立发布,进程内故障会互相影响(一个模块 OOM 会拖垮整个进程)。
我们现在处的阶段(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/)