項目架構級別規約框架Archunit調研


背景

最近在做一個新項目的時候引入了一個架構方面的需求,就是需要檢查項目的編碼規范、模塊分類規范、類依賴規范等,剛好接觸到,正好做個調研。

很多時候,我們會制定項目的規范,例如:

  • 硬性規定項目包結構中service層不能引用controller層的類(這個例子有點極端)。
  • 硬性規定定義在controller包下的Controller類的類名稱以"Controller"結尾,方法的入參類型命名以"Request"結尾,返回參數命名以"Response"結尾。
  • 枚舉類型必須放在common.constant包下,以類名稱Enum結尾。

還有很多其他可能需要定制的規范,最終可能會輸出一個文檔。但是,誰能保證所有參數開發的人員都會按照文檔的規范進行開發?為了保證規范的實行,Archunit以單元測試的形式通過掃描類路徑(甚至Jar)包下的所有類,通過單元測試的形式對各個規范進行代碼編寫,如果項目代碼中有違背對應的單測規范,那么單元測試將會不通過,這樣就可以從CI/CD層面徹底把控項項目架構和編碼規范。

簡介

Archunit是一個免費、簡單、可擴展的類庫,用於檢查Java代碼的體系結構。提供檢查包和類的依賴關系、調用層次和切面的依賴關系、循環依賴檢查等其他功能。它通過導入所有類的代碼結構,基於Java字節碼分析實現這一點。的主要關注點是使用任何普通的Java單元測試框架自動測試代碼體系結構和編碼規則

引入依賴

一般來說,目前常用的測試框架是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>

由於-junit4中依賴到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接口實現,默認提供可選的規則有:

// 不包含測試類
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接口只有一個方法:

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)

技術公眾號(《Throwable文摘》),不定期推送筆者原創技術文章(絕不抄襲或者轉載):

娛樂公眾號(《天天沙雕》),甄選奇趣沙雕圖文和視頻不定期推送,緩解生活工作壓力:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM