backend scaffold
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
dependencies {
|
||||
testImplementation 'com.tngtech.archunit:archunit-junit5:1.4.2'
|
||||
// 依赖所有被检查的模块,否则 ClassFileImporter 扫不到它们的字节码。
|
||||
// bootstrap 里全是 implementation 依赖,只会进 runtimeClasspath——扫描够用了。
|
||||
testImplementation project(':bootstrap')
|
||||
// 规则里要按类型引用 ApiResult / @RestController / @RequestMapping(比字符串匹配安全,
|
||||
// 类改名了编译期就报错),这几个得显式进 compileClasspath。
|
||||
testImplementation project(':platform:platform-web')
|
||||
}
|
||||
+166
@@ -0,0 +1,166 @@
|
||||
package com.continental.retailapp.architecture
|
||||
|
||||
import com.continental.retailapp.platform.web.ApiResult
|
||||
import com.tngtech.archunit.base.DescribedPredicate
|
||||
import com.tngtech.archunit.core.domain.JavaClass
|
||||
import com.tngtech.archunit.core.importer.ClassFileImporter
|
||||
import com.tngtech.archunit.core.importer.ImportOption
|
||||
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes
|
||||
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.methods
|
||||
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses
|
||||
import com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noFields
|
||||
import com.tngtech.archunit.library.Architectures.layeredArchitecture
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.springframework.beans.factory.annotation.Autowired
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
/**
|
||||
* 把 01-project-structure.md / 02-layering.md 里定的规则变成可执行测试。
|
||||
*
|
||||
* 这个模块依赖所有其他模块,是唯一能看到全代码库字节码的地方。
|
||||
*
|
||||
* 两个使用上的注意点:
|
||||
* 1. `ImportOption.DoNotIncludeTests()` 不能省——测试代码里 mock、构造 Entity、跨层引用都是正常的。
|
||||
* 2. 匹配不到任何类的规则默认会失败,还没建起来的包要配 `.allowEmptyShould(true)`,
|
||||
* 而不是把整条规则删掉。
|
||||
*/
|
||||
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 模块发布的接口/传输模型/事件")
|
||||
// bff-orchestration 目前还是空模块,规则对它空转
|
||||
.allowEmptyShould(true)
|
||||
.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..")
|
||||
// 对文档的有意偏离 #4:10-testing.md 原文是 api 层 mayNotBeAccessedByAnyLayer()。
|
||||
// 但 02-layering.md 和 06-api-design.md 都规定 Response DTO 定义在 api 层、
|
||||
// 而 Entity/投影 → Response 的转换(含 MapStruct mapper)必须发生在 application 层——
|
||||
// 那么 application 返回 api 层的 Response 类型就是文档自己示例里就有的依赖
|
||||
// (`StoreAppService.listAccessibleStores(): List<StoreResponse>`)。
|
||||
// 按原文写会把 02 自己的示例判红,所以放开 application → api 这一条。
|
||||
// 真正的红线是下面单独一条"api 不能碰 infrastructure",它保持严格。
|
||||
// 这和文档已经为 infrastructure 做过的同类调整是一个道理。
|
||||
.whereLayer("api").mayOnlyBeAccessedByLayers("application")
|
||||
.whereLayer("application").mayOnlyBeAccessedByLayers("api")
|
||||
.whereLayer("domain").mayOnlyBeAccessedByLayers("application", "infrastructure")
|
||||
// 注意这里是 application 而不是"谁都不能访问":跳过 domain 层的简单 CRUD 场景下,
|
||||
// application 直接依赖 infrastructure 里定义的 repository 接口是 02-layering.md 明确允许的。
|
||||
.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`() {
|
||||
noClasses()
|
||||
.that().resideInAPackage("..domain..")
|
||||
.should().dependOnClassesThat().resideInAnyPackage("org.springframework..", "jakarta.persistence..")
|
||||
.check(classes)
|
||||
}
|
||||
|
||||
// —— 规则组三:Entity 边界(02-layering.md / 06-api-design.md 里逐字相同的那句话)——
|
||||
// 「XxxEntity 不出现在 api 层的任何签名或 import 里,也不跨出所在模块的边界。」
|
||||
|
||||
@Test
|
||||
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 层不能触碰 Entity`() {
|
||||
noClasses()
|
||||
.that().resideInAPackage("..api..")
|
||||
.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)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.continental.retailapp.architecture
|
||||
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.streams.toList
|
||||
|
||||
/**
|
||||
* 03-persistence.md 承诺的那条扫描测试。
|
||||
*
|
||||
* MySQL 里 schema ≡ database,**跨 database join 只要账号有权限就是合法的**,
|
||||
* 编译器和 ArchUnit 都拦不住——它们看的是字节码,而库名是字符串。
|
||||
* 所以额外加一条源码扫描作为软约束的第二道防线。
|
||||
*/
|
||||
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")}" }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user