Moje odkazy
Obsah článku:
vydáno: 18. 5. 2020 02:30, aktualizováno: 14. 11. 2024 20:56
Třetím dílem dnes zakončíme sérii věnovanou komplexitě softwaru (co to je, jak vzniká). Podíváme se na možná řešení tohoto problému a zejména na prevenci, protože úspěšný boj s komplexitou zpravidla začíná už v době návrhu.
Řada problémů a selhání v IT nemá technické nýbrž společenské příčiny, takže začneme těmi (první tři kapitoly), a pak se už dostaneme k těm technickým řešením. Ta se týkají jednak našeho vlastního kódu a jednak závislostí našeho programu a obecně architektury.
Přestože zadání bývá v zásadě dané a moc o něm diskutovat nelze (ostatně naše práce je implementovat zadání pomocí softwaru, nikoli říkat, že ho implementovat nechceme), je třeba pro úplnost zmínit i tuto možnost. Na tvorbě zadání se obvykle podílí více lidí (jak na straně zákazníka, tak na straně dodavatele – a je celkem jedno, jestli to jsou dvě firmy nebo třeba dvě oddělení v jedné firmě) a často to funguje trochu jako tichá pošta, takže úkol, který přistane na stole programátorovi, se může dost lišit od toho, co bylo původně požadováno a co by bylo užitečné. (viz klasický vtipný obrázek s houpačkou) Zároveň zadavatel má často dost zkreslené představy o pracnosti – a to v obou směrech – takže někdy nebude požadovat funkci/vlastnost, která by byla užitečná a nedala by moc práce, protože se bude bát, že by to bylo drahé, zatímco jindy se do zadání dostanou požadavky z kategorie „bylo by hezké kdyby… ale vlastně to moc nepotřebujeme“, které jsou ale zrovna hodně pracné, a někde se cestou ztratí ta informace o tom, jak moc je tento požadavek (ne)důležitý, takže se vyplýtvá hodně energie na něco, co by pro všechny zúčastněné bylo lepší škrtnout.
Z těchto důvodů je potřeba zadání revidovat na základě hrubých odhadů pracnosti nebo i jen neformální diskuse mezi oběma stranami. Požadavky, které nemusíme implementovat, budou mít nulovou komplexitu, bude v nich nula chyb a údržba jejich neexistujícího kódu nás nebude nic stát – to je bezkonkurenčně nejlepší výsledek, kterého nemůžeme jiným způsobem dosáhnout.
A ačkoli to může z pohledu tohoto seriálu znít nelogicky, někdy má naopak smysl posunout zadání opačným směrem tzn. přidat požadavky a rozšířit funkcionalitu. Navýšení komplexity jednoho modulárního (viz níže) softwaru totiž může snížit komplexitu někde jinde, v důsledku čehož komplexita celku klesne – a s ní i celkové náklady na vývoj a údržbu. Kromě toho existuje tzv. vynálezcův paradox: nalezení a implementace obecnějšího řešení může být jednodušší než u konkrétního řešení (které je omezené a méně univerzální, ale přesto může být pracnější).
Na vývoji větších informačních systémů se obvykle podílí více dodavatelů. Ti si sice na jednu stranu konkurují, ale zároveň spolu musí vycházet a spolupracovat. Zákazník pak potřebuje být zadobře se všemi dodavateli, což souvisí i s tím, že se s nimi chce „spravedlivě podělit“ o zakázky. Rozhodnutí, který tým bude který požadavek implementovat, je pak často více politickým nebo obchodním rozhodnutím než inženýrským. Většinou toto rozhodnutí pak implikuje, v kterém systému se daný požadavek implementuje. U svobodného softwaru tomu tak být nemusí, protože kód je sdílený a tým, který má volné kapacity a kompetence může pracovat i na části systému, která není primárně jeho.
Pokud převládne politika a obchod, má to často negativní dopad na kvalitu softwaru (a následně na jeho užitečnost pro zákazníka a cenu údržby), protože se jednotlivé funkcionality implementují na nevhodných místech systému, dochází k několikanásobným konverzím, vznikají zbytečné abstrakce a fasády, duplikuje se logika či data atd. Příliš agresivní obchodní politika dodavatele (snaha získat zakázku za každou cenu), pak může být kontraproduktivní a ztrátová – radost z obchodního úspěchu brzy vystřídá zármutek z inženýrských neúspěchů týmu, který pracuje na pokřiveném a nelogicky strukturovaném systému. Některé zakázky je tedy lepší odmítnout nebo vyměnit za jiné, čímž se využije kapacita týmu a udrží dobré obchodní vztahy, ale nedojde ke zmrzačení struktury informačního systému.
Jednoduché je to v případě, kde jeden dodavatel je hlavní a plní roli systémového integrátora – je to jeho odpovědnost. Pokud je systémovým integrátorem zákazník, potřebuje schopného architekta, který má pravomoc rozhodovat a tyto věci ovlivnit. V případě živelnějšího a chaotičtějšího vývoje je to pak sázka do loterie resp. záleží jen na dobré vůli a příčetnosti jednotlivých dodavatelů.
Nejen z hlediska komplexity je velice důležité znát charakter projektu a produktu, na kterém pracujeme, a mít jasno v prioritách. Když vyvíjíme systém pro příští dekádu nebo dvě, je to zcela jiný případ, než když vyvíjíme jednorázový software, který bude sloužit třeba týden, měsíc nebo půl roku. Také je dost rozdíl, zda se software používá a) ad-hoc v jakémsi polo-ručním režimu, kdy s ním pracuje někdo kompetentní, kdo kontroluje vstupy a výstupy a je schopen případné chyby opravit, nebo zda b) software běží autonomně a přijímá data či požadavky od nedůvěryhodných uživatelů (potenciálních útočníků). Ještě jiná liga je potom software, na kterém závisí lidské životy nebo jehož selhání může vést k velkým finančním nebo materiálním ztrátám.
Pokud se zkušení inženýři na něčem neshodnou, bývá to často způsobené právě tím, že mají odlišnou představu o charakteru projektu a v důsledku toho mají jiné priority. Někdo píše kód s vědomím, že ho bude udržovat i za několik let. A někdo je prudce kreativní, potřebuje používat nejnovější frameworky a experimentální knihovny a nic než současnost pro něj nemá cenu. Každý máme nějakou povahu a výchozí mentální nastavení, ve kterém uvažujeme. V zásadě jsou oba přístupy (až na úplné extrémy) legitimní a mají svoje využití. Důležité ale je tenhle svůj výchozí režim znát a umět se buď přepínat nebo si vybírat projekty, které mu odpovídají.
U nekritických programů s kratší životností lze udělat řadu kompromisů a jedním z nich je i nadměrná komplexita. Tu lze tolerovat resp. risknout, že nestihne způsobit velké problémy, a použít třeba nějaké monolitické hotové řešení obsahující i spoustu nepotřebných funkcí (tj. kódu a pravděpodobně i chyb). Zatímco když jde o seriózní vývoj a software má mít dlouhodobou životnost nebo nám záleží na bezpečnosti, musíme lépe vybírat, z čeho a jak systém poskládáme, a měli bychom usilovat o minimalistické řešení, kde nic nepřebývá. Platí zde klasické pravidlo: konstrukce je dokonalá, ne když už není co přidat, ale když už není co odebrat.
Existuje řada pravidel, doporučení i všeobjímajících metodik, jak správně vyvíjet software. Kromě toho tu máme i návrhové vzory (případně tzv. idiomy) a anti-vzory. Často citovaný je např. soubor principů SOLID obsahující principy: Single responsibility, Open–closed, Liskov substitution, Interface segregation a Dependency inversion. Všeobecně známé doporučení nám říká, že bychom neměli kopírovat/duplikovat kód, DRY (Do not Repeat Yourself), měli bychom mít SSoT (Single Source Of Truth), neměli bychom podléhat syndromu NIH (Not Invented Here), měli bychom zachovávat jednoduchost dle KISS, nebo vyvíjet MVP či dodržovat GRASP.
Někdy se stává, že jdou proti sobě a nelze vyhovět všem doporučením. To je dané jednak tím, že jednotlivá doporučení pocházejí z různých prostředí a jsou určena (byť to třeba není nikde explicitně uvedeno) pro odlišné typy projektů (viz předchozí kapitola). A jednak tím, že někdy dokonalé řešení jednoduše neexistuje a je třeba vybalancovat priority v nějakém vhodném poměru. Ostatně ve fyzickém světě platí totéž – nemůžeme mít výrobek, který je zároveň univerzální, malý, lehký, spolehlivý, levný a snadno vyrobitelný bez pokročilých technologií. Musíme si vybrat, čemu dáme přednost a co obětujeme.
Neměli bychom plýtvat výkonem a psát zbytečně neefektivní kód, ale zároveň bychom neměli optimalizovat předčasně. Donald Knuth k optimalizaci říká: „We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.“
Doporučení a metodiky mají obvykle širší záběr a týkají se obecně vývoje softwaru. V následujících podkapitolách se soustředíme na to, co nám pomůže předejít komplexitě kódu nebo ji snížit. Je to sice jedno z mnoha kritérií/priorit, ale jak jsme si uvedli v prvním díle tohoto seriálu, komplexita má dopady na udržovatelnost, bezpečnost, výkon, náklady na vývoj atd.
Řada lidí považuje např. anemický doménový model za anti-vzor. Tento model spočívá v tom, že nevyužíváme potenciálu objektově orientovaného programování (OOP) a nespojujeme data a chování do objektů – formálně se stále o objekty jedná, ale v jedněch objektech máme převážně data (slouží jako datové struktury), zatímco v jiných chování (slouží jako procedury či funkce). Doménové objekty (entity) zde obsahují minimum logiky. Ta pak bývá definovaná v servisních třídách. Ačkoli se používá objektový (nebo častěji multi-paradigmatický) programovací jazyk, reálně jde spíše o procedurální (nebo někdy funkcionální) programování. Z pohledu OOP se jedná o chybu nebo minimálně sub-optimální návrh, nicméně je otázka, jestli nutně musíme programovat objektově. Existuje např. mnoho úspěšných aplikací nebo celých informačních systémů založených na relačních databázích, kde žádné objekty nejsou a máme zde jen záznamy a procedury (či funkce). OOP by nemělo být nějakým náboženstvím (touhle fází si prošlo někdy na přelomu 80. a 90. let), ale nástrojem, který použijeme, když je nám to k užitku.
Z pragmatického hlediska je třeba si přiznat, že udělat dobrý objektový návrh není triviální úloha a často dojde k selhání už na začátku nebo se během iterativního vývoje původní návrh postupně tak pokřiví, že užitečnost objektů (spojujících data a logiku) zásadně klesne. Dobré OOP je zajisté skvělá věc, ale místo špatného OOP je asi lepší nemít žádné OOP. Pár datových struktur a k tomu pár procedur může představovat nižší komplexitu než zmatený „objektový“ návrh – ten může být těžší udržovat a rozvíjet.
Ve většině případů bude dobré mít kostru programu objektovou a následně používat různé přístupy (programování procedurální, funkcionální, deklarativní…) vhodné pro danou úlohu. Mimochodem, takový návrhový vzor továrna (factory) je vlastně prvkem funkcionálního programování, ačkoli je implementovaný formou objektu. Stejně jako funkci můžeme objekt továrny vytvořit (parametrizovat) na jednom místě a předat jinam, kde se bude volat a vracet nějaké další objekty (nebo klidně další továrny tzn. další funkce).
Můžeme si definovat tři druhy objektů. Jedny jsou obrazem objektů či entit existujících ve vnějším fyzickém světě (např. osoba, materiál, doklad). Druhé jsou konstruktem programátora a nemají svůj obraz ve fyzickém světě – „žijí“ jen uvnitř našeho softwaru (např. různé služby, technická infrastruktura). A třetí fungují jako proxy a zprostředkovávají komunikaci s nějakou entitou ve fyzickém světě nebo s externím systémem. Na ty první bývá nejlepší se dívat jako na záznamy (prosté datové struktury) a pracovat s nimi procedurálně – tyto objekty totiž nemají žádné chování, samy od sebe nic nedělají a jen pasivně čekají, až s nimi někdo jiný pohne (synchronizuje stav v informačním systému se stavem vnějšího světa). Ty druhé jsou „živé“ a vlastní chování skutečně mají – k nim je tedy dobré přistupovat objektově a aplikovat na ně doporučení a principy pro správný objektový návrh. Třetí typ objektů je na tom podobně a navenek se rovněž tváří jako živý objekt s vlastním chováním, takže pro návrh jeho rozhraní platí totéž, ačkoli uvnitř moc vlastní logiky nebývá a objekt jen zprostředkovává komunikaci s někým jiným.
Nicméně tohle téma by zasloužilo spíš samostatný článek, protože tady už dost odbíhám od tématu, kterým je komplexita softwaru.
Teoreticky na programovacím jazyce nezáleží, protože v libovolném si můžeme vytvořit potřebné abstrakce a dobře oddělit logiku programu od nízkoúrovňového kódu. I v takovém Céčku jde psát objektově, dokonce pro něj existuje i GC (garbage collector), i v Javě šlo psát funkcionálně dávno před verzí 8 a dokonce jsem viděl i objektové návrhové vzory implementované nad relační databází. Ovšem jedna věc je, čeho lze teoreticky dosáhnout, a druhá věc je běžná praxe. Když čtu aplikační kód psaný v C, tak je to téměř vždy utrpení, protože se tam logika programu a tok dat ztrácí mezi nízkoúrovňovými konstrukty a opakovaně se tam řeší věci, které měly být na jednom místě.
Proto je dobré volit jazyk, jehož úroveň odpovídá úrovni vyvíjeného softwaru. To většinou znamená nepsat aplikační software v Céčku. Teoreticky by mohly být skvělou volbou (čistě) funkcionální jazyky, Haskell, různé Lispy atd., které jsou matematicky dokonalé a nejeden akademik by nad takovým kódem zajásal. Pro běžnou praxi, kdy nás zajímá víc výsledný užitek, než „krása“ kódu, se ale jeví mnohem vhodnější multi-paradigmatické staticky typované jazyky, ve kterých lze kombinovat různé přístupy – můžeme programovat objektově, ale i procedurálně, použít funkcionální přístup tam, kde to dává smysl, programovat deklarativně atd. Statický typový systém nám pak poskytne typovou kontrolu a ušetří nám část práce a kódu. Určitě bychom se měli vyhnout ruční správě paměti – tzn. použít jazyk s garbage collectorem, RAII či chytrými ukazateli. Pro většinu aplikací bych tedy doporučoval jazyky jako Java, Rust, D nebo C++.
Nicméně často si jazyk vybírat nemůžeme, protože software již existuje a nechceme ho celý přepisovat, nebo jsme v situaci, kdy knihovna v jazyce X za nás udělá většinu práce – a pak často použijeme jazyk X, i když bychom ho jinak nepreferovali. Díky IPC (meziprocesové komunikaci), FFI (foreign function interface) a dalším možnostem integrace (např. GraalVM) lze poskládat systém z komponent psaných v různých jazycích. Opět zde hledáme nějaký rozumný kompromis a rovnováhu – příliš mnoho jazyků a obecně heterogenní technologie na jednu stranu zvyšují komplexitu a nároky na vývojáře, ale na druhou stranu můžou zase vést ke snížení komplexity dané komponenty a ušetřit nám v konkrétním případě dost práce a kódu.
Nejdůležitější vlastností API (veřejného rozhraní) není krása nebo elegance, dokonce to není ani konzistence (např. názvů a pořadí parametrů – byť bychom se o tuto konzistenci měli snažit). Nejdůležitější je stabilita resp. zpětná kompatibilita. Jakmile API jednou zveřejníme, mělo by platit pokud možno navždy a programy, které ho používají by měly fungoval pořád, aniž by bylo nutné je přepisovat. API se může rozvíjet a rozšiřovat, ale tyto změny mají být zpětně kompatibilní. API by mělo být sémanticky verzované, a pak můžeme dělat i nekompatibilní změny, pokud zvýšíme major verzi – nicméně to nechceme dělat moc často (uživatele to rozhodně nepotěší, byť aspoň z čísla verze hned uvidí, že mohou čekat nekompatibilní změny).
Z výše uvedeného vyplývá, že nabídnout veřejné API (jakkoli je to správné a prospěšné) je velký závazek a přestavuje nezanedbatelnou režii. Jaké API a komu poskytovat – to je strategické rozhodnutí, které nemůže dělat řadový programátor – mělo by se jednat o řízený proces. Veřejné rozhraní není zadarmo a „dělat API“ rozhodně nespočívá v použití nějakého kouzelného nástroje, který vygeneruje cosi z čehosi – to je až to poslední – primárně jde o ten návrh, rozhodnutí a závazky.
Oproti tomu interní rozhraní (např. signatura metody, kterou píšeme a používáme jen „my“) zdaleka takový závazek nepředstavuje a můžeme ho relativně bezstarostně měnit (byť i tohle má svoje ale: např. když budeme chtít začleňovat změny z jiných verzí, např. portovat opravy nebo celé funkcionality, hodí se těchto rozdílů mezi větvemi mít co nejméně, aby na sebe kód pasoval).
K veřejnému a internímu rozhraní nemůžeme přistupovat stejně. Je chyba dělat nahodilé nekompatibilní změny ve veřejném API. Ale stejně tak je chyba aplikovat na interní rozhraní všechna pravidla a doporučení určená pro veřejná API. To by bylo jednak neekonomické a jednak by to bránilo údržbě a vylepšování kódu, refaktoringu. Komplexita by nám v průběhu let pořád rostla a měli bychom plno zastaralého a nepotřebného kódu nebo interní kód trpící zbytečně overengineeringem.
A aby to nebylo tak jednoduché, nejde o prosté binární dělení na „veřejné“ a „interní“, ale je to spíš celá škála (taky záleží odkud a z jaké dálky se díváme):
V případě 1) musíme dodržovat víceméně absolutní zpětnou kompatibilitu, zatímco v případě 6) si můžeme dělat v podstatě, co chceme, a nikdo si stěžovat nebude (můžeme nadávat jen sami sobě).
Z hlediska komplexity je tedy dobré u interních rozhraní psát kódu co nejméně a nepouštět se do žádných velkých akcí – to může jít i proti různým doporučením ohledně správného objektového návrhu. Např. místo pěti tříd můžeme napsat dvě metody, které se dohromady vejdou na jednu obrazovku. Mnoho abstrakcí zde můžeme vynechat, můžeme chodit zkratkami… To všechno díky tomu, že kdykoli můžeme provést změnu a původní jednoduchý kód na to „lepší“ řešení transformovat – až to bude potřeba (a v řadě případů tento moment nikdy nenastane a ten jednoduchý kód tam může zůstat). Nicméně ani tato rada neplatí absolutně – argumentem proti ní je to výše zmíněné začleňování kódu z jiných větví (pokud vyvíjíme většinou lineárně a změny mezi větvemi často neportujeme, tak se nás to netýká) a dále pak nutnost změny na více místech (to nás netrápí, pokud se daný kód používá jen na jednom nebo několika málo místech). Také záleží na organizaci práce: pokud člověk vyvíjí sám, může psaní kódu odložit až na dobu, kdy tento kód bude skutečně potřeba. Zatímco když se vyvíjí v týmu, je žádoucí, aby zkušenější programátoři připravili vzory a vyšlapali cestu mladším kolegům, kteří později naváží na jejich práci. Psaní kódu, který není bezprostředně potřeba (ale brzy bude), může být tedy i formou komunikace a sloužit k předávání znalostí mezi členy týmu.
U veřejných rozhraní bychom si naopak na správném návrhu měli dát záležet – a to i za cenu toho, že kódu bude víc. Veřejná rozhraní zasluhují nejvíce úsilí a pečlivosti. Měli bychom přemýšlet pár kroků dopředu a připravit se na budoucí rozvoj rozhraní. Neznamená to, že vše musíme implementovat hned v první verzi, ale měli bychom mít představu a plán, jak se bude rozhraní vyvíjet a tomu přizpůsobit návrh (nešít ho na míru jen momentálním požadavkům). Vše sice předvídat nelze a dříve či později přijdou i nečekané změny, ale to neznamená, že bychom budoucí rozvoj měli z úvah o návrhu úplně vypustit.
Případy 2) až 5) pak leží někde mezi tím a měli bychom u nich hledat nějaké přiměřené řešení.
Už v učebnicích pro začátečníky se čtenář dozví, že by neměl kopírovat kód a pokud dělá něco opakovaně, měl by to přesunout do znovupoužitelné rutiny či třídy. Většina nadbytečného kódu ale pravděpodobně nevzniká takto hloupým kopírováním kódu, ale opakovanou implementací podobných algoritmů a vzorů, které by šly zobecnit. Kromě obvyklých doporučení a principů správného objektového návrhu nám pomohou další techniky – těm jsou věnovány následující podkapitoly.
Na druhou stranu je třeba říct, že postupovat příliš horlivě při odstraňování (údajných) duplicit v kódu může být taky na škodu. Když někde např. vidíme pět velice podobných řádků pod sebou (které klidně mohly vzniknout kopírováním a přepsáním pár znaků), nemůžeme jednoznačně říct, že se jedná o chybu a že by společný kód těchto řádků měl být vyčleněn do znovupoužitelné metody/funkce. Tento kód má někdy povahu (statické) konfigurace či dat a přesun do metody by kód mohl spíš znepřehlednit, protože čtenář by musel hledat na dvou místech, zatímco teď těch pět řádků vidí najednou, pohromadě a na první pohled je jasné, v čem jsou stejné a v čem se liší. Další úskalí je, že bychom mohli omylem použít stejnou rutinu či třídu na místech, kde je logika věci odlišná a jen shodou okolností je výsledek stejný – tohle může v jedné verzi programu fungovat, ale v budoucnu se to ukáže jako nepružné nebo dokonce chybné a přidělá nám to práci. Je to asi jako kdyby někdo v relační databázi pod záminkou normalizace navázal model faktury na aktuální záznamy zboží, a pak se divil, že se mu na vydaných fakturách zpětně mění ceny. Tohle nemá s normálními formami nic společného a jde jednoznačně o chybný návrh, který nerespektuje logiku věci.
Předchozí odstavec je jen varováním před opačným extrémem – neměl by nás odradit od psaní znovupoužitelného kódu – pouze bychom každé opatření (změnu) měli testovat a ověřovat, zda přispívá ke snížení celkové komplexity (nebo má jiný přínos) a zároveň neohrožuje věcnou správnost našeho softwaru.
Bez generického programování bychom si museli vybrat, zda budeme duplikovat kód nebo zda přijdeme o typovou kontrolu. Ani jedno z toho nechceme – generické programování je proto velice užitečným rozšířením OOP resp. programování obecně (generické můžou být i neobjektové funkce, procedury nebo struktury).
Díky generikům můžeme vytvořit třídy nebo metody, které pracují s určitými objekty, ale nemusí znát přesně typ těchto objektů, a přesto zachovávají typovou kontrolu, takže lze v kódu vyjádřit určité předpoklady jako, že objekt na vstupu (např. parametr jedné metody) bude mít stejný typ jako objekt na výstupu (např. návratová hodnota jiné metody), nebo že dva parametry musí být stejného typu nebo jiné vazby (generické typy lze zanořovat, takže to nemusí být shoda typů samotných objektů, ale třeba nějakých jejich členů). Můžeme tak mít jeden algoritmus, jeden kód, který opakovaně použijeme v různých situacích.
Nejtypičtější je využití generik u kolekcí či kontejnerů. Jedná se o obecné nízkoúrovňové datové struktury: seznam, mapa, sada, fronta atd. Ten vztah, který chceme pomocí generik vyjádřit je zde zřejmý: do kolekce vkládáme objekty určitého typu a stejný typ by měly mít i objekty, které z kolekce získáváme zpět. Generika ale můžeme využít i v našem vlastním kódu a na vyšší úrovni abstrakce navrhnout třídy, které jsou generické a vycházejí z obchodní logiky naší aplikace. Jde tedy o jeden způsobů, jak si ušetřit psaní kódu a snížit komplexitu našeho softwaru.
Generické programování je podporované v řadě jazyků. Může být implementováno různými způsoby – např. v Javě jsou to generika zatímco v C++ nebo D jsou to šablony. Na první pohled vypadají stejně a je tam dost velký překryv (základní využití v kolekcích). Ale oba přístupy mají svoje specifika a umí něco, co ten druhý přístup neumožňuje. V případě šablon se v podstatě kód v době kompilace rozkopíruje do tolika variant, s kolika typy jsme danou šablonu použili jinde v kódu. Kdežto u generik jde spíš o typovou kontrolu v době kompilace + přetypování v době běhu (kód je stále tentýž a pracuje s obecnými objekty). V obou případech se ale jedná o velice hodnotnou vlastnost programovacího jazyka.
Typickým příkladem deklarativního programování je SQL – dotazem neříkáme systému řízení báze dat (SŘBD – DBMS), jak má postupovat, nýbrž deklarujeme, co má být výsledkem (množina záznamů s určitými vlastnostmi). Jak systém tyto záznamy najde, to už je na něm. Z hlediska komplexity je na tomto přístupu zajímavé to, že složitá logika byla implementována a otestována jen jednou a to v softwaru sdíleném mezi obrovským množstvím uživatelů (např. PostgreSQL, MariaDB nebo SQLite), takže se náklady na vývoj a údržbu rozložily mezi ně. Oproti tomu SQL dotazy nacházející se v jednotlivých aplikacích už jsou relativně jednoduché – díky tomu, že jen deklarují, co má být výsledkem, a nemusí popisovat postup, jak data získat. Deklarativní programování tak umožnilo koncentrovat složitou logiku na jedno místo a opakovaně ji používat. Celková komplexita tak klesla (oproti situaci, kdy by každá aplikace implementovala vlastní formát ukládání dat a vyhledávala v nich imperativním kódem).
Pravděpodobně nebudeme vyvíjet nic tak velkého jako SQL, ale přesto můžeme využít výhod tohoto přístupu – přesunout složitější logiku na jedno místo a opakovaně ji používat. Dokonce to ani nemusí mít podobu (doménově specifického) jazyka, pro který bychom definovali gramatiku. Např. v jazyce Java máme od verze 5 (tzn. od roku 2004) k dispozici systém anotací – ty umožňují přidat metadata ke třídám, rozhraním, metodám a proměnným. Díky tomu lze vynechat spoustu nudného opakujícího se imperativního kódu a jednoduše deklarovat nějaké vlastnosti nebo chování. Složitá logika je pak definována jen na jednom místě – buď v anotačním procesoru, který se volá během kompilace, nebo v jiných třídách, které zpracují anotace při startu aplikace nebo později v době běhu.
Anotace se často používají pro různé mapování (např. mezi objekty a XML: JAXB nebo mezi objekty a záznamy v databázi: JPA), pro řízení přístupu (deklarujeme role, které smějí danou metodu volat), různé optimalizace (anotací např. určíme, že se návratová hodnota má uložit do mezipaměti a při opakovaném volání vracet odtamtud), validace, logování či pro vystavení externího rozhraní (např. SOAP webová služba nebo REST služba – u nich jen deklarujeme URL, oprávnění a mapování parametrů, místo abychom HTTP požadavky zpracovávali ručně psaným imperativním kódem). Anotace využijeme i při aspektově orientovaném programování (AOP).
Kromě těchto již hotových anotací a kódu pro jejich obsluhu si můžeme definovat i vlastní anotace, které budou podporovat obchodní logiku naší aplikace a ušetří nám imperativní kód. Nicméně návrh vlastních anotací (na rozdíl od používání již hotových) je náročnější činnost, která by se neměla dít úplně živelně a měl by na ni – stejně jako na návrh interních frameworků a knihoven – dohlížet zkušenější vývojář nebo architekt.
Ačkoli základ našeho programu bude pravděpodobně objektový, může se někdy hodit použít prvky funkcionálního programování, jako je předávání funkcí. Multi-paradigmatické jazyky (Java, C++ atd.) tohle explicitně podporují a umožňují elegantní zápis formou lambda výrazů. Nicméně i v čistě objektovém jazyce můžeme dělat totéž – funkce je vlastně speciální případ objektu, který má právě jednu metodu a žádné vedlejší efekty nebo stav. I když jazyk nepodporuje lambda výrazy, je možné tyto funkce instanciovat jako anonymní vnitřní třídy.
Můžeme tedy mít objekt (resp. konstruktor či metodu), který bude jako parametr přijímat nejen data, ale i funkce, které bude následně volat. Tenhle přístup nám, podobně jako jiné návrhové vzory, umožňuje lépe separovat odpovědnost jednotlivých objektů/tříd – ty se pak můžou lépe soustředit na jednu konkrétní činnost, zatímco ostatní činnosti delegují (ať už na funkce nebo továrny nebo jiné obecné objekty, které dostaly parametrem).
Snížení komplexity dosáhneme tím, že buď jednu funkci použijeme opakovaně na víc místech, nebo častěji tím, že vytvoříme obecnější třídu, která bude parametrizovaná předanou funkcí a díky tomu bude opakovaně použitelná v různých kontextech. Místo několika tříd tak můžeme mít jen jednu, které při vytváření instance nebo volání metody předáme lambda výrazem nějaké specifické chování.
Částečně je tu překryv s dědičností a kompozicí, pomocí kterých lze dosáhnout podobného efektu. Funkce tvoří jakousi nejmenší jednotku, zatímco objekt v sobě sdružuje typicky více metod + vnitřní stav. Pokud tedy potřebujeme předat více rutin logicky patřících k sobě a případně i data, je lepší se držet objektového paradigmatu. Ale pokud nám stačí předat právě jednu rutinu (bez stavu a vedlejších efektů), může nám funkcionální přístup ušetřit práci a zpřehlednit kód. Toho využijeme např. při transformacích nebo filtrování. A to nejen u nízkoúrovňových objektových proudů ze standardní knihovny, ale i u našich vlastních tříd specificky navržených pro obchodní logiku naší aplikace. Tzn. tento přístup je aplikovatelný i na vyšší úrovni abstrakce.
Velká část aplikační logiky spočívá v tom, že se vyhodnotí nějaká sada podmínek a na základě toho, jaké podmínky byly vyhodnoceny kladně se poté nad vstupními daty nebo požadavkem provede nějaká akce. Typické je to pro různé schvalovací procesy, ale stejný princip najdeme i jinde. Tato soustava podmínek a akcí nemusí vyústit v hromadu IFů a mnoho řádků nepřehledného kódu, který budou ostatní programátoři pracně dešifrovat. Často je lepší se na tyto podmínky a akce dívat jako na data, nikoli jako na kód, který by měl být napsán. Tato data jsou pak vyhodnocena obecným kódem, který zkontroluje podmínky a provede příslušné akce.
Těmi daty jsou typicky nějaké objekty reprezentující jednotlivá pravidla, která obsahují podmínky a akce (nebo výstupní proměnné). Přes tyto objekty pak iterujeme, hledáme vyhovující pravidlo, a pak provedeme jeho akci (nebo nastavíme proměnné). Jedná se o obecný princip, který můžeme implementovat víceméně v libovolném jazyce – klidně i v Céčku (viz např. přidání podpory zvukové karty do Linuxu, kterého lze dosáhnout i prostým naplněním datových struktur tzn. nemusíme napsat ani řádek procedurálního kódu a ani jeden IF). Pravidla můžeme instanciovat ručně v kódu a vložit je do seznamu, který pak budeme procházet. I toto může být pokrok proti složitým IFům s mnoha podmínkami. Navíc to lze kombinovat i s deklarativním programováním a anotacemi (viz výše). Dalšího zlepšení dosáhneme tím, že data přesuneme mimo zdrojové kódy – např. do XML souboru nebo do databáze. Objekty pravidel pak vzniknou deserializací tohoto souboru nebo načtením z DB. Výhodou je, že pravidla může číst i upravovat i neprogramátor. Ke XML se dá snadno napsat XSL, díky kterému si kdokoli může pravidla zobrazit přehledně a s vysvětlením (už tohle ušetří spoustu práce, protože analytik se kdykoli může sám podívat, jak systém aktuálně funguje). Díky XSD zase poskytneme dokumentaci a kontrolu tomu, kdo bude soubor s pravidly upravovat. K databázi lze snadno udělat GUI, kde půjde pravidla prohlížet případně i měnit. Také můžeme mít odlišná pravidla pro různé instalace (zákazníky, prostředí…). Celkově díky tomuto přístupu můžeme mít klidně desítky nebo stovky pravidel, ale program je pořád stejně jednoduchý a pořád používáme tutéž verzi programu, která byla jednou vyvinuta a jednou otestována. S rostoucím počtem pravidel se nezvyšuje komplexita programu.
Jedná se o tzv. rozhodovací tabulky případně rozhodovací stromy. Existují různé knihovny, které tento přístup podporují. Ale jejich použití nemusí být nutné ba ani žádoucí. Pokud máme k dispozici jednoduchou a kvalitní knihovnu, která nám ušetří práci, není důvod ji nepoužít, ale jinak pamatujme na to, co jsme si řekli už v předchozím díle: samotný vyšší programovací jazyk spolu se svojí standardní knihovnou jsou samy o sobě frameworkem. Zejména objektové/multi-paradigmatické jazyky nám poskytují vše, co k implementaci tohoto principu potřebujeme. Většina kódu bude stejně specifická pro naši aplikaci (naplnění vstupních proměnných, provedení akcí nebo nastavení výstupních proměnných).
Kdy zvolit tento přístup? Čím více podmínek jednotlivá pravidla mají a čím je vyšší pravděpodobnost, že se budou měnit (ať už v čase nebo napříč různými instalacemi), tím spíš bychom měli uvažovat o řízení daty místo o zadrátování logiky přímo do kódu.
Kromě prosté iterace za běhu programu je možné i generovat kód ze souborů pravidel v době kompilace. To snižuje flexibilitu, ale může zvýšit výkon. Případně můžeme kód generovat za běhu pomocí JIT kompilátoru. Nicméně je třeba zvážit, zda je nárůst výkonu relevantní, aby nešlo o předčasnou optimalizaci.
Dalším posunem může být BPMN resp. DMN a BPEL, ve kterém si nakreslíme proces a pak ho necháme vykonávat nějakým frameworkem. Tuhle vizi, že programy bude kreslit analytik a programátor nebude potřeba, potkávám celou dobu, co jsem v oboru. Teoreticky je to skvělá myšlenka, ale v praxi bývá úspěšnost těchto řešení sporná. Jedna věc je totiž nakreslit ty krabičky a pospojovat je šipkami. A druhá věc je, co se má dít uvnitř těch krabiček a jak to má být napojené na okolí – a tam už programátoři potřeba jsou a tento přístup jim může i ztížit práci natolik, že celkové náklady i komplexita budou vyšší. Nasazení takového řešení je potřeba pečlivě zvážit a je potřeba otestovat, zda celková efektivita je skutečně vyšší. Častěji bude lepším řešením nějaký lehký workflow engine nebo právě ty rozhodovací tabulky či stromy, které lze implementovat snadno a většinu přínosů nám poskytnou i za zlomek ceny/komplexity.
Jen málo programů se spouští přímo na CPU/MCU a nepotřebuje nic dalšího. Většina programů závisí minimálně na standardní knihovně nebo nějakém běhovém prostředí, které slouží jako rozhraní mezi programem a operačním systémem. A protože nechceme vynalézat kolo a implementovat sami podporu různých datových formátů, šifrování, síťových protokolů, obvyklých algoritmů atd., programy obvykle závisí i na dalších knihovnách. Část těchto knihoven závisí jen na standardní knihovně nebo běhovém prostředí. A část závisí na dalších knihovnách a ty zase na dalších… Tím nám vzniká strom závislostí, který může být někdy (ne)pěkně košatý.
Tento strom si můžeme sestavovat ručně a kreslit si ho na papír (což je mimochodem docela fajn metoda, když se zamýšlíme nad návrhem svého vlastního systému). Ale pokud chceme prozkoumat závislosti nějakých existujících programů, je efektivnější využít metadat, která jsou obsažena v balíčkovacích systémech GNU/Linuxových distribucí. Tyhle systémy musí vědět, co na čem závisí, aby věděly, co instalovat, a obvykle umí tyto informace poskytnout ve strojově čitelné formě, ze které se pak dá vygenerovat graf. Většinou používám distribuce na bázi Debianu/Ubuntu, takže si ukážeme tento postup v nich, nicméně jinde by to mělo být podobné.
Předem je třeba říct, že se na věc budeme dívat trochu z dálky. Tahle srovnání nemusí být úplně fér, protože se díváme až na výsledek, ke kterému přispěli svým dílem i autoři dané distribuce, a to, co původně vytvořil autor programu, mohlo vypadat trochu jinak. Hodnotíme tedy současně práci autora i distributora, ne jen autora. Další věc je, že v grafu vidíme jen počet uzlů a hran, ale nevidíme velikost těchto uzlů – všechny balíčky/moduly vypadají stejně velké, takže více malých modulů může vypadat hůře, než jeden velký monolit (v grafu je to pořád stejně velký obdélník). Nicméně jde nám o získání hrubé představy. K detailní analýze se dostaneme později.
V lepším případě vypadá strom závislostí nějak takto:
a v horším třeba takhle:
Plné rozlišení sem ani nedávám, nicméně obsah samotný není až tak důležitý. Jde o řádovou představu o složitosti. V prvním případě jde o soustavu pár knihoven, jejichž smysl a vztahy by šlo vysvětlit několika větami. V druhém případě jde o nepřehlednou změť, kterou je prakticky nemožné mentálně uchopit a nějak nad ní uvažovat.
Stejně jako nám statistika počtu řádků dává hrubou představu o komplexitě samotného programu, tak nám tento graf dává hrubou představu o komplexitě ukryté v závislostech daného programu. Pro získání celkové představy bychom měli spočítat i řádky kódu pro každý uzel tohoto grafu – jejich součet pak představuje celkový otisk daného softwaru. Sdílené komponenty (operační systém, standardní knihovna, další běžně používané knihovny, programy nebo služby…) bychom pak měli rozpočítat mezi ostatní programy, které používáme a které sdílí tyto závislosti.
Závislosti v době sestavení (build, kompilace, tvorba balíčku) se obvykle liší od závislostí v době běhu (instalace balíčku, spouštění programu). Pro sestavení potřebujeme kompilátor (GCC, OpenJDK atd.), vývojářské verze balíčků knihoven obsahující hlavičkové soubory, nástroje typu make
, cmake
, testovací frameworky, interprety skriptovacích jazyků a různé další nástroje.
V době běhu pak program potřebuje binární balíčky knihoven a případně balíčky dalších programů, které volá jako služby nebo podprocesy.
Z hlediska rizik jsou podstatné oba druhy závislosti, protože i komplexita pomocných nástrojů použitých při sestavení vstupuje do výsledného produktu a může nás ohrožovat. Např. i testovací nástroj volaný v rámci sestavení může do programu zanést chyby nebo bezpečnostní díry. Pokud předpokládáme zlý úmysl, problém se může skrývat úplně kdekoli – v libovolném artefaktu, který do procesu sestavení vstupuje. Mimochodem, z tohoto důvodu je dobré, aby různé testovací a pomocné nástroje (např. generátory dokumentace, které mohou přinášet netriviální komplexitu) ležely bokem a program šlo sestavit i bez nich. To neznamená, že se vykašleme na spouštění testů nebo generování dokumentace – jen tyto procesy fyzicky oddělíme tak, aby prokazatelně nemohly nijak ovlivnit samotný sestavovaný program.
Pokud ale špatný úmysl nepředpokládáme a jde nám hlavně o odhalení neúmyslných chyb a o návrh vylepšení, budeme se soustředit primárně na běhové závislosti. Jejich grafy bývají méně děsivé a čitelnější. Tyhle závislosti lépe vypovídají o samotném softwaru a pravděpodobně to jsou podstatné části, na kterých program závisí a bez kterých se neobejde. Oproti tomu třeba takový Perl použitý při sestavení pro nějaké pomocné generování nebo filtrování nijak esenciální není a může být snadné se ho zbavit a snížit komplexitu. Nicméně tohle už je na detailní zkoumání.
V Debianu, Ubuntu a dalších podobných distribucích GNU/Linuxu slouží k získání potřebných dat příkaz debtree
. Jako parametr mu zadáme název balíčku, který nás zajímá, a dostaneme data ve formátu DOT. Ten si pak programem Graphviz (příkaz dot
) převedeme na bitmapový (PNG) nebo vektorový (SVG) obrázek.
Příkaz debtree
přijímá další parametry, kterými můžeme říct, jaké typy vazeb a uzlů chceme do grafu zahrnout. Připravil jsem jednoduchý skript generate-dependency-graphs.sh, který generuje více variant grafu a názvy balíčků čte ze standardního vstupu.
cat packages.txt | ./generate-dependency-graphs.sh
Připravíme si tedy soubor se seznamem balíčků (na každém řádku jeden název), a pak vygenerujeme všechny grafy jednou dávkou.
Výsledkem jsou orientované grafy. Uzly grafu jsou balíčky (programy či knihovny) a hrany grafu jsou jejich závislosti nebo jiné vazby:
symbol | význam |
→ modrá šipka
|
závisí na balíčku (v době běhu) |
→ fialová šipka
|
závisí na balíčku (v době běhu, vyžadován ještě před instalací/konfigurací) |
→ zlatá šipka, tučně
|
závisí na balíčku (v době sestavení) |
→ světle zlatá šipka
|
závisí na balíčku (v době sestavení, nezávisle na architektuře) |
→ černá šipka |
doporučuje balíček (není nutné ho instalovat) |
⇢ černá šipka, tečkovaná |
navrhuje balíček (není nutné ho instalovat) |
→ zelená šipka
|
poskytuje virtuální balíček |
→ červená šipka
|
koliduje s balíčkem (nelze je instalovat současně) |
▭ obdélník |
balíček |
▤ obdélník, více řádků |
alternativní balíčky (není potřeba instalovat všechny) |
◇ kosočtverec |
koncový balíček (má další závislosti, ale nejsou uvedeny v grafu) |
○ osmiúhelník |
virtuální balíček |
Nás zajímají zejména modré, zlaté a filalové šipky, protože ty vyjadřují závislosti, bez kterých daný balíček neobejde.
Protože existují určité balíčky, na kterých závisí prakticky všechno a akorát by snižovaly čitelnost grafů, používá debtree
seznam /etc/debtree/skiplist
, na kterém jsou balíčky, které se mají přeskočit – ty se v grafech vůbec neobjeví, přestože na nich programy závisí. Těchto balíčků je jen pár a patří sem např. standardní C a C++ knihovny nebo zlib
. Jejich komplexita je buď malá nebo nevyhnutelná, takže nemá smysl se jimi moc trápit.
Pak tu máme tzv. koncové balíčky, jejichž seznam najdeme v souboru /etc/debtree/endlist
. Tyto balíčky se v grafu objeví ve formě kosočtverce, ale jejich závislosti už v grafu uvedeny nejsou. Pokud tedy v grafu narazíme na kosočtverec, měli bychom mu věnovat pozornost, protože za tímto jediným uzlem grafu se často skrývá netriviální komplexita. Jde o často používané knihovny, které je lepší zkoumat samostatně a nezanášet jimi grafy jednotlivých programů. Patří sem např. knihovny pro X.Org, KDE, GNOME, Javu či TeX.
Oba seznamy si můžeme upravit nebo si přidat vlastní balíčky do ~/.debtree/skiplist
resp. ~/.debtree/endlist
. Pro generování grafů v tomto článku jsem použil výchozí seznamy.
Virtuální balíčky nás nemusí trápit – jsou to jen metadata balíčkovacího systému – reálný balíček může deklarovat, že poskytuje virtuální balíček (ten sám o sobě tedy neexistuje, je to jen jméno, zástupný symbol). Jiné balíčky můžou na těch virtuálních záviset, což pak vyvolá instalaci nějakého reálného balíčku.
Skript generuje několik typů grafu:
…_debtree_minimal.png
– jen nezbytné závislosti…_debtree_alternatives.png
– včetně všech alternativních závislostí…_debtree_default.png
– včetně alternativ, doporučených a navrhovaných balíčků a konfliktů…_debtree_build.png
– závislosti potřebné pro sestavení balíčkuAlternativy = je na výběr více závislostí a stačí instalovat jednu z nich. Doporučené, navrhované a konfliktní balíčky = graf obsahuje spoustu balíčků, které není potřeba instalovat. Tyhle informace jsou zajímavé pro detailnější analýzu. Ale pokud chceme jen rychle zjistit, zda je na místě spíš radost nebo zděšení, měli bychom se dívat na minimální variantu grafu (a dát si pozor na kosočtverce).
Efektivní nástroj na vizualizaci a zkoumání závislostí bychom měli. Teď se tedy můžeme podívat na to, jak by graf závislostí měl vypadat, abychom minimalizovali komplexitu a tím její negativní dopady. A stejně jako u chyb na úrovni kódu zde platí, že čím dříve chybu odhalíme a začneme řešit, tím bude její oprava levnější. Nad grafem závislostí a jejich komplexitou bychom se tedy měli zamýšlet už v době návrhu. Pozdější změny jsou sice možné, avšak výrazně složitější. A to jak z technických důvodů, tak z organizačních – v počáteční fázi projektu bývá prostor dělat věci pořádně, zatímco později roste tlak na dodržení termínů a dodání funkcionalit a vlastností, které lze bezprostředně zpeněžit. Pokud tedy něco na začátku projektu resp. při návrhu zanedbáme, pravděpodobně to už nikdy nedoženeme. V podstatě jediná možnost, je pak spojit tyto změny s nějakou jinou investicí (nový velký zákazník, změna zaměření produktu atd.).
Ačkoli mluvíme primárně o závislosti na knihovnách, může se jednat o závislost na libovolném zdroji: program spouštěný jako podproces (LibreOffice nebo TeX pro generování PDF, ImageMagick pro úpravy a konverze obrázků atd.), datové soubory (ikony, textury, zvuky, písma atd.), síťové služby (internetový vyhledávač, srovnávač cen obchodů, předpověď počasí, měnové kurzy, vyhledávač tras na mapě, OCR či překladač textů atd.) nebo dokonce specifický hardware (GPU, šifrovací nebo jiný akcelerátor, telefonní karta, čidla nebo jiná vstupní a výstupní zařízení) nebo jiná komponenta.
Většina z následujících doporučení vychází z osvědčených postupů používaných při návrhu tříd v OOP. Těmito principy je dobré se řídit i na úrovni větších celků než jsou třídy. Nejprve si projdeme teoretické možnosti a pak si na pár pár praktických příkladech ukážeme, jak situaci vylepšit.
Tohle doporučení je zřejmé, ale pro úplnost je třeba ho zmínit. Jsou knihovny, které poskytují obrovský balík funkcí (a tím pádem kódu za nimi, případně dalších závislostí), často se jedná o historické monolity, které vznikly postupným nabalováním. Ale můžeme se setkat i s opačným extrémem – „knihovnami“ tvořenými třeba jen pěti řádky kódu. Granularita knihoven se tedy velmi různí.
Klasickou chybou je použití příliš velké knihovny, která toho umí víc, než potřebujeme – naše uživatele pak vystavujeme vyšší komplexitě, než je (vzhledem k funkcím poskytovaným naším programem) nutné. Např. když potřebujeme jen počítat hashe, tak je chyba použít velkou kryptografickou knihovnu, která kromě nich implementuje i všemožné šifrování a podepisování, což je komplexita mnohonásobně převyšující naše potřeby.
Na druhé straně ale nelze říct, že čím menší knihovny, tím lépe. Negativní vliv nemá jen celkové množství kódu (součet řádků napříč všemi závislostmi), ale i počet prvků systému. Každý prvek (v tomto případě knihovna, závislost) představuje určitou režii. Každý prvek – bez ohledu na to, kolik kódu za ním je – ubírá naši energii, žádá si naši pozornost a čas. Jednotlivé knihovny mají různý životní cyklus, různé tempo vydávání nových verzí, různé závislosti, různé konvence a styly programování. Můžeme narazit na problémy s nekompatibilitou u tranzitivních závislostí – když dvě komponenty budou záviset na třetí komponentě, ale každá na jiné verzi. Kromě toho bychom pro každou knihovnu měli hlídat, zda neobsahuje bezpečnostní chyby, zda nevyšla nová verze nebo zjišťovat, zda se o ni její autor ještě stará nebo jestli máme převzít vývoj my a začít si chyby raději opravovat sami. Další téma je odlišné pojetí paralelismu (vlákna, podprocesy…), správy paměti či v některých jazycích i tak základní věc jako odlišné reprezentace textových řetězců – ty pak musíme překládat sem a tam, abychom jednotlivé knihovny mezi sebou mohli propojit. Z těchto důvodů jsou ideální menší knihovny vyvíjené ovšem jedním týmem tak, aby měly minimální nebo žádný funkční překryv, kompatibilní plán vydávání nových verzí, držely se jednotných konvencí, používaly slučitelné návrhové vzory a přístupy. Nicméně tohle je trochu utopie – v reálném světě je víceméně nevyhnutelné kombinovat knihovny od různých autorů – ale měli bychom se aspoň snažit udržet počet prvků systému přijatelně nízký.
Balíčkovací systémy jako javovský Maven nebo jeho obdoba v jiných jazycích (Cargo v Rustu, PyPI v Pythonu, Composer v PHP, NPM v JavaScriptu a další) činí přidávání závislostí velice snadným – knihovny se automaticky stáhnou z internetu včetně svých závislostí a nainstalují. Ovšem stejně snadno si můžeme zadělat na problémy v budoucnu. To, co dnes krásně funguje, se později může ukázat jako časovaná bomba – všimneme si toho, až když je pozdě, ve chvíli, kdy na dané knihovně závisí náš kód a funkcionalita dodaná uživatelům. Zbavit se takové závislosti nebo opravit chyby je pak mnohonásobně pracnější, než bylo zanesení této závislosti do našeho softwaru. Je třeba mít na paměti, že těch pár nevinně vyhlížejících řádků v konfiguračním souboru může výrazně navýšit komplexitu našeho softwaru. Přidávání závislostí by tedy měl být řízený proces a mělo by podléhat důkladné revizi.
V případě standardní knihovny je běžné záviset přímo na jejích třídách, strukturách či funkcích. Není obvyklé a většinou ani žádoucí vytvářet si vlastní implementace textových řetězců, seznamů, map a jiných základních konceptů. Vzniká zde tudíž těsná vazba mezi aplikací a standardní knihovnou a náš kód není přenositelný na jinou standardní knihovnu – stejně jako není přenositelný do jiného jazyka (nepůjde jeho kompilátorem přeložit). Tahle závislost je v pořádku – výběr jazyka (a tím pádem standardní knihovny) je strategické rozhodnutí a pokud bychom ho měnili, jsme smířeni s tím, že budeme vše přepisovat (leda že bychom psali v nějakém pseudokódu a až z něj strojově překládali do konkrétních jazyků… což ale není běžný postup).
U ostatních knihoven a komponent je ale v řadě případů lepší se takové závislosti vyhnout – náš program by neměl záviset na konkrétní implementaci, ale na abstraktním rozhraní, ke kterému může existovat (nebo později vzniknout) implementací více. Ukážeme si proto různé varianty – konstelace balíčků/modulů – které nám poskytují různou míru (ne)závislosti programu na knihovně.
Vysvětlivky pro následující grafy:
P – program: aplikace, kterou vyvíjíme a která využívá funkcionalitu dané knihovny; případně nemusí jít o aplikaci, ale o knihovnu, která závisí na jiné knihovně
R – rozhraní: popisuje datové struktury a operace, které s nimi lze provádět (hlavičky metod, funkcí či procedur); tento balíček by neměl obsahovat žádný spustitelný kód (jde o abstraktní rozhraní); definujeme ho buď my, nebo autor knihovny nebo nezávislá třetí strana; po technické stránce může jít např. o hlavičkový soubor v případě jazyků C/C++, nebo o malý JAR v případě Javy, který bude obsahovat javovská rozhraní (interface
) a třídy (class
), nebo XSD obsahující popis struktur předávaných ve formátu XML nebo pak WSDL, které k němu přidává sémantiku operací (SOAP webové služby), nebo CORBA či D-Bus rozhraní, MIB pro SNMP, popis struktur v ASN.1 nebo jiném formátu; rozhraním může být i souborový formát a dohoda o tom, že se v určitém adresáři budou nacházet soubory s určitými názvy a obsahem, případně formát zpráv, rozvržení sdílené paměti, síťový protokol nebo cokoli jiného, co nám poslouží jako API, kterým propojíme dvě části systému; nezáleží tedy na formě, ale na obsahu – je důležité, aby rozhraní bylo dobře specifikované, dokumentované a vyvíjelo se předvídatelně
A – adaptér: tenká mezivrstva, která implementuje rozhraní a volá knihovnu tzn. neimplementuje funkce vlastním kódem, ale pouze mapuje datové struktury a volání; technicky vzato, adaptér je většinou knihovna závislá na jiné knihovně; kolik logiky zde bude, to záleží na podobnosti rozhraní a knihovny; pokud můžeme knihovnu upravovat nebo jsme dokonce její autoři, můžeme adaptér úplně vynechat a napsat knihovnu tak, aby rovnou implementovala rozhraní
K – knihovna: softwarová knihovna obsahující nějaké užitečné funkce a většinou i nějaké datové struktury (pokud si u parametrů a návratových hodnot nevystačíme s primitivními datovými typy nebo strukturami ze standardní knihovny); v případě nativních knihoven to budou .so
soubory, v případě Javy .jar
soubory, v případě jiných jazyků moduly v nějakém jejich formátu; případně to může být jakákoli komponenta, subsystém, služba nebo jiná závislost (viz výše)
Tohle je nejintimnější vztah mezi programem a knihovnou. Na mnoha místech našeho kódu voláme knihovní funkce nebo používáme struktury definované v knihovně, jako by byly naše vlastní.
Tento vztah je běžné mít se standardní knihovnou našeho jazyka. V případě ostatních knihoven je to ale více či méně na škodu. Z krátkodobého hlediska ušetříme trochu kódu, ale ve střednědobém a dlouhodobém horizontu na tuto závislost můžeme doplatit a bude nám vadit, že jsme vázaní zrovna na tuto konkrétní knihovnu.
Jsou případy, kdy je i tento typ vztahu přijatelný – zejména pokud se jedná spíš o framework než o knihovnu. Nicméně vybírat bychom měli velice pečlivě a víc než kde jinde zde platí rada, že knihovna by měla mít důvěryhodného autora (dobré vyhlídky do budoucna) nebo velmi nízkou komplexitu (abychom případně vývoj mohli převzít sami), ideálně pak obě tyto vlastnosti najednou.
Závislosti se můžeme částečně zbavit tím, že knihovní funkce budeme volat jen z malé části našeho kódu – jejich voláním pak nebude prolezlý celý náš program. V praxi to znamená, že si definujeme vlastní rozhraní a k němu adaptér, který ho napojuje na knihovnu. Případně to lze zjednodušit a rozhraní s adaptérem spojit do jednoho celku a mít tak třeba jen jednu třídu, která bude obalovat danou knihovnu.
Navenek – při pohledu na graf závislostí – to vypadá pořád stejně: program závisí na knihovně. Ale zlepšení spočívá v tom, že když budeme chtít knihovnu vyměnit, budeme muset upravit jen malou část našeho programu.
Jak už to bývá, nic není zadarmo – museli jsme napsat trochu kódu navíc (rozhraní a adaptér), což nám zvýšilo komplexitu. Stále ale jde o jednodušší variantu s menší režií mj. proto, že rozhraní zde neplní roli veřejného API, takže nemusíme dodržovat zpětnou kompatibilitu. Také můžeme implementovat jen malou podmnožinu knihovních funkcí. Díky tomu víme, které funkce reálně využíváme, takže jednoho dne můžeme celkem snadno nahradit komplexní knihovnu nějakou štíhlejší nebo si napsat vlastní.
Rozhraní ani adaptér nejsou součástí našeho programu, ale nachází se v samostatných balíčcích. V lepším případě už rozhraní definoval někdo za nás a existuje jedna nebo více jeho implementací. Nemusíme tak psát ani rozhraní, ani adaptér a komplexita našeho programu se oproti přímému napojení na knihovnu nijak nezvyšuje.
Tenhle přístup je docela běžný – jsou to např. databázové ovladače (JDBC, ODBC, či PDO) nebo souborové systémy (aplikaci je jedno, jestli je vespod Ext4, XFS, Btrfs nebo jiný FS – pracuje se soubory skrze rozhraní poskytované operačním systémem). Stejně tak je takovým rozhraním třeba POSIX MQ – v jednotlivých OS mohou být tyto fronty implementované různě, ale rozhraní je pořád stejné. Když se nebudeme omezovat jen na klasické knihovny, tak na stejném principu fungují i síťové protokoly např. LDAP, SMTP, SNMP a spousta dalších. Rozhraním je zde síťový protokol, který má svoji specifikaci a ke kterému existuje řada implementací serverové i klientské části.
Adaptér lze vynechat – autor knihovny může implementovat rovnou dané rozhraní. To se děje typicky v případě, kdy rozhraní [R] bylo definováno dříve než knihovna [K] a kdy zároveň není potřeba implementovat jiná alternativní rozhraní.
Pokud pro nás nikdo dosud potřebné rozhraní nedefinoval, můžeme to udělat sami. Přeci jen pokud vyvíjíme více programů, bylo by neefektivní v každém z nich implementovat znovu [R] a [A]. Tato cesta je trochu náročnější, protože jednak definujeme veřejné API, což je dost velký závazek (viz výše) a jednak potřebujeme přimět autora knihovny, aby naše rozhraní implementoval (napsal adaptér). Případně můžeme zvolit variantu č. 5 (viz níže), kde si adaptér napíšeme sami a od autora knihovny žádnou součinnost nepotřebujeme. Definovat nový standard (rozhraní) je netriviální úkol, ale pokud existuje dost programů, které by ho využily, má to smysl.
V grafu vidíme, že nám stále vede šipka od [P] ke [K] resp. k [A+K]. Závislost zadrátovaná v kódu tu sice je, ale často může jít o jediný řádek, který lze snadno upravit nebo přesunout do konfigurace. A taky je odtud už jen krůček k tzv. obrácenému řízení (IoC a DI, viz níže), které se často používá v souvislosti právě třeba s těmi databázovými ovladači a spojeními.
Díky IoC (inversion of control) se zbavíme přímé závislosti programu na knihovně/adaptéru. Program závisí jen na rozhraní – o napojení konkrétní implementaci se nestará, protože to za něj řeší běhové prostředí.
Minimalizujeme množství kódu a komplexitu a zároveň je náš program velice flexibilní a znovupoužitelný – nemusíme ho upravovat, abychom ho napojili na jinou knihovnu. Více o IoC a DI si povíme v samostatné kapitole níže.
Toto je mírně upravená varianta č. 3. Adaptér se nachází v samostatném balíčku, což znamená, že jeho autorem nemusí být autor knihovny a tuto knihovnu není potřeba nijak upravovat.
Adaptér si můžeme napsat sami nebo jeho vývoj někomu zadat. Důležité je, že adaptér závisí jen na abstraktním rozhraní a knihovně, nikoli na programu. Adaptér tak lze napsat i bez programu [P], což usnadňuje vývoj a snižuje komplexitu, se kterou se musí potýkat autor adaptéru.
Na závěr tu máme spojení variant č. 4 a 5. To znamená, že máme výhody obráceného řízení a zároveň není nutné upravovat knihovnu, protože adaptér je v samostatném balíčku.
Adaptér může napsat kdokoli, nicméně tato varianta dává smysl, i když se do jeho vývoje zapojí autor knihovny. Týká se to případů, kdy existuje více (konkurenčních) rozhraní nebo kdy je z nějakého důvodu žádoucí někdy používat i původní nativní rozhraní dané knihovny. Pak není potřeba zvyšovat komplexitu knihovny přidáváním kódu adaptéru či adaptérů. Ten může zůstat klidně bokem, v samostatném úložišti a balíčku. Adaptér závisí na knihovně, ale knihovna nezávisí na adaptéru.
Za běžný či klasický přístup se považuje to, že když jedna komponenta (program) potřebuje jinou komponentu (knihovnu, objekt, službu) tak si ji sama někde najde nebo sama vytvoří její instanci. V kontrastu s tím je tzv. obrácené řízení, kdy výběr či vytvoření té potřebné komponenty je v režii někoho zvenčí (nikoli toho programu, uživatele této komponenty). Je tedy trochu diskutabilní, který přístup je „klasický“ a který „obrácený“… nicméně toto je v současnosti zavedená terminologie, tak se jí budeme držet.
.so
knihovny) se objevily na class path, takže i když je formálně uvnitř SL, praktický výsledek je takový, že komponenty jsou injektovány zvenčí (když se na program díváme trochu s odstupem jako na celek a neřešíme nějaké konkrétní třídy uvnitř).Tyto pojmy jsou relativně nové (cca konec 90. let) a často se spojují s objektově orientovaným programováním a zejména s Javou. Ve skutečnosti jde ale jen o novější (a detailnější) formulaci klasické unixové moudrosti a tradice (začátek už v roce 1969, unixová epocha se pak počítá od 1. 1. 1970). Ty shrnul ESR v kapitole Basics of the Unix Philosophy své knihy The Art of Unix Programming. Pro nás jsou relevantní zejména tyto body:
Make each program do one thing well.
Rule of Modularity: Write simple parts connected by clean interfaces.
Rule of Composition: Design programs to be connected to other programs.
Rule of Separation: Separate policy from mechanism; separate interfaces from engines.
Rule of Extensibility: Design for the future, because it will be here sooner than you think.
Zvlášť pravidlo modularity si zaslouží ocitovat celé:
As Brian Kernighan once observed, „Controlling complexity is the essence of computer programming“ [Kernighan-Plauger]. Debugging dominates development time, and getting a working system out the door is usually less a result of brilliant design than it is of managing not to trip over your own feet too many times.
Assemblers, compilers, flowcharting, procedural programming, structured programming, „artificial intelligence“, fourth-generation languages, object orientation, and software-development methodologies without number have been touted and sold as a cure for this problem. All have failed as cures, if only because they ‚succeeded‘ by escalating the normal level of program complexity to the point where (once again) human brains could barely cope. As Fred Brooks famously observed [Brooks], there is no silver bullet.
The only way to write complex software that won't fall on its face is to hold its global complexity down — to build it out of simple parts connected by well-defined interfaces, so that most problems are local and you can have some hope of upgrading a part without breaking the whole.
Když mluvíme o abstraktních rozhraních, IoC nebo DI, může to někomu znít jako zavádění jakýchsi novot a snaha dělat věci jinak… ve skutečnosti je to ale návrat k dávným tradicím a dodržování postupů, které byly správné odjakživa. Avšak ani nedávnou historii bychom neměli ignorovat – zejména ve světě Javy došlo k rozvinutí těchto myšlenek a uvedení do běžné, každodenní praxe řadových programátorů.
Tyto postupy však nejsou specifické pro OOP natož pro nějaký konkrétní programovací jazyk – jde o obecně platné principy, které lze aplikovat kdekoli. Dokonce i tam, kde neprogramujeme ale jen pracujeme s daty – např. i na takovou sadu ikon se lze dívat jako na komponentu, kterou někam injektujeme nebo vyhledáme service locatorem – rozhraním jsou v tomto případě předem definované názvy souborů a jim přiřazený význam, např. edit-copy.png
je ikona symbolizující akci „Úpravy / Kopírovat“ – to je abstraktní definice – konkrétní grafické ztvárnění pak závisí na dané implementaci této komponenty (sady ikon), kterých je typicky více a které lze (konfigurací) přepínat.
A byla by chyba tyto myšlenky považovat jen za nějakou programátorskou záležitost a implementační detail. Má smysl je aplikovat i na úrovni větších celků – na úrovni modulů, systémových komponent nebo spolupracujících systémů – nikoli jen v rámci správného OOP návrhu jednotlivých tříd schovaných kdesi v útrobách programu. Dokonce si troufnu tvrdit, že správný návrh na úrovni těch větších celků je důležitější než správný a hezký kód kdesi uvnitř programu. Jde tedy o téma, kterým by se měli zabývat (také) analytici, architekti a vlastníci (softwarových) produktů.
V kapitole Abstraktní rozhraní výše jsme si vyjmenovali šest variant uspořádání vztahů mezi programem, rozhraním, adaptérem a knihovnou. Která varianta je ale ta správná? A existuje vůbec obecně preferovaná varianta, ke které bychom měli vždy směřovat?
Čím více času a úsilí jsme do vývoje programu investovali a čím hodnotnější software jsme vytvořili, tím víc bychom se ho měli snažit odstínit od jeho okolí. Takový program je naše bohatství, naše zlato a měli bychom ho chránit před závislostmi, které ho můžou ohrožovat a táhnout ke dnu. Takový program by měl být použitelný ideálně sám o sobě – a pokud potřebuje součinnost dalších komponent, měl by mít dobře definované rozhraní pro jejich napojení. Program by pak měl být schopen běhu i bez těchto komponent (pokud nejsou nezbytně nutné) nebo umožnit napojení alternativních implementací. Pokud jsme vytvořili hodnotný software obsahující zajímavé funkcionality, je dost možné, že ho uživatelé budou chtít nasadit i v jiném kontextu, než jsme původně uvažovali my – můžou chtít některé komponenty vyměnit za jiné nebo si napsat vlastní implementace. Komplexita závislostí pak nebude bezpodmínečně součástí komplexity našeho programu. Dobře definovaná rozhraní chrání naši investici a umožňují použít to dobré, co uvnitř našeho softwaru je, mnohem flexibilnějším a svobodnějším způsobem.
A pak jsou programy, které samy o sobě nic moc hodnotného nedělají a napsat je nedalo moc práce. Přesto takový program může být užitečný a má smysl, aby existoval – vtip je v tom, že užitečný je jako celek společně s nějakou knihovnou nebo externí komponentou – a právě ta udělá drtivou většinu práce. Program samotný je pak třeba jen jednoduché GUI nebo CLI, které má pár políček, kam uživatel vyplní parametry, a tlačítko, kterým se zavolá daná knihovna či komponenta. A vzhledem k tomu, že navrhnout a udržovat (zejména veřejné) rozhraní není zadarmo (viz výše) mohly by náklady na abstrakci klidně převýšit případný užitek – dost možná by bylo jednodušší napsat celý program znovu ve chvíli, kdy se rozhodneme změnit klíčovou knihovnu pod ním.
Triviální programy tedy můžeme přímo napojit na knihovny či jiné komponenty tzn. použít variantu č. 1:
U hodnotnějšího softwaru bychom měli směřovat k variantě č. 4 (alternativně č. 6):
ke které se můžeme dopracovat i postupně přes varianty č. 2 a č. 3 (alternativně č. 5).
Díky dobře definovanému rozhraní nebude hodnota našeho softwaru snižována příliš velkou komplexitou jeho závislostí – náš program bude lépe použitelný i samostatně a v odlišném prostředí, než pro které byl původně vyvíjen. A díky IoC / DI nebude v našem programu potřeba měnit ani jedinou řádku – pro napojení na jiné komponenty postačí úprava konfigurace. To cenné a jedinečné uvnitř našeho softwaru, s čím jsme si dali tolik práce, tak najde širší uplatnění a bude užitečnější, než kdybychom závislosti zadrátovali natvrdo do zdrojového kódu.
V nejjednodušším případě jen lehce zobecníme rozhraní stávajících knihoven a máme hotovou abstrakci. Tenhle přístup se hodí např. u knihoven pro načítání různých formátů – třeba u obrázků bude na vstupu proud bajtů a na výstupu bitmapa a případně nějaká metadata.
Někdy ale granularita knihoven nemusí odpovídat našemu záměru. Některé knihovny toho umí příliš mnoho a spojují více (z našeho pohledu nesouvisejících) funkcí dohromady. Zatímco jindy bude vhodnější více knihoven spojit do jednoho celku a zastřešit společným rozhraním.
A pak jsou případy, kdy je rozhraní knihovny příliš bohaté nebo se jedná spíše o framework, kterému se aplikace musí přizpůsobit a běží spíš uvnitř něj a dle jeho pravidel, než že by ho používala. Vytvářet abstrakci, která umožní např. vyměnit Qt za GTK nebo jiný GUI toolkit, je sice možné, ale smysl to bude dávat asi jen v dost specifických případech, kdy si vystačíme s malou podmnožinou ovládacích prvků a jednoduchým GUI. V ostatních případech by tvorba takového rozhraní (a příslušných adaptérů) představovala větší pracnost než užitek. To ale neznamená, že bychom na nezávislost na GUI knihovnách měli úplně rezignovat – jen může být rozumné vést ten řez jinudy – hranice pak nebude kopírovat rozhraní stávajících knihoven, ale povede někde uvnitř naší aplikace. Tu rozdělíme na vrstvy a společný kód vyčleníme do sdílené knihovny nebo služby běžící na pozadí. K tomu budeme mít několik alternativních UI vrstev, které tu knihovnu nebo službu budou volat (kromě Qt a GTK můžeme mít i TUI, CLI, D-Bus nebo webové rozhraní).
Tím se plynule dostáváme k modulární architektuře. Zda považujeme nějakou komponentu za náš modul nebo spíš externí systém či závislost, je otázka úhlu pohledu.
Někteří lidé jsou až posedlí čísly 80 a 20 a snaží se je napasovat na všechno. Já nebudu tvrdit, že 80 % uživatelů používá 20 % funkcí softwaru (a každý jiných 20 %). Ta čísla se budou případ od případu lišit, nicméně pravda je taková, že ne všichni uživatelé používají všechny funkce. Proč by tedy měli být zatěžováni (nebo dokonce ohrožováni v případě bezpečnostních chyb) komplexitou částí, které nepoužívají?
Přirozeným řešením je modulární architektura. Uživatel si pak instaluje jen moduly, které potřebuje. Tím se zbaví jak nadbytečné komplexity našeho vlastního kódu, tak komplexity nadbytečných závislostí.
S trochou nadsázky můžeme říct, že Unix resp. dnes GNU/Linux by se měl inspirovat u Javy, aby se tak vrátil ke svým vlastním kořenům a znovu našel to, co na něm bylo tak skvělé… a kruh se uzavřel. Když se totiž podíváme na grafy závislostí v dnešních distribucích, má to k ideálu často hodně daleko – šipky vedou mnohde opačným směrem, než by měly, nebo mezi krabičkami, které by vůbec propojené (natvrdo) být neměly.
V důsledku nevhodně navržených závislostí a vazeb jsou uživatelé a správci zatíženi vyšší komplexitou, než by bylo nutné. Nadbytečná komplexita dopadá i na programátory, kteří chtějí či mají implementovat např. nějaký modul nebo filtr a místo toho, aby dostali dobře definované abstraktní rozhraní, tak se musí potýkat s větším a hůře uchopitelným softwarovým balíkem. Špatně definovaná nebo zcela chybějící rozhraní snižují ochotu lidí dobrovolně se zapojit a přispět k vývoji a prodražují práci těch, kteří jsou za své příspěvky placeni.
Postfix je jedním z nejpoužívanějších poštovních (SMTP) serverů. Je zodpovědný za příjem zpráv, jejich řazení do fronty a následné odesílání nebo předávání dalším komponentám (typicky Dovecot, který zprávy uloží a zpřístupňuje uživatelům skrze protokoly IMAP4 a POP3). Ke své činnosti Postfix potřebuje informace o tom, jaké domény jsou jeho, jaké jsou v nich schránky a aliasy, kteří uživatelé (tzn. jména a hesla) mohou odesílat poštu ven atd.
Tyto informace lze ukládat mnoha různými způsoby – některý správce si vystačí se statickou konfigurací v textových souborech, jiný je bude mít na centrálním LDAP serveru a někdo je dá do relační databáze jako SQLite, PostgreSQL či MySQL atd.
Pro každé z těchto úložišť je potřeba použít jinou knihovnu – klienta implementujícího protokol LDAP nebo ovladač příslušné databáze. Pokud by Postfix měl přímou závislost na jednotlivých knihovnách, nutil by všechny uživatele instalovat všechny knihovny – uživatel by pak byl zatížen a ohrožován nadbytečnou komplexitou. Kdyby pak např. v knihovně pro MySQL byla bezpečnostní chyba, mohla by ohrozit i uživatele, kteří MySQL vůbec nepoužívají.
Autor Postfixu k tomu naštěstí přistupoval jinak a navrhl svůj software modulárně. Jádro Postfixu tedy na knihovnách pro LDAP, PostgreSQL, SQLite, MySQL atd. nezávisí, ačkoli Postfix tato úložiště umí vyžít:
Běhové závislosti Postfixu tak vypadají celkem příčetně, aniž by bylo potřeba omezovat funkcionalitu. Trik spočívá v tom, že tato funkcionalita je přesunuta do volitelných modulů, které si nainstaluje jen ten, kdo je využije.
V případě MySQL modulu (u LDAPu nebo PostgreSQL je ten strom košatější) pro Postfix pak ty závislosti vypadají takto:
Z pohledu uživatele Postfixu jde o víceméně dokonalé řešení. Z pohledu vývojáře modulu to ale úplně optimální není – modul totiž závisí na celém balíku postfix
, který představuje poměrně vysokou komplexitu (ačkoli to v grafu na první pohled vidět není, viz také vysvětlivka ke kosočtverci). Tento návrh jde vylepšit tím, že modul nebude záviset na celém Postfixu, ale pouze na malém a dobře definovaném abstraktním rozhraní. Na tomto rozhraní bude záviset i Postfix, ale odpadne přímá závislost mezi modulem a Postfixem. Výhodou je jednak to, že autor modulu se nemusí potýkat s komplexitou Postfixu – dostane pouze jasně definované rozhraní, které implementuje. A jednak to, že stejné rozhraní by mohl použít i jiný software než Postfix. Tím softwarem může být třeba sada testů, kterou se ověří, že modul vyhovuje požadavkům, ale můžeme se dopracovat i k tomu, že toto rozhraní adoptují i jiné programy, např. jiný poštovní server nebo podobný software. Moduly napsané původně pro Postfix pak rázem budou znovupoužitelné a půjde je nasadit i jinde.
Tyto moduly jsou součástí zdrojových kódů Postfixu a jsou udržované autorem Postfixu. Abychom dostali např. postfix-mysql.so
, musíme si stáhnout zdrojové kódy celého Postfixu a kompilaci provést v jeho adresáři. Do procesu tvorby modulu pak vstupuje komplexita celého Postfixu, nikoli jen komplexita modulu a rozhraní. To, že se tyto moduly potom nacházejí v oddělených balíčcích, které lze instalovat samostatně, je zásluha GNU/Linuxových distribucí. Díky abstraktnímu rozhraní by šlo vyvíjet moduly i nezávisle a pravděpodobně by se do jejich vývoje zapojilo více autorů. To v současnosti není dost dobře možné, protože – jak se dočteme v dokumentaci – neexistuje stabilní rozhraní pro moduly:
Postfix dynamically-linked libraries and database plugins implement a Postfix-internal API that changes without maintaining compatibility.
Kromě výše uvedených databázových modulů lze možnosti Postfixu rozšiřovat i pomocí tzv. milterů. (složenina mail + filter). Z architektonického pohledu je milter rovněž modulem. Ovšem miltery se používají v odlišných situacích (než např. modul pro LDAP). Milter umožňuje vstoupit do procesu příjmu zprávy a ovlivnit chování v jednotlivých jeho krocích.
Poznámka: pro pochopení funkce a smyslu milterů je dobré vědět, jak funguje posílání e-mailu pomocí protokolu SMTP. Nejedná se totiž a atomickou operaci (jedno volání), ale jde o konverzaci mezi klientem a serverem, během které se strany nejdřív vzájemně představí a klient pak posílá jednotlivé příkazy, kterými nastaví adresu odesílatele, adresy příjemců a nakonec pošle obsah zprávy, a pak se buď rozloučí nebo pokračuje odesíláním dalších zpráv. Server na každý příkaz odpovídá buď 250 (OK) nebo nějakým chybovým kódem (případně může natvrdo ukončit spojení nebo záměrně zdržovat…). To znamená, že server může odmítnout zprávu hned na začátku, nebo může zamítnout některé příjemce, nebo si počká na obsah zprávy, a pak se teprve rozhodne, zda zprávu přijme nebo odmítne. Takže když např. zpráva míří do neexistující schránky nebo server považuje odesílatele za spammera, ušetříme si přenášení zbytečných kilobajtů nebo megabajtů po síti a zastavíme odesílatele hned na začátku. Kolem této konverzace se dá vystavět všelijaká logika a přizpůsobit si proces zpracování e-mailů na míru. A právě proto, aby poštovní server mohl zároveň zůstat jednoduchý (dělal jednu věc a dělal ji pořádně), tu máme miltery. Poštovní server přeposílá jednotlivé kroky konverzace milteru (či milterům) a ten vždy řekne, zda se má pokračovat normálně dál nebo zda zprávu odmítnout či zahodit. Milter také může upravovat adresy příjemců, hlavičky zprávy nebo dokonce i přepisovat její obsah. Typické použití jsou různé anti-spamové a anti-virové filtry, DKIM atd. Některé miltery ani nemusí do příjmu zpráv nijak aktivně zasahovat a mohou jen poslouchat události a zaznamenávat je.
Koncept milterů vznikl původně v rámci SMTP serveru Sendmail, ale později se z tohoto rozhraní stal de facto oborový standard a používají ho i další SMTP servery jako právě Postfix. Jednotlivé miltery pak běžně píší nezávislí vývojáři. Milter je tedy znovupoužitelná komponenta (modul), kterou lze nasadit v různých SMTP serverech.
Shodou okolností teď jeden milter vyvíjím a ačkoli Postfix používám, nejsem na něm přímo závislý a nemusím se potýkat s jeho komplexitou, protože jsem od něj odstíněn právě tím abstraktním rozhraním – protokolem milterů (jednoduchý binární protokol založený na posílání zpráv přes soket).
Kromě databázových modulů a milterů lze rozšířit možnosti Postfixu i pomocí různých skriptů či podprogramů, kterým se za určitých podmínek předávají zprávy přes STDIO. Dále pak můžeme protokolem LMTP připojit Dovecot nebo jiný server, který bude odpovědný za ukládání zpráv (a typicky i jejich zpřístupnění uživatelům protokoly POP3 nebo IMAP4). Z pohledu Postfixu je Dovecot spíše spolupracující externí systém než modul. Pokud se ale díváme na poštovní systém jako na celek, můžeme i Dovecot považovat za jeho modul. Důležité každopádně je, že nemáme přímou závislost mezi Postfixem a Dovecotem – je mezi nimi dobře definované abstraktní rozhraní (LMTP).
Zjednodušený a nekompletní logický pohled na závislosti v poštovním systému:
Postfixu lze vytknout absenci stabilního rozhraní pro databázové moduly, kvůli které je obtížné až nemožné tyto moduly vyvíjet nezávisle. Dále je pak problém se závislostmi v době kompilace .deb balíčku Postfixu – ten závisí na balíčcích MySQL, LDAP, PostgreSQL, SQLite a dalších – ovšem to je chyba dané distribuce, nikoli Postfixu samotného (ten jde ze zdrojových kódů zkompilovat i bez těchto závislostí). V případě milterů a LMTP ale Postfix správně používá abstraktní rozhraní a nepřidává neopodstatněné závislosti. Uživatele, kterému např. stačí ukládat zprávy do adresáře na disku tak neobtěžuje komplexita Dovecotu. Uživatele, který nepotřebuje anti-virus, neobtěžuje komplexita ClamAVu atd. Celkově si tedy Postfix zaslouží kladné hodnocení a můžeme ho považovat za vhodnou inspiraci.
Většina aplikací potřebuje nějak ukládat strukturovaná data a velké databáze typu PostgreSQL nebo MySQL či MariaDB, které používají architekturu klient-server, nejsou vždy vhodné. Na druhou stranu: vymýšlet si vlastní formáty uložení dat a psát si vrstvu pro práci s nimi je zase dost práce navíc a znamená to navýšení komplexity samotné aplikace. Máme tu tedy díru na trhu, kterou skvěle vyplňuje právě SQLite – malá relační databáze, která nepotřebuje žádný server, jedná se totiž o softwarovou knihovnu a celá databáze pak běží v rámci procesu našeho programu (self-contained, serverless, zero-configuration). Vše tedy probíhá jen jako volání funkcí a není tu žádná komunikace po síti ani po místních soketech nebo jiné IPC.
Zajímavost: vývojáři SQLite používají verzovací systém Fossil, který veškerou historii zdrojových kódů a další data ukládá do SQLite databáze (podobně jako Monotone). Letos dokonce vývojáři SQLite uzavřeli svoji e-mailovou konferenci a přenesli svoji komunikaci na fórum, které je součástí Fossilu. Tento krok vzbudil trochu pozdvižení, ale vlastně proč ne? Třeba je tohle cesta. Teď si stačí naklonovat úložiště Fossilem (pozor: je potřeba mít verzi 2.7 nebo vyšší) a máme lokálně na svém disku i celý archiv diskusí. Tohle šlo asi naposledy udělat s NNTP, ale s běžnou e-mailovou konferencí je to buď nemožné nebo dost velké peklo. Teď už jen zbývá, aby někdo k tomu Fossilu napsal IMAP4 nebo NNTP adaptér, a půjde pro diskuse používat i standardní klienty.
SQLite nabízí i některé poměrně pokročilé funkce (např. window), ale přesto se nemůže s výše zmíněnými DBMS moc srovnávat co do funkčnosti. Absence statického typování je z uživatelského hlediska taky spíš na překážku (z pohledu implementátora to jde pochopit a jistý smysl to dává; uživatel to pak může částečně kompenzovat pomocí ODBC ovladače, který typy odvodí). Ve výchozím stavu jde tedy o poměrně jednoduchý software – přesto lze jeho funkčnost rozšiřovat takřka donekonečna. Můžeme si totiž napsat modul, který načteme jako dynamickou knihovnu do SQLite (resp. libovolného programu používajícího SQLite), a tento modul nám přidá potřebné skalární či agregační funkce, řazení (collation) nebo dokonce virtuální tabulky dynamicky poskytující data z vnějších zdrojů nebo alternativní VFS (úložiště dat).
Rozhraní modulů je definováno v souboru sqlite3ext.h
(neplést s sqlite3.h
), který má cca 500 řádek kódu a obsahuje jen signatury funkcí a komentáře – není zde tedy žádný spustitelný kód a jedná se o čistě abstraktní rozhraní. Když píšeme modul, závisíme jen na tomto souboru a kompilátoru, ale nikoli na SQLite jako takovém – náš modul je po kompilaci dynamickou knihovnou a není linkovaný k libsqlite3.so
. Dá se říct, že funkce z libsqlite3.so
jsou v době běhu do našeho modulu injektovány zvenčí. Logický pohled na závislosti je tedy následující:
V distribucích, jako je Debian, to sice takhle ideálně nevypadá, protože balíček libsqlite3-dev
, ve kterém najdeme soubor sqlite3ext.h
, deklaruje závislost na balíčku libsqlite3-0
, takže distribuce nás donutí nainstalovat si kromě rozhraní i implementaci, ale to už je věc dané distribuce, nikoli SQLite jako takového – to je skutečně navržené tak, že pro psaní modulů implementaci (libsqlite3.so
) nepotřebujeme a stačí nám abstraktní rozhraní. Jednou zkompilovaný modul pak můžeme nahrát jak v interaktivním interpretru /usr/bin/sqlite3
, tak i v aplikacích, které SQLite interně používají – a to dokonce i v těch, které v sobě mají SQLite zakompilované staticky a nepoužívají sdílenou knihovnu libsqlite3.so
.
Vytvoření modulu s pár funkcemi je práce na pár řádků – viz sqlite-demo-modul. Jeho kompilací získáme sdílenou knihovnu libdemo.so
, kterou si načteme pomocí load_extension()
v SQL a následně můžeme v SQL dotazech volat funkce get_pid()
, value_count()
a multiply()
:
SELECT load_extension('./libdemo.so'); -- načte modul
SELECT get_pid(); -- vrátí ID procesu např. 23684
SELECT value_count(); -- vrátí počet parametrů: 0
SELECT value_count('a'); -- vrátí počet parametrů: 1
SELECT value_count(1, 2); -- vrátí počet parametrů: 2
SELECT value_count(1, 'x', 2); -- vrátí počet parametrů: 3
SELECT multiply(2, 3); -- vrátí součin parametrů: 6
SELECT multiply(2, 2, 2); -- vrátí součin parametrů: 8
které jsme si implementovali v C resp. C++:
#include <cstdio>
#include <sqlite3ext.h>
#include <unistd.h>
#define C_API extern "C"
#define SQL_FN(functionName) void functionName (sqlite3_context* ctx, int valueCount, sqlite3_value** values)
SQLITE_EXTENSION_INIT1;
SQL_FN(valueCount) {
sqlite3_result_int(ctx, valueCount);
}
SQL_FN(getPID) {
sqlite3_result_int(ctx, getpid());
}
SQL_FN(multiply) {
sqlite3_int64 result = valueCount == 0 ? 0 : 1;
for (int i = 0; i < valueCount; i++) result *= sqlite3_value_int64(values[i]);
sqlite3_result_int64(ctx, result);
}
C_API int sqlite3_demo_init(sqlite3* db, char** error, const sqlite3_api_routines* api) {
SQLITE_EXTENSION_INIT2(api);
sqlite3_create_function(db, "value_count", -1, SQLITE_UTF8, nullptr, valueCount, nullptr, nullptr);
sqlite3_create_function(db, "get_pid", 0, SQLITE_UTF8, nullptr, getPID, nullptr, nullptr);
sqlite3_create_function(db, "multiply", -1, SQLITE_UTF8, nullptr, multiply, nullptr, nullptr);
return SQLITE_OK;
}
Více viz oficiální dokumentace, kde najdeme i ukázky pokročilejších modulů implementujících virtuální tabulky či VFS.
Zdrojové kódy SQLite (resp. soubor sqlite3ext.h
) stojí za přečtení a mohou sloužit jako inspirace pro případ, že bychom vytvářeli modulární systém nebo aplikaci v jazyce C. Existence abstraktního rozhraní výrazně snižuje vstupní bariéru pro zapojení se do vývoje. Ať už půjde o nezávislé dobrovolníky nebo si vývoj modulu u někoho objednáme, dotyčnému programátorovi stačí jen ten pětisetřádkový soubor s definicí rozhraní (v případě SQLite – u našeho vlastního systému to může být ještě méně) a naše požadavky, ale SQLite (nebo náš systém) si na svůj počítač vůbec instalovat nemusí a není zatížen jejich komplexitou. Ano, zní to možná trochu paranoidně a ano, dotyčný programátor si dost možná daný software stejně někam nainstaluje – ale ta možnost, že tak učinit nemusí, je známkou příčetného modulárního návrhu.
Základem zvukového systému v GNU/Linuxu je ALSA. Obsahuje ovladače zvukových karet (jsou součástí Jádra), emulaci staršího rozhraní OSS (Open Sound System – /dev/dsp
) a API zpřístupňující zvukový subsystém aplikacím (jak PCM, tak MIDI). ALSA sama o sobě umí míchat více zvukových proudů dohromady (více aplikací může současně přehrávat zvuk), což si můžeme vyzkoušet:
mpv --audio-device=alsa/dmix:CARD=Rubix44 ~/hudba/Twin\ Peaks/ &
mpv --audio-device=alsa/dmix:CARD=Rubix44 ~/hudba/Freaks\ and\ Geeks/
# Když místo „dmix“ dáme „hw“ bude hrát jen Angelo Badalamenti,
# zatímco machři a šprti budou muset čekat na uvolnění zvukové karty.
přesto se ale nad nízkoúrovňovým zvukovým systémem obvykle používá nějaká nadstavba (tzv. zvukový server) a aplikace se napojují až na ni. Historicky jsme tu měli aRts a ESD, dnes se používá nejčastěji PulseAudio (spotřební záležitost) a JACK (profesionální audio). Tyto nadstavby umožňují lépe a flexibilněji směrovat zvukové proudy, měnit jejich hlasitost, aplikovat různé efekty a filtry. Případně teď vzniká PipeWire, který má ambice uspokojit jak běžné uživatele, tak profesionály a k tomu nabídnout i podporu videa a vyšší bezpečnost u kontejnerizovaných aplikací (uvidíme, kam se to vyvine…). My se teď podíváme na JACK a LV2, což je stabilní a v praxi ověřený software.
JACK běží jako proces na pozadí (démon) a na jedné straně poskytuje API aplikacím (přehrávače atd.) a na druhé straně je napojen na zvukový subsystém (v našem případě ALSA) a přes něj na zvukovou kartu. Aplikace se připojují k JACKu a informují ho o tom, jaké mají vstupy a výstupy. JACK pak udržuje graf propojení jednotlivých vstupů a výstupů a přeposílá na základě něj zvukové proudy.
Je to jako když v klasickém unixovém shellu skládáme dohromady malé univerzální příkazy (grep
, sed
, cut
, sort
, awk
, perl
atd.), tvoříme z nich roury a pouštíme skrz ně textové proudy, nebo jako když přes relační roury proháníme strukturovaná data. JACK nám umožňuje dělat totéž se zvukem. Uzly grafu reprezentují procesy a mají vstupy a výstupy, které můžeme propojovat. Vytváříme tak složitější systém z malých nezávislých programů spolupracujících navzdory tomu, že neví nic o ostatních prvcích grafu – pouze implementují společné rozhraní. Viz také kapitola The great parts v Classic pipeline example. Je jedno, zda zvuk pochází ze zvukové karty, kam máme připojený gramofon, mikrofon nebo rádio, nebo zda je generován nějakým programem (hudební přehrávač nebo třeba SDR). Jednou je to zvukový proud ve světě JACKu a od té doby se s ním už pracuje stejně.
Když chceme zvukový proud jen přehrát, tak ho napojíme rovnou na výstup zvukové karty. Také ho ale můžeme rozdvojit a kromě přehrávání i nahrávat (třeba programem Audacity nebo příkazem jack_rec
). Je to jako tee
v klasickém unixovém shellu nebo třeba jako Wire Tap v Camelu. Pak tu máme programy, které mají jak vstup, tak výstup. To jsou většinou různé filtry a efekty. Jedním z takových programů je Jalv – hostitel pro moduly ve formátu LV2 (pokračovatel LADSPA). Tyto moduly se většinou používají uvnitř nějakého tvůrčího softwaru (např. Ardour, Qtractor či Guitarix), ale můžeme je použít i s jednoduchým programem, jako je ten Jalv, který souží jen jako adaptér mezi rozhraním LV2 a rozhraním JACK. Spustíme si v něm např. x42 Equalizer, který umí analyzovat spektrum a vykreslovat ho jako vodopádový graf a zároveň nám umožní zvuk upravovat – posílit či zeslabit určité frekvence:
JACK podporuje jak PCM, tak MIDI. Stejně jako napojujeme zvukové proudy např. z mikrofonů nebo přehrávačů do filtrů a výstupů, můžeme propojovat i signál z MIDI kláves do MIDI filtrů a syntezátorů (např. synthv1), které z MIDI dat vygenerují PCM zvukový proud. Máme tedy dva samostatné grafy (PCM a MIDI) a ty můžou být v určitém místě přemostěné programem jako je synthv1. Vyzkoušet si to můžeme i bez fyzických kláves – příkazem jack-keyboard
si spustíme virtuální klávesy na obrazovce, ty pak můžeme napojit do synthv1 a jeho výstup poslat do reproduktorů.
Z hlediska závislostí vypadá náš zvukový systém takto (logický pohled):
Přehrávače (VLC, MPV atd.) a nahrávací software závisí jen na rozhraní JACK a neví nic o LV2 a dalších komponentách. Jednotlivé efekty a filtry zase závisí jen na rozhraní LV2 a neví nic o JACKu, přehrávačích a dalším softwaru. LV2 moduly lze používat i bez JACKu, existuje např. příkaz lv2apply
, který aplikuje zadaný efekt na zvukový soubor. Každá komponenta si tedy řeší svoji relativně úzkou oblast a přestože je schopná s ostatními spolupracovat, není zatížena jejich komplexitou a jejich (tranzitivními) závislostmi. Díky tomu může existovat i třeba vysoce komplexní efekt či filtr využívající další knihovny – a program typu Ardour nebo Qtractor ho může využívat, ale přesto nenarůstá jeho komplexita – program zůstává stále jednoduchý, protože závisí jen na rozhraní, nikoli na konkrétních modulech (filtrech a efektech).
Tuhle infrastrukturu nemusíme používat jen v souvislosti s hudbou. Můžeme mít např. JACK nebo LV2 modul, který bude kódovat či dekódovat morseovku nebo nějaký digimód (RTTY, Olivia MFSK, APRS/AX.25 atd.).
Při implementaci zvukových modulů (JACK klientů) je potřeba brát ohled na to, že vše probíhá v (téměř) reálném čase a snažíme se o minimální latence. Není to tedy tak jednoduché, jako když píšeme funkci pro SQL, která doběhne, až doběhne. Zvukové moduly proto bývají vícevláknové a funkce, která přijme zvuková data nesmí dělat nic, co by zdržovalo – pokud je potřeba např. něco vypsat na standardní výstup či ukázat na obrazovce, něco náročnějšího počítat nebo komunikovat s jinou komponentou, je potřeba to udělat asynchronně v jiném vlákně. Pro komunikaci mezi vlákny se typicky používá neblokující cyklická fronta (ring buffer). Výsledkem kompilace modulu pak bývá spustitelný soubor linkovaný k libjack.so
(případně to může být zase sdílená knihovna, která se načte do jiného programu). Po spuštění se připojí k jackd
démonu a zaregistruje u něj svoje vstupy a výstupy – ty pak můžeme propojit s ostatními prvky grafu pomocí GUI nástroje jako je QjackCtl. Kromě toho existují i interní moduly, které se dají načíst přímo do běžícího jackd
a tím odpadne část režie.
Pomocí nástroje relpipe-in-jack (zatím ještě trochu v experimentálním stavu) můžeme propojit zvukový systém (resp. zatím jeho MIDI část) s relačními rourami. Tento nástroj překládá MIDI zprávy přijaté z JACKu na relační data – záznamy, které posílá na standardní výstup – tyto záznamy pak můžeme zpracovávat dalšími relačními nástroji a skripty. Toho lze využít pro monitorování MIDI zpráv proudících skrze JACK – zobrazíme si je pomocí relpipe-out-gui
a můžeme průběžně sledovat, co posílají jak SW komponenty (např. jack-keyboard
nebo nějaký přehrávač), tak HW zařízení (typicky klávesy nebo třeba mixážní pulty s USB rozhraním, které se běžně tváří jako MIDI ovladač):
Nebo si můžeme pomocí jednoduchého skriptu navázat spouštění oblíbených programů na stisky kláves na MIDI klávesnici nebo ovladači:
#!/bin/bash
read_nullbyte() { local IFS=; for v in "$@"; do export "$v"; read -r -d '' "$v"; done }
relpipe-in-jack \
| relpipe-out-nullbyte \
| while read_nullbyte event channel note_on note_pitch note_velocity controller_id controller_value raw; do
if [[ "$note_pitch" == "21" && "$note_on" == "true" ]]; then kcalc &
elif [[ "$note_pitch" == "23" && "$note_on" == "true" ]]; then dolphin &
fi
done
Existující programy pro práci se zvukem podobné mapování kláves běžně podporují, ale tento skript můžeme nechat běžet na pozadí, i když zrovna nechceme mít spuštěný žádný komplexnější software. Nicméně je to spíše ukázka toho, jak lze propojit i zdánlivě nesouvisející světy. Případně bychom mohli JACK využít (zneužít) pro směrování libovolných dat (jako tzv. message broker) maskovaných za MIDI zprávy nebo PCM proudy. Když zůstaneme u audia, můžeme mít třeba modul, který bude posílat zvuk na paralelní port a na něj připojený covox (ale tohle patří spíš na úroveň Jádra a Alsy než do JACKu).
Modulem (klientem) v JACK systému může být libovolný program, často má jen vstup nebo jen výstup a uživatelské rozhraní závisí čistě na autorovi. Oproti tomu moduly implementující rozhraní LV2 mají trochu pevněji danou strukturu – v zásadě se očekává, že to budou filtry transformující zvukový vstup na zvukový výstup a k tomu budou mít několik parametrů, kterými půjde transformaci ovlivnit. A jak jsme si již říkali, tyto moduly nepotřebují běžící JACK a lze je použít i pro dávkové zpracování souborů. LV2 moduly jsou tvořeny sdílenou knihovnou (.so
) a metadaty ve formátu Turtle (způsob zápisu RDF). V metadatech je deklarativně popsáno, jaké má modul porty, což jsou v tomto případě jak zvukové vstupy a výstupy, tak parametry, které se pak nastavují ovládacími prvky. Díky tomu lze modul parametrizovat i z příkazové řádky nebo se pro něj na základě tohoto deklarativního popisu vygeneruje GUI. Tentýž modul pak v GTK3 hostiteli (jalv.gtk3
) může vypadat takto:
zatímco v Qt5 (jalv.qt5
) takto:
Výhodou tohoto přístupu je i jednotný způsob ukládání a načítání konfigurace. Místo toho, aby si každý modul implementoval serializaci a deserializaci parametrů po svém, je tahle úloha vyřešena na jednom místě – obecně pro všechny moduly.
Kromě toho může modul nabízet i vlastní specifické GUI – viz x42 Equalizer výše – takže může vykreslovat libovolnou grafiku jako třeba spektrum, ale přesto stále v metadatech deklaruje svoje parametry, takže ho lze používat i z příkazové řádky nebo pro něj vykreslit generické GUI:
Z vlastní zkušenosti můžu říct, že generovat UI na základě deklarativního popisu není triviální – jde o hledání vhodného kompromisu mezi generickou jednoduchostí a podporou specifických případů. Příliš omezený systém nebude vyhovovat všem. Na druhé straně, čím více možností a stupňů volnosti, tím obtížnější bude implementace hostitelské aplikace, která má UI generovat. Zdá se mi, že autorům specifikace LV2 se povedlo s touto výzvou vypořádat docela dobře. A dovedu si představit podobný framework parametrizovatelných filtrů s generovaným UI i pro transformaci jiných dat než zvukových.
LV2 moduly jsou identifikované pomocí URI (např. http://gareus.org/oss/lv2/fil4#stereo
), což je skvělá věc, protože tím je zaručena globální jedinečnost a nemůže docházet ke kolizím názvů. Kdokoli tak může vytvářet moduly a vymýšlet si pro ně vlastní názvy (v rámci svého jmenného prostoru, např. internetové domény), aniž by se musel dovolovat nějaké centrální autority nebo se s kýmkoli koordinovat.
Moduly se instalují do /usr/lib/lv2/
či ~/.lv2/
, kde má každý modul svoji složku (na jejím názvu nezáleží) a v ní manifest.ttl
, ve kterém je uveden název sdílené knihovny (.so
) a souboru s metadaty (.ttl
) popisujícími porty modulu. Seznam dostupných modulů si vypíšeme příkazem lv2ls
a detaily konkrétního modulu získáme příkazem lv2info
, kterému zadáme jako parametr URI modulu. Máme tedy registr instalovaných modulů a jednotlivé aplikace (zvukové editory atd.) pak můžou uživateli nabízet jejich seznam, aniž by bylo potřeba moduly instalovat do jednotlivých aplikací. Díky deklarativním metadatům lze uživateli zobrazit informace o modulu (popis, vstupy, výstupy, parametry atd.), aniž by bylo potřeba načítat jeho spustitelný kód.
Vstupním bodem pro spuštění modulu je pak tato funkce:
LV2_SYMBOL_EXPORT const LV2_Descriptor* lv2_descriptor(uint32_t index) {
switch (index) {
case 0: return &descriptor;
default: return NULL;
}
}
která vrací strukturu (případně struktury, pokud je v jedné knihovně více filtrů) obsahující URI a ukazatele na funkce:
static const LV2_Descriptor descriptor = {
AMP_URI,
instantiate,
connect_port,
activate,
run,
deactivate,
cleanup,
extension_data
};
Vlastní transformace pak probíhá ve funkci run()
. V případě jednoduchého zesilovače resp. úpravy hlasitosti zde prostě jen vynásobíme vstupní hodnotu koeficientem a vložíme na výstup. Ve zdrojových kódech lv2 najdeme několik základních příkladů. Dále se pak můžeme inspirovat u x42, ZamAudio nebo Guitarix. V minimalistické variantě nám pro implementaci modulu stačí hlavičkové soubory LV2 a kompilátor (např. GCC):
g++ -g -shared -fPIC demo.cpp -o libdemo.so
žádné další závislosti ani nástroje nepotřebujeme (metadata si napíšeme ručně).
Zkompilovaný modul a .ttl soubory pak nakopírujeme do složky v ~/.lv2/
a můžeme hned spustit třeba pomocí jalv
, kterému zadáme jako parametr URI našeho modulu. Modul se nám objeví v JACK grafu a můžeme na něj napojovat vstupní a výstupní proudy. Případně použijeme modul bez JACKu a proženeme skrz něj obsah nějakého zvukového souboru příkazem lv2apply
. Máme tak znovupoužitelnou komponentu, kterou lze používat jak pro zpracování v reálném čase, tak i dávkově a jak s GUI, tak z příkazové řádky.
Většina serverových programů si serverové sokety (TCP, UDP, UDS, SCTP…), na kterých naslouchá a přijímá požadavky, vytváří sama. Musí tomu ale tak být? Je to vůbec vhodné řešení? Na serverový soket se můžeme dívat jako na zdroj (závislost). Program ho využívá, ale to neznamená, že by si ho musel sám vytvářet. Tento zdroj můžeme injektovat (viz IoC a DI výše) do programu zvenčí a tím oddělit odpovědnost za vytvoření zdroje a jeho použití (zpracování příchozích požadavků). Podrobnosti jsem popisoval už v jednom z předchozích článků: Předávání soketu zvenku.
Program se pak může soustředit na svoji úlohu (vyřizování požadavků, poskytování služby), zatímco starosti s vytvářením soketu přenechá svému rodičovskému procesu (většinou nějaký super-server nebo init systém).
Máme na výběr z několika variant:
Tímto způsobem můžeme i šetřit zdroje (RAM, CPU): když nejsou připojeni žádní klienti, nemusí program vůbec běžet a zabírat místo v paměti a cykly procesoru. Program se spustí až ve chvíli, kdy je to potřeba. Na druhou stranu, spouštět proces pro každého klienta není při velkém počtu klientů zrovna efektivní. Proto se hodí socket activation. Po nějakém čase neaktivity může program zase skončit a vrátit soket rodičovskému procesu, který bude sledovat, zda se někdo chce připojit a případně pak zase program spustí.
Software, který umí využít zděděné FD místo toho, aby si je otevíral sám, lépe naplňuje představu programu, který dělá jednu věc a dělá ji dobře. Takové programy jsou znovupoužitelnější a lze je lépe skládat do větších celků, aniž bychom je museli upravovat nebo konfigurovat. V principu je to totéž, jako program, který čte ze standardního vstupu (FD 0) a zapisuje na standardní výstup (FD 1) případně chybový výstup (FD 2), akorát to není prostý filtr, ale serverový software.
Zděděné FD nemusí být jen sokety, můžou to být samozřejmě i běžné soubory nebo jiné zdroje.
Infrastrukturu pro uvedení těchto principů do praxe tu máme už desítky let. Jediné, co chybí je standardní metoda pro předání metadat. U jednoduchých scénářů to není potřeba – pokud program naslouchá jen na jednom soketu, dostane ho na FD 0 a není co řešit (takhle funguje i klasický xinetd
). Pokud ale program pracuje s více druhy soketů (např. HTTP a HTTPS nebo POP3 a IMAP4 nebo třeba oddělené rozhraní pro administraci), tak je potřeba nějak předat informaci, které FD jsou které. Systemd sice má jakési vlastní proprietární API, ale už názvy funkcí jako sd_listen_fds()
či sd_listen_fds_with_names()
napovídají, že to není nic přenositelného a standardního. Program, který by takové funkce využíval, se stává závislým na systemd, vzniká zde svým způsobem vendor lock-in a nebude nám to fungovat s jinými init systémy či super-servery.
Standardní API použitelné napříč různými implementacemi by ale mohlo být velice jednoduché. Podobně jako při DI třeba v Javě komponenta deklaruje sloty, do kterých jsou pak zvenku injektovány zdroje, tak by program mohl deklarovat typy FD, které podporuje. Pro jejich identifikaci by bylo ideální použít URI, abychom měli názvy globálně jedinečné a nedocházelo ke kolizím (nicméně z pohledu implementace je URI jen obyčejný textový řetězec – nejde o žádnou komplikaci). Rodičovský proces by pak vytvořil (na základě své konfigurace resp. přání uživatele) různé FD a do proměnných prostředí by zapsal jejich typy. Což by třeba v případě WWW serveru mohlo vypadat takto:
INHERITED_FD_3_TYPE="tag:httpd.apache.org,2020:socket:http"; # HTTP na první IP adrese
INHERITED_FD_4_TYPE="tag:httpd.apache.org,2020:socket:https"; # HTTPS na první IP adrese
INHERITED_FD_5_TYPE="tag:httpd.apache.org,2020:socket:http"; # HTTP na druhé IP adrese
INHERITED_FD_6_TYPE="tag:httpd.apache.org,2020:socket:https"; # HTTPS na druhé IP adrese
a v případě poštovního serveru takhle:
INHERITED_FD_3_TYPE="tag:postfix.org,2020:socket:imap4"; # IMAP4 bude na FD 3
INHERITED_FD_4_TYPE="tag:postfix.org,2020:socket:pop3"; # POP3 bude na FD 4
Případně by kromě URI šlo poskytnout i další atributy, kterými bychom mohli předat upřesňující informace a rozlišit více FD stejného typu:
INHERITED_FD_3_TYPE="tag:postfix.org,2020:socket:imap4";
INHERITED_FD_3_ATTR_a="hodnota atributu a…";
INHERITED_FD_3_ATTR_b="hodnota atributu b…";
INHERITED_FD_3_ATTR_c="hodnota atributu c…";
(názvy atributů už nemusí být globálně unikátní, protože jsou vázané na konkrétní FD a jeho URI, takže se nacházejí ve jmenném prostoru daného programu a nehrozí zde kolize)
Program by pak na základě těchto metadat věděl, jak má s jednotlivými FD pracovat, jaké služby na nich má poskytovat nebo že jde třeba o určitý otevřený soubor, který si má přečíst či do něj něco zapsat. Program by mohl pracovat přímo s proměnnými prostředí (což lze v libovolném jazyce) nebo by k tomu existovala triviální knihovna pro jednotlivé jazyky, která by nabízela funkce nebo objektové API pro přístup k těmto metadatům. Pak by taky bylo užitečné, aby program strojově čitelnou formou uměl sdělit, jaké sloty (v tomto případě FD) podporuje. Nicméně tohle API si zaslouží vlastní prostor… (tady už je to trochu mimo téma článku).
Řada programů a knihoven se konfiguruje v době kompilace (sestavení). Částečně se to děje automaticky a volby se nastaví, podle toho, jaké knihovny byly v systému nalezeny, a částečně o tom rozhoduje uživatel tím, jaké parametry nastaví – může zapnout či vypnout různé funkcionality nebo zvolit alternativní implementace (když je knihoven stejného typu dostupných více).
Problém tohoto přístupu je v tom, že narušuje systém závislostí. Balíčky (ve smyslu balíčkovacích systémů) jsou identifikovány svým názvem a verzí (v lepším případě, jako u Javy a Mavenu, se používají jmenné prostory tzn. trojice groupId
, artifactId
a version
, ale to teď není podstatné). Jednotlivé balíčky (programy či knihovny) pak deklarují svoje závislosti na jiných balíčcích a odkazují se právě na název (či jmenný prostor + název) a verzi (či rozsah verzí). Na základě toho pak balíčkovací systém ví, co vše je potřeba nainstalovat a dokáže říct, jestli jsou závislosti splněné (tudíž by programy měly fungovat). Když ale balíček zkompilujeme s jinými volbami a část jeho funkcionalit vypneme nebo jinak změníme jeho vlastnosti, tak programy, které na nich závisí, nebudou fungovat – systém se tak dostává do nedefinovaného stavu, kdy závislost je sice formálně splněna (balíček s daným názvem v dané verzi je přítomen), ale reálně můžou programy padat nebo se chovat nevyzpytatelně, protože budou volat chybějící funkce nebo funkce, které vždy vracejí chybu.
Kompilační volby by se proto měly používat maximálně pro výběr alternativních implementací (např. dvě různé šifrovací knihovny, které se můžou lišit třeba výkonem nebo reputací autora, ale navenek poskytují stejnou funkcionalitu). Pokud lze pomocí odlišných parametrů vygenerovat balíček se stejným názvem/verzí, ale s odlišnou sadou funkcí, jedná se o chybu návrhu. Ti, kdo na balíčku závisí, mohou deklarovat jen název/verzi, a tudíž nemůžou mít jistotu, že potřebné funkce budou dostupné – celý systém závislostí pak selhává a přestává plnit svoji funkci.
Možných řešení je několik:
knihovna123-basic
, knihovna123-full
.knihovna123-core
a k němu virtuální balíčky knihovna123-funkce1
, knihovna123-funkce2
atd. To dává programům (nebo jiným knihovnám) možnost vyjádřit, na kterých funkcionalitách přesně závisí (vyjmenují požadované virtuální balíčky). Pokud nějaká funkce chybí, musíme celou knihovnu překompilovat s novými volbami, což je komplikace, ale pořád jsme na tom lépe než dřív, protože víme hned, že závislost není splněna (místo toho, aby se balíčkovací systém tvářil, že je všechno OK, a programy pak za běhu záhadně padaly). A jak jsme si říkali v předchozím díle: čím dříve chybu odhalíme a opravíme, tím bude oprava levnější.knihovna123-core
, knihovna123-funkce1
, knihovna123-funkce2
, ale budou to skutečné (ne virtuální) balíčky a budou se kompilovat nezávisle na sobě (resp. funkce budou záviset na jádru, ale ne naopak). Pokud některá funkce chybí, zkompilujeme (nebo odněkud stáhneme) jen balíček této funkce (modulu). Zde se uplatní i principy IoC a DI.Programy a knihovny, které v době kompilace umožňují zapínat a vypínat funkcionality, bývají celkem jasným adeptem na modularizaci. Ta prospěje nejen jim samotným, ale zejména těm, kdo na nich závisí.
Od počítačů očekáváme, že to budou deterministické stroje a že na stejný vstup odpoví vždy stejným výstupem. A když používáme program a chceme jeho chování upravit, potřebujeme mít jistotu, že máme kompletní zdrojové kódy a jsme schopní z nich zkompilovat program, který se bude chovat přesně stejně, jako ten současný – pak teprve můžeme provést požadovanou změnu v kódu a opět program zkompilovat. Předejdeme tak nepříjemnému překvapení, kdy se naše úprava sice projeví, ale současně s ní zmizí jiné funkce programu, na které jsme byli zvyklí, nebo se jinak změní chování programu. Také nechceme, aby nám distributor mohl k softwaru přibalit škodlivý kód nebo prováděl bez našeho vědomí jiné zásahy do programu.
Sestavení programu (build) by proto mělo být reprodukovatelné. To znamená, že ze stejné verze zdrojových kódů by měl vzejít vždy stejný binární balíček. A slovem „stejný“ zde rozumíme: na bit přesně stejný, se stejnou délkou a stejným hashem. Někdy je nereprodukovatelnost zapříčiněna celkem neškodnými chybami, jako je třeba zakompilování aktuálního času nebo názvu počítače do výsledného balíčku. Ale i těchto chyb je potřeba se zbavit, abychom byli schopní rozpoznat ty závažnější, které už vliv na funkci mají.
Pokud se v průběhu sestavení stahují nějaké části z internetu, je to problém. Komplexita takového softwaru je prakticky nekonečná – vlastnosti výsledného balíčku závisí na tom, co se zrovna stáhlo ze sítě, a dnes zkompilovaný program může fungovat úplně jinak než ten, který zkompilujeme zítra. Potřebujeme tedy kompletní zdrojové kódy včetně všech závislostí. Sestavení by pak mělo být možné i kompletně offline na počítači odpojeném od internetu. Trochu lepší situace je, když se v průběhu sestavení kontrolují hashe stažených artefaktů. Pak může dojít „jen“ k tomu, že příště nebudeme schopní program sestavit, protože soubory s požadovaným hashem nepodaří stáhnout – vzdálené servery, které nemáme pod kontrolou, budou pod daným názvem nabízet jiné soubory nebo třeba ukončí provoz a jednoduše zmizí. Software, který nejsme schopní reprodukovat, je celkem k ničemu a není radno na něj spoléhat.
Programy lze linkovat i staticky, takže pak v době běhu mají minimum závislostí. To ale nic neřeší, protože ty závislosti a komplexita tam jsou stále, jen nejsou vidět – o to je to vlastně horší. Jednak to vytváří falešný pocit jednoduchosti a jednak uživatel nebo správce těžko zjistí, z čeho se program skládá a které jeho části mohou být zastaralé a vyžadovat aktualizaci. Program je pak černá skříňka, o kterou se musí starat její autor (v případě svobodného softwaru se do role autora alespoň může přepnout i ten správce nebo uživatel, ale znamená to ruční zkoumání kódu a závislostí). Balíčkovací systémy oproti tomu fungují transparentně, poskytují nám jednotné rozhraní k instalaci i aktualizacím veškerého softwaru a strojově čitelný přehled o závislostech mezi balíčky (programy a knihovnami).
V určitých speciálních případech má statické linkovaní svoje uplatnění – jde o různá omezená či rozbitá prostředí a dočasné instalace. Staticky linkované binárky můžeme použít např. k distribuci úloh na více strojů – nakopírujeme na ně soubory, spustíme a pak zase smažeme. Statické linkovaní nicméně nelze považovat za způsob snižování komplexity softwaru – je to pouze způsob, jak v určitém směru zjednodušit proces nasazení (za cenu jiných nevýhod).
Nejprve jsme proškrtali postradatelné požadavky v zadání, potom jsme implementovali software podle těch nejlepších doporučení, svědomitě vybírali závislosti, odstínili se od nich pomocí rozhraní a systém rozdělili na moduly, aby uživatelé nebyli zatíženi komplexitou částí, které nepotřebují. Co když nám ale náš software stále připadá moc složitý?
Je možné, že už jsme blízko hranice dané inherentní složitostí a další zjednodušení už není bez změny zadání možné. V tomto případě si můžeme ještě trochu pomoci přeskupením prvků. Člověk není stroj a jeho kognitivní schopnosti mají svoje limity. Millerovo magické číslo nám říká, že člověk dokáže udržet v krátkodobé paměti maximálně 7±2 prvků. Počítači je celkem jedno, kolik má program tříd, kolik má třída metod, výčtový typ členů nebo komponenta parametrů. Počítač zvládne hravě vykreslit na obrazovku desítky ikon nebo vyhodnotit desítky CLI parametrů u jednoho příkazu. Počítač často škáluje lineárně a vypořádá se i s velmi vysokým počtem prvků. U člověka se to ale na určité hranici láme a schopnost porozumět systému a pracovat s ním prudce klesá. Platí to pro programátory upravující software i pro uživatele, kteří ho mají používat. Samotnou změnou struktury tedy můžeme lidem trochu pomoci vypořádat se s komplexitou. Nicméně vzhledem k tomu, že dodatečné zásahy do struktury systému jsou drahé a někdy skoro nemožné, je třeba na kognitivní schopnosti člověka brát ohledy průběžně při návrhu a vývoji.
A zejména pokud jsme museli během vývoje udělat nějaký kompromis, přijde nám vhod izolace komponent v době běhu. Měli bychom se řídit principem minimálních práv – pokud daná komponenta nepotřebuje (z hlediska zadání) určitá práva, tak by je v době běhu neměla mít – mělo by pro ni být fyzicky nemožné překročit určité hranice. Pokud např. nemáme úplně dobrý pocit z komplexity nebo kvality určité knihovny, ale přesto jsme ji z nějakých důvodů museli začlenit do našeho systému, měli bychom se od ní izolovat nejen z hlediska návrhu (rozhraním), ale i v době běhu. Nebudeme pak volat přímo funkce této knihovny (a spouštět tak její kód v rámci procesu našeho programu), ale spustíme ji např. jako podproces nebo službu pod jiným uživatelem s omezenými právy. Způsobů izolace existuje celá škála – od prostředků daného programovacího jazyka (např. SecurityManager v Javě), přes procesy běžící v témže operačním systému ale pod jiným uživatelem (klidně přes sudo
), bezpečnostní moduly Jádra (AppArmor či SELinux), cgroups, kontejnery, virtualizaci až po oddělení na úrovni hardwaru (dedikované servery, VLANy nebo fyzicky oddělené sítě a případně i zcela offline stroje, ke kterým se chodí jen jednou za čas s přenosným diskem). Tyto bariéry obvykle chrání zbytek systému před danou komponentou – ale někdy tomu může být i naopak: např. když potřebujeme zabezpečit kryptografický modul před vyzrazením soukromých klíčů nebo ochránit logovací server tak, aby nebylo možné zvenku smazat auditní záznamy. Stejně tak se vyplatí izolovat a ochránit zálohovací systém, aby do něj šlo data běžně posílat, ale ne je mazat (k tomu by měla být nutná jiná oprávnění). Z hlediska konektivity by pak přístup do sítě a zejména internetu měly mít pouze ty komponenty, které k tomu mají legitimní a zdokumentovaný důvod – ostatní by neměly mít právo inicializovat spojení ven a měly by mít povoleno pouze přijímat příchozí spojení dle předem dohodnutých pravidel. Tato opatření sice nedokáží snížit komplexitu, ale pomohou jednak odvrátit některá rizika a jednak vynutí pravidla a omezení, která by jinak existovala jen na papíře a bylo by možné je porušovat.
Neustále rostoucí komplexita softwaru je velký problém našeho oboru, který bohužel nemá snadné řešení. Jedná se o neustálý boj na mnoha frontách a hledání rovnováhy mezi různými prioritami. Snad vám tedy tento seriál poskytl nějaké podněty k přemýšlení, které vám pomohou s hledáním vlastního řešení.
P.S. Tento článek jsem musel značně prošrktat a zkrátit, takže řada témat se sem nedostala – např. prosahující abstrakce, clueless, vztah mezi API a SPI, komplexita webu a www prohlížečů… Některé z těchto témat zaslouží samostatný článek někdy v budoucnu.
Témata: [počítačová bezpečnost] [softwarové inženýrství] [softwarová architektura]
Zdá se mi, že autor přehlíží nebo rovnou nemá rád a neuznává dynamické jazyky a je příznivcem statických typů. Škoda že se k nim nevyjádřil více, protože mně osobně se tyto jeví tak, že dynamické jazyky typu Python jsou nástroje, které svou jednoduchostí snižují komplexitu software.
Service locator je antipattern a nikomu bych ho nedoporučoval. Jinak OK, pěkný článek.