Project architecture level specification framework Archunit research

background

Recently, when we are doing a new project, we have introduced an architecture requirement, that is, we need to check the coding specification, module classification specification, class dependency specification, etc. of the project, just in touch, just for a survey.

Many times, we will formulate project specifications, such as:

  • It is mandatory that the service layer in the project package structure cannot refer to the class of the controller layer (this example is a bit extreme).
  • It is hard to specify that the class name of the Controller class defined in the Controller package ends with "Controller", the input parameter type of the method ends with "Request", and the return parameter name ends with "Response".
  • Enumeration types must be placed under the common.constant package, ending with the class name Enum.

There are many other specifications that may need to be customized and may eventually output a document. However, who can guarantee that all parameter developers will follow the specification of the document? In order to ensure the implementation of the specification, Archunit scans all classes under the class path (or even Jar) package in the form of unit test, and writes code for each specification in the form of unit test. If there is a violation of the corresponding single test specification in the project code, the unit test will not pass, so that the control project architecture and coding specification can be completely put from the CI/CD level.

brief introduction

Archunit Is a free, simple, extensible class library for checking the architecture of Java code. It provides other functions such as checking the dependencies of packages and classes, calling levels and aspects, and circular dependency checking. It implements this by importing the code structure of all classes based on Java bytecode analysis. The main concern is to test the code architecture and coding rules automatically using any common Java unit test framework.

Introducing dependency

Generally speaking, junit4 is the commonly used testing framework at present. Junit4 and archunit need to be introduced:

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

Since - junit4 depends on slf4j, it is better to introduce an slf4j implementation in the test dependency, such as logback:

<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.2.3</version>
    <scope>test</scope>
</dependency>

How to use

Mainly from the following two aspects to introduce the use of:

  • Specifies parameters for class scanning.
  • Built in rule definition.

Specify parameters for class scanning

The prerequisite for judging code or dependency rules is to import all classes that need to be analyzed. Class scan import depends on ClassFileImporter, and the bottom layer relies on ASM bytecode framework to parse the bytecode of class files. The performance will be much higher than that of class scan framework based on reflection. The optional parameters for ClassFileImporter construction are ImportOption(s). The scanning rules can be implemented through the ImportOption interface. The default optional rules are:

// Test class not included
ImportOption.Predefined.DONT_INCLUDE_TESTS

// Does not contain classes in Jar package
ImportOption.Predefined.DONT_INCLUDE_JARS

// It does not include classes in Jar and Jrt packages, and features of JDK9
ImportOption.Predefined.DONT_INCLUDE_ARCHIVES

For example, we implement a custom ImportOption implementation to specify the package path to exclude scanning:

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;
    }
}

The ImportOption interface has only one method:

boolean includes(Location location)

Among them, Location contains metadata of path information, Jar file and other judgment attributes, which is convenient to use regular expressions or direct logical judgment.

Then we can construct the ClassFileImporter instance through the DontIncludePackagesImportOption implemented above:

ImportOptions importOptions = new ImportOptions()
        // Do not scan jar packages
        .with(ImportOption.Predefined.DONT_INCLUDE_JARS)
        // Exclude non scanned packages
        .with(new DontIncludePackagesImportOption("com.sample..support"));
ClassFileImporter classFileImporter = new ClassFileImporter(importOptions);

After getting the ClassFileImporter instance, we can import the classes in the project through the corresponding methods:

// Specify type to import a single class
public JavaClass importClass(Class<?> clazz)

// Specify types to import multiple classes
public JavaClasses importClasses(Class<?>... classes)
public JavaClasses importClasses(Collection<Class<?>> classes)

// Import a class by specifying a path
public JavaClasses importUrl(URL url)
public JavaClasses importUrls(Collection<URL> urls)
public JavaClasses importLocations(Collection<Location> locations)

// Import a class through a class path
public JavaClasses importClasspath()
public JavaClasses importClasspath(ImportOptions options)

// Import classes via file path
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)

// Importing classes through Jar file objects
public JavaClasses importJar(JarFile jar)
public JavaClasses importJars(JarFile... jarFiles)
public JavaClasses importJars(Iterable<JarFile> jarFiles)

// Import classes through package path - this is a common method
public JavaClasses importPackages(Collection<String> packages)
public JavaClasses importPackages(String... packages)
public JavaClasses importPackagesOf(Class<?>... classes)
public JavaClasses importPackagesOf(Collection<Class<?>> classes)

The method of importing classes provides multi-dimensional parameters, which is very convenient to use. For example, if you want to import all the following classes of the com.sample package, you only need to do this:

public class ClassFileImporterTest {

    @Test
    public void testImportBootstarpClass() throws Exception {
        ImportOptions importOptions = new ImportOptions()
                // Do not scan jar packages
                .with(ImportOption.Predefined.DONT_INCLUDE_JARS)
                // Exclude non scanned packages
                .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));
    }
}

The obtained JavaClasses are the collection of JavaClasses, which can be simply compared to the collection of classes in reflection. The code rules and dependency rule judgments used later are strongly dependent on JavaClasses or JavaClasses.

