Структура спецификации уровня архитектуры проекта Archunit Research

Java

задний план

Недавно, когда я работал над новым проектом, было введено архитектурное требование, то есть необходимо было проверить спецификацию кодирования, спецификацию классификации модулей, спецификацию зависимостей классов и т. д. проекта.

Много раз мы разрабатываем спецификации для таких проектов, как:

  • Обязательно в структуре пакета проектаserviceна слои нельзя ссылатьсяcontrollerКласс слоя (этот пример немного экстремальный).
  • Жесткие правила определены вcontrollerв упаковкеControllerИмя класса класса заканчивается на «Контроллер», имя входного параметра метода заканчивается на «Запрос», а имя возвращаемого параметра заканчивается на «Ответ».
  • типы enum должны быть помещены вcommon.constantПод пакетом он заканчивается именем класса Enum.

Есть много других спецификаций, которые, возможно, потребуется настроить, что в конечном итоге может привести к созданию документа. Однако кто может гарантировать, что все люди, разрабатывающие параметры, будут развиваться в соответствии со спецификациями документа? Для того, чтобы обеспечить реализацию спецификации,ArchunitВ форме модульных тестов, просканировав все классы в пакете classpath (даже Jar), ​​закодируйте каждую спецификацию в виде модульных тестов.Если в коде проекта есть нарушение соответствующей спецификации модульного теста, модульный тест будет not pass. , чтобы вы могли полностью контролировать структуру проекта и спецификации кодирования с уровня CI/CD. Дата написания этой статьи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 разделен на три уровня, наиболее важными из которых являются уровень «Основной», уровень «Язык» и уровень «Библиотека».

API основного уровня

API уровня ядра ArchUnit в основном похож на собственный API отражения Java, напримерJavaMethodиJavaFieldсоответствует в родном отраженииMethodиField, которые предоставляют такие вещи, какgetName(),getMethods(),getType()иgetParameters()и другие методы.

Кроме того, ArchUnit расширяет некоторые API для описания отношений между зависимыми кодами, такими какJavaMethodCall,JavaConstructorCallилиJavaFieldAccess. Он также предоставляет API-интерфейсы, такие как импорт отношений доступа между классами Java и другими классами Java, такими какJavaClass#getAccessesFromSelf().

Вместо этого вам нужно импортировать скомпилированные классы Java в путь к классам или в пакет Jar.ClassFileImporterВыполните эту функцию:

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

API уровня Ланга

API уровня Core очень мощный и предоставляет необходимую информацию о статической структуре Java-программы, но прямому использованию API уровня Core будет не хватать выразительности для модульного тестирования, особенно с точки зрения архитектурных правил.

По этой причине ArchUnit предоставляет API уровня Lang, который предоставляет мощный синтаксис для абстрактного выражения правил. Большинство API уровня Lang используют потоковое программирование для определения методов.Например, правила для указания определений пакетов и взаимосвязей вызовов следующие:

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

API слоя библиотеки

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Имя класса класса заканчивается на «Контроллер», имя входного параметра метода заканчивается на «Запрос», а имя возвращаемого параметра заканчивается на «Ответ».

Примеры официально предоставленных пользовательских правил:

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 Digest"), который время от времени выкладывает оригинальные технические статьи автора (никогда не занимайтесь плагиатом и не перепечатывайте):