Jak zobrazujeme obrázky

předchozím článku jsme si ukázali, jak v Mediu ukládáme soubory obrázků na úložiště a mapujeme je na entity. To bylo jednoduché. Mnohem zajímavější je, jak obrázky zobrazit v aplikaci navenek.

Těžko budete nutit klienta, aby každý obrázek před nahráním zmenšil a ořízl na velikost odpovídající zobrazení na webu. Už vůbec ne na všechny velikosti, které na webu používá. A co když přidáte další zobrazení? Nebo se změní stávající velikosti kvůli redesignu? Zmenšovat obrázky jen přes HTML je pak neuctivé k času načítání stránky a majitelům mobilních FUP. Jak elegantně to děláme u nás?

Malá lákací ukázka: zobrazení obrázku na titulce jednoho našeho projektu vypadá v kódu takto:

Ano, správně, je to zápis v šablonovacím jazyce Latte. Getter $performance->getImage() vrací, tadá, instanci třídy Image, kterou jsme si popsali v předchozím článku. Ta se předá šablonovacímu filtru (v dřívějších verzích Nette se filtrům říkalo helpery) thumbnail, spolu s klíčem, který obsahuje informaci o tom, jaké změny před zobrazením na obrázku použít. Ten vrátí cestu k souboru na disku a předá ji filtru imagehtml, který cestu zkrátí na URL, vytvoří HTML tag, cestu mu nastaví do atributu src, a popisek z $performance->getDescription() do atributu alt. Elegantněji už to snad ani nejde.

Rozeberme si to teď trochu podrobněji. Filtr thumbnail volá třídu ThumbnailGenerator. Ta krom instance IFileRepository a instance IImageFileNamingStrategy dostane pole klíčů jednotlivých velikostí obrázků používaných v dané aplikaci. Třeba z konfigurace.

ThumbnailGenerator by měl obdržet sice instanci stejné třídy FilesystemRepository, ale s jiným $rootDir, než je nastaven v instanci pro originály obrázků. Originály chceme typicky ukládat někde mimo document root, zatímco zmenšeniny by měly být z internetu dostupné.

Atributy width a height jsou jasné, vlajky flag používáme tři: fit vsouká obrázek do daných rozměrů a nikdy jej nezvětšuje, crop jej na ně ořízne. Pofiderní vlajka enlarge funguje jako fit, ale vsouká obrázek na dané rozměry, i kdyby byl sebemenší.

Klíče nám slouží jen pro pohodlnější určení varianty zmenšení a předávání parametru v šabloně (viz poznámky). Klidně bychom mohli předávat přímo trojici argumentů, ale takto alespoň víme, co se všechno v aplikaci používá, máme to přehledně na jednom místě a bráníme divokým vývojářům v přílišném rozletu.

Pak se nám bude hodit metoda getThumbnailName(), o kterou obohatíme rozhraní IImageFileNamingStrategy, potažmo v našem konkrétním případě jeho implementaci HashFileNamingStrategy. Ta bude přes ThumbnailGenerator vracet jméno souboru zmenšeniny obrázku, podle cesty k originálu a zadaných parametrů. Parametry jsme podle klíče z šablony našli v konfiguraci.

Tím dostaneme z cesty k souboru, který jsme si uložili, a klíče large jméno souboru.

Ten předáme šabloně a ta ho (po úpravách filesystémové cesty na korektní URL s cestou k obrázkům) vypíše na výstup. A máme hotovo.

…cože? Že jsme nikde nevytvořili samotnou zmenšeninu obrázku? Správně, nevytvořili. Starat se o vytváření všech zmenšenin při uploadu je zbytečně náročné. Navíc některým nahraným obrázkům se nikdy nemusejí vytvořit všechny možné velikosti. A znovu – co když variantu přidáme? Museli bychom projít všechny originály a variantu jim vytvořit ad hoc.

Ne, vytvoření raději necháme na aplikaci spolu s chytrým rewritem a/nebo routou.

Jak obrázkům vytváříme zmenšeniny

Pokud už soubor s požadovaným obrázkem na disku existuje, vrátí ho webový server rovnou. Nespouští se vůbec žádné PHP s nějakým Nette a vším tím overheadem okolo. Prostě se ze serveru vydá statický soubor.

Co se ale stane, když se uživatel pokusí přistoupit ke zmenšenině obrázku, která ještě na serveru není? Využijeme teď toho, že se frameworku předávají všechny požadavky na přístup k cestám, které na serveru fyzicky neexistují.

Zásadní technikou zpracování požadavků frameworkem je přesměrování všech potřebných volání na jednotný vstupní bod – v případě Nette je to soubor index.php. Ten pak už požadavky zpracuje interně.

