U ovom članku će biti predstavljeno ZF2 rešenje za problem osvežavanja asset-a ukeširanih od strane browser-a, nakon što se isti izmene i puste u produkciju.
Pored samog HTML-a, web stranica sa sobom obično nosi i određene resurse, najčešće u vidu fajlova, zadužene za njeno dekorisanje (CSS i slike), ali i obogaćivanje i unapređivanje korisničkog interfejsa (JS). U web development terminologiji, za sve njih postoji jedan zajednički izraz – assets, kojeg ćemo koristiti u nastavku teksta.
Do problema sa asset-ima dolazi nakon deploy-ovanja određenih izmena nad njima u produkciju, kada se očekuje se da one odmah budu na snazi, vidljive krajnjem korisniku web aplikacije/sajta. Kako ih ne bi preuzimao sa servera pri svakom otvaranju određene stranice, web browser će keširati asset-e, u slučaju da je sam server adekvatno podešen (Expires headers). Imajući to u vidu, korisnik, da bi video izmene, mora da preduzme dodatne zahvate (reload stranice, reset keša i slično), a kako je velika verovatnoća da taj posetilac sajta baš i nije kompjuterski ekspert, te za tako nešto nije ni sposoban, isti će biti veoma razočaran i ljut zbog toga što ne vidi najsvežiju verziju sajta.
Rešenje je u verzionisanju asset-a, kako bi oni bili automatski osveženi nakon što se nova verzija deploy-uje i pusti u produkciju.
Pre nego što detaljnije pređemo na samo verzionisanje asset-a, u prethodnom delu sam pomenuo jedan bitan detalj. Da bi browser uopšte bio u stanju da kešira asset-e, naš server mora biti adekvatno podešen. To ćemo postići slanjem određenih instrukcija browser-u, za svaki tip asset-a koje mu serviramo. Primer koji sledi podrazumeva da se naša aplikacija "vrti" na Apache-u, ali princip je isti u slučaju bilo kog drugog servera, npr. Nginx, razlika je samo u sintaksi. Dakle, da bismo omogućili keširanje naših CSS i JS fajlova od strane browser-a, u glavni .htaccess fajl naše ZF2 aplikacije, smešten u document root-u (public folder), potrebno je dodati ovako nešto:
<IfModule mod_expires.c> ExpiresActive on # CSS ExpiresByType text/css "access plus 1 year" # JavaScript ExpiresByType application/javascript "access plus 1 year" </IfModule>
Ovo u prevodu znači da ukoliko je dostupan mod_expires Apache modul, a najčešće jeste, resursi koji su tipa text/css (CSS) i application/javascript (JS) imaju rok trajanja od godinu dana, što praktično znači da ih browser, nakon što ih prvi put preuzme, neće potraživati duži vremenski period.
Da bismo omogućili automatsko osvežavanje ukeširanih asset-a, potrebno je da browser-u nekako stavimo do znanja da je određeni fajl promenjen. Nailazio sam na različite načine za ostvarivanje ovog zadatka, a svi oni su zapravo neznatne varijacije jednog istog principa, koji se svodi na dve stvari:
Što se prvog koraka tiče, ideja je da u našem HTML-u, umesto na primer:
<link rel="stylesheet" href="css/style.css">
… imamo:
<link rel="stylesheet" href="css/style__3.css">
… pri čemu broj 3 u ovom slučaju predstavlja verziju.
Na taj način, nakon deploy-ovanja nove verzije, browser će shvatiti da se radi o "novom" fajlu (do tada se taj asset servirao kao css/style__2.css ), što ga automatski primorava da pošalje novi HTTP request za taj resurs, koji je zapravo isti fajl, ali sa novim, izmenjenim sadržajem.
Da bismo ovo ostvarili u okviru neke ZF2 aplikacije, bilo da se radi o CSS ili JS asset-ima, a podrazumevajući da za njihovo učitavanje koristimo HeadLink i HeadScript /InlineScript view helper-e, napravićemo custom varijante ovih helper-a, koji će dinamički umetati broj verzije u rezultujući tag za učitavanje odgovarajućeg asset-a:
namespace Application\View\Helper; use Zend\View\Helper\HeadLink as ZfHeadLink; use stdClass; use Application\Module; class HeadLink extends ZfHeadLink { public function itemToString(stdClass $item) { if ($item->rel == 'stylesheet' && isset($item->href)) { $item->href = preg_replace('#\.css$#', '.__' . Module::VERSION_CODE . '.css', $item->href); } return parent::itemToString($item); } }
namespace Application\View\Helper; use Zend\View\Helper\HeadScript as ZfHeadScript; use Application\Module; class HeadScript extends ZfHeadScript { public function itemToString($item, $indent, $escapeStart, $escapeEnd) { if (isset($item->attributes['src'])) { $src = $item->attributes['src']; $item->attributes['src'] = preg_replace('#\.js$#', '.__' . Module::VERSION_CODE . '.js', $src); } return parent::itemToString($item, $indent, $escapeStart, $escapeEnd); } }
namespace Application\View\Helper; class InlineScript extends HeadScript { protected $regKey = 'Zend_View_Helper_InlineScript'; public function __invoke($mode = HeadScript::FILE, $spec = null, $placement = 'APPEND', array $attrs = array(), $type = 'text/javascript') { return parent::__invoke($mode, $spec, $placement, $attrs, $type); } }
namespace Application; class Module { const VERSION_CODE = 1; //… }
Kao što se može videti na osnovu kôda, helper-e sam izveo iz ZF-ovih, redefinisao njihove itemToString() metode, kako bih u njima izvršio dinamičko umetanje verzije. Za potrebe ovog primera, čuvanje informacije o verziji sam maksimalno uprostio, postavivši ga u samoj Module klasi našeg Application modula, u vidu statičkog integer-a. Naravno, u realnim uslovima, taj brojač bi se verovatno čuvao u nekom fajlu, koji bi se automatski inkrementirao prilikom deploy-a, itd.
Da bi naši helper-i bili aktivni umesto ZF-ovih, potrebno je da override-ujemo prijave tih servisa u plugin manager-u zaduženom za view helper-e, tačnije da registrujemo naše pod istim nazivima. To možemo uraditi kroz module.config.php našeg modula:
'view_helpers' => array( 'invokables' => array( 'headlink'=>'Application\View\Helper\HeadLink', 'headscript'=>'Application\View\Helper\HeadScript', 'inlinescript'=>'Application\View\Helper\InlineScript', ), )
Ono što nam preostaje (drugi korak) jeste da vršimo rewrite rezultujućih, verzionisanih URL-ova asset-a na originalne fajlove. To ćemo postići dodavanjem odgovarajućeg rewrite rule-a u .htaccess :
RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.+)\.__\d+\.(js|css)$ $1.$2 [L]
Kompletan izgled .htaccess fajla:
# Set proper MIME types for files <IfModule mod_mime.c> # JavaScript AddType application/javascript js # Other… </IfModule> # ETag removal <IfModule mod_headers.c> Header unset ETag </IfModule> FileETag None # Expires headers <IfModule mod_expires.c> ExpiresActive on # CSS ExpiresByType text/css "access plus 1 year" # JavaScript ExpiresByType application/javascript "access plus 1 year" # Other… </IfModule> # Filename-based versioning RewriteCond %{REQUEST_FILENAME} !-f RewriteRule ^(.+)\.__\d+\.(js|css)$ $1.$2 [L] # If the requested filename - serve it, RewriteCond %{REQUEST_FILENAME} -s [OR] RewriteCond %{REQUEST_FILENAME} -l [OR] RewriteCond %{REQUEST_FILENAME} -d RewriteRule ^.*$ - [NC,L] # …otherwise, rewrite all other queries to index.php. RewriteCond %{REQUEST_URI}::$1 ^(/.+)(.+)::\2$ RewriteRule ^(.*) - [E=BASE:%1] RewriteRule ^(.*)$ %{ENV:BASE}index.php [NC,L]
Očekujem da će biti dosta onih koji će komentarisati kako je mnogo jednostavniji način nalepiti broj verzije na sam naziv asset-a, u vidu query string-a, tako da na primer imamo: http://www.test.com/style.css?v=123. Ovo je POGREŠNO, jer se request-ovi sa query string-om NE keširaju od strane browser-a! Više o svemu tome možete saznati ovde: http://www.stevesouders.com/blog/2008/08/23/revving-filenames-dont-use-querystring/.
Jedini, pravi način za rešavanje problema automatskog osvežavanja asset-a nakon što oni budu ukeširani od strane browser-a jeste njihovim verzionisanjem, i to umetanjem verzije u sam naziv fajla, tj u sam URL asset-a. Konkretan primer u ovom članku je u vidu rešenja verzionisanja asset-a u ZF2-baziranoj aplikaciji, ali isti princip je primenljiv u slučaju bilo kog drugog PHP framework-a, pa i bilo kog drugog programskog jezika.