Tworząc architekturę modularną w Javie, możemy napotkać pewien problem, który zostanie opisany za chwilę. Żeby go rozwiązać, możemy skorzystać z gotowego rozwiązania, jakim jest ArchUnit.

Zdefiniowane problemu

Nie chcę tutaj opisywać jak dokładnie działa ArchUnit. Chciałbym skupić się na problemie obecnym w Javie oraz sposobie jego rozwiązania. Naprawdę prostym i naiwnym sposobie rozwiązania.

Załóżmy, że mamy dwa moduły i chcielibyśmy, aby komunikowały się one ze sobą za pomocą udostępnianego API, na przykład w postaci fasady. Stwórzmy pakiet a, reprezentujący moduł A, w którym umieścimy serwis będący beanem Springowym. Przy okazji stworzymy drugą klasę o zakresie pakietowym, która będzie symulowania potrzebę rozbicia jednej klasy ze względu na czytelność, czy też odpowiedzialność. To również będzie bean Springa. Spring da sobie radę i znajdzie taką klasę pakietową, aby wrzucić ją do kontekstu.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package pl.cezarysanecki.blog.a;

@Component
@RequiredArgsConstructor
public class ServiceA {

    private final ServiceHelperA helperA;

    public int calculate() {
        return helperA.returnSomething();
    }

}

@Component
class ServiceHelperA {

    int returnSomething() {
        return 1;
    }

}

Dodatkowo w ramach modułu A będzie istniał podmoduł reprezentowany przez pakiet a.inner i serwis ServiceInnerA. I tutaj napotkamy problem. Ten podmoduł powinien być dostępny tylko w ramach modułu A. Natomiast, aby moduł A mógł skorzystać z dostępnych tam klas, muszą one być upublicznione.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package pl.cezarysanecki.blog.a;

@Component
@RequiredArgsConstructor
public class ServiceA {

    private final ServiceHelperA helperA;
    private final ServiceInnerA innerA;

    public int calculate() {
        return helperA.returnSomething() + innerA.returnSomething();
    }

}

@Component
class ServiceHelperA {

    int returnSomething() {
        return 1;
    }

}

package pl.cezarysanecki.blog.a.inner;

@Component
public class ServiceInnerA {

    public int returnSomething() {
        return 1;
    }

}

Stwórzmy drugi moduł w pakiecie b z klasą ServiceB. W nim możemy skorzystać z klasy ServiceA oraz ServiceInnerA, co jest dla nas niedopuszczalne. Java nie urchoni nas przed takim wykorzystaniem. Jak sobie z tym poradzić?

1
2
3
4
5
6
7
8
9
10
11
12
@Component
@RequiredArgsConstructor
public class ServiceB {

    private final ServiceA serviceA;
    private final ServiceInnerA innerA;

    public int calculate() {
        return serviceA.calculate() + innerA.returnSomething();
    }

}

ArchUnit na pomoc!

Jeśli na etapie kompilacji nie możemy sobie z tym poradzić to może w runtime damy radę? W ten oto sposób musimy wprowadzić ArchUnit do projektu (albo inne narzędzie o podobnym działaniu). Dzięki niemu możemy zdefiniować reguły architektoniczne, w postaci testów, przy wykorzystaniu przyjemnego API. Będą one obowiązywały na poziomie aplikacji.

ArchUnit jest naprawdę elastyczny. Można definiować własne reguły, ale ten udostępniony przez twórców jest w większości przypadków wystarczający. Dobrze, sprawdźmy, jak możemy zaradzić naszemu problemowi.

1
2
3
4
5
6
7
8
9
10
11
12
@AnalyzeClasses(packages = "pl.cezarysanecki.blog")
public class ArchTest {

    @com.tngtech.archunit.junit.ArchTest
    private static final ArchRule ARCH_TEST = noClasses()
            .that()
            .resideInAPackage("..b..")
            .should()
            .dependOnClassesThat()
            .resideInAnyPackage("..a.*");

}

Taka reguła informuje, że klasy w pakiecie b nie mogą korzystać z żadnych klas znajdujących się w podpakietach pakietu a. Uruchamiając taką regułę dla obecnej sytuacji dostajemy poniższy błąd.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..b..' should depend on classes that reside in any package ['..a.*']' was violated (3 times):
Constructor <pl.cezarysanecki.blog.b.ServiceB.<init>(pl.cezarysanecki.blog.a.ServiceA, pl.cezarysanecki.blog.a.inner.ServiceInnerA)> has parameter of type <pl.cezarysanecki.blog.a.inner.ServiceInnerA> in (ServiceB.java:0)
Field <pl.cezarysanecki.blog.b.ServiceB.innerA> has type <pl.cezarysanecki.blog.a.inner.ServiceInnerA> in (ServiceB.java:0)
Method <pl.cezarysanecki.blog.b.ServiceB.calculate()> calls method <pl.cezarysanecki.blog.a.inner.ServiceInnerA.returnSomething()> in (ServiceB.java:16)
java.lang.AssertionError: Architecture Violation [Priority: MEDIUM] - Rule 'no classes that reside in a package '..b..' should depend on classes that reside in any package ['..a.*']' was violated (3 times):
Constructor <pl.cezarysanecki.blog.b.ServiceB.<init>(pl.cezarysanecki.blog.a.ServiceA, pl.cezarysanecki.blog.a.inner.ServiceInnerA)> has parameter of type <pl.cezarysanecki.blog.a.inner.ServiceInnerA> in (ServiceB.java:0)
Field <pl.cezarysanecki.blog.b.ServiceB.innerA> has type <pl.cezarysanecki.blog.a.inner.ServiceInnerA> in (ServiceB.java:0)
Method <pl.cezarysanecki.blog.b.ServiceB.calculate()> calls method <pl.cezarysanecki.blog.a.inner.ServiceInnerA.returnSomething()> in (ServiceB.java:16)
at com.tngtech.archunit.lang.ArchRule$Assertions.assertNoViolation(ArchRule.java:94)
at com.tngtech.archunit.lang.ArchRule$Assertions.check(ArchRule.java:86)
at com.tngtech.archunit.lang.ArchRule$Factory$SimpleArchRule.check(ArchRule.java:165)
at com.tngtech.archunit.lang.syntax.ObjectsShouldInternal.check(ObjectsShouldInternal.java:81)
at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:167)
at com.tngtech.archunit.junit.internal.ArchUnitTestDescriptor$ArchUnitRuleDescriptor.execute(ArchUnitTestDescriptor.java:150)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1597)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1597)

Mamy wskazanie, która reguła została złamana i w jakim miejscu. Naprawiając kod i uruchamiając ponownie test architektoniczny, wszystko powinno działać prawidłowo. Takie reguły możemy wpiąć w pipeline CI.

1
2
3
4
5
6
7
8
9
10
11
@Component
@RequiredArgsConstructor
public class ServiceB {

    private final ServiceA serviceA;

    public int calculate() {
        return serviceA.calculate();
    }

}

Podsumowanie

Powyższa reguła nie jest w żaden sposób elastyczna. Jest to tylko pogląd na to, jak działa ArchUnit i jak może nam pomóc w chronieniu reguł architektonicznych, które zdefiniowaliśmy w naszym zespole. Na pewno da się wymyślić coś bardziej uniwersalnego, które będzie chronić wszystkie moduły przed tego typu zmianami.

Mam nadzieję, że ten wpis Cię zainspirował i wzbogaci Twoje narzędzia programistyczne!