Toto je routa Nette Frameworku, která požadavky na neexistující zmenšeniny posílá speciálnímu presenteru ImageServicePresenter, který:

  • metodou v HashFileNamingStrategy z parametru $key zrekonstruuje informace o zmenšenině,
  • zkontroluje, jestli je kombinace vlastností zmenšeniny platná, tedy dostupná v nastavení (určitě bychom nechtěli, aby nám nějaký vtipálek pustil generování všech možných kombinací velikostí a vlajek),
  • zkontroluje, jestli existuje entita, pro kterou má zmenšeninu vytvořit,
  • pro jistotu ověří existenci originálního souboru,
  • vytvoří zmenšeninu podle zadaných kritérií, uloží ji na disk a vrátí její data (aby je uživatel viděl přímo ve svém požadavku).

Pokud jakákoliv z kontrol neprojde, vrací požadavek chybu 404.

Při dalším pokusu o přístup k té samé zmenšenině už server vrátí rovnou její statickou variantu, protože soubor fyzicky existuje.

Na začátku routy pro zjednodušení chybí cesta do adresáře s obrázky, samozřejmě nemáme 256 adresářů struktury hned v rootu webu.
Ve výchozím nastavení serveru nginx se do PHP-FPM vůbec nepouštějí požadavky na soubory s příponami typickými pro statický obsah. To je při zprovozňování ImageServicePresenteru potřeba mít na paměti a tuto podmínku odstranit.

Poznámky z jedné kapsy

K výše zmíněným jménům souboru jsme došli přes několik iterací. Původně jsme měli název originálu jako čistý MD5 hash a jméno souboru zmenšeniny jako jiný MD5 hash. Což je sice zvenku možná krásně čisté, aplikace se o to postará, ale pro debugování je to peklo.

Proto jsme do jména originálu přidali ID souboru (poznámka o kolizích byl jen lehký trolling), abychom mohli lépe dohledat, ke které entitě soubor patří.

Soubory se i celkem jednoduše procházejí běžnými konzolovými nástroji jako find, takže není problém třeba promazat ty, jejichž parametry už nevyhovují nastavení projektu.

Jedna ze slepých cestiček také byla místo trojice výška/šířka/vlajka ukládat do jména souboru jen klíč, ale záhy jsme zjistili, že pak soubory budou vyhnívat, můžou vznikat nepotřebné duplicitní soubory, při změně parametrů klíče se musí původní soubory ručně promazat, jinak se nenavytváří znovu, a jiné.

Co může být problém je změna velikosti obrázku na nějaké hodně navštěvované stránce – server pak začne generovat víc zmenšenin než běžně a může ho to i zabít. Pak je na pořadu dne zmenšeniny navytvářet nějakým workerem bokem v době, kdy není zátěž na serveru taková.

Při práci se jmény souborů používáme všude absolutní cesty.

Samozřejmě se může stát, že getter nevrátí instanci Image – nemusí být pro $performance povinná. Proto vypadá šablona častěji nějak takto:

dummyimagehelper vrací dummy obrázky připravené specificky pro náš projekt. Ať už jsou vytvořené pro jednotlivé varianty zmenšenin ručně, nebo ať už je necháváme generovat z nějakého předpřipraveného zdroje podobně, jako zmenšeniny skutečných originálů.

Související články

Zveřejněno 5.11.2014 v rubrice Programování a vývoj se štítky , , .
humpal

Matěj Humpál

Vývojář internetových aplikací. Hudebník hubou i rukama.

Google+ profil @neldorling humpal@medio.cz

Komentáře k článku

[1] Michal Sänger | 5. 11. 2014 v 15.35

Super článek, taky to tak používám.

Místo ukládání zmenšenin na disk je pohodlnější použít CDN. Na zvládnutí resize peak je vhodné zapojit frontu.

[2] rbas | 5. 11. 2014 v 15.52

Na některých projektech používám hodně podobný přístup. Je dobré, že náhledy jsou více či méně cache o kterou se dá přijít.
Tímto přístupem se trochu zesložití práce s obrázky přímo z disku (zjištění velikosti, načtení do paměti atd..). Musí se řešit zda obrázek na něm již je a popř. ho nechat vygenerovat. Každopádně to není nic neřešitelného.

Já ještě umožňuji do konfigurace zadávat nějaké další nastavení které umožňuje imagick jako jsou např. colorspace, format, quality atd… Je to dost pohodlné.

[3] rotten77 | 6. 11. 2014 v 8.57

