1. 程式人生 > 程式設計 >專案架構級別規約框架Archunit調研

專案架構級別規約框架Archunit調研

背景

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

很多時候,我們會制定專案的規範,例如:

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

還有很多其他可能需要定製的規範,最終可能會輸出一個檔案。但是,誰能保證所有引數開發的人員都會按照檔案的規範進行開發?為了保證規範的實行,Archunit

以單元測試的形式通過掃描類路徑(甚至Jar)包下的所有類,通過單元測試的形式對各個規範進行程式碼編寫,如果專案程式碼中有違背對應的單測規範,那麼單元測試將會不通過,這樣就可以從CI/CD層面徹底把控項專案架構和編碼規範。本文的編寫日期是2019-02-16,當時Archunit的最新版本為0.9.3,使用JDK 8

簡介

Archunit是一個免費、簡單、可擴充套件的類庫,用於檢查Java程式碼的體系結構。提供檢查包和類的依賴關係、呼叫層次和切面的依賴關係、迴圈依賴檢查等其他功能。它通過匯入所有類的程式碼結構,基於Java位元組碼分析實現這一點。Archunit的主要關注點是使用任何普通的Java單元測試框架自動測試程式碼體系結構和編碼規則

引入依賴

一般來說,目前常用的測試框架是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介面實現,預設提供可選的規則有:

// 不包含測試類
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\"結尾",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)