17. 3. 2014

Google Guava – bilancování v předvečer releasu Javy 8

Tento článek záměrně načasovávám na dobu, kdy vychází release GA Javy 8. Pozoruji, jak se už cca od Vánoc na internetu začínají množit články typu "Lambda funkce snadno a rychle" nebo "X nových věcí v Javě 8" (oblíbený formát dzone.com) a počítám s tím, že po 18.3.2014 bude svět Java vývojářů tímto tématem ještě více zaplaven. Proč jsem si tedy vymyslel, že zrovna teď napíšu o Guavě? Není tato knihovna utilit s příchodem Javy 8 už mrtvá nebo alespoň za zenitem?

Následující zamyšlení možná pomůže se odpovědi na tuto otázku přiblížit. Guavu aktivně používám od verze 7, která byla vydána roku 2011. Vyzkoušel jsem na našem projektu velkou část funkcionality, kterou nabízí. Odebírám maily z issue trackeru. Navrhl jsem jeden malý enhancement, který byl akceptován. Uspořádal jsem v našem týmu interní školení se zaměřením na to, jak naše dosud používané patterny v kódu psát efektivněji pomocí Guavy. Výrazně mi usnadnila myšlenkový přechod z objektově-imperativního paradigmatu na objektově-funkcionální. Rád bych zde shrnul svůj pohled na její hlavní přednosti a slabiny a na to, jak je případně ovlivní nástup Javy 8.

Co se mi na Guavě líbí

Nabízí nové datové struktury

Každý, kdo navrhuje nějaký algoritmus, dobře ví, jak důležité je zvolit správné a efektivní datové struktury a Guava tým v tomto jistě není výjimkou. Doba, kdy jediný způsob, jak reprezentovat neskalární data, je seznam, je doufám nenávratně pryč a když mi ji někdo připomene např. kódem, který namísto použití Set prochází v O(n) seznam, zda v něm neexistuje prvek, který má v úmyslu vložit, vytahám ho za uši. Pokud je pro vás samozřejmé vědět o asymptotické složitosti základních kolekcí, pokud máte v hlavě zažité rozhodování á la např. tento diagram, pokud často potřebujete struktury jako Map<Klíč,Set<Hodnota>> nebo máte představu, k čemu je dobrá mapa s unikátními hodnotami, pak si v packagi com.google.common.collect přijdete na své a multisety, bimapy, tabulky a jiné se stanou cenným pomocníkem ve vaší práci.

Je škoda, že takové třídy nebyly přidány do Javy 8, v níž by byly ještě užitečnější díky streamům.

Minimalizuje objem pomocného kódu na projektu

Nepočítám-li projekty, u kterých je neexistence takového kódu cílená a zdůvodněná, asi bychom v běžném životě těžko hledali projekt, který by neměl alespoň jednu třídu Utils, Helper, apod. V takové třídě se shromažďuje vše, co je příliš obecné v porovnání s doménou řešeného problému, co se často opakuje (v horším případě u čeho vývojář podlehne klamnému dojmu, že se to bude často opakovat :-)) nebo prostě která se jinam nevešla. Napsat takové třídy může být lákavé pro někoho, kdo si chce zaprogramovat, ale představuje to zátěž pro údržbu a jako komponenta s vysokou mírou znovupoužití přináší zvýšené nároky na kvalitu a zpětnou kompatibilitu. Použitím Guavy se značně zvýší pravděpodobnost, že tyto utility nebude nutné psát resp. že volání stávajícího proprietárního kódu půjde poměrně snadno refactorovat na volání Guavy.

Promyšlený kontrakt utility metod


Na API všech tříd Guavy je vidět, že ho navrhoval někdo, kdo jej opravdu používá a ví, že kód se jednou píše, ale mnohokrát čte. Je patrné, že řešený problém je důkladně zanalyzován a oproti např. Apache commons je mnohem důsledněji aplikováno objektové paradigma. Klasickým příkladem je třída Splitter, která rozlišuje vlastnosti procesu dělení řetězce (metody splitteru) a samotný vstup (parametry metody split), v protikladu ke skoro kombinatorické explozi čtrnácti nejrůznějších přetížení metod začínajících split... ve třídě StringUtils v Apache commons. Podobně ze srovnání vyjde CharMatcher a metody StringUtils.is.... Přetěžování metod se přitom v Guavě nebojí – když uznají za vhodné, udělají klidně 13 přetížení ImmutableList.of a 6 přetížení ImmutableMap.of. Tato čísla nejsou od stolu vystřelené Pišvejcovy konstanty, ale jsou podložena statistickými měřeními v codebase Googlu. Hojně se používá fluent API (řetězení metod). Názvy metod jsou akční, s důrazem na maximálně vypovídající popis činnosti metody (srovnej např. pojmenování Strings.nullToEmpty s ekvivalentním StringUtils.defaultString v Apache commons). To mi mimochodem připomíná, že až na název metody of a pár dalších specifických míst se dá ve většině případů použít statický import metody, a to bez velké újmy na čitelnosti.