Stejný způsob používám už tak 2 roky – ze stejného důvodu. Pokud dojde ke změně designu, tak je potřeba všechny obrázky znovu zmenšit. Ale nejenom to – zastávám názor, že uživatel nemá zasahovat do kódu (a tím vzhledu) stránky, takže nějaké vkládání přes TinyMCE se mi nelíbí. Proto uživatel vloží pouze obrázek a kodér v šabloně určí, jak se má zobrazit. Výhoda různých velikostí pro mobil/tablet/desktop je pak jasná.

Podobně řeším i např. videa z YouTube. Uživatel nevkládá ale pouze informaci, že se na tomto místě má zobrazit YouTube videa. Na mobilní verzi pak můžu zobrazit jenom malý náhled s odkazem na YouTube.

[4] David Brtník | 6. 11. 2014 v 16.05

Dobrý den,

můžute prosím ještě detailněji popsat, jak se rozhodne jestli se má rovnou vracet statický obsah nebo spouštět aplikace která nejprve zmenšninu generuje, ukládá a potom ji vrací?

Já mám teď ve stávajícím řešení v aplikaci logiku, která při php generování stránky ověří jestli zmenšenina existuje (přes file_exists) a pokud ano, dává se do html odpovědi statickou url (kterou potom obslouží ngix). Pokud neexistuje, vloží se url kterou obslouží apache, php vygeneruje změnšeninu, uloží a tu vrátí.

Pro první volání je tedy url obrázku jiná než pro všechny ostatní.

Jde to řešit nějak elegantně. Vždy vložit rovnou statickou url a pokud soubor neexistuje tak přes ngix vrátit obsah a pokud ne tak spustit php generátor?

Prosím případně vysvětlení spíše pro laika :)

Děkuji
David Brtník

[6] Matěj Humpál | 6. 11. 2014 v 20.59

[4] Davide, tohle neřešíme my, ale rovnou webový server, apache nebo nginx. Pokud cíl z cesty dotazu existuje jako fyzický soubor, naservíruje jej server přímo sám a aplikace se ke slovu vůbec nedostane. Pokud neexistuje, cesta z dotazu se předá aplikaci a aplikace zajistí vygenerování a odeslání dat souboru zpět uživateli. Zásadní je, že URL zmenšeniny JE vždy stejná, jako bychom počítali s tím, že je zmenšenina už vygenerovaná.

Je to tak srozumitelnější?

[5] Davide, proč by kolidující md5 hashe obrázků měly mít s tímto tématem něco společného?

[7] Petr Sládek | 7. 11. 2014 v 19.15

Ahoj, supr článek :)

Jen nešlo by trochu víc rozvést jak funguje ta třída
ThumbnailGenerator? Chápu, že ta má metodu do které se dostane entita Image a podle configu velikost a flag obrázku a ta vrátí absolutní cestu k té zmenšenině.
Takže ona musí dostat 2x instanci fileRepository? jednu na zmenšeniny a druhou na originály. Protože getThumbnailName potřebuje path k originálu pro vrácení filename zmenšeniny. A pak ještě potřebuju zjistit cestu ke zmenšenině.

Ještě by mě zajímalo jak z absolutní cesty k souboru potom generujete nějak jednoduše url k souboru (obrázku)?

A taky jak napsat tu routu pro to aby brala ten neexistující soubor 2f/25/2f2555d95ee03f950c9ddae2f1692c55-42-960×960-fit.jpg

Omlouvám se jestli jsou to stupidní otázky :D
A díky za odpověd :)

[8] David Brtník | 7. 11. 2014 v 19.16

Dobrý den,

díky. Tím klíčovým prvkem co jsem neznal je RewriteCond %{REQUEST_FILENAME} !-f

[9] Matěj Humpál | 10. 11. 2014 v 12.05

[7] Petře, u nás ThumbnailGenerator dostane jen FileRepository pro zmenšeniny, cestu k originálu si tahá z instance IImageFileNamingStrategy. Ano, snad je pravda, že předání dvou instancí FileRepository by bylo čistší řešení.

URL obrázku generuje WebPath helper, který má jako závislost cestu k public adresáři a tu pak z absolutní cesty zmenšeniny ořízne – takže vznikne veřejná cesta k obrázku.

Konkrétní vzhled routy se ztratil v editaci HTML, napravili jsme to – nyní už je ta definice v pořádku.

[10] Jakub Trmota | 13. 11. 2014 v 20.52

Na ČSFD se nám osvědčilo vytvářet zmenšeniny pomocí nginxu a jeho modulu image filter. Stará se to o vytváření nových miniatur a jejich cachování a na serveru se statickými daty nemáme vůbec nainstalované PHP.