项目架构级别规约框架Archunit调研

背景

最近在作一个新项目的时候引入了一个架构方面的需求,就是须要检查项目的编码规范、模块分类规范、类依赖规范等,恰好接触到,正好作个调研。html

不少时候,咱们会制定项目的规范,例如:java

  • 硬性规定项目包结构中service层不能引用controller层的类(这个例子有点极端)。
  • 硬性规定定义在controller包下的Controller类的类名称以"Controller"结尾,方法的入参类型命名以"Request"结尾,返回参数命名以"Response"结尾。
  • 枚举类型必须放在common.constant包下,以类名称Enum结尾。

还有不少其余可能须要定制的规范,最终可能会输出一个文档。可是,谁能保证全部参数开发的人员都会按照文档的规范进行开发?为了保证规范的实行,Archunit以单元测试的形式经过扫描类路径(甚至Jar)包下的全部类,经过单元测试的形式对各个规范进行代码编写,若是项目代码中有违背对应的单测规范,那么单元测试将会不经过,这样就能够从CI/CD层面完全把控项项目架构和编码规范。本文的编写日期是2019-02-16,当时Archunit的最新版本为0.9.3,使用JDK 8git

简介

Archunit是一个免费、简单、可扩展的类库,用于检查Java代码的体系结构。提供检查包和类的依赖关系、调用层次和切面的依赖关系、循环依赖检查等其余功能。它经过导入全部类的代码结构,基于Java字节码分析实现这一点。Archunit的主要关注点是使用任何普通的Java单元测试框架自动测试代码体系结构和编码规则github

引入依赖

通常来讲,目前经常使用的测试框架是Junit4,须要引入Junit4Archunit正则表达式

<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));
    }
}
复制代码

获得的JavaClassesJavaClass的集合,能够简单类比为反射中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中:

  • NO_CLASSES_SHOULD_ACCESS_STANDARD_STREAMS:不能调用System.out、System.err或者(Exception.)printStackTrace。
  • NO_CLASSES_SHOULD_THROW_GENERIC_EXCEPTIONS:类不能直接抛出通用异常Throwable、Exception或者RuntimeException。
  • NO_CLASSES_SHOULD_USE_JAVA_UTIL_LOGGING:不能使用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.moduleonecom.myapp.moduletwocom.myapp.modulethree三个包路径中的类不能造成一个循环依赖缓,例如:

ClassOneInModuleOne -> ClassOneInModuleTwo -> ClassOneInModuleThree -> ClassOneInModuleOne
复制代码

编写规则以下:

slices().matching("com.myapp.(*)..").should().beFreeOfCycles()
复制代码

核心API

把API分为三层,最重要的是"Core"层、"Lang"层和"Library"层。

Core层API

ArchUnit的Core层API大部分相似于Java原生反射API,例如JavaMethodJavaField对应于原生反射中的MethodField,它们提供了诸如getName()getMethods()getType()getParameters()等方法。

此外ArchUnit扩展一些API用于描述依赖代码之间关系,例如JavaMethodCallJavaConstructorCallJavaFieldAccess。还提供了例如Java类与其余Java类之间的导入访问关系的API如JavaClass#getAccessesFromSelf()

而须要导入类路径下或者Jar包中已经编译好的Java类,ArchUnit提供了ClassFileImporter完成此功能:

JavaClasses classes = new ClassFileImporter().importPackages("com.mycompany.myapp");
复制代码

Lang层API

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

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)

相关文章
相关标签/搜索