Tento přístup je příkladný i pro práci na vlastním projektu. Jeho nejčastějšími opaky, kterých bývám svědkem, jsou: nedostatek úsilí věnovaného výstižnému pojmenování, použití příliš abstraktních termínů náchylných k tomu, že si pod nimi každý představí něco jiného, a děravý návrh meziobjektové komunikace (přílišná závislost na interním stavu, tichý předpoklad volání metod v určitém pořadí nebo volání metody předka apod.). Podívejte se např. na diskusi (včetně Google+), která se vedla o to, zda se u stopek pro měření času má nahradit volání new Stopwatch().start() za factory metodu Stopwatch.createStarted(), protože je tak z názvu víc zřejmé, zda jsou stopky spuštěné a eliminuje se nejasnost, v jakém stavu jsou po skončení konstruktoru. Kolik programátorů by nad tím mávlo rukou?

Oracle si je u Javy jistě vědom této nutnosti udělat API co nejpříjemnější a nejlogičtější. Situaci docela vystihl Marcus Lagergren – speaker z Oraclu na Geeconu 2013, kterého jsem se na toto téma – zda si Java 8 vezme v tomto inspiraci z úspěšných opensource projektů – ptal, a jehož odpověď zněla "We inspired from everything." Pokud se mám držet výše uvedených ukázek, v Javě 8 teď nacházíme jak jednoduché metody jako String.join nebo Objects.toString připomínající stavbou a pojmenováním Apache commons, tak složitější (byť oproti Guavě méně všestranné) konstrukce jako StringJoiner. Dlužno připomenout, že Java 8 měla pro přidání metod o jednu motivaci navíc: použití jako method reference. Je taky férové dodat, že je dobře, že se Oracle nesoustředil na tyto titěrnosti, ale na věci podstatné a principielní. Takže máme Optional, které nahrazuje guavácké Optional, CompletableFuture zhruba nahrazující guavácké ListenableFuture a především streamy. Při zamhouření oka si lze představit, že aplikace streamů na výsledek String.split nebo použití Pattern.splitAsStream bude srovnatelně čitelné s použitím guaváckého Splitteru.

Realistický přístup k funkcionálnímu programování

Funkcionální paradigma za poslední roky prokázalo, že si zaslouží návrat na scénu. Dokládá to množství nových funkcionálních jazyků a funkcionálních rysů do existujících jazyků včetně Javy 8. Nechci zde rozvíjet úvahy, zda to je či není pozdě. V duchu výroku "Opravdový programátor umí psát FORTRANské programy v kterémkoliv jazyce." ze slavného článku z roku 1983 je spíš zajímavé sledovat, jak se dá funkcionálně programovat v Javě před verzí 8. Nic moc, že? Podpora v jazyce vesměs žádná, zápis anonymních tříd zdlouhavý. Tak si něco napíšu, řekne si autor budoucí knihovny. Tady je právě ta past: tato oblast není zas až tak těžká na naprogramování – napsat interface Predicate nebo Function a kód, který pomocí nich prochází generickou kolekci a transformuje/filtruje/redukuje její prvky, s trochou zkušenosti celkem jde. To, co snahu o překlenutí absence funkcionálních rysů dělá úspěšnou, jsou zde více než jinde netechnické aspekty. Nestačí dát jen nástroj, ale i jakousi filozofii, jak s ním mají uživatelé nakládat a přitom si zachovat zdravý odstup s vědomím, že to, co používají, je v podstatě jen workaround, protože samotný jazyk nám, co si máme povídat, nepomáhá.