Built in rule definition

After class scanning and class importing, we need to set check rules and apply them to all imported classes. In this way, we can filter rules for all classes - or apply rules to all classes and make assertions.

The rule definition depends on the ArchRuleDefinition class. The created rule is the ArchRule instance. The rule instance creation process generally uses the flow method of the ArchRuleDefinition class. These flow method definitions conform to the thinking logic of human thinking, and are relatively simple to start with. For example:

ArchRule archRule = ArchRuleDefinition.noClasses()
    // All classes under the service package
    .that().resideInAPackage("..service..")
    // Cannot call any class under the controller package
    .should().accessClassesThat().resideInAPackage("..controller..")
    // Assertion description - reason to print when rules are not met
    .because("Cannot be in service Call in package controller Class in");
    // Judge all JavaClasses
archRule.check(classes);

The above shows an example of customizing a new ArchRule. We have built in some common ArchRule implementations, which are located in general coding rules:

  • No? Classes? Show? Access? Standard? Streams: cannot call System.out, System.err, or (Exception.)printStackTrace.
  • No ﹣ classes ﹣ show ﹣ row ﹣ generic ﹣ exceptions: a class cannot throw a generic Exception, Throwable, Exception, or RuntimeException directly.
  • NO_CLASSES_SHOULD_USE_JAVA _UTIL_LOGGING: you cannot use the log component under the java.util.logging package path.

For more built-in archrules or general built-in rules, please refer to Official example.

Basic use examples

Basic use examples, mainly from some common coding specifications or project specification writing rules to check all classes of the project.

Package dependency check

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..");

Class dependency check

ArchRule archRule = ArchRuleDefinition.classes()
    .that().haveNameMatching(".*Bar")
    .should().onlyBeAccessed().byClassesThat().haveSimpleName("Bar");

Class contained in package relation check

ArchRule archRule = ArchRuleDefinition.classes()
    .that().haveSimpleNameStartingWith("Foo")
    .should().resideInAPackage("com.foo");

Inheritance check

ArchRule archRule = ArchRuleDefinition.classes()
    .that().implement(Collection.class)
    .should().haveSimpleNameEndingWith("Connection");
ArchRule archRule = ArchRuleDefinition.classes()
    .that().areAssignableTo(EntityManager.class)
    .should().onlyBeAccessed().byAnyPackage("..persistence..");

Annotation check

ArchRule archRule = ArchRuleDefinition.classes()
    .that().areAssignableTo(EntityManager.class)
    .should().onlyBeAccessed().byClassesThat().areAnnotatedWith(Transactional.class)

Logical layer call relation check

For example, the project structure is as follows:

- com.myapp.controller
    SomeControllerOne.class
    SomeControllerTwo.class
- com.myapp.service
    SomeServiceOne.class
    SomeServiceTwo.class
- com.myapp.persistence
    SomePersistenceManager

For example, we stipulate:

  • The class in package path com.myapp.controller cannot be referenced by other level packages.
  • Classes in package path com.myapp.service can only be referenced by classes in com.myapp.controller.
  • Classes in package path com.myapp.persistence can only be referenced by classes in com.myapp.service.

The rules are as follows:

layeredArchitecture()
    .layer("Controller").definedBy("..controller..")
    .layer("Service").definedBy("..service..")
    .layer("Persistence").definedBy("..persistence..")

    .whereLayer("Controller").mayNotBeAccessedByAnyLayer()
    .whereLayer("Service").mayOnlyBeAccessedByLayers("Controller")
    .whereLayer("Persistence").mayOnlyBeAccessedByLayers("Service")

Circular dependency check

For example, the project structure is as follows:

- com.myapp.moduleone
    ClassOneInModuleOne.class
    ClassTwoInModuleOne.class
- com.myapp.moduletwo
    ClassOneInModuleTwo.class
    ClassTwoInModuleTwo.class
- com.myapp.modulethree
    ClassOneInModuleThree.class
    ClassTwoInModuleThree.class

For example, we stipulate that classes in the package paths of com.myapp.moduleone, com.myapp.moduletwo and com.myapp.modulethree cannot form a circular dependency buffer, for example:

ClassOneInModuleOne -> ClassOneInModuleTwo -> ClassOneInModuleThree -> ClassOneInModuleOne

The rules are as follows:

slices().matching("com.myapp.(*)..").should().beFreeOfCycles()

Core API

The API is divided into three layers, the most important of which are Core layer, Lang layer and Library layer.

Core layer API

The Core layer API of ArchUnit is mostly similar to the Java Native reflection API. For example, JavaMethod and JavaField correspond to methods and fields in native reflection. They provide methods such as getName(), getMethods(), getType(), and getParameters().

In addition, ArchUnit extends some APIs to describe the relationship between dependent codes, such as JavaMethodCall, JavaConstructorCall or JavaFieldAccess. It also provides APIs such as javaclass ා getaccesssfromself(), for example, the import access relationship between Java classes and other Java classes.

