8. 1. 2018

Čitelný toString na method reference

Začalo to nenápadnou souhrou několika drobných okolností za poslední dobu: Nedávno jsem se na twitteru trochu otřel o PowerMock. Nikdy jsem si pořádně nesáhl na problematiku classloaderů. Baví mě pohybovat se na hraně oblasti, kdy se má mezi javisty obecně za to, že něco nejde (objev setAccessible(true) v čase začátků s Javou, objev triku, jak přepsat final referenci v době, kdy jsem žral blog Petera Lawreye, a teď je čas se posunout dál). A konečně brzy po přechodu na Javu 8 jsem asi jako většina javistů poznal, že lambdy a method references jsou nový živočišný druh a je potřeba se je naučit zkrotit. Jedna nepříjemná drobnost při debugování byla absence vypovídající toString() reprezentace.

Motivace

Např. máme-li třídu com.Cat a definujeme objekt Function<Cat,String> fn = Cat::getName;, hodnotou fn.toString() bude něco jako com.Cat$$Lambda$534/1037477394@38071cdb.

V našem reálném projektu je sada testů ověřujících určité properties nebo jejich kombinace na určitých POJO classách. Testy se opírají o predikáty resp. kompozici predikátu a funkce. Např. k otestování property Cat.name postačí jednoduchá helper metoda

static Predicate<Cat> catName(String expectedCatName) {
    return cat -> Objects.equals(expectedCatName, cat.getName());
}

Predikáty je možné pak dále skládat pomocí .and(), .or() a .negate().

Poznámka: ano, totéž by jistě šlo vyjádřit přímo pomocí logických operátorů, ať přímo v kódu testovací metody, nebo v lambdě. Mám ale rád kód, který se koncentruje na co nejčistší vyjádření úmyslu bez balastu okolo (za předpokladu, že existuje helper metoda verify(Predicate<?>... conditions), zápis verify(catName("Tiger").and(catAge(2))) u mne suverénně vede) a v tomto smyslu považuju testy i za něco, na čem si to mohu s použitím funkcionálního paradigmatu vyzkoušet.

Jak ale reportovat, pokud takový test zfailuje? Máme k dispozici pořadí podmínky a případně řádek ze stacktracu. Osobně mi taková informace stačí, nicméně feedbacku od kolegy, že příčina není z chybové zprávy intuitivně vidět, se nedivím – ta defaultní toString hodnota, která je ve zprávě použita, opravdu čitelnosti nepřidá.

Definice cíle

Samotnou kompozici predikátu a funkce řeší Guava. Zatímco v Javě jsou od verze 8 tisíce programátorů okouzleny interfacem java.util.function.Predicate a tím, jak elegantně jsou pomocí lambd implementovány defaultní metody .and(), .or() nebo výše zmíněná kompozice, Guava už od Javy 5 nabízí v třídě Predicates metody and, or, not i compose. Poslední zmiňovaná má tvar compose(predicate, function) a vrátí predikát volající predicate.test(function.apply(input)). Od Guavy 21 navíc guavácký interface Predicate extenduje ten defaultní z Javy, což umožňuje jej volně používat všude, kde se očekává ten javovský.

Guavácká metoda compose, stejně jako ostatní metody pro logické operátory, není implementována lambdou, ale obyčejnou třídou, která překrývá toString() čitelnou implementací – řetězec "<p>(<f>)", kde <p> resp. <f> jsou hodnoty toString() vnitřního predikátu resp. funkce. Je tedy fajn, že použití metody compose() výsledný řetězec "nezkazí", ale za to, co vrátí toStringy skládaných konstruktů, samozřejmě neodpovídá.

Jako predikát testující rovnost by v našem případě šla použít method reference navázaná na instanci – expectedCatName::equals. To ale jednak trpí stejnou nečitelností toString() reprezentace, jednak neřeší porovnání s null. Obě nevýhody se dají sfouknout opět použitím Guavy, v tomto případě metodou Predicates.equalTo(...). A stejně jako u ostatních metod z třídy Predicates i zde je predikát implementován plnohodnotnou třídou s vlastním čitelným toStringem "Predicates.equalTo(<a>)", kde <a> je toString() argumentu, což už je zpravidla jednoduchý typ, v němž je metoda toString() opět překryta.

