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

18 KiB
Raw Blame History

01. 工程结构(后端)

技术栈

Kotlin + Spring Boot + GradleGroovy DSLbuild.gradle)。

版本基线

组件 版本 说明
Spring Boot 4.1.0 OSS 支持至 2027-07。3.x 全系列 OSS 支持已结束(3.5 于 2026-06-30 结束),新项目不应再从 3.x 起步
Spring Cloud 2025.1.2Oakwood Boot 4.1 需要 2025.1.2 及以上;提供 spring-cloud-kubernetes 5.0.2,见 07-config-governance.md
Kotlin 2.4.10
Java 21LTS,用 Gradle toolchain 锁定)

Spring Boot 4 连带升级了 Spring Framework 7 / Spring Security 7 / Jackson 3,第三方库必须选对应版本线,各篇文档里出现的坐标已按此对齐:

版本 注意点
Resilience4j 2.4.0 artifact 是 resilience4j-spring-boot4,不是 -spring-boot32.4.0 才加的 Boot 4 支持
springdoc-openapi 3.0.3 Boot 4 对应 springdoc 3.xBoot 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/@SpyBeanspringmockk 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

升级 Boot 版本时,这张表要整体复核一遍,不要只改 Boot 版本号。

决策

单一 Gradle 多模块工程,落地为模块化单体(Modular Monolith:只有一个可执行部署单元,内部按 architecture-diagram 里 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-adaptermini-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。同时它们也由 Gradle 物理强制:domains/workbenchbuild.gradle 里根本不会声明对 domains/identity-store 的依赖,编译时 import 不到。

模块目录名与包名的对应关系

目录名带连字符,包名不能带——这个映射必须写死,因为 10-testing.md 的 ArchUnit 规则是按包名匹配的,改一个就要改另一个:

模块目录 基础包名
domains/identity-storedomains/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-storeidentitystore),唯一的例外是 bff-orchestrationbffbfforchestration 读不出来)。新增模块时按这个规则取名,并同步更新 10-testing.md 里 ArchUnit 的 domains 列表——那个列表漏了谁,谁的边界就没人管。

integration/*platform/* 不按 api/application/domain/infrastructure 分四层(它们本来就不是业务域),内部按自己的职责组织包即可,见 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)混淆。

跨 domain 的写操作和状态联动优先走领域事件而不是直接调接口,规则见 11-cross-domain-collaboration.md

build.gradle 示例

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 示例

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 示例(体现依赖规则)

// 注意:库模块不 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 示例

dependencies {
    // 契约模块保持极简:不依赖 Spring Web / JPA / 其他 domain
    // 这样任何 domain 依赖它都不会顺带把实现细节拉进来。
}

bootstrap/build.gradle 示例

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')
}
// 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.gradleincludebootstrap/build.gradle 里加依赖、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 模块里了,不需要先花时间考古"到底谁在用我的什么"。

参考链接