Gradle Chainsaw Plugin

Gradle Chainsaw Plugin

Prezentacja nowej wtyczki do Gradle'a do budowania aplikacji modułowych Javy 9.

poniedziałek, 9 października 2017

Informatyka

Jak nietrudno się domyślić z lektury bloga, od wielu miesięcy interesuję się nowym systemem modułów, który pojawił się w niedawno wydanej Javie 9. We wpisie Zrozumieć moduły w Javie 9 przedstawiłem całe zagadnienie od strony ogólnej i wspomniałem o wsparciu ze strony istniejących narzędzi, a w szczególności systemów budowania Gradle i Maven. Twórcy Gradle'a opublikowali w lipcu artykuł pokazujący, jak zbudować projekt korzystający z modułów oraz udostępnili wtyczkę experimental-jigsaw. Pobawiłem się nią trochę więcej i niestety okazało się, że ma ona zbyt duże braki funkcjonalne, by mogła być używana produkcyjnie, a na dodatek wszystko wskazuje na to, że projekt został porzucony. Zacząłem rozszerzać kod wtyczki, początkowo z myślą o tym, aby wystawić Pull Request, ale... w końcu stworzyłem na jej bazie zupełnie nowy projekt. Tak powstał Gradle Chainsaw Plugin i w tym wpisie chcę pokazać, jak z niego korzystać.

Przykładowy projekt

Zacznijmy od stworzenia przykładowego projektu o następującej strukturze:

  • /src/main/java/com/example/foo/ - kod projektu
  • /src/test/java/com/example/foo/ - kod testów
  • /build.gradle
  • /settings.gradle

W katalogu z kodem projektu należy zrobić kilka absolutnie dowolnych klas - ich zawartość jest dla nas kompletnie nieistotna.

Będziemy też potrzebowali jakiegoś skryptu Gradle'a potrafiącego zbudować zwykłą aplikację Javy 9 (bez modułów) oraz uruchomić testy z użyciem JUnit 5:

buildscript {
    repositories {
        mavenLocal()
        mavenCentral()
    }
    dependencies {
        classpath 'org.junit.platform:junit-platform-gradle-plugin:1.0.0'
    }
}

plugins {
    id 'java'
    id 'idea'
}

apply plugin: 'org.junit.platform.gradle.plugin'

group 'com.example.foo'
version '0.1.0-SNAPSHOT'

ext.log4jVersion = '2.6.2'
ext.junitVersion = '5.0.0'
ext.mockitoVersion = '2.10.0'

sourceCompatibility = 1.9
targetCompatibility = 1.9

repositories {
    mavenLocal()
    jcenter()
}

junitPlatform {
    filters {
        engines {
            include 'junit-jupiter'
        }
    }
    logManager 'org.apache.logging.log4j.jul.LogManager'
}

dependencies {
    testCompile('org.junit.jupiter:junit-jupiter-api:' + junitVersion)
    testCompile('org.mockito:mockito-core:' + mockitoVersion)
    testRuntime('org.junit.jupiter:junit-jupiter-engine:' + junitVersion)
    testRuntime('org.apache.logging.log4j:log4j-core:' + log4jVersion)
    testRuntime('org.apache.logging.log4j:log4j-jul:' + log4jVersion)
}

Ostatnim plikiem jest malutki settings.gradle:

rootProject.name = 'modular'

Tworzymy deskryptor modułu

Pierwszym krokiem do wprowadzenia modułów jest stworzenie deskryptora /src/main/java/module-info.java, który będzie użyty przez kompilator oraz maszynę wirtualną do rozeznania się, co nasz moduł udostępnia i czego potrzebuje:

module com.example.foo {
   exports com.example.foo;
}