Tím si vysvětluji, proč se vyrojilo tolik knihoven pro podporu funkcionálního programování v Javě, z nichž většina je téměř neznámá: Apache Commons, Functional Java, FunctionalJ, LambdaJ, Jambda, Totally lazy, Bolts, Fun4J, nadstavba nad Guavou Fugue... Nechci, aby následující text vyzněl cynicky, vážím si práce všech, kteří produkují opensource software, ale většina knihoven nabízí v podstatě jen vlastní funkcionální interfacy (které se mezi knihovnami liší jen pojmenováním) a nad nimi nějakou sadu utility metod. Ta jde mimochodem někdy pěkně do šířky (osobně si neumím představit, kdy by se mi vyplatilo např. použít toto místo klasické konstrukce switch). Známější knihovny pak použivají chytřejší triky, jejichž výhod i úskalí jsou si předpokládám jejich uživatelé vědomi: proxy objekty (LambdaJ) nebo preprocessing nějaké čitelnější syntaxe.

Filozofie, kterou Guava ve funkcionálním programování razí, je: "dobře, tady je funkcionální podpora, ale je to nutné zlo, jazyk na to stejně není vybaven", případně "nepřeplácejte to s anonymními třídami, nesnažte se o one-liner za každou cenu" (slušné znění). V issue trackeru resp. na StackOverflow lze najít zoufalé příspěvky typu "jak se mám podrbat levou nohou za pravým uchem" – rozuměj jak zřetězit šňůru anonymních predikátů a funkcí k dosažení požadované transformace dat – následované trpělivou reakcí někoho z Guava týmu typu "použij obyčejný for/if". Přiznávám, že jsem si tímto obdobím taky prošel, vyléčil se z něj a o to víc přístup Guavy vnímám jako pokorný a pravdivý.

Java 8 tento realistický přístup zachovává, vždyť tělo lambda funkce může být imperativní blok kódu. Je tedy trochu paradoxem, že ten, kdo dosud programoval imperativně nebo pouze pomocí anonymních tříd, bude mít z přechodu větší dojem zlepšení než ten, kdo využíval složitější konstrukce. Např. pro výběr všech osob ve věku 36 let je přechod z anonymního predikátu

Iterables.filter(personSet, new Predicate<Person>() {
    public boolean apply(Person p) {
        return p.getAge() == 36; 
    } 
}

na lambda funkci

personSet.stream().filter(p -> p.getAge() == 36)

poměrně značná úspora, zatímco při realizaci pomocí

Function<Person,Integer> ageFunction = new Function<Person,Integer>() {
    public Integer apply(Person p) {
        return p.getAge(); 
    } 
}
Iterables.filter(personSet, Predicates.compose(Predicates.equalTo(30),Person.ageFunction));

jen ušetříme funkci díky referenci na metodu, ale vlastní kód se o tolik nezjednoduší:

personSet.stream().filter(Predicates.compose(Predicates.isEqual(30),Person::getAge));

Jsem velmi zvědavý, jaký bude další život funkcionálních rysů v Guavě. Určitě tam ještě nějakou dobu budou – cílovou skupinou současné Guavy jsou uživatelé Javy 5 až 7. Nicméně nedivil bych se, kdyby je jednou zcela vyhodili. Díky tomu, že lambda funkce je kompatibilní s každým funkcionálním interfacem, s jehož jedinou abstraktní metodou se shoduje v typech parametrů a návratové hodnoty, by tento přechod mohl být celkem pohodlný pro uživatele nejen Guavy, ale všech ostatních výše vyjmenovaných funkcionálních knihoven.

Symptomy živého opensource projektu

Guava je knihovna Googlu, není proto překvapením, že je hostována na Googlecode a ne třeba na Githubu. Na oficiální site projektu je vidět, že nejsou zanedbány žádné vlastnosti dobrého opensource projektu. Cenným zdrojem informací je wiki návod – soubor stránek s názvem končícím ...Explained. Jednoduchý issue tracker se srozumitelnými stavy issues, vyznačením, zda je příspěvek od člena Guava týmu, a flexibilním avšak nepřeplácaným vyhledávačem. Pozitivní přijetí komunitou, komunikace přes StackOverflow i Google+.

Nechci se v tomto ohledu pouštět do porovnávání Guavy a Javy, jedna knihovna a celá platforma jsou zkrátka nesrovnatelné. Přesto mi přijde, že porovnávat s Javou knihovnu typu Guava je přece jen o malinko menší blbost než jinou knihovnu. Guava cílí na to být obecně (znovu)použitelná a univerzální. Neuškodí jí ani to, že je spojovaná s velkou společností (mám pochopení, pokud to někomu dává dobrý pocit, ale reálně nemám tušení, do jaké míry je to v Googlu aktivita nadšenců a do jaké míry to má politickou podporu). Java jako platforma má samozřejmě taky zvládnuté věci vyjmenované v předchozím odstavci, je to ale přece jen větší kolos. Zatímco nástroje typu Googlecode a způsob, jakým je dobré opensource projekty využívají, jsou pro mne intuitivní a rychle pochopitelné, v protikladu k tomu musím přiznat, že procesu JCP a způsobu, jakým se tvoří a vyřizují JSR a JEP, rozumím jen velmi povrchně. Nepůsobí na mne "nízkoprahově", nemám z něj dojem otevřených dveří.

Pružné zacházení s novými nebo odstavenými featurami

"Compatibility matters" – toto motto si pamatujeme ještě z doby Sunu. Bylo jedním z příčin úspěchu Javy, protože sázelo na lidskou nechuť měnit něco, co funguje. Díky němu si i dnes mohu být teoreticky jistý, že mi můj úžasný program, který projde data v Dictionary, zobrazí je pomocí GridBagLayoutu a pak se ukončí pomocí Thread.stop(), stále poběží. (Čtenář jistě pochopí moji lenost doplňovat k těmto třídám živé odkazy :-)). Guava tuto zátěž vytvářet nechce. Deprecované API (když už nějaké je) je odstraněno 18 měsíců od označení @Deprecated. Neustálené nebo experimentální API je označeno anotací @Beta, která představuje jakousi výstrahu "používat na vlastní nebezpečí". Je vidět, že Guava vznikla v prostředí, kde kód hodně žije, a proto i od projektů, které ji používají, očekává jistou tvárnost. Specifickým fenoménem, který do tohoto bodu řadím, je hřbitov nápadů – seznam featur, o kterých se autoři zařekli, že tam nikdy nebudou a přes to nejede vlak.

