項目架構級別規約框架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)

相關文章
相關標籤/搜索