Při implementaci
Predicate<Cat> catName(String expectedCatName) {
    return compose(equalsTo(expectedCatName), Cat::getName);
}

má takový predikát hodnotu toString() rovnu Predicates.equalTo(Tiger)(com.Cat$$Lambda$534/1037477394@38071cdb).

Zbývá něco udělat s funkcí. Ideální (vyžadující minimální zásah líného uživatele kódu) zápis by vypadal:

Predicate<Cat> catName(String expectedCatName) {
    return compose(equalsTo(expectedCatName), enrichWithToString(Cat::getName));
}

Cílem je implementovat metodu enrichWithToString, aby výsledný zápis byl "Predicates.equalTo(Tiger)(Cat::getName)". Dá se to?

Základní idea

Akceptovaná odpověď na SO otázku sice zní "nedá", to bychom ale nesměli znát, jak to chodí na SO! Jiné odpovědi nebo komentáře už ukazují příznivější odpovědi, i když řešení mají omezení. Trik spočívá v tom, že musí vzniknout proxy instance pro třídu Cat, na ní se definuje interceptor, v jehož těle se zaznamená název volané metody, a nakonec se metoda na proxy instanci vyvolá skrze Function.apply.

Proxy instance je fake objekt, který nemá a vlastně nesmí mít nic společného s jakoukoli užitečnou instancí třídy Cat. Je úplně jedno, co method interceptor vrátí (pouze to nesmí způsobit pád na nekompatibilitu s návratovým typem metody – ideální je proto vracet defaultní hodnotu pro daný typ, což pro primitivní i referenční typy řeší např. guavácká třída Defaults). Také je třeba dodat, že Cat je třída, nikoli interface, proto nestačí vestavěná java.lang.reflect.Proxy a musí se použít externí knihovna, typicky CGLIB.

Uvedený fígl používá (také odkazováno ze SO) např. Benji Weber pro fluent zápis SQL příkazu pomocí Javy, kde pomocí method reference specifikuje typově bezpečným způsobem sloupce v SQL příkazu. (BTW pokud vám to jméno připadá povědomé, ano, je to ten člověk, který přišel i s trikem, jak znásilnit lambdy pro zápis map pomocí šipky.) Vyskytl se i na blogu Hibernatu. Co všechno jsou lidi schopni udělat, jen aby nemuseli odkazovat na field přes řetězec...

Získání classy

První potíž je v tom, že jak Benji Weber, tak Hibernate třídu znají. Pro ně je získání názvu property jen dílčím usecasem zasazeným do širšího kontextu, jen jedním článkem v řetězu metod, na jehož začátku klient třídu (class objekt) explicitně sdělí. Náš případ je odlišný – utility metoda, která se od třídy nemůže odpíchnout. Můžeme udělat ústupek a metodu enrichWithToString definovat jako

<I,O> Function<I,O> enrichWithToString(Class<I> baseClass, Function<I,O> function) {...}

Neodbytnou otázku "šlo by to bez třídy baseClass?" ale takové řešení nezažene. Naštěstí si třídu lze zjistit. Method reference jako taková o třídě neví; pokud však příslušnou funkci serializujeme, lze serializovanou podobu obnovit do instance třídy java.lang.invoke.SerializedLambda. Ta už obsahuje signaturu volané metody (pouze typy, ne názvy!) ve tvaru obvyklém pro virtuální stroj, jak ho známe třeba z programu javap, v našem případě (Lcom/Cat;)Ljava/lang/String;. Protože se jedná o Function, spolehneme se na to, že mezi kulatými závorkami je pouze jeden argument (nenechme se zmást tím, že getter je bez argumentů - u instančních metod se this považuje za první argument) a několika řetězcovými úpravami z něj získáme název classy a Class objekt (související otázky na SO zde a zde, pro jednoduchost vynechávám cyklus přes předky lambda třídy).

Metoda enrichWithToString bude tedy dostávat SerializableFunction, což je jen

public interface SerializableFunction<I,O> extends Function<I,O>, Serializable {}

což na předání method reference nemá vliv. Finální podoba získání třídy je pak:

