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

背景

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

很多时候,我们会制定项目的规范,例如:

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

还有很多其他可能需要定制的规范,最终可能会输出一个文档。但是,谁能保证所有参数开发的人员都会按照文档的规范进行开发?为了保证规范的实行,Archunit以单元测试的形式通过扫描类路径(甚至Jar)包下的所有类,通过单元测试的形式对各个规范进行代码编写,如果项目代码中有违背对应的单测规范,那么单元测试将会不通过,这样就可以从CI/CD层面彻底把控项项目架构和编码规范。

简介

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

引入依赖

一般来说,目前常用的测试框架是Junit4,需要引入Junit4和archunit:


    com.tngtech.archunit
    archunit
    0.9.3
    test


    junit
    junit
    4.12
    test

由于-junit4中依赖到slf4j,因此最好在测试依赖中引入一个slf4j的实现,例如logback:


    ch.qos.logback
    logback-classic
    1.2.3
    test

如何使用

主要从下面的两个方面介绍一下的使用:

  • 指定参数进行类扫描。
  • 内建规则定义。

指定参数进行类扫描

需要对代码或者依赖规则进行判断前提是要导入所有需要分析的类,类扫描导入依赖于ClassFileImporter,底层依赖于ASM字节码框架针对类文件的字节码进行解析,性能会比基于反射的类扫描框架高很多。ClassFileImporter的构造可选参数为ImportOption(s),扫描规则可以通过ImportOption接口实现,默认提供可选的规则有:

// 不包含测试类
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 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接口只有一个方法:

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> classes)

// 通过指定路径导入类
public JavaClasses importUrl(URL url)
public JavaClasses importUrls(Collection urls)
public JavaClasses importLocations(Collection 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 paths)

// 通过Jar文件对象导入类
public JavaClasses importJar(JarFile jar)
public JavaClasses importJars(JarFile... jarFiles)
public JavaClasses importJars(Iterable jarFiles)

// 通过包路径导入类 - 这个是比较常用的方法
public JavaClasses importPackages(Collection packages)
public JavaClasses importPackages(String... packages)
public JavaClasses importPackagesOf(Class... classes)
public JavaClasses importPackagesOf(Collection> 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 haveAFieldAnnotatedWithPayload =
    new DescribedPredicate("have a field annotated with @Payload"){
        @Override
        public boolean apply(JavaClass input) {
            boolean someFieldAnnotatedWithPayload = // iterate fields and check for @Payload
            return someFieldAnnotatedWithPayload;
        }
    };

ArchCondition onlyBeAccessedBySecuredMethods =
    new ArchCondition("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 predicate =
                new DescribedPredicate("定义在club.throwable.controller包下的所有类") {
                    @Override
                    public boolean apply(JavaClass input) {
                        return null != input.getPackageName() && input.getPackageName().contains("club.throwable.controller");
                    }
                };
        ArchCondition condition1 = new ArchCondition("类名称以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 condition2 = new ArchCondition("方法的入参类型命名以\"Request\"结尾,返回参数命名以\"Response\"结尾") {
            @Override
            public void check(JavaClass javaClass, ConditionEvents conditionEvents) {
                Set 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参数),起到了十分明显的成效。

参考资料:

  • User Guide

个人博客

  • Throwable's Blog

(e-a-2019216 c-1-d)

你可能感兴趣的:(项目架构级别规约框架Archunit调研)