I když ani v tomto aspektu nelze Guavu s Javou porovnávat, vnímám pozitivně jak dynamičnost Guavy, tak konzervativní postoj Javy.

Co mi na Guavě vadí

Funkcionálních rysů mohlo být přece jen víc

Ne zas o moc, abych si neprotiřečil výše napsané oslavné ódě :-). Ale často postrádám vše, co souvisí s prací s dvojicemi: Pair, operace fold, BiFunction, případně transformace známá z Pythonu pod názvem zip. Také FluentIterable (analogie sekvenčního streamu z Javy 8) bylo přidáno až ve verzi 12, přičemž ostatní fluent kolekce a mapy zatím nejsou a je docela možné, že teď už se s nimi dělat nebudou. Totéž platí zřejmě i pro nedostatek pomocných funkcí a predikátů, např. by se hodilo cosi jako Collections.sizeFunction nebo Strings.lengthFunction. To ale Java 8 aspoň vyřešila mechanismem "method references".

Open source verze je podmnožina interní knihovny Googlu


Tento bod nepatří v pravém smyslu do výčtu negativ. Pokud chcete používat Guavu, počítejte s tím, že v issue trackeru můžete narazit na řadu připomínek, na které Guava tým reaguje: "jo, to už používáme interně, možná to někdy releasnem", takže aktuálně je jediná možnost si to sám naprogramovat. O důvodech můžeme asi jen spekulovat, ale myslím, že nejčastějšími důvody jsou nevyjasněnost, zda požadavek zapadá do filozofie Guavy, a nedostatek statistických měření z codebase.

Případně (mám ten dojem např. odtud – všimněte si datumu) i strategicko-obchodní důvody a snaha nedělat knihovnu zas úplně dokonalou – Google je komerční firma a nic opensourcovat nemusel; pokud tak udělal, jsem za to jedině rád a rozhodně tento bod nemíním jako výtku.

Odmítání a váhavost u některých požadavků

