最近在作一个新项目的时候引入了一个架构方面的需求,就是须要检查项目的编码规范、模块分类规范、类依赖规范等,恰好接触到,正好作个调研。html
不少时候,咱们会制定项目的规范,例如:java
service
层不能引用controller
层的类(这个例子有点极端)。controller
包下的Controller
类的类名称以"Controller"结尾,方法的入参类型命名以"Request"结尾,返回参数命名以"Response"结尾。common.constant
包下,以类名称Enum结尾。还有不少其余可能须要定制的规范,最终可能会输出一个文档。可是,谁能保证全部参数开发的人员都会按照文档的规范进行开发?为了保证规范的实行,Archunit
以单元测试的形式经过扫描类路径(甚至Jar)包下的全部类,经过单元测试的形式对各个规范进行代码编写,若是项目代码中有违背对应的单测规范,那么单元测试将会不经过,这样就能够从CI/CD层面完全把控项项目架构和编码规范。本文的编写日期是2019-02-16
,当时Archunit
的最新版本为0.9.3
,使用JDK 8
。git
Archunit是一个免费、简单、可扩展的类库,用于检查Java代码的体系结构。提供检查包和类的依赖关系、调用层次和切面的依赖关系、循环依赖检查等其余功能。它经过导入全部类的代码结构,基于Java
字节码分析实现这一点。Archunit的主要关注点是使用任何普通的Java单元测试框架自动测试代码体系结构和编码规则。github
通常来讲,目前经常使用的测试框架是Junit4
,须要引入Junit4
和Archunit
:正则表达式
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit</artifactId>
<version>0.9.3</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
复制代码
项目依赖slf4j
,所以最好在测试依赖中引入一个slf4j
的实现,例如logback
:编程
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.2.3</version>
<scope>test</scope>
</dependency>
复制代码
主要从下面的两个方面介绍一下的使用:架构
须要对代码或者依赖规则进行判断前提是要导入全部须要分析的类,类扫描导入依赖于ClassFileImporter
,底层依赖于ASM字节码框架针对类文件的字节码进行解析,性能会比基于反射的类扫描框架高不少。ClassFileImporter
的构造可选参数为ImportOption(s)
,扫描规则能够经过ImportOption
接口实现,默认提供可选的规则有:app
// 不包含测试类
ImportOption.Predefined.DONT_INCLUDE_TESTS
// 不包含Jar包里面的类
ImportOption.Predefined.DONT_INCLUDE_JARS
// 不包含Jar和Jrt包里面的类,JDK9的特性
ImportOption.Predefined.DONT_INCLUDE_ARCHIVES
复制代码
举个例子,咱们实现一个自定义的ImportOption
实现,用于指定须要排除扫描的包路径:框架
public class DontIncludePackagesImportOption implements ImportOption {
private final Set<Pattern> EXCLUDED_PATTERN;
public DontIncludePackagesImportOption(String... packages) {
EXCLUDED_PATTERN = new HashSet<>(8);
for (String eachPackage : packages) {
EXCLUDED_PATTERN.add(Pattern.compile(String.format(".*/%s/.*", eachPackage.replace("/", "."))));
}
}
@Override
public boolean includes(Location location) {
for (Pattern pattern : EXCLUDED_PATTERN) {
if (location.matches(pattern)) {
return false;
}
}
return true;
}
}
复制代码
ImportOption
接口只有一个方法:maven
boolean includes(Location location) 复制代码
其中,Location
包含了路径信息、是否Jar文件等判断属性的元数据,方便使用正则表达式或者直接的逻辑判断。
接着咱们能够经过上面实现的DontIncludePackagesImportOption
去构造ClassFileImporter
实例:
ImportOptions importOptions = new ImportOptions()
// 不扫描jar包
.with(ImportOption.Predefined.DONT_INCLUDE_JARS)
// 排除不扫描的包
.with(new DontIncludePackagesImportOption("com.sample..support"));
ClassFileImporter classFileImporter = new ClassFileImporter(importOptions);
复制代码
获得ClassFileImporter
实例后咱们能够经过对应的方法导入项目中的类:
// 指定类型导入单个类
public JavaClass importClass(Class<?> clazz) // 指定类型导入多个类 public JavaClasses importClasses(Class<?>... classes) public JavaClasses importClasses(Collection<Class<?>> classes) // 经过指定路径导入类 public JavaClasses importUrl(URL url) public JavaClasses importUrls(Collection<URL> urls) public JavaClasses importLocations(Collection<Location> locations) // 经过类路径导入类 public JavaClasses importClasspath() public JavaClasses importClasspath(ImportOptions options) // 经过文件路径导入类 public JavaClasses importPath(String path) public JavaClasses importPath(Path path) public JavaClasses importPaths(String... paths) public JavaClasses importPaths(Path... paths) public JavaClasses importPaths(Collection<Path> paths) // 经过Jar文件对象导入类 public JavaClasses importJar(JarFile jar) public JavaClasses importJars(JarFile... jarFiles) public JavaClasses importJars(Iterable<JarFile> jarFiles) // 经过包路径导入类 - 这个是比较经常使用的方法 public JavaClasses importPackages(Collection<String> packages) public JavaClasses importPackages(String... packages) public JavaClasses importPackagesOf(Class<?>... classes) public JavaClasses importPackagesOf(Collection<Class<?>> classes) 复制代码
导入类的方法提供了多维度的参数,用起来会十分便捷。例如想导入com.sample
包下面的全部类,只须要这样:
public class ClassFileImporterTest {
@Test
public void testImportBootstarpClass() throws Exception {
ImportOptions importOptions = new ImportOptions()
// 不扫描jar包
.with(ImportOption.Predefined.DONT_INCLUDE_JARS)
// 排除不扫描的包
.with(new DontIncludePackagesImportOption("com.sample..support"));
ClassFileImporter classFileImporter = new ClassFileImporter(importOptions);
long start = System.currentTimeMillis();
JavaClasses javaClasses = classFileImporter.importPackages("com.sample");
long end = System.currentTimeMillis();
System.out.println(String.format("Found %d classes,cost %d ms", javaClasses.size(), end - start));
}
}
复制代码
获得的JavaClasses
是JavaClass
的集合,能够简单类比为反射中Class
的集合,后面使用的代码规则和依赖规则判断都是强依赖于JavaClasses
或者JavaClass
。
类扫描和类导入完成以后,咱们须要定检查规则,而后应用于全部导入的类,这样子就能完成对全部的类进行规则的过滤 - 或者说把规则应用于全部类而且进行断言。
规则定义依赖于ArchRuleDefinition
类,建立出来的规则是ArchRule
实例,规则实例的建立过程通常使用ArchRuleDefinition
类的流式方法,这些流式方法定义上符合人类思考的思惟逻辑,上手比较简单,举个例子:
ArchRule archRule = ArchRuleDefinition.noClasses()
// 在service包下的全部类
.that().resideInAPackage("..service..")
// 不能调用controller包下的任意类
.should().accessClassesThat().resideInAPackage("..controller..")
// 断言描述 - 不知足规则的时候打印出来的缘由
.because("不能在service包中调用controller中的类");
// 对全部的JavaClasses进行判断
archRule.check(classes);
复制代码
上面展现了自定义新的ArchRule
的例子,中已经为咱们内置了一些经常使用的ArchRule
实现,它们位于GeneralCodingRules
中:
java.util.logging
包路径下的日志组件。更多内建的ArchRule
或者通用的内置规则使用,能够参考官方例子。
基本使用例子,主要从一些常见的编码规范或者项目规范编写规则对项目全部类进行检查。
ArchRule archRule = ArchRuleDefinition.noClasses()
.that().resideInAPackage("..com.source..")
.should().dependOnClassesThat().resideInAPackage("..com.target..");
复制代码
ArchRule archRule = ArchRuleDefinition.classes()
.that().resideInAPackage("..com.foo..")
.should().onlyAccessClassesThat().resideInAnyPackage("..com.source..", "..com.foo..");
复制代码
ArchRule archRule = ArchRuleDefinition.classes()
.that().haveNameMatching(".*Bar")
.should().onlyBeAccessed().byClassesThat().haveSimpleName("Bar");
复制代码
ArchRule archRule = ArchRuleDefinition.classes()
.that().haveSimpleNameStartingWith("Foo")
.should().resideInAPackage("com.foo");
复制代码
ArchRule archRule = ArchRuleDefinition.classes()
.that().implement(Collection.class)
.should().haveSimpleNameEndingWith("Connection");
复制代码
ArchRule archRule = ArchRuleDefinition.classes()
.that().areAssignableTo(EntityManager.class)
.should().onlyBeAccessed().byAnyPackage("..persistence..");
复制代码
ArchRule archRule = ArchRuleDefinition.classes()
.that().areAssignableTo(EntityManager.class)
.should().onlyBeAccessed().byClassesThat().areAnnotatedWith(Transactional.class)
复制代码
例如项目结构以下:
- com.myapp.controller
SomeControllerOne.class
SomeControllerTwo.class
- com.myapp.service
SomeServiceOne.class
SomeServiceTwo.class
- com.myapp.persistence
SomePersistenceManager
复制代码
例如咱们规定:
com.myapp.controller
中的类不能被其余层级包引用。com.myapp.service
中的类只能被com.myapp.controller
中的类引用。com.myapp.persistence
中的类只能被com.myapp.service
中的类引用。编写规则以下:
layeredArchitecture()
.layer("Controller").definedBy("..controller..")
.layer("Service").definedBy("..service..")
.layer("Persistence").definedBy("..persistence..")
.whereLayer("Controller").mayNotBeAccessedByAnyLayer()
.whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
.whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service")
复制代码
例如项目结构以下:
- com.myapp.moduleone
ClassOneInModuleOne.class
ClassTwoInModuleOne.class
- com.myapp.moduletwo
ClassOneInModuleTwo.class
ClassTwoInModuleTwo.class
- com.myapp.modulethree
ClassOneInModuleThree.class
ClassTwoInModuleThree.class
复制代码
例如咱们规定:com.myapp.moduleone
、com.myapp.moduletwo
和com.myapp.modulethree
三个包路径中的类不能造成一个循环依赖缓,例如:
ClassOneInModuleOne -> ClassOneInModuleTwo -> ClassOneInModuleThree -> ClassOneInModuleOne
复制代码
编写规则以下:
slices().matching("com.myapp.(*)..").should().beFreeOfCycles()
复制代码
把API分为三层,最重要的是"Core"层、"Lang"层和"Library"层。
ArchUnit的Core层API大部分相似于Java原生反射API,例如JavaMethod
和JavaField
对应于原生反射中的Method
和Field
,它们提供了诸如getName()
、getMethods()
、getType()
和getParameters()
等方法。
此外ArchUnit扩展一些API用于描述依赖代码之间关系,例如JavaMethodCall
, JavaConstructorCall
或JavaFieldAccess
。还提供了例如Java类与其余Java类之间的导入访问关系的API如JavaClass#getAccessesFromSelf()
。
而须要导入类路径下或者Jar包中已经编译好的Java类,ArchUnit提供了ClassFileImporter
完成此功能:
JavaClasses classes = new ClassFileImporter().importPackages("com.mycompany.myapp");
复制代码
Core层的API十分强大,提供了须要关于Java程序静态结构的信息,可是直接使用Core层的API对于单元测试会缺少表现力,特别表如今架构规则方面。
出于这个缘由,ArchUnit提供了Lang层的API,它提供了一种强大的语法来以抽象的方式表达规则。Lang层的API大多数是采用流式编程方式定义方法,例如指定包定义和调用关系的规则以下:
ArchRule rule =
classes()
// 定义在service包下的所欲类
.that().resideInAPackage("..service..")
// 只能被controller包或者service包中的类访问
.should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");
复制代码
编写好规则后就能够基于导入全部编译好的类进行扫描:
JavaClasses classes = new ClassFileImporter().importPackage("com.myapp");
ArchRule rule = // 定义的规则
rule.check(classes);
复制代码
Library层API经过静态工厂方法提供了更多复杂而强大的预约义规则,入口类是:
com.tngtech.archunit.library.Architectures
复制代码
目前,这只能为分层架构提供方便的检查,但未来可能会扩展为六边形架构\管道和过滤器,业务逻辑和技术基础架构的分离等样式。
还有其余几个相对强大的功能:
com.tngtech.archunit.library.dependencies.SlicesRuleDefinition
。com.tngtech.archunit.library.GeneralCodingRules
。PlantUML
组件支持,功能位于包路径com.tngtech.archunit.library.plantuml
下。通常来讲,内建的规则不必定可以知足一些复杂的规范校验规则,所以须要编写自定义的规则。这里仅仅举一个前文提到的相对复杂的规则:
controller
包下的Controller
类的类名称以"Controller"结尾,方法的入参类型命名以"Request"结尾,返回参数命名以"Response"结尾。官方提供的自定义规则的例子以下:
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;
}
};
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));
}
}
}
};
classes().that(haveAFieldAnnotatedWithPayload).should(onlyBeAccessedBySecuredMethods);
复制代码
咱们只须要模仿它的实现便可,具体以下:
public class ArchunitTest {
@Test
public void controller_class_rule() {
JavaClasses classes = new ClassFileImporter().importPackages("club.throwable");
DescribedPredicate<JavaClass> predicate =
new DescribedPredicate<JavaClass>("定义在club.throwable.controller包下的全部类") {
@Override
public boolean apply(JavaClass input) {
return null != input.getPackageName() && input.getPackageName().contains("club.throwable.controller");
}
};
ArchCondition<JavaClass> condition1 = new ArchCondition<JavaClass>("类名称以Controller结尾") {
@Override
public void check(JavaClass javaClass, ConditionEvents conditionEvents) {
String name = javaClass.getName();
if (!name.endsWith("Controller")) {
conditionEvents.add(SimpleConditionEvent.violated(javaClass, String.format("当前控制器类[%s]命名不以\"Controller\"结尾", name)));
}
}
};
ArchCondition<JavaClass> condition2 = new ArchCondition<JavaClass>("方法的入参类型命名以\"Request\"结尾,返回参数命名以\"Response\"结尾") {
@Override
public void check(JavaClass javaClass, ConditionEvents conditionEvents) {
Set<JavaMethod> javaMethods = javaClass.getMethods();
String className = javaClass.getName();
// 其实这里要作严谨一点须要考虑是否使用了泛型参数,这里暂时简化了
for (JavaMethod javaMethod : javaMethods) {
Method method = javaMethod.reflect();
Class<?>[] parameterTypes = method.getParameterTypes();
for (Class parameterType : parameterTypes) {
if (!parameterType.getName().endsWith("Request")) {
conditionEvents.add(SimpleConditionEvent.violated(method,
String.format("当前控制器类[%s]的[%s]方法入参不以\"Request\"结尾", className, method.getName())));
}
}
Class<?> returnType = method.getReturnType();
if (!returnType.getName().endsWith("Response")) {
conditionEvents.add(SimpleConditionEvent.violated(method,
String.format("当前控制器类[%s]的[%s]方法返回参数不以\"Response\"结尾", className, method.getName())));
}
}
}
};
ArchRuleDefinition.classes()
.that(predicate)
.should(condition1)
.andShould(condition2)
.because("定义在controller包下的Controller类的类名称以\"Controller\"结尾,方法的入参类型命名以\"Request\"结尾,返回参数命名以\"Response\"结尾")
.check(classes);
}
}
复制代码
由于导入了全部须要的编译好的类的静态属性,基本上是能够编写全部可以想出来的规约,更多的内容或者实现能够自行摸索。
经过最近的一个项目引入了Archunit
,而且进行了一些编码规范和架构规范的规约,起到了十分明显的效果。以前口头或者书面文档的规范能够经过单元测试直接控制,项目构建的时候强制必须执行单元测试,只有全部单测经过才能构建和打包(禁止使用-Dmaven.test.skip=true
参数),起到了十分明显的成效。
参考资料:
(本文完 e-a-2019216 c-1-d)