Gdzie kompilator nie może, tam ArchUnita pośle
We wszystkich językach programowania istnieje jakiś system typów. Może on być skrajnie prymitywny, jak w asemblerze – typ to 4 bajty lub 2 bajty. Może być ukryty jak w ForthW – co masz na stosie, to masz na stosie, i ty za to odpowiadasz. Może być niejawny, jak w bashu – wszystko jest strigiem, chyba że używamy tego inaczej. Typy mogą być sprawdzane statycznie, jak w Rustcie i Javie, albo dynamicznie jak w Pythonie i Ruby. Może być silne (Java) lub słabe (JavaScript). Może w końcu być nominalne, czyli dane „pasują” do typu, jeżeli nazwaliśmy je tak jak typ (Java), albo strukturalne (duck typing), gdzie dane pasują do typu, jeżeli mają taką sama strukturę. Tych podziałów jest wiele i na różnym poziomie. Koniec końców przychodzi interpreter lub kompilator i próbuje na podstawie definicji języka oraz naszego kodu coś wyprodukować. Jeżeli popełnimy jakiś błąd przy określeniu typów, to dostaniemy czy to błąd kompilacji, czy to błąd w czasie wykonywania kodu. Takie zachowanie jest „mentalnie” proste, choć reguły rządzące poszczególnymi systemami typów mogą być bardzo skomplikowane.
Wraz z systemem typów w parze idzie też opis widoczności, czyli to w jaki sposób poszczególne typy mogą wchodzić ze sobą w interakcje. Opis ten to sławetne „modyfikatory dostępu”, które mogą, ale nie muszą, istnieć w danym języku. Jak to działa w Javie wiadomo. Problem pojawia się, gdy z jakiegoś powodu musimy dołożyć jeszcze jedną „warstwę” widoczności związaną z umiejscowieniem i rolą danego typu w naszej architekturze. I tu na pomoc przychodzą… interfejsy. Przynajmniej w Javie, bo założenie jest takie, że pomiędzy pakietami komunikujemy się za pomocą interfejsów. Stąd też domyślna widoczność metod jest publiczna. Jeżeli do tego dołożymy rekordy, które zdefiniujemy w ramach tych interfejsów, to mamy bardzo ładnie zdefiniowane API.
A co z konkretnymi klasami? Słuszne pytanie, bo przecież nie można stworzyć instancji interfejsu. Tu mamy tą straszną AbstractBeanFactoryFactory
,
czyli wzorce kreacyjne. Opakowaliśmy je jakieś 23 lata temu w Springa, a jeszcze chwile wcześniej w JEE. Zatem nie jest źle, prawda? No nie do końca,
ponieważ nadal mamy problem z dostępem na poziomie architektury. Jeżeli interfejs Repository
jest publiczny, to zarówno Service
jak i Controller
będą mieć do niego dostęp. Niby mamy moduły, ale… nikt nam nie zabroni wpisać requires repository
w module-info
kontrolera :D
Tu z pomocą przychodzi ArchUnit, czyli narzędzie do „testowaina architektury”. Nawet kiedyś dawno temu mówiłem o tym na
SegFaulcie. Definiujemy kilka prostych regułek, które mówią które pakiety, gdzie powinny być używane i
pora na CSa. I tak mniej więcej 95% programistów używa ArchUnita. Pozostałe 5% używa go jeszcze do sprawdzenia czy nie wołamy jakiś głupot w stylu
System.out.println
. Jednak to nie rozwiązuje wszystkich naszych problemów.
Publiczne, ale pakietowe, ale nie w testach
Mamy sobie taką biblioteczkę jak Quarkus Mailer. Definiujemy sobie interfejs za pomocą którego, będziemy wysyłać maile.
Listing 1. MailerService
do wysyłki maili
public interface MailerService {
boolean sendPasswordReset(PasswordReset passwordReset);
record LostPassword(String passwordResetToken, String expirationDate) implements MailTemplate.MailTemplateInstance {
}
}
I wszystko byłoby fajnie, gdyby nie ten nieszczęsny MailTemplate.MailTemplateInstance
. Biblioteka wymaga, żeby rekordy implementujące ten interfejs
były publiczne, żeby biblioteka mogła się do nich dostać bez problemów. Jednocześnie takie upublicznienie powoduje, że możemy wysłać maila z dowolnego
miejsca w kodzie, bo interfejs zawiera m.in. metody do obsługi wysyłki. Co można z tym zrobić?
Napisać dużo kodu
Usuwamy z definicji rekordu implementację interfejsu. Następnie w niepublicznej implementacji MailerService
definiujemy statyczny, publiczny rekord,
który już będzie implementować interfejs MailTemplate.MailTemplateInstance
. Następnie za pomocą MapStructa lub z palca piszemy maper, który zmienia
rekord z interfejsu na rekord z implementacji. Biblioteka jest zadowolona, mechanizm mailingu niewidoczny, a i liczba linii kodu się zgadza. Problem
rozwiązany za pomocą systemu typów i ich przepakowywania.
Tylko, że w ten sposób powstaje nam ciężki do przetestowania kod, bo w testach musimy czasami tego maila wysłać z pominięciem serwisu i niekoniecznie z poziomu pakietu z mailingiem.
Napisać regułkę ArchUnit
To jest trochę bardziej skomplikowane, ale możemy pokusić się o napisanie generycznej reguły, która będzie brzmieć:
Sprawdź, czy dany rekord jest tworzony jedynie w klasach implementujących interfejs w którym zdefiniowano ten rekord lub w metodach oznaczonych jako
@Test
.
I tego już żaden system typów nie ogarnie. Dlaczego? Ponieważ nie istnieje język, którego system typów pozwoliłby na jednoczesne sprawdzenie:
- miejsca użycia typu – tylko w klasach implementujących dany interfejs
- kontekstu wywołania – lub metodach oznaczonych jako
@Test
Dlaczego? Ponieważ systemy typów co do zasady są pomyślane tak, żeby sprawdzać poprawność danych i zgodność interfejsów (w szerszym znaczeniu – co możemy użyć). Nie są jednak przeznaczone do weryfikowania kontekstu wywołania. Do weryfikacji kontekstu wywołania służy analiza statyczna. Do analizy statycznej posłuży nam ArchUnit, w którym napiszemy taka oto regułkę:
Listing 2. Nasz weryfikator:
import static com.tngtech.archunit.lang.SimpleConditionEvent.violated;
import com.tngtech.archunit.core.domain.JavaClass;
import com.tngtech.archunit.core.domain.JavaConstructorCall;
import com.tngtech.archunit.lang.ArchCondition;
import com.tngtech.archunit.lang.ConditionEvent;
import com.tngtech.archunit.lang.ConditionEvents;
import java.util.Arrays;
import java.util.List;
import org.junit.jupiter.api.Test;
class NoInnerRecordsAreUsedOutsideOfItsOwnerOrTest extends ArchCondition<JavaClass> {
private static final String TEST_FULL_NAME = Test.class.getName();
private final Class<?> owner;
private final List<String> forbiddenNames;
public NoInnerRecordsAreUsedOutsideOfItsOwnerOrTest(Class<?> owner) {
super("Use %s defined records only in %s", owner.getSimpleName(), owner.getSimpleName());
this.owner = owner;
this.forbiddenNames = Arrays.stream(owner.getDeclaredClasses())
.filter(c -> c.isRecord())
.map(Class::getName)
.toList();
if (forbiddenNames.isEmpty()) {
throw new IllegalArgumentException("Class " + owner.getName() + " has no inner classes");
}
}
@Override
public void check(JavaClass javaClass, ConditionEvents events) {
javaClass.getConstructorCallsFromSelf()
.stream()
.filter(call -> isInNames(call) && isNotImplementOwner(javaClass) && isNotCallInTestMethod(call))
.map(this::createViolationEvent)
.forEach(events::add);
}
private ConditionEvent createViolationEvent(JavaConstructorCall call) {
final String calledName = call.getOrigin().getOwner().getFullName();
final String calledPlace = call.getOrigin().getFullName();
return violated(call, "Invalid call of " + calledName + " in " + calledPlace);
}
private boolean isNotCallInTestMethod(JavaConstructorCall call) {
return !call.getOrigin()
.getAnnotations()
.stream()
.anyMatch(a -> a.getRawType().getName().equals(TEST_FULL_NAME));
}
private boolean isInNames(JavaConstructorCall call) {
return forbiddenNames.contains(call.getTarget().getOwner().getName());
}
private boolean isNotImplementOwner(JavaClass javaClass) {
return !javaClass.getAllRawInterfaces().stream()
.anyMatch(i -> i.getName().equals(owner.getName()));
}
}
Co tu się dzieje?
Po pierwsze w konstruktorze pobieramy listę rekordów zdefiniowanych w danej klasie. Jeżeli nie ma rekordów, to leci wyjątek. Następnie w metodzie
check
dzieje się „magia”. ArchUnit uruchamia tę metodę dla każdej klasy w naszym kontekście weryfikacji (o tym za chwilę) i pobiera z niej wszystkie
wywołane w tej klasie konstruktory » javaClass.getConstructorCallsFromSelf()
. Dla każdego wywołania konstruktora sprawdzamy, czy jest to wywołanie
konstruktora naszego rekordu » isInNames
i czy klasa wywołująca nie implementuje interfejsu » isNotImplementOwner
i czy wywołanie nie nastąpiło w
testach » isNotCallInTestMethod
. Jeżeli wszystkie te założenia są prawdziwe, to tworzymy (map
) i zgłaszamy (forEach->add
) naruszenie.
Czym jest kontekst weryfikacji?
Jest to zbiór klas, które chcemy zweryfikować. W naszym przypadku chcemy zweryfikować wszystkie klasy z pakietu bazowego naszego projektu, więc możemy wywołać test w następujący sposób:
Listing 3. Wywołanie testu
class MailerServiceRecordsUsageArchTest implements ArchTestTrait {
@Test
void recordsInMailerServiceShouldBeCreatedOnlyInSpecificClasses() {
classes()
.should(innerRecordUsage(MailerService.class))
.check(new ClassFileImporter()
.withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS)
.importPackages("pl.koziolekweb.."));
}
}
Przy czym tutaj jest to trochę preoptymalizowany, ponieważ ArchUnit udostępnia filtr ImportOption.Predefined.DO_NOT_INCLUDE_TESTS
, który na
podstawie ścieżki do pliku odsiewa klasy testowe. Uwzględnia jednak jedynie domyślną strukturę mavena, gradle i IntelliJ, więc w przypadku gdy używamy
inne IDE, lub zmieniliśmy konfigurację, to będzie problem.
Tak przygotowana regułę możemy spakować w paczkę i wystawić w firmowym repo.
Podsumowanie
ArchUnit, podobnie jak inne narzędzia do statycznej analizy kodu dopełnia system typów o weryfikację kontekstu wykonania. Pozwala to na ograniczenie ilości kodu, którego jedynym zadaniem mapowanie typów, by zaspokoić potrzeby związane z izolacją poszczególnych modułów. Niby niewiele, ale jednak bardzo dużo.