O přidání nějaké featury do Guavy se rozhoduje podle principu utility times ubiquity. To znamená, že featura má šanci se do Guavy dostat, pokud je v porovnání s implementací bez Guavy významným přínosem v oblasti množství napsaného kódu, čitelnosti nebo výkonu (utility) a pokud pro ni existuje množství use casů (ubiquity). Zaručený způsob, jak dostat zamítnutí požadavku, je napsat, že by to v Guavě mělo být jen tak pro úplnost. Na spoustě požadavcích v issue trackeru je sice vidět, že mnoho uživatelů o tomto pravidlu neví a dostávají reakci zaslouženě, nicméně v některých případech ho Guava tým dodržuje až příliš křečovitě.

Příkladem je třeba chybějící přetížení metody Iterables.getFirst() bez defaultní hodnoty (oficiálně doporučeným workaroundem je prosté volání iterator().next()), zatímco Iterables.getLast() má přetížení s defaultní hodnotou i bez ní. Domnívám se, že ustoupit z principu ortogonality ve prospěch principu jednotnosti a abstrakce by v tomto případě bylo lepší.

Dalším jinak správným postojem, který bývá v případě Guavy doveden do extrému, je dlouhé promýšlení nového požadavku. Uživatel pošle návrh na přidání funkcionality, někdo z Guava týmu mu odpoví, že je to specifický případ něčeho obecnějšího, issue se přepne do stavu Research a začne běžet čas. K dnešnímu dni to je 190 požadavků, přičemž polovina z nich je starší dvou let. Jak jsem psal v minulém bodu, stavím se ke Guavě s respektem a vděčností. Rozhodně si nepředstavuji, že se celou tu dobu tým radí, jak vyvinout něco úplně dokonalého, mají své práce dost. Je potřeba s tím prostě jen počítat. Dlužno dodat, že v případě, že se dočkám, to pak stojí za to.

Nejvíce mi chybí přidání fluent kolekcí a map, kde mi na současném stavu vadí nepřehlednost, s jakou se do sebe vnořují volání Maps.transformValues(Maps.filterValues(map,predicate),function). Domnívám se, že přechodem na fluent zápis by zde byl princip utility times ubiquity krásně splněn. Podobných případů se ale mezi požadavky ve stavu Research dá najít víc.

O filozofii Guavy taky vypovídá věta "když to nepotřebuje nikdo v Googlu, potřebuje to vůbec někdo?". I když hlavním kritériem pro ubiquity jsou měření na codebase Googlu, při reakci na nový požadavek od někoho zvenčí se Guava tým vždy silně zajímá o realistický, praktický use case. V naprosté většině případů se jedná o dobrou radu od zkušeného programátora, ale je potřeba mít na paměti, že co je dobré pro Google, nemusí být dobré i pro mne.

Každý bod končí přenesením příslušné úvahy na Javu 8 a zde to bude triviální: na ni jsme čekali dlouho a teď jsme se dočkali :-).

Monolitický jar

Guava se distribuuje jako jeden jar, který v aktuální verzi 16 má 2.1 MB. Pro řadu uživatelů už to je moc, nicméně oficiálně doporučovaný workaround je poměrně nestandardní – použití nástroje ProGuard. Na našem projektu je součástí klientské aplikace stahované přes Java Web Start. Vzhledem k ostatním okolnostem je to zatím únosné, přesto se moc netěším na dobu, kdy nastane potřeba tento stav změnit. Bude to pak zřejmě rozhodování, zda se pustit do konfigurace ProGuardu a zásahu do build procesu anebo – alternativně – zda nesáhnout po open source projektu Seeds, což je Guava rozdělená do několika jarů (funkčně shodné, pouze je změněn název hlavního package) dobrovolníkem Jesusem Zazuetou, kterému zřejmě došla trpělivost a udělal analýzu závislostí a rozdělení na submoduly sám (odkazy na nové porty Seeds pro nové releasy Guavy posílá jako komentáře do issue trackeru).

Návod na použití ProGuardu je poměrně vágní, což mi moc nesedí dohromady s precizností ostatních návodů. Vzhledem k tomu, že končí větou "We would like to hear about your experiences using ProGuard with Guava so we can improve this page.", poslední editace stránky je z května 2012 a uživatelská komunita je jinak poměrně živá, vyvozuji z toho, že komunita tento bod filozofie Guavy nepřijímá za vlastní a podobně jako my to vždycky radši nějak překousnou s velkým jarem.


Touto poslední připomínkou ke Guavě se zároveň loučím i s Javou 8, protože očekávám, že popisovaný problém bude vyřešen v Javě 9 – projektu Jigsaw. Ale o tom si povíme zas za 2 roky :-).