Deskryptory opisywane są przy pomocy specjalnego meta-języka wzorowanego na składni Javy. Z punktu widzenia języka moduł jest kolekcją pakietów i określa ich reguły widoczności. Z tego powodu nazwa modułu zawsze powinna wywodzić się bezpośrednio od nazwy głównego pakietu - to tak, jakbyśmy powiedzieli, że "zajmujemy" ten pakiet wraz z zawartością na wyłączność. U nas wszystkie klasy znajdują się wewnątrz com.example.foo, dlatego tak właśnie zostanie nazwany nasz moduł. Modułu nie należy mylić z artefaktem (archiwum JAR), który powstaje w trakcie budowania - nazwy artefaktu i modułu nie muszą, a nawet nie powinny się pokrywać. Dlaczego Java nie wymusza takiej reguły automatycznie? Wynika to z potrzeb wstecznej kompatybilności, w końcu system modułów pojawia się w języku mającym za sobą 20 lat historii i musi on jakoś działać razem z już istniejącym kodem.

Przy wybieraniu nazwy modułu i tworzeniu pakietów musimy pamiętać o następujących regułach:

  • w Javie 9 pakiet może znajdować się w co najwyżej jednym module. Innymi słowy, nie mogą istnieć dwa moduły zawierające ten sam pakiet; jeśli spróbowalibyśmy ich użyć, pokaże nam się błąd kompilacji.
  • powyższa reguła oraz ustawiane przez nas eksporty (o nich zaraz) nie działają w głąb. Innymi słowy, eksportując pakiet com.example.foo, nie eksportujemy podpakietu com.example.foo.bar (o ile tego wprost nie zaznaczymy).
  • z powodu pierwszej reguły, nie powinniśmy nigdy zmieniać nazw modułów. Wyobraźmy sobie, że rozwijamy projekt A:
    1. wypuszczamy wersję A-1.0 z nazwą modułu com.example.a
    2. ktoś robi projekt B z zależnością od A-1.0 i w deskryptorze deklaruje, że wymaga naszego modułu: requires com.example.a
    3. wypuszczamy kolejną wersję A-1.1, w której zmieniamy nazwę modułu com.example.test.a
    4. ktoś robi projekt C z zależnością od A-1.1 i w deskryptorze deklaruje, że wymaga naszego modułu: requires com.example.test.a
    5. ktoś inny robi projekt D, w którym daje zależności od B i C,
    6. okazuje się, że projektu D nie da się skompilować, ponieważ Java widzi dwa moduły: com.example.a oraz com.example.test.a z tymi samymi pakietami.

Taki scenariusz zwie się module hell i musimy go unikać za wszelką cenę.

Popatrzmy teraz, co może znajdować się we wnętrzu deskryptora. Oto wykaz dostępnych konstrukcji:

Konstrukcja Znaczenie
requires <nazwa modułu>; W kodzie naszego modułu możemy używać zawartości wyeksportowanych pakietów innego modułu.
requires transitive <nazwa modułu>; Zależność przechodnia - z zawartości wymaganego modułu będą mogły też korzystać wszystkie moduły bazujące na naszym.
requires static <nazwa modułu>; Zależność opcjonalna - podany moduł jest potrzebny do kompilacji, ale nie musi być załadowany w trakcie uruchomienia - dopóki ktoś nie skorzysta z klasy używającej rzeczy z tego modułu, nic się nie stanie.
exports <nazwa pakietu>; Eksport pakietu - inne moduły, które wymagają naszego modułu, będą mogły korzystać z jego zawartości (kompilacja, wykonanie, refleksja).
exports <nazwa pakietu> to <lista modułów>; Możemy też wyeksportować pakiet tylko do wybranych modułów (ich nazwy oddzielamy przecinkami).
opens <nazwa pakietu>; Słabsza wersja eksportu - zawartość pakietu nie jest widoczna podczas kompilacji, ale możliwy jest dostęp przez refleksję w trakcie wykonania programu.
opens <nazwa pakietu> to <lista modułów>; Analogicznie, jak w przypadku eksportu - otwieramy nasz pakiet tylko dla wybranych modułów.
uses <interfejs serwisu>; Wsparcie dla Service Loadera: nasz moduł wystawia punkt rozszerzania w postaci interfejsu, dla którego inne moduły mogą dostarczyć implementacje.
provides <ifc> with <lista impl.>; Wsparcie dla Service Loadera: Nasz moduł dostarcza implementacje serwisu wystawionego przez inny moduł.

