專案架構級別規約框架Archunit調研
背景
最近在做一個新專案的時候引入了一個架構方面的需求,就是需要檢查專案的編碼規範、模組分類規範、類依賴規範等,剛好接觸到,正好做個調研。
很多時候,我們會制定專案的規範,例如:
- 硬性規定專案包結構中
service
層不能引用controller
層的類(這個例子有點極端)。 - 硬性規定定義在
controller
包下的Controller
類的類名稱以"Controller"結尾,方法的入參型別命名以"Request"結尾,返回引數命名以"Response"結尾。 - 列舉型別必須放在
common.constant
包下,以類名稱Enum結尾。
還有很多其他可能需要定製的規範,最終可能會輸出一個檔案。但是,誰能保證所有引數開發的人員都會按照檔案的規範進行開發?為了保證規範的實行,Archunit
2019-02-16
,當時Archunit
的最新版本為0.9.3
,使用JDK 8
。
簡介
Archunit是一個免費、簡單、可擴充套件的類庫,用於檢查Java程式碼的體系結構。提供檢查包和類的依賴關係、呼叫層次和切面的依賴關係、迴圈依賴檢查等其他功能。它通過匯入所有類的程式碼結構,基於Java
位元組碼分析實現這一點。Archunit的主要關注點是使用任何普通的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>
複製程式碼
專案依賴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));
}
}
複製程式碼
得到的JavaClasses
是JavaClass
的集合,可以簡單類比為反射中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.moduleone
、com.myapp.moduletwo
和com.myapp.modulethree
三個包路徑中的類不能形成一個迴圈依賴緩,例如:
ClassOneInModuleOne -> ClassOneInModuleTwo -> ClassOneInModuleThree -> ClassOneInModuleOne
複製程式碼
編寫規則如下:
slices().matching("com.myapp.(*)..").should().beFreeOfCycles()
複製程式碼
核心API
把API分為三層,最重要的是"Core"層、"Lang"層和"Library"層。
Core層API
ArchUnit的Core層API大部分類似於Java原生反射API,例如JavaMethod
和JavaField
對應於原生反射中的Method
和Field
,它們提供了諸如getName()
、getMethods()
、getType()
和getParameters()
等方法。
此外ArchUnit擴充套件一些API用於描述依賴程式碼之間關係,例如JavaMethodCall
, JavaConstructorCall
或JavaFieldAccess
。還提供了例如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)