Class<?> resolveInputClass(Function<?,?> original) throws Exception {
    Method writeReplace = original.getClass().getDeclaredMethod("writeReplace");
    writeReplace.setAccessible(true);
    SerializedLambda lambda = (SerializedLambda)writeReplace.invoke(original);
    String className = lambda.getImplMethodSignature().replaceAll(".*\\(L(.*?);\\).*", "$1").replaceAll("/", ".");
    return Class.forName(className);
}

Instancování classy

Nyní když známe classu, stačí vzít CGLIB, vytvořit pomocný mock a odchytnout na něm volání metody:
String resolveMethodName(Class<?> clazz, SerializableFunction<?,?> original) {
    MutableObject<String> methodName = new MutableObject<>();
    Object proxy = Enhancer.create(clazz, (MethodInterceptor) (obj, method, args, methodProxy) -> {
        methodName.setValue(method.getName());
        return Defaults.defaultValue(method.getReturnType());
    });
    original.apply(clazz.cast(proxy));
    return methodName.getValue();
}
Toto je dost dobré, pokud je Cat obyčejná POJO třída (bean), jejíž konstruktor i gettery jsou public. Bohužel stačí malé omezení a řešení je nepoužitelné:

Instancování classy s privátním konstruktorem

V našem případě je třída Cat immutable a vytváří se s použitím builder patternu. Builder sám je vnitřní třída a aby byl jediným prostředkem, jak instancovat třídu, je konstruktor private. Principem CGLIBu je ale založení subclassy a jako u každé jiné třídy její konstruktor musí vidět na konstruktor předka (zajímavé overview zde). Příslušné zásahy by asi bylo možné udělat reflexí přímo; protože ale na projektu stejně máme PowerMock, nebudem si práci komplikovat a použijeme rovnou jeho nástroje. Proxy ve výše uvedené ukázce vytvoříme takto:
Object proxy = PowerMockito.mock(clazz, (Answer<Object>) invocation -> {
    methodName.setValue(invocation.getMethod().getName());
    return Defaults.defaultValue(invocation.getMethod().getReturnType());
});

Instancování classy s finálním getterem

Další špek nastává, když jsou gettery ve třídě Cat zabetonovány jako finální. Invocation handler se v takovém případě neprovolá, a to ani při použití PowerMocku, jak je uvedeno v poslední ukázce. Proč? Uvedené použití mocku sice zařídí "hacknutí" privátního konstruktoru, ale celkově nezapadá do způsobu, jak se od uživatele PowerMocku očekává, že framework bude používat (více info).

Bylo by tedy fajn nejdřív proniknout do toho, jak PowerMock funguje. Nemám v úmyslu tímto článkem soudit počínání programátorů, kteří ho z nějakého důvodu použijí, a zaměřím se pouze na fakta. Pokud se nějaký test potřebuje provrtat do jinak nepřístupné třídy, ať už kvůli viditelnosti, final modifikátoru nebo static modifikátoru, uživatel frameworku musí spustit test pod PowerMockRunnerem a pomocí anotace PrepareForTest zadat seznam tříd, jejichž omezení chce po frameworku překonat. Framework pak v rámci runneru inicializuje izolované prostředí, jehož základním stavebním kamenem je speciální classloader. Pak tímto classloaderem nahraje všechny classy potřebné pro test (tj. nejen testovací classu, ale i classy ze samotného PowerMocku, CGLIB atd.) s výjimkou hackovaných tříd. Ty předtím pomocí Javassistu upraví tak, že odstraní překážející modifikátory (neplatí pro static metody – ty mají vlastní proces, který jsem moc nezkoumal, ale i ten v zásadě stojí na classloaderu). A pak se test (včetně jednotlivých kroků předepsaných JUnit frameworkem) spustí v prostředí speciálního classloaderu.