Deklaracjami, z których będziemy najczęściej korzystać, są oczywiście requires oraz exports. Pierwszej z nich musimy użyć za każdym razem, gdy chcemy w naszym module użyć jakiejś wyeksportowanej klasy z innego modułu. Druga natomiast służy do wystawiania klas dla innych modułów. To jest właśnie najciekawsza część całego systemu modułów i wypełnienie ważnej luki. Ile razy zdarzyło się Wam, że po wydaniu kolejnej wersji biblioteki ktoś przyszedł i narzekał, że coś mu przestało działać, a po sprawdzeniu okazało się, że użył jakichś wewnętrznych bebechów, o których istnieniu nie powinien wiedzieć? Ile razy zaimportowaliście klasę ImmutableList z pakietu jersey.repackaged.com.google.guava, zamiast z "oficjalnej" Guavy? Ile razy usuwaliście taki import z kodu zrobiony przez kogoś innego? Moduły pozwalają łatwo sobie z tym poradzić - po prostu teraz mamy pełną kontrolę nad tym, które pakiety udostępniamy i komu. Wewnętrzne zabawki trzymamy w pakietach, których nie eksportujemy i sprawa jest rozwiązana.

Po takim wstępie powinno być jasne, co nasz moduł robi - eksportuje zawartość pakietu com.example.foo innym.

Jak to zbudować?

Kolejnym krokiem jest zbudowanie modułu. Gradle nie posiada obecnie oficjalnego wsparcia dla budowania modułowych aplikacji i tu właśnie do akcji wkracza wtyczka Chainsaw. Jej użycie jest stosunkowo proste. Wszystko, co musimy zrobić, to ją załadować i ustawić nazwę naszego modułu:

plugins {
    id 'java'
    id 'idea'
    id 'com.zyxist.chainsaw' version '0.1.3'
}

// ...

javaModule.name = 'com.example.foo'

Nazwa modułu jest potrzebna, aby wtyczka mogła ustawić odpowiednie flagi dla kompilatora oraz maszyny wirtualnej. Technicznie nic nie stoi na przeszkodzie, aby nazwę tę wyciągać z deskryptora, poza tym, że konieczne jest napisanie kawałka kodu, który by potrafił taki plik przeczytać. Pamiętajmy, że minimalne wymagania Gradle'a to Java 7, dlatego nie można do tego celu użyć oficjalnych API z "dziewiątki". Dodatkową funkcjonalnością dostarczaną przez moduł jest sprawdzenie zgodności nazwy modułu z głównym pakietem. Jeśli te nazwy będą różne, dostaniemy błąd.

Dodajmy testy

Testy jednostkowe to interesujący przypadek użycia dla systemu modułów. Wewnątrz katalogu /src/test/java nie tworzymy żadnego deskryptora. Kompilator widzi je jako część naszego modułu (tylko na potrzeby ich wykonania, nie będą oczywiście zapakowane do końcowego archiwum), dzięki czemu możemy bez problemu używać w nich dokładnie tych samych pakietów oraz testować wewnętrzne zabawki, do których normalnie nie byłoby dostępu. Pojawia się jednak pewien problem z wykorzystaniem dodatkowych bibliotek testowych takich, jak choćby API JUnita, czy Mockito. Nie chcielibyśmy z oczywistych względów umieszczać ich w deskryptorze modułu.

Chainsaw pomaga nam tutaj na dwa sposoby. Po pierwsze, pozwala na zdefiniowanie w pliku build.gradle dodatkowych modułów testowych, które zostaną podrzucone kompilatorowi testów i maszynie wirtualnej dynamicznie, poprzez odpowiednie flagi. Po drugie, potrafi rozpoznać na liście naszych zależności zarówno JUnit 4, jak i JUnit 5 i dodać odpowiednie moduły za nas. Dlatego wszystko, co pozostaje nam zrobić, to poinformować go również o bibliotece Mockito:

