简要介绍
ArchUnit 是一个免费、简单和可扩展的库,可以使用任何普通的 Java 单元测试框架检查 Java 代码的架构和编码规则。
基本原理
ArchUnit 通过分析给定的 Java 字节码,将所有类导入到 Java 代码结构中,来检查包、类、层、切片上依赖关系,包括对循环依赖关系等问题的检查。
版本分支
ArchUnit 于2017年4月23日发布第一个版本,2022年10月3日发布了 1.0.0 版本,共32次Release。
ArchUnitNet 是一个 关于.NET/C# 的架构测试工具。
体系结构
ArchUnit 由 ArchUnit、 ArchUnit-junit4、ArchUnit-junit5-api、 ArchUnit-junit5-engine 和
ArchUnit-junit5-engine-api 等模块组成,还为最终用户提供了 archunit-example 模块。
ArchUnit 模块包含编写架构测试所需的核心基础结构,如ClassFileImporter、域对象和规则语法结构。ArchUnit 分为 Core、Lang 和 Library 三层,Core 层处理基本的基础结构,比如将字节码导入为Java对象; Lang 层提供以简洁的方式制定架构规则的语法; Library 层包含更为复杂的预定义规则,如多层分层架构。
ArchUnit-junit4 模块包含与 JUnit 4集成的基础结构,特别是用于缓存导入类的 ArchUnitRunner。
ArchUnit-junit5-* 模块包含与 JUnit 5集成的基础结构,并包含在测试运行之间缓存导入类的基础结构。ArchUnit-junit5-API 包含用户 API,用于编写支持 ArchUnit 的 JUnit 5的测试,ArchUnit-junit5-engine 包含运行这些测试的运行时引擎。
ArchUnit-junit5-engine-API 包含一些 API 代码,这些 API 代码用于那些想要对运行 ArchUnit JUnit 5测试进行更详细控制的工具,特别是一个 FieldSelector,它可以用来指示 ArchUnitTestEngine 运行一个特定的规则字段(比较 JUnit 4和5 Support)。
archunit-example 模块包含违反这些规则的示例体系结构规则和示例代码。在这里可以找到关于如何为项目设置规则的灵感,或者在 ArchUnit-最新发布版本的示例。
有一个maven插件arch-unit-maven-plugin,可以从 Maven 运行 ArchUnit 规则。
安装导入
要使用 ArchUnit,在类路径中包含相应的 JAR 文件就足够了。
# junit4 maven 依赖,for junit4
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit4</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>
# junit5 maven 依赖,for junit5
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.0.0</version>
<scope>test</scope>
</dependency>
快速体验
@RunWith(ArchUnitRunner.class) // Junit5不需要这行
@AnalyzeClasses(packages = "com.mycompany.myapp") // ① 导入要分析的类
public class MyArchitectureTest {
@ArchTest // ② 方式一:使用静态字段,对要分析的类的架构规则进行断言
public static final ArchRule myRule = classes()
.that().resideInAPackage("..service..")
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
@Test // ② 方式二:使用方法,并自行导入类,对要分析的类的架构规则进行断言
public void Services_should_only_be_accessed_by_Controllers(){
JavaClasses importedClasses = new ClassFileImporter()
.importPackages("com.mycompany.myapp");
ArchRule myRule = classes()
.that().resideInAPackage("..service..")
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
myRule.check(importedClasses);
}
}
详细功能
// 不允许任何 source 包中的类依赖于 foo 包中的类
noClasses().that().resideInAPackage("..source..")
.should().dependOnClassesThat().resideInAPackage("..foo..");
// foo 包中的类只能被 source.one 包和本包中的类依赖
classes().that().resideInAPackage("..foo..")
.should().onlyHaveDependentClassesThat()
.resideInAnyPackage("..source.one..", "..foo..")
// 名为 *Bar 的类只能被名为 Bar 的类依赖
classes().that().haveNameMatching(".*Bar")
.should().onlyHaveDependentClassesThat().haveSimpleName("Bar")
// Foo 开头的类只能放在 com.foo 包下
classes().that().haveSimpleNameStartingWith("Foo")
.should().resideInAPackage("com.foo")
// 实现 Connection 接口的类名称只能以 Connection 结尾
classes().that().implement(Connection.class)
.should().haveSimpleNameEndingWith("Connection")
// 用到 EntityManager 的类只能在 persistence 包下
classes().that().areAssignableTo(EntityManager.class)
.should().onlyHaveDependentClassesThat()
.resideInAnyPackage("..persistence..")
// 用到 EntityManager 的类需要依赖于 Transactional 注解
classes().that().areAssignableTo(EntityManager.class)
.should().onlyHaveDependentClassesThat()
.areAnnotatedWith(Transactional.class)
layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer() // controller层不能被其它层访问
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller") // service层只能被controller层访问
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service") // persistence层只能被service层访问
// com.myapp 的直属子包间不能存在循环依赖
slices().matching("com.myapp.(*)..").should().beFreeOfCycles()
深入了解
//使用预定义导入选项从classpath导入类
new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_JARS)
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importClasspath();
//从文件路径导入类
JavaClasses classes = new ClassFileImporter().importPath("/some/path/to/classes");
// 自定义导入选项,以忽略测试类
ImportOption ignoreTests = new ImportOption() {
@Override
public boolean includes(Location location){
return !location.contains("/test/"); // ignore any URI to sources that contains '/test/'
}
};
// 使用自定义规则从classpath导入类
JavaClasses classes = new ClassFileImporter()
.withImportOption(ignoreTests).importClasspath();
大多数对象类似于 Java 反射 API,包括继承关系。因此,一个 JavaClass 具有一些 JavaMember,JavaMember 可以是 JavaField、 JavaMethod、 JavaConstruction (或 JavaStaticInitializer)。
CodeUnit 虽然不存在于反射 API 中,但是为任何可以访问其他代码的东西引入一个概念是有意义的。它要么是一个方法,一个构造函数(包括类初始化器) ,要么是一个类的静态初始化器(例如静态块,静态字段赋值,等等)。
对另一个类的访问也是一个不在反射范畴的概念,ArchUnit在最细粒度上,只能从 CodeUnit 通过 JavaFieldAccess 、JavaMethodCall、JavaConstructorCall 来分别访问字段、方法或构造函数。
由于被访问的字段、方法、构造函数可能定义在超类中,所以引入了FieldAccessTarget、MethodCallTarget、ConstructorCallTarget等Target系列概念,用于解析到真正的目标类。
由于导入的类集并不总是包含所有的类,所以上图中resolves to可能解析到0个对象。
另外,上图中MethodCallTarget可以resolves to多个JavaMethod,其原因在于某个方法可能实现了多个接口,如下图所示。
Core API具备强大的功能,但是Lang API更为简洁。
// 本段代码为使用Core API断言规则
Set<JavaClass> services = new HashSet<>();
for (JavaClass clazz : classes) {
// choose those classes with FQN with infix '.service.'
if (clazz.getName().contains(".service.")) {
services.add(clazz);
}
}
for (JavaClass service : services) {
for (JavaAccess<?> access : service.getAccessesFromSelf()) {
String targetName = access.getTargetOwner().getName();
// fail if the target FQN has the infix ".controller."
if (targetName.contains(".controller.")) {
String message = String.format(
"Service %s accesses Controller %s in line %d",
service.getName(), targetName, access.getLineNumber());
Assert.fail(message);
}
}
}
// 如下代码片段为使用Lang API实现如上相同的规则断言
ArchRule rule = ArchRuleDefinition.noClasses()
.that().resideInAPackage("..service..")
.should().accessClassesThat().resideInAPackage("..controller..");
rule.check(importedClasses);
// 如下代码展示Lang API提供的 and、or 等组合功能
noClasses()
.that().resideInAPackage("..service..")
.or().resideInAPackage("..persistence..")
.should().accessClassesThat().resideInAPackage("..controller..")
.orShould().accessClassesThat().resideInAPackage("..ui..")
rule.check(importedClasses);
Lang 层除了为类提供API之外,还为其成员提供了正反两个系列的API,包括members()、noMembers()、fields()、noFields()、codeUnits()、noCodeUnits()、constructors()、noConstructors()等。
// 如下代码片段展示与成员方法有关的API
ArchRule rule = ArchRuleDefinition.methods()
.that().arePublic()
.and().areDeclaredInClassesThat().resideInAPackage("..controller..")
.should().beAnnotatedWith(Secured.class);
rule.check(importedClasses);
在ArchUnit,大多数规则都是如下架构。
classes that ${PREDICATE} should ${CONDITION}
如果预定义API不能满足要求,可以自定义规则。
// 定义一个 Predicate
DescribedPredicate<JavaClass> haveAFieldAnnotatedWithPayload =
new DescribedPredicate<JavaClass>("have a field annotated with @Payload"){
@Override
public boolean apply(JavaClass input){
boolean someFieldAnnotatedWithPayload = // iterate fields and check for @Payload
return someFieldAnnotatedWithPayload;
}
};
// 定义一个Condition
ArchCondition<JavaClass> onlyBeAccessedBySecuredMethods =
new ArchCondition<JavaClass>("only be accessed by @Secured methods") {
@Override
public void check(JavaClass item, ConditionEvents events){
for (JavaMethodCall call : item.getMethodCallsToSelf()) {
if (!call.getOrigin().isAnnotatedWith(Secured.class)) {
String message = String.format(
"Method %s is not @Secured", call.getOrigin().getFullName());
events.add(SimpleConditionEvent.violated(call, message));
}
}
}
};
// 对类集应用 Predicate 和 Condition
classes().that(haveAFieldAnnotatedWithPayload).should(onlyBeAccessedBySecuredMethods);
// 对于不常见的规则,最好按照如下方法记录其理由
classes().that(haveAFieldAnnotatedWithPayload)
.should(onlyBeAccessedBySecuredMethods)
.because("@Secured methods will be intercepted, checking for increased privileges " +
"and obfuscating sensitive auditing information");
// 如果规则复杂,且自动生成的规则文本太复杂,可以使用如下方式完全覆盖规则说明
classes().that(haveAFieldAnnotatedWithPayload)
.should(onlyBeAccessedBySecuredMethods)
.as("Payload may only be accessed in a secure way");
因遗留代码或其它无法满足规则的情况,可以将一个名为
archunit_ignore_patterns.txt 的文本文件放在classpath的根目录下,并在每一行使用一个可以匹配要忽略的冲突的正则表达式。
# 这里可以写上忽略的原因
.*some\.pkg\.LegacyService.*
ArchUnit 在 Library 层预定义了若干架构检查的 API。目前可以方便地检查分层架构和洋葱架构,将来可能会扩展到管道、过滤器,以及业务和技术关注点分离等。
// 架构检查的入口点
com.tngtech.archunit.library.Architectures
// 以下是对分层架构的检查示例
layeredArchitecture()
.consideringAllDependencies()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service")
// 以下为对洋葱架构(又称六边形架构、端口和适配器架构)的检查示例
onionArchitecture()
.domainModels("com.myapp.domain.model..")
.domainServices("com.myapp.domain.service..")
.applicationServices("com.myapp.application..")
.adapter("cli", "com.myapp.adapter.cli..")
.adapter("persistence", "com.myapp.adapter.persistence..")
.adapter("rest", "com.myapp.adapter.rest..");
// 切片检查的入口点
com.tngtech.archunit.library.dependencies.SlicesRuleDefinition
// 检查 myapp 包的下一级子包中的类不存在循环依赖
SlicesRuleDefinition.slices()
.matching("..myapp.(*)..")
.should().beFreeOfCycles()
// 检查 myapp 包的所有子包中的类不存在循环依赖
SlicesRuleDefinition.slices()
.matching("..myapp.(**)")
.should().notDependOnEachOther()
// 检查 myapp 包和 service 包之间的包中的类不存在相互依赖情况
SlicesRuleDefinition.slices()
.matching("..myapp.(**).service..")
.should().notDependOnEachOther()
如果以上切片不能满足要求,还可以使用SliceAssignment类来定制切片。
ArchUnit 通过 GeneralCodingRules 类提供了一组通用性较高的编码规则检查。
DependencyRules 类提供了一组检查类之间依赖关系的规则和条件。
ProxyRules 提供了关于使用代理对象的检查。
ArchUnit 在
com.tngtech.archunit.library.plantuml 包下提供了一个支持 PlantUML 的特性,用于直接从 PlantUML 派生出检查规则,对相应的类进行检查。
URL myDiagram = getClass().getResource("my-diagram.puml");
classes().should(
adhereToPlantUmlDiagram(myDiagram,
consideringAllDependencies())
);
支持的UML只能是组件图,用Java类的Package作为组件原型。ArchUnit对组件图还有一些特殊要求,同时提供一些检查的额外选项。
' 如果使用如下组件图进行检查,target中的类依赖source中的类将违反规则
@startuml
[某个源组件] <<..some.source..>>
[某个目标组件] <<..some.target..>> as target
[某个源组件] --> target
@enduml
当违规行为过多,无法立即修复时,需要建立一种迭代机制,防止代码基线进一步恶化。
ArchUnit 的 FreezingArchRule 类提供这方面的帮助,将现有违规行为记录到 ViationStore 中,然后后续检查只报告新增的违规行为,并忽略已知的违规。一旦违规得到修复,FreezingArchRule 将自动将其从已知冲突中去除,无需额外回归。代码行号的变化不会影响违规行为。
// 冻结某个规则
ArchRule rule = FreezingArchRule
.freeze(classes().should()./*complete ArchRule*/);
FreezingArchRule 默认使用一个简单的纯文本文件保存 ViationStore,以便利用 VCS 进行跟踪管理。该文件的路径包括 ViationStore 的创建和更新行为也是可配置的,方便用于CI环境。
# ViationStore 文件
freeze.store.default.path=/some/path/in/a/vcs/repo
# 是否允许创建 ViationStore,默认为 false
freeze.store.default.allowStoreCreatinotallow=true
# 是否允许更新 ViationStore,默认为 true
freeze.store.default.allowStoreUpdate=false
# 是否允许重新冻结所有违规行为,表示随时接受新的违规而只报告成功,默认为 false
freeze.refreeze=true
# 支持自定义冻结存储(继承com.tngtech.archunit.library.freeze.ViolationStore)
freeze.store=fully.qualified.name.of.MyCustomViolationStore
# 如下行用于为自定义存储类设置属性
freeze.store.propOne=valueOne
freeze.store.propTwo=valueTwo
# 支持自定义违规行匹配器
freeze.lineMatcher=fully.qualified.name.of.MyCustomLineMatcher
与代码质量度量(如圈复杂度或方法长度)类似,软件架构度量力求度量软件的结构和设计。
ArchUnit 可以用来计算一些众所周知的软件体系结构度量。
import com.tngtech.archunit.library.metrics.ArchitectureMetrics;
// ...
JavaClasses classes = // ...
Set<JavaPackage> packages = classes.getPackage("com.example").getSubpackages();
// These components can also be created in a package agnostic way, compare MetricsComponents.from(..)
MetricsComponents<JavaClass> components = MetricsComponents.fromPackages(packages);
// 计算 John Lakos 提出的依赖度量指标,指示系统组件间依赖程度
LakosMetrics metrics = ArchitectureMetrics.lakosMetrics(components);
// CCD 累积组件依赖,加总所有组件所有向外依赖数
System.out.println("CCD: " + metrics.getCumulativeComponentDependency());
// ACD 平均组件依赖,CCD除以组件数
System.out.println("ACD: " + metrics.getAverageComponentDependency());
// RACD 相对平均依赖,ACD除以组件数
System.out.println("RACD: " + metrics.getRelativeAverageComponentDependency());
// NCCD 系统的 CCD 除以具有相同数量成分的平衡二叉搜索树的 CCD
System.out.println("NCCD: " + metrics.getNormalizedCumulativeComponentDependency());
// 计算 Robert C. Martin 提出的度量指标,指示组件之间的耦合度
ComponentDependencyMetrics metrics = ArchitectureMetrics.componentDependencyMetrics(components);
//CE 传出耦合,对任何其它组件的依赖数
System.out.println("Ce: " + metrics.getEfferentCoupling("com.example.component"));
//CA 传入耦合,来自任何其它组件的依赖数
System.out.println("Ca: " + metrics.getAfferentCoupling("com.example.component"));
// I 不稳定性,Ce/(Ca + Ce)
System.out.println("I: " + metrics.getInstability("com.example.component"));
// A 抽象性,组件内抽象类的数量 / 组件中所有类的数量
// 在 ArchUnit 中,抽象值仅基于公共类,即从外部可见的类。
System.out.println("A: " + metrics.getAbstractness("com.example.component"));
// D 距离主序列, | A + I - 1 |, 即距离(A = 1,I = 0)和(A = 0,I = 1)之间的理想线的归一化距离
System.out.println("D: " + metrics.getNormalizedDistanceFromMainSequence("com.example.component"));
// 计算 Herbert Dowalil 提出的可见性指标,指示组件的信息隐藏能力
VisibilityMetrics metrics = ArchitectureMetrics.visibilityMetrics(components);
// RV 相对可见性,当前组件中可见元素数量 / 当前组件中所有元素数量
System.out.println("RV : " + metrics.getRelativeVisibility("com.example.component"));
// ARV 平均相对能见度,RV的均值
System.out.println("ARV: " + metrics.getAverageRelativeVisibility());
// GRV 全局相对可见性,所有组件中的可见元素数量 / 所有组件中素有元素数量
System.out.println("GRV: " + metrics.getGlobalRelativeVisibility());
// 以下为基本用法,项目太大时,会因为类的导入导致性能较差,也容易出错
@Test
public void rule1(){
JavaClasses importedClasses = new ClassFileImporter().importClasspath();
ArchRule rule = classes()...
rule.check(importedClasses);
}
// 以下为正常用法
// 缓存基于测试类,同一个测试类中声明的多个规则重用缓存
// 缓存基于导入位置,从相同URI导入时会发生重用,这种形式为软引用,内存不足时会被清除
@RunWith(ArchUnitRunner.class) // 此行JUnit5不需要
@AnalyzeClasses(packages = "com.myapp")
public class ArchitectureTest {
// 可将规则声明为静态字段
@ArchTest
public static final ArchRule rule1 = classes().should()...
@ArchTest
public static final ArchRule rule2 = classes().should()...
@ArchTest
public static void rule3(JavaClasses classes){
// 静态方法,会使用缓存
}
}
// 控制要导入的类
@AnalyzeClasses(packages = {"com.myapp.subone", "com.myapp.subtwo"})
// 也可以利用具有代表性的类,会导入该类所在包的所有类,这种方式便于重构
@AnalyzeClasses(packagesOf = {SubOneConfiguration.class, SubTwoConfiguration.class})
// 也可以通过实现 LocationProvider 来控制要导入哪些类
public class MyLocationProvider implements LocationProvider {
@Override
public Set<Location> get(Class<?> testClass) {
// Determine Locations (= URLs) to import
// Can also consider the actual test class, e.g. to read some custom annotation
}
}
@AnalyzeClasses(locations = MyLocationProvider.class)
// 可以利用导入选项控制要导入的类。导入选项类也支持自定义。
@AnalyzeClasses(importOptions = {DoNotIncludeTests.class,
DoNotIncludeJars.class})
// 缓存过多的类可能会产生GC延迟。
// 可以通过 CacheMode.PER_CLASS 只启用基于测试类的缓存,
// 而禁用基于位置的缓存
@AnalyzeClasses(packages = "com.myapp.special",
cacheMode = CacheMode.PER_CLASS)
可使用 @ArchIgnore 注解来忽略对规则的检查。
public class ArchitectureTest {
// 会运行
@ArchTest
public static final ArchRule rule1 = classes().should()...
// 不会运行
@ArchIgnore
@ArchTest
public static final ArchRule rule2 = classes().should()...
}
可使用如下方式对检查规则进行分组,提升规则的组织性,并允许在项目或模块间复用规则。
public class ServiceRules {
@ArchTest
public static final ArchRule ruleOne = ...
// 其他规则
}
public class PersistenceRules {
@ArchTest
public static final ArchRule ruleOne = ...
// 更多规则
}
@RunWith(ArchUnitRunner.class) // Junit5不需要此行
@AnalyzeClasses
public class ArchitectureTest {
@ArchTest
static final ArchTests serviceRules = ArchTests.in(ServiceRules.class);
@ArchTest
static final ArchTests persistenceRules = ArchTests.in(PersistenceRules.class);
}
ArchUnit 通过使用空格替换原始规则名称中的下划线,提供了在测试报告中生成更多可读名称的可能性。
// 如果方法或字段的名称为:
some_Field_or_Method_rule
// 则在测试报告中会被替换为:
some Field or Method rule
也可以将下面的属性移除或设置为false来关闭该特性。
junit.displayName.replaceUnderscoresBySpaces=true
高级配置
ArchUnit 会使用 Java 资源标准加载机制来加载classpath根下的 archunit.properties 配置文件。
可以通过将系统属性传递给执行ArchUnit的JVM进程来覆盖 archunit.properties 中的配置。
-Darchunit.propertyName=propertyValue
# 不要从类路径中解析缺失的类依赖,以提升分析性能(默认行为)
resolveMissingDependenciesFromClassPath=false
# 只从类路径解析 some.pkg.[one, two] 两个包的类,其它则使用存根
classResolver=com.tngtech.archunit.core.importer.resolvers.SelectedClassResolverFromClasspath
classResolver.args=some.pkg.one,some.pkg.two
# 从以下类继承一个自定义类解析器,如:some.pkg.MyCustomClassResolver
com.tngtech.archunit.core.importer.resolvers.ClassResolver
# 在 archunit.properties 中配置自定义类解析器
classResolver=some.pkg.MyCustomClassResolver
# 为自定义类解析器提供所需参数
classResolver.args=myArgOne,myArgTwo
// 以下是默认迭代次数,设置为 -1 表示直到解析出所有类型才停止。
// 设置为 0 将禁用自动解析,类信息可能会不完整或错误
// 较大或负值可能对性能产生巨大影响
#字段类型、方法参数类型等成员类型
import.dependencyResolutionProcess.maxIterationsForMemberTypes = 1
# 访问类型,如一个方法调用其它类中的方法
import.dependencyResolutionProcess.maxIterationsForAccessesToTypes = 1
# 超类,如被继承的类、被实现的接口
import.dependencyResolutionProcess.maxIterationsForSupertypes = -1
# 封闭类型,如嵌套类的外部类
import.dependencyResolutionProcess.maxIterationsForEnclosingTypes = -1
# 注解类型,包括注解的参数
import.dependencyResolutionProcess.maxIterationsForAnnotationTypes = -1
# 泛型签名类型,如包含参数化类型的类型
import.dependencyResolutionProcess.maxIterationsForGenericSignatureTypes = -1
# 通过以下方式激活类 MD5(默认为false,以提升性能) ,用于跟踪异常行为
enableMd5InClassSources=true
# 获取类 MD5 的方法如下
javaClass.getSource().get().getMd5sum()
// ArchUnit 默认禁止对一组空类进行检查
# 可以针对单个规则应用 allowEmptyShould(true) 来覆盖对空检查的默认行为
classes().that()...should()...allowEmptyShould(true)
# 允许全局空检查
archRule.failOnEmptyShould=false
# 限制循环依赖检查最大周期,默认为100
cycles.maxNumberToDetect=50
# 限制每个周期报告的依赖数量(无论如何总是检查所有依赖),只影响所需的堆大小,默认为20
cycles.maxNumberOfDependenciesPerEdge=5
# 首先通过继承下面的类来实现一个自定义错误消息的类,如:some.pkg.MyCustomFailureDisplayFormat
com.tngtech.archunit.lang.FailureDisplayFormat
# 然后在archunit.properties中作如下配置
failureDisplayFormat=some.pkg.MyCustomFailureDisplayFormat