Aby bylo jasno, řeknu to ještě jinými slovy a explicitně. PowerMock na jednu stranu obsahuje neuvěřitelné reflexní hacky (např. zasahuje i do tříd na pomezí Mockita a CGLIBu – taková reflexe na reflexi). Na druhou stranu je toto kouzlení s reflexí nepochopitelně těsně svázáno s featurami testovacího frameworku jako např. právě runnery, notifikace, before/after apod., což komplikuje až vylučuje použití samotné reflexní magie zvlášť. Pokud tedy chceme činnost PowerMocku napodobit pro náš usecase, máme dvě možnosti: buď přistoupit na jeho hru a poštvat ho na uměle vytvořenou potěmkinádu oanotovaných pomocných testovacích tříd, anebo pochopit, co framework interně dělá a napodobit to. První možnost uvádím jen pro úplnost, asi všichni intuitivně cítíme, jaká by to byla prasárna. Výsledné řešení je tedy druhá varianta, která upřímně není od PowerMocku zcela odstřižena, ale používá z něj pouze třídy a metody s izolovanou odpovědností a už se nepodřizuje mašinérii okolo testovacího frameworku.

Pokud bychom inlinovali veškerý kód, který v PowerMocku při testu běží a ořezali ho jen na věci související s naším usecasem, pak dostaneme:

interface MockLoaderWork {
    String getMethodName(MockClassLoader mockLoader, String className, byte[] functionSerializedInParentClassLoader) throws Exception;
}

class MockLoaderWorkImpl implements MockLoaderWork {
    private String methodName;
    public String getMethodName(MockClassLoader mockLoader, String className, byte[] functionSerializedInParentClassLoader) throws Exception {
        Class<?> classLoadedByMockLoader = Class.forName(className, false, mockLoader);
        Object mock = PowerMockito.mock(classLoadedByMockLoader, (Answer<Object>) invocation -> {
            methodName = invocation.getMethod().getName();
            return Defaults.defaultValue(invocation.getMethod().getReturnType());
        });
        Function<Object,Object> functionDeserializedInMockClassLoader = SerializationUtils.deserialize(functionSerializedInParentClassLoader);
        functionDeserializedInMockClassLoader.apply(mock);
        return methodName;
    }
}

String resolveMethodName(Class<?> clazz, SerializableFunction<?,?> original) throws Exception {
    MockClassLoader mockLoader = new MockClassLoader(new String[] {clazz.getName()}, new String[] {MockLoaderWork.class.getName()}, null);
    mockLoader.setMockTransformerChain(singletonList(new MainMockTransformer()));
    mockLoader.addClassesToModify(clazz.getName());
    Class<MockLoaderWorkImpl> mockClassLoaderWorkClass = (Class<MockLoaderWorkImpl>)Class.forName(MockLoaderWorkImpl.class.getName(), false, mockLoader);
    MockLoaderWork mockClassLoaderWork = mockClassLoaderWorkClass.getConstructor().newInstance();
    ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
    Thread.currentThread().setContextClassLoader(mockLoader);
    try {
        byte[] functionSerializedInParentClassLoader = SerializationUtils.serialize(original);
        return mockClassLoaderWork.getMethodName(mockLoader, clazz.getName(), functionSerializedInParentClassLoader);
    } finally {
        Thread.currentThread().setContextClassLoader(originalClassLoader);
    }
}

MockClassLoader je již zmíněný pomocný classloader PowerMocku. Je v hierarchii classloaderů potomkem hlavního classloaderu, pod nímž se spouští test. Ponechávám ho, protože má interně dobře vyřešenou delegaci na parent classloader.

Poznámka ke classloaderům: Classloadery fungují jako izolovaná prostředí, v jejichž rámci se obvykle pohybujeme, když hovoříme o třídě a její identitě. Mohou tedy existovat dvě třídy stejného názvu, ale pokud jsou každá nahraná jiným classloaderem, mohou se vyskytnout zdánlivě bizarní chyby jako ClassCastException: com.Cat cannot be cast to com.Cat, nefunguje přiřazení mezi odpovídajícími instancemi nebo má každá svoji instanci, přestože ctí GoF design pattern Singleton. To vše až v runtimu, překladač žádnou z uvedených chyb nepozná. Jak tedy může jeden classloader pracovat s třídami známými pouze druhému classloaderu? Odpověď zní: přes interface. Parent naloaduje interface, child classu, která interface implementuje. Parent nepotřebuje znát konkrétní classu, přistupuje na objekt přes interface, které zná.

