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.