Závěr

Domnívám se, že Guava bude mít stále smysl pro Javu 5 až 7, ale v zeštíhlené podobě i pro Javu 8. Guava je mi dobrou pomůckou při plnění programátorských úkolů, kde je nežádoucí přílišné odvádění pozornosti k implementačním detailům. V článku jsem se snažil o prezentaci pragmatického postoje – nejsem fanboy, nenosím ji na tričku a pokud čas ukáže, že je za ni lepší náhrada, nemám nejmenší problém ji postupně opustit. To však nesnižuje uznání, které bych chtěl vyjádřit autorům za to, že taková knihovna existuje a dali ji komunitě k dispozici.

Máte s Guavou taky vyhraněnou zkušenost? Setkali jste se v Guavě s dalšími příklady fenoménů, které jsem popisoval? Budu rád, když se podělíte v komentářích.


8 komentářů:

  1. Pěkné shrnutí, je vidět, že s Guavou máte dost praktických zkušeností. Já si myslím, že s Javou 8 z ní funkcionální prvky nutně nezmizí, ony ty slavné streamy totiž nejsou žádný zázrak. Po pravdě řečeno, jejich API pověsti Javy jakožto ukecaného jazyka zrovna moc nepomůže. Jeden příklad za všechny:

    String s = list.stream().collect(Collectors.joining(", "));

    Guava zde stále vládne:

    String s = Joiner.on(", ").join(list);

    Sice tomu moc nevěřím, ale právě teď by chlapci z Googlu mohli naplno realizovat všechny odkládané plány s fluent predikáty, mapami apod. a vytvořit tak v podstatě méně ukecanou alternativu ke streamům z JDK, které jsou sice krásně mocné a obecné, ale na triviální operace s malými kolekcemi je ten boilerplate stále hrozivý.

    OdpovědětVymazat
    Odpovědi
    1. String s = String.join(", ", list);

      He?

      Vymazat
    2. No jo, ale to lze pouze pokud list je List CharSequencí. Kdyby to byl list čehokoliv jiného, tak už máme smůlu.

      Vymazat
    3. Ta varianta s Collectors.joining je na tom stejně, takže jsem předpokládal, že jde o Stringy. Když Stringy nemám, musím list.stream().map(Objects::toString).collect(Collectors.joining(", ")), což je pravda děsivé.

      Vymazat
    4. Díky za komentáře, i když reaguji se značným zpožděním. Pro mě je zápis list.stream().collect(joining(", ")) stále atraktivní. Statické importy používám celkem dost a operátor :: mi taky nevadí. Guava je v architektuře hodně dole, ale JVM je přece jen níž. Takže těch pár znaků navíc by mi za ten pocit, že se nemusí do projektu přidávat nová knihovna, stálo. To myslím samozřejmě s nadsázkou - Guava tam stejně bude, ale už z ní nebude tolik využíváno (stejně se teď hovoří o tom, jak bude osekaná pro JDK7 a hlavně 8), sníží se koheze, bude snazší vyzobat pro projekt jen některé featury pomocí ProGuardu atd...

      Omlouvám se za pozdní odpověď. Jako odškodné přikládám dva odkazy, které v době vzniku článku existovaly, ale nevěděl jsem o nich a pěkně se s článkem doplňují (něco jsem trefil, v něčem jsem byl mimo :-) :
      http://www.reddit.com/r/java/comments/1y9e6t/ama_were_the_google_team_behind_guava_dagger/?limit=500
      https://plus.google.com/113026104107031516488/posts/ZRdtjTL1MpM

      Vymazat
  2. Kdybych si mel neco profesne vytknout tak to, ze Guavu neznam dostatecne dobre.

    To co se da vytknout Guave tak je to nezachovavani zpetne kompatibility.
    Je to potencialne nebezpecne protoze Guava se pouziva vsude a casto se stane
    ze se vam pres tranzitivni zavislosti sejde vic verzi knihovny. Donedavna jsme meli
    na classpath Guavy od verze 9 do 15. Nastesti s tim nebyly problemy.

    Google rika ze zpetnou kompatibilitu interne neresi protoze pri kazde nove verzi Guavy
    migruji cely svuj codebase pomoci Map/Reduce na novou verzi.

    OdpovědětVymazat