V případě PowerMocku je touto dvojicí interface PowerMockJUnitRunnerDelegate a třída PowerMockJUnit44RunnerDelegateImpl, pro náš případ stačí implementovat jednodušší MockLoaderWork a MockLoaderWorkImpl reprezentující práci vykonanou v child classloaderu. Druhý parametr konstruktoru MockClassLoader jsou třídy a package, které se nahrávají parent classloaderem (přesněji: které se nahrávají parent classloaderem nad rámec tříd vestavěných v PowerMocku – takže PowerMockJUnitRunnerDelegate tam být nemusí, náš interface však ano).

MainMockTransformer pak provádí transformaci Javassistem. Přiřazení proměnné mockClassLoaderWork zajišťuje viditelnost instance třídy v child classloaderu parent classloaderem. Pro vlastní práci se pak musí aktuální vlákno oblbnout novým classloaderem (to je opět jen kvůli těsnému provázání PowerMocku s testovacím frameworkem, kdy API pro výrobu mocku používá thread-local objekt pro ukládání stavu fluent chainu). Nakonec je nutné, aby funkci Cat::getName viděl i child classloader, což normálně nejde, ale díky serializaci i toto obejdeme a child classloader si tak vytvoří identickou funkci i u sebe. Zbytek už je stejný.

Závěr


Protože zjišťování je poměrně náročné, ještě výsledek zacachujeme proti identitě method reference. Finální řešení je pak třída implementující interface Function, která deleguje na původní SerializableFunction a implementuje toString() takto:

@Override
public String toString() {
    String result = toStringCache.computeIfAbsent(original, orig -> {
        try {
            Class<?> inputClass = resolveInputClass(original);
            String methodName = resolveMethodName(inputClass, original);
            return String.format("%s::%s", inputClass.getSimpleName(), methodName);
        } catch (Exception e) {
            return original.toString();
        }
    });
    return result;
}

Na poměrně banálním a do jisté míry umělém problému (jehož řešení nicméně do projektu commitnu, s odstupem se to už nezdá jako raketová věda) jsem si vyzkoušel řadu nových věcí.

Prvním přínosem bylo hlubší pochopení vlastností method references. Nalezení názvu metody je usecase učebnicového typu; z příkladů a API method interceptoru plyne, že tímto způsobem bychom mohli odchytnout celý objekt Method. V textu článku jsou zalinkovány odkazy na praktické aplikace, jak syntaxi method references využít pro psaní deklarativního a přitom typově bezpečného kódu. Method references rozostřují hranici mezi kódem a daty, na což jsou zvyklí uživatelé ryze funkcionálních jazyků. I když Java reflection API v tomto nedosahuje jejich elegance, přesto je fajn, že v Javě tento konstrukt existuje a je škoda, když se nějaký problém dá řešit jen s pomocí hacků.

Druhým neméně důležitým přínosem bylo proniknutí do vnitřností PowerMocku a činnosti classloaderů. Podařilo se mi pochopit PowerMock do té míry, abych z něj extrahoval podstatné části a obešel ty nepodstatné. Konstrukty použité z PowerMocku by samozřejmě šly dále "inlinovat" a dosáhnout tak úplné nezávislosti na PowerMocku, což bych pravděpodobně udělal, kdyby se jednalo o hlavní (src/main) kód.

Na konec stojí za zmínku pár drobných praktických zkušeností. Ukázalo se, že Guava není mrtvá ani po releasu Javy 8, neboť tam, kde experiment odhalil nepraktičnost lambd, si Guava s problémem poradila svým sice starým, ale lépe fungujícím způsobem. Zajímavostí byly také clashe s debuggerem při debugování uvedených kusů kódu – při zobrazení proxy instance v debuggeru se na ní provolal toString() pro účely debuggeru, což method interceptor ale také zachytil a tudíž zachycený název metody pak nesprávně vyšel "Cat::toString".

Další materiály:
PowerMock FAQ.
Objenesis - jednoduchá knihovna pro instancování nesnadno instancovatelných tříd.
ClassCastException z důvodu neviditelnosti přes classloadery a ještě jednou.
Jak zjistit, že třída je nahraná classloaderem.
ClassLoader tutorial na JavaWorldu.
Skvělý ClassLoader tutorial od ZeroTurnaround.


Žádné komentáře:

Okomentovat