To import the compiled Java classes in the class path or Jar package, ArchUnit provides the ClassFileImporter to complete this function:

JavaClasses classes = new ClassFileImporter().importPackages("com.mycompany.myapp");

Lang layer API

The API of the Core layer is very powerful, which provides information about the static structure of Java programs. However, using the API of the Core layer directly will lack expressiveness for unit testing, especially in the aspect of architecture rules.

For this reason, ArchUnit provides the Lang layer API, which provides a powerful syntax to express rules in an abstract way. The API of the Lang layer mostly defines methods in the way of flow programming. For example, the rules for specifying package definition and calling relationship are as follows:

ArchRule rule =
    classes()
         // Define the desired class under the service package
        .that().resideInAPackage("..service..")
         // Can only be accessed by a class in the controller package or service package
        .should().onlyBeAccessed().byAnyPackage("..controller..", "..service..");

After writing the rules, you can scan based on importing all compiled classes:

JavaClasses classes = new ClassFileImporter().importPackage("com.myapp");
ArchRule rule = // Defined rules
rule.check(classes);

Library layer API

The Library layer API provides more complex and powerful predefined rules through the static factory method. The entry classes are:

com.tngtech.archunit.library.Architectures

At present, this can only provide a convenient check for layered architecture, but in the future, it may be expanded to hexagon architecture, pipeline and filter, separation of business logic and technical infrastructure, etc.

There are several other relatively powerful features:

  • Code slicing function, the entry is com.tngtech.archunit.library.dependencies.SlicesRuleDefinition.
  • General coding rules. The entry is com.tngtech.archunit.library.GeneralCodingRules.
  • PlantUML component support, the function is located in the package path com.tngtech.archunit.library.plantuml.

Write complex rules

Generally speaking, the built-in rules do not necessarily meet some complex verification rules, so it is necessary to write custom rules. Here is just a relatively complex rule mentioned earlier:

  • The class name of the Controller class defined in the Controller package ends with "Controller", the input parameter type name of the method ends with "Request", and the return parameter name ends with "Response".

Examples of custom rules officially provided are as follows:

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

We just need to imitate its implementation, as follows:

public class ArchunitTest {

    @Test
    public void controller_class_rule() {
        JavaClasses classes = new ClassFileImporter().importPackages("club.throwable");
        DescribedPredicate<JavaClass> predicate =
                new DescribedPredicate<JavaClass>("Defined in club.throwable.controller All classes under package") {
                    @Override
                    public boolean apply(JavaClass input) {
                        return null != input.getPackageName() && input.getPackageName().contains("club.throwable.controller");
                    }
                };
        ArchCondition<JavaClass> condition1 = new ArchCondition<JavaClass>("Class names Controller Ending") {
            @Override
            public void check(JavaClass javaClass, ConditionEvents conditionEvents) {
                String name = javaClass.getName();
                if (!name.endsWith("Controller")) {
                    conditionEvents.add(SimpleConditionEvent.violated(javaClass, String.format("Current controller class[%s]Naming not\"Controller\"Ending", name)));
                }
            }
        };
        ArchCondition<JavaClass> condition2 = new ArchCondition<JavaClass>("Method is named after\"Request\"End, return parameter named with\"Response\"Ending") {
            @Override
            public void check(JavaClass javaClass, ConditionEvents conditionEvents) {
                Set<JavaMethod> javaMethods = javaClass.getMethods();
                String className = javaClass.getName();
                // In fact, if you want to be strict, you need to consider whether generic parameters are used, which is temporarily simplified
                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("Current controller class[%s]Of[%s]Method input does not take\"Request\"Ending", className, method.getName())));
                        }
                    }
                    Class<?> returnType = method.getReturnType();
                    if (!returnType.getName().endsWith("Response")) {
                        conditionEvents.add(SimpleConditionEvent.violated(method,
                                String.format("Current controller class[%s]Of[%s]Method return parameter does not\"Response\"Ending", className, method.getName())));
                    }
                }
            }
        };
        ArchRuleDefinition.classes()
                .that(predicate)
                .should(condition1)
                .andShould(condition2)
                .because("Defined in controller Under bag Controller The class name of the class is\"Controller\"At the end, the input parameter type of the method is named with\"Request\"End, return parameter named with\"Response\"Ending")
                .check(classes);
    }
}

Because all the compiled static properties of the class are imported, basically all the imaginable specifications can be written, and more contents or implementations can be explored by themselves.

Summary

Archunit has been introduced through a recent project, and some coding specifications and architecture specifications have been made, which has played a very obvious effect. Before, the specification of oral or written documents can be directly controlled by unit test. Unit test must be enforced when the project is built. Only when all single tests pass, can they be built and packaged (the parameter - Dmaven.test.skip=true is prohibited), which has played a very significant effect.

reference material:

Personal blog

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

Published 129 original articles, won praise 6, visited 6318
Private letter follow

Tags: Java Junit Programming

Posted on Wed, 12 Feb 2020 01:25:32 -0800 by kokomo310