// uwaga, ta nazwa modułu została wygenerowana automatycznie przez Javę!!!
// od wersji 2.10.5 mockito będzie używał oficjalnej nazwy org.mockito
javaModule.extraTestModules = ['mockito.core']

Spróbujmy stworzyć sobie prosty test, aby upewnić się, że wszystko działa:

package com.example.foo;

import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;

public class FooTest {
   @Test
   public void shouldDoSomething() {
      Object obj = mock(Object.class);
      assertTrue(true);
   }
}

Próba kompilacji powinna się powieść, a po uruchomieniu powinniśmy zobaczyć raport z testów.

Problematyczne zależności

Wydawałoby się, że to już wszystko, w czym moglibyśmy potrzebować pomocy. Nic bardziej mylnego! Wspomniałem już wcześniej, że moduły pojawiają się w języku, który ma za sobą 20-letnią historię? Oznacza to, że na wolności hula sobie całkiem sporo JAR-ów, które z modułami niezbyt dobrze współpracują. Gdy próbujemy użyć w naszej modułowej aplikacji archiwum JAR bez deskryptora, Java robi z niego tzw. "automatyczny moduł":

  • automatyczny moduł eksportuje wszystkie swoje pakiety i ma dostęp do wszystkich innych modułów (oraz wyjątkowo do classpath, który normalnie w modułach jest nieużywany),
  • nazwa automatycznego modułu wybierana jest automatycznie:
    • albo na podstawie wpisu Automatic-Module-Name w manifeście,
    • a jeśli takiego nie ma, generowana jest z nazwy archiwum JAR.

Tym, co może nam zaszkodzić w pierwszej kolejności, to brak wpisu Automatic-Module-Name w manifeście. Musimy wtedy bazować na de facto losowej nazwie, która nie ma nic wspólnego z tym, co w module siedzi. Jeszcze gorzej jest, kiedy częścią tej losowej nazwy są słowa zastrzeżone w Javie. Przykładowo, jeśli nasze archiwum nazywałoby się foo-native, to Java wybierze dla niego nazwę foo.native, jednak nie będziemy w stanie jej użyć. Dlaczego? native to słowo kluczowe - nie można go używać ani w nazwach pakietów, ani... w nazwach modułów! A jeśli opublikujemy nasz moduł zależny od takiej losowej nazwy, a później twórcy tamtej biblioteki dodadzą odpowiedni wpis, trafimy na scenariusz module hell przedstawiony we wcześniejszej części wpisu. Dlatego pamiętajmy o jednej zasadzie:

Nigdy nie publikuj w repozytoriach (Maven Central, JCenter, firmowy Artifactory itd.) modułów mających zależności do archiwów JAR, które ani nie są modułami, ani nie mają wpisu Automatic-Module-Name w manifeście.

Niestety z problemami nazewnictwa Chainsaw nie bardzo jest nam w stanie pomóc, ponieważ ciężko, żeby wypakowywał ściągnięte z sieci JAR-y i coś w nich zmieniał. To jednak nie koniec potencjalnych problemów. Na początku wpisu wspomniałem o zasadzie, że dwa moduły nie mogą zawierać tego samego pakietu. Jednak aż do Javy 9 nie było takiego wymagania i w sieci istnieje całkiem pokaźna liczba JAR-ów, które powielają te same nazwy pakietów (tzw. package split). Jeśli przypadkiem będziemy chcieli użyć dwóch zależności korzystających z tego samego pakietu, kompilator zacznie rzucać błąd. I tu możemy skorzystać z kolejnego dobrodziejstwa Chainsaw o nazwie patchowanie modułów, będącego rodzajem tymczasowej taśmy klejącej pozwalającej nam pospinać wszystko, dopóki autorzy innych bibliotek nie uporządkują sobie paru rzeczy w swoim kodzie.

Najbardziej niechlubnym przykładem takiego package splitu jest niepozorne archiwum jsr305 zawierające zestaw adnotacji wykorzystywanych przez statyczne analizatory kodu. Problem polega na tym, że JSR-305 został porzucony i oficjalnie nigdy się nie ukazał, tymczasem wspomniane archiwum to losowe adnotacje pozbierane przez twórców projektu FindBugs. Niestety, wykorzystują one pakiet javax.annotation, kolidujący z pakietem dostarczanym przez samo JDK oraz oficjalnie wydany JSR-250. jsr305.jar zanieczyścił całkiem sporą część środowiska Javy, wliczając w to tak znane biblioteki, jak Google Guava. Przypuśćmy, że mamy to nieszczęście, że w zależnościach mamy zarówno jsr305.jar, jak i jsr250-api.jar i kompilator odmawia nam współpracy. Tym, co należy zrobić, to spatchować drugi z JAR-ów pierwszym:

javaModule.patchModules 'com.google.code.findbugs:jsr305': 'javax.annotation:jsr250-api'

dependencies {
    patch 'com.google.code.findbugs:jsr305:1.3.9'

    compile 'javax.annotation:jsr250-api:1.0'
    compile 'com.google.guava:guava:23.1-jre'
}

Działanie:

  1. zaczynamy od poinformowania, że jsr250-api będzie patchowany przez jsr305,
  2. później musimy wpisać zależność od JSR-305 w specjalnej konfiguracji patch,
  3. wtyczka wytnie wspomnianą zależność ze wszystkich innych konfiguracji. Konfiguracja patch jest właśnie po to, by Gradle wciąż tego problematycznego JAR-a widział, gdyż będzie on wciąż potrzebny.

Wynikiem powyższego działania będzie "wirtualny" automatyczny moduł jsr250.api zawierający adnotacje z obu archiwów. Gradle będzie pamiętał o tym podczas kompilowania, uruchamiania testów oraz odpalania aplikacji z poziomu samego Gradle'a. Jeśli jednak chcielibyśmy później wynikowy JAR uruchamiać normalnie, np. poprzez skrypt shellowy, to musimy pamiętać o ręcznym dodaniu do wywołania polecenia java flagi --patch-module. W naszym deskryptorze wpiszemy:

module com.example.foo {
   requires jsr250.api;  // uwaga - wygenerowana, niestabilna  nazwa modułu!!!
   requires guava; // uwaga - wygenerowana, niestabilna nazwa modułu!!!
}

Powyższego rozwiązania nie powinniśmy traktować jako "docelowego". Patchowanie to ma charakter tymczasowy, dopóki twórcy naszych zależności nie posprzątają swojego kodu i nie dostosują się do nowych reguł. Oczekiwane jest, że z biegiem czasu takich archiwów będzie coraz mniej, a my będziemy mogli się patchowania pozbywać.

Podsumowanie

Dzięki wtyczce Chainsaw możliwe jest pobawienie się modułami bez konieczności ręcznego kompilowania kodu Javy. Przetestowałem ją w praktyce na kilku projektach i potwierdziłem, że potrafi radzić sobie z problematycznymi sytuacjami, na jakie dotąd trafiłem. Planuję ją dalej rozwijać tak, aby system modułów jak najszybciej zawitał pod strzechy. Tymczasem zapraszam do odwiedzenia strony projektu oraz oznaczenia go gwiazdką na Githubie:

Tomasz Jędrzejewski

Programista Javy, lider techniczny. W wolnych chwilach podróżuje, realizując od kilku lat projekty długodystansowych wypraw pieszych.

Autor zdjęcia nagłówkowego: kimmo tirkkonen, CC-BY-2.0

Komentarze (0)

Skomentuj

Od 3 do 40 znaków.

Wymagany, nie będzie publikowany.

Odpowiedz na pytanie.

Edycja Podgląd

Od 10 do 8000 znaków.

Wszystkie komentarze są moderowane i muszą być zatwierdzone przed publikacją.