Najjednoduchší MVC framework v PHP (part #2)

05.02.2009 00:27

Vítam Vás pri dlho odkladanom pokračovaní seriálu, v ktorom si naprogramujeme jednoduchý, light-weight MVC framework. V dnešnej časti dokončíme popis kódu routra, naprogramujeme si nejaké Controllery a skúsime aplikáciu spustiť

Na začiatku sa chcem ospravedlniť za strašný delay, ktorý uplynul od prvej časti. Je zbytočné popisovať dôvody, ale e-maily od niektorých členov ma primäli k rýchlemu napísaniu pokračovania. Na frameworku som od tej doby dosť popracoval - vylepšil som niektoré veci (napr. autoloader) a výsledok sa mi tak páčil, že som sa musel so svojimi výsledkami podeliť.

Chcem upozorniť, že zmeny vo pôvodnom kóde boli dosť výrazné a preto je pôvodná verzia prvej časti neaktuálna. Nič sa však nebojte - článok som aktualizoval, postačí preto, ak si ho znovu prečítate a prebudujete projekt od znova.

Minule som skončil popisom metódy parseUrl() (v starej verzii sa volala getController()), ktorá z URL extrahovala názvy Controllera, action a zostavila meno súboru, ktorý by mal obsahovať definíciu triedy daného Controllera. Táto metóda bola volaná inou metódou routra - delegate(). Táto zas bola volaná na konci nášho bootstrappera ako príkaz, ktorý spúšťa celú aplikáciu. Zobrazme si znova zdrojový kód routra.

Metóda delegate() na riadku 171. má všetky údaje, ktoré z URL extrahovala parseUrl(). delegate() vyextrahované údaje okamžite spracováva. Najskôr do registra odloží vyextrahované URL argumenty. Môžeme si ich tak v Controlleri z registra vytiahnuť a na základe ich hodnoty servírovať žiadané údaje.

Metóda ďalej overuje prítomnosť súboru s definíciou Controllera v rámci adresára controllers. Ak súbor neexistuje, nastaví ako spracovateľa požiadavky ErrorController::index(). Metóda potom overí, či je súbor s definíciou Controllera čitateľný. Tu v prípade nezdaru vyhadzuje Router výnimku (môžete ju zachytiť v bootstrapperi, aby end-user nevidel nepríjemnú err. hlášku). Takáto chyba je však silno nepravdepodobná, nakoľko keby nebolo možné inkludovať súbory z disku, aplikácia by zhavarovala už dávno, v bootstrapperi.

Po týchto overovacích procedúrach je konečne natiahnutý súbor s definíciou Controller triedy. Hneď na to je z tejto triedy vytvorený objekt Controllera. Do jeho konštruktora je predaný objekt $registry, aby sme zabezpečili dostupnosť globálnych údajov. Router nakoniec vykoná posledné overenie - skontroluje, či definícia Controllera obsahuje metódu uloženú v $action. Ak metóda nie je v Controlleri definovaná (user zadal nesprávnu URL, napr. http://smvcfw/news/zmeskakravinblablabla), je opäť vykonávanie posunuté na ErrorController::index(). Než sa ale vytvorí jeho objekt, sú nepotrebné údaje uvoľnené z pamäti.
Nakoniec metóda predá riadenie do Controllera->akcie().

Detekcia jazyka

Možno si ešte pamätáte, že v bootstrapperi sme volali tento kód:

$registry['homelink'] = $router->getBaseUrl();
$registry['lang']  = $router->getClientLang();

getBaseUrl() získava URL na indexovú stránku aplikácie. Dopracuje sa k nej naozaj jednoduchým spracovaním niektorých premenných v superglobálnom poli $_SERVER (viď kód, riadok 217).

getClientLang() je už zložitejší oriešok. Spomínal, že autodetekcia prebieha iba na indexovej stránke. To je z toho dôvodu, že predpokladám takýto scenár:
- v našom frameworku vytoríme super-cool web aplikáciu, ktorá bude multijazyčná
- o stránkach sa dozvie nejaký angličan alebo povedzme francúz
- zahraničný návštevník do URL nezapísal žiadny identifikátor jazykovej mutácie:
http://www.example.com/[jazyk]

Metóda getClientLang() musí vedieť, že klient je na indexovej stránke, volá preto parseUrl(), ktorá jej vráti názvy Controllera, akcie a prípadného indentifikátora jazyka.

getClientLang() potom overí, či parseUrl() získala nejaký identifikátor jazyka. Vstupom na stránky s URL bez jazykového identifikátora, vieme s istotou povedať, že parseUrl() žiadny jazyk nezistila. Nasleduje teda overenie názvov Controllera a akcie. Ak zahraničný návštevník vstúpi na inú ako indexovú stránku, napr. www.cool-stranky.sk/clanky/december, žiadna detekcia neprebehne a metóda vráti DEFAULTLANG.

Na indexovej stránke však jazyk detegovať budeme. Tu som musel vložiť ďalšiu IF-ELSE vetvu, nakoľko som zistil, že validátory, crawlery a podobné potvory nemajú v USER_AGENTe pole pre identifikátor jazyka. Týmto "klientom" teda tiež servírujeme DEFAULTLANG verziu stránok. Keď však na stránky vstúpime pomocou štandardného browsera, je identifikátor prítomný. Ten je posielaný v HTTP hlavičkách a má cca takúto formu:

var_dump($_SERVER['HTTP_ACCEPT_LANGUAGE']);
...
string(32) "sk,cs;q=0.8,en-us;q=0.5,en;q=0.3"

Obsah stringu je determinovaný nastavením operačného systému a jeho regionálnymi nastaveniami. To môže byť mierny problém, nakoľko ak je človek chronickým odporcom čohokoľvek lokalizovaného (aj regionálnych nastavení), tak sa na DEFAULTLANG verziu stránok nedostane. Metóda getClientLang() totiž v ďalšom kroku parsuje hlavičku pomocou metódy getUAlang(). Tá z hlavičky vyextrahuje jazykový identifikátor s najväčšou váhou (prvý pred čiarkou). Tento primárny "extrakt" je dorovnaný na dvojznakovú formu pomocou konštrukcie switch () {}. Ak metóda getUAlang() nepozná primárny identifikátor, default: vetva switch-u vráti DEFAULTLANG. Vrátený dvojznakový identifikátor je následne porovnaný s poľom podporovaných jazykov, získaným z registra. V prípade zhody s poľom a v prípade, že sa vyextrahovaný identifikátor nerovná DEFAULTLANG, je klient presmerovaný na URL s identifikátorom jazyka v URL.

Ak teda na indexovú stránku vstúpi angličan s korektne nastaveným operačným systémom, getClientLang() ho presmeruje na URL www.cool-stranky.sk/en

Túto funkčnosť si môžete aj sami overiť. Nainštalujte si do Firefoxu rozšírenie Tamper Data. Spustite ho, v hornej časti okna kliknite na tlačidlo "Start Tamper" a v prehliadači zadajte URL nášho testovacieho servera:
http://smvcfw/

Zobrazí sa výzva s troma možnosťami. Kliknite na tlačidlo Tamper. Zobrazí sa nové okno, v ktorom môžete zmanipulovať takmer každý jeden bajt HTTP požiadavku, posielaného na server. Skúste vo vyznačenom poli prepísať úvodný identifikátor z "sk" na "en" a odošlite.

Aby test fungoval, je treba v adresári controllers vytvoriť prázdny súbor IndexController.php, aby parseUrl() nevracala ErrorController.

Všimnite si, že aplikácia vás presmerovala na URL http://smvcfw/en. Po presmerovaní si aplikácia opäť zisťuje jazyk, ktorý bude stránka servírovať, nakoľko je volanie getClientLang() umiestnené v bootstrapperi. Tentokrát však parseUrl() už vráti aj identifikátor jazykovej mutácie z URL a getClientLang() vykoná iba rýchlu kontrolu voči poľu podporovaných jazykov, ktoré máme uložené v registri. Ak nie je zhoda zistená, je klient presmerovaný na indexovú stránku, bez identifikátora v URL.

Takto teda funguje detekcia jazyka aplikáciou. Framework sa vďaka hore popísanému kódu správa tak, že návštevník z nepodporovaného štátu (nepodporovaného našou aplikáciou) obdrží DEFAULTLANG mutáciu stránok. Rovnaká situácia nastane aj v prípade, že sa do URL snaží človek zapísať nepodporovaný jazykový identifikátor. Framework nakoniec vždy nejakým (hore popísaným) spôsobom získa identifikátor jazykovej mutácie (či už z URL, alebo UA, alebo nastaví DEFAULTLANG) a ten potom zapíše do registra. S touto informáciou potom môžete vo svojích Controlleroch ľubovoľne naložiť.

Toľko teda k popisu kódu triedy Router. Ako som už písal v prvej časti, je to najdôležitejšia trieda vo frameworku, nakoľko pred predaním kontroly do Controllera, vykonáva všetku tú užitočnú činnosť s parsovaním URL a detegovaním jazykovej mutácie. Prejdime však znova od popisu k reálnemu písaniu kódu.

Controllery a akcie

Framework je v tomto bode prakticky plne pripravený na spustenie. Môžeme kľudne začať písať nové súbory s Controllermi a akciami a ono to bude fungovať. V medziobdobí som však vymyslel drobné vylepšenie, ktoré je veľmi praktické - všetky nové Controllery budú potomkami triedy Controllers_AbstractController. Definícia tejto triedy je uložená v súbore lib/Controllers/AbstractController.php. Možno ste sa v úvodnej časti čudovali, prečo má FW dva adresáre pre Controllery. Toto je prvý dôvod - v adresári lib/Controllers je uložená naša abstraktná trieda pre nové Controllery a je teda "natiahnuteľná" autoloaderom.

Toto paradigma je veľmi užitočné, umožňuje nám to pridávať spoločný kód Controllerov na jediné miesto. Napr. môžete do abstraktnej triedy naprogramovať metódy s význačnými menami isPost(), isGet(), isXmlHttp() a pod. A to je veľmi praktické. V budúcnosti, keď budete potrebovať pridať do všetkých Controllerov novú funkciu alebo vlastnosť, bude stačiť editácia na jedinom mieste.

Poďme sa teda pozrieť, čo je obsahom našej abstraktnej triedy.

lib/Controllers/AbstractController.php

<?php
abstract class Controllers_AbstractController
{
    protected $registry;
    protected $view;
    protected $args;
    public function __construct($registry)
    {
        $this->registry = $registry;
        $this->view = $this->registry['view'];
        $this->args = $this->registry['args'];
        $this->view->set('homelink', $this->registry['homelink']);
    }
    public function render($template)
    {
        echo $this->view->fetch($template);
    }
}

Trieda vykonáva a implementuje naozaj základ požadovaných vlastností. Hlavne teda inicializuje interné premenné tak, aby sme pre ich obsah nemuseli v našich Controlleroch volať register. Okrem toho konštruktor vo $view nastavuje premennú homelink - nebudeme to musieť robiť manuálne v našich Controlleroch. Ďalej je implementovaná veľmi jednoduchá metóda na renderovanie stránky.

Poďme teda k samotnému, prvému pokusu spustenia aplikácie. Vo vašom adresári controllers (ten prvý) by ste mali mať prázdny súbor IndexController.php. Zapíšte do neho tento kratučký kód (zahrievacie kolo):

controllers/IndexController.php

<?php
class IndexController extends Controllers_AbstractController
{
    public function index()
    {
        echo "Hello from IndexController::index(), lang:";
        echo $this->registry['lang'];
    }
}

Skúste aplikáciu spustiť. Skúste sa aj pohrať s indentifikátorom jazykovej mutácie v URL, prípade opäť zaexperimentujte s Tamper Data. Možno Vás napadne do tohto Controllera vložiť ďalšiu metódu, povedzme test() a skúsiť ju spustiť pomocou URL http://smvcfw/index/test. Bohužiaľ vás musím sklamať, nebude to fungovať. FW bude stále spúšťať metódu index(). Dôvodom je to, že ak je v URL na mieste pre Controller zapísaná hodnota 'index', tak sa nám vôbec nevytvorí premenná $_GET['route'], ktorá je nutná na parsovanie URL. Neviem čo je presným dôvodom, ale toto správanie má na svedomí asi Apache v súčinnosti s direktívou DirectoryIndex a veľmi jednoduchým "rewrite" pravidlom v hlavnom .htaccess súbore.

Ale tento bug/feature je IMO nezávadný, veď kto by chcel mať vo svojej aplikácii URL v tvare http://smvcfw/index/... ? Routovanie všetkých ostatných Controllerov funguje podľa očakávaní. Skúsme vytvoriť ďalší Controller:

controllers/NewsController.php

<?php
class NewsController extends Controllers_AbstractController
{
    public function index()
    {
        echo "Hello from NewsController::index(), lang:";
        echo $this->registry['lang'];
    }
    public function archive()
    {
        echo "Hello from NewsController::archive(), lang:";
        echo $this->registry['lang'];
        if (!empty($this->args)) {
                echo '<pre>'; print_r($this->args);
        }
    }
}

Skúste modelovú URL z prvej časti seriálu:
http://smvcfw/en/news/archive/2008/june

Tak čo - páči sa Vám výsledok? FW za vás vykonal nudnú prácu spojenú s parsovaním URL a vy máte všetky potrebné údaje vo svojich Controlleroch ako na dlani.

VIEW vo frameworku sMVCfw
Náš VIEW je v Controlleroch dostupný ako členská premenná $this->view (viď AbstractController.php). Ukážku, ako funguje VIEW a šablónovací stroj v našom frameworku, predvediem na ďalšom Controlleri. Jedná sa o náš "handler" errorov 404, ktorý som už niekoľko krát spomenul - ErrorController. Tu je jeho kód:

controllers/ErrorController.php

<?php
class ErrorController extends Controllers_AbstractController
{
    public function index()
    {
        $localizator = new Localize();
        $lang = $this->registry['lang'];
        $title    = $localizator->getLocalizedMessage('Error404', $lang);
        $req_page = urldecode("http://$_SERVER[SERVER_NAME]$_SERVER[REQUEST_URI]");
        $this->view->set('lang', $lang);
        $this->view->set('title', $title);
        $this->view->set('sitename', $this->registry['sitename']);
        $this->view->set('req_page', $req_page);
        $this->view->set('content', $this->view->fetch('ErrorMessages/404_' . $this->registry['lang'] . '.tpl'));
        // render error page
        header("HTTP/1.0 404 Not Found");
        $this->render('error.tpl');
        die;
    }
}

V kóde sa objavila referencia na triedu Localize(). Tá ma za úlohu lokalizovať rôzne hlášky v aplikácii. Jej zdrojový kód si stiahnite z pastebin a uložte do súboru lib/Localize.php. Nebudem ju podrobne popisovať, dodám len, že lokalizácie sú v mojej implementácii uložené v asociatívnom poli, kde názov indexu (kľúča) odpovedá názvu jazykovej mutácie.

Z lokalizátora teda získame prekladovú hlášku "Error404". Táto hodnota bude titulkom chybovej stránky. Následne zo superglobálnej premennej $_SERVER zistíme, čo to úbohý užívateľ zadal do adresného riadka. Získanými údajmi začneme plniť náš VIEW. Posledný riadok s volaním $this->view->set() je zaujímavý. Premennú "content" naplníme obsahom inej, preparsovanej šablóny. Takéto "dvojúrovňové renderovanie" nám umožňuje emulovať rederovanie do layoutu. K spusteniu Controllera nám ešte chýbajú uvedené šablóny a CSS súbor. Všetko potrebné som uložil na tejto adrese. Archív rozbalte a súbory uložte do adekvátnych adresárov.

Teraz už môžete skúsiť do adresného riadka zadať nezmyselnú URL. Výsledok by mal vypadať cca takto:

Všimnite si, že funguje podávanie lokalizovaných správ. Venujte trocha času štúdiu šablón. Pozrite sa, ako je riešený odkaz na náš CSS súbor. Prezrite si zdrojový kód výslednej stránky.

MODEL

Ostáva popísať posledný komponent nášho MVC frameworku. Správne použitie MODELu si predvedieme v súčinnosti s novým Controllerom. Zároveň vám na tomto príklade ukážem, ako šablónový stroj dokáže iterovať nad dvojrozmerným poľom. Vytvorte nový Controller:

controllers/TableController.php

<?php
class TableController extends Controllers_AbstractController
{
    public function index()
    {
        $lang = $this->registry['lang'];
        $model = new Models_Table($this->registry);
        $content = $model->getTableMarkup();
        $localizator = new Localize();
        $title = $localizator->getLocalizedMessage('TitleTable', $lang);
        $this->view->set('lang', $lang);
        $this->view->set('sitename', $this->registry['sitename']);
        $this->view->set('title', $title);
        $this->view->set('headline', 'TableController::indexAction()');
        $this->view->set('content', $content);
        $this->render('main.tpl');
    }
}

Náš model predstavuje trieda Models_Table. Jej konštruktoru predáme register, aby mal model prístup v uloženým údajom (toto otravné predávanie registra eliminujeme v tretej časti seriálu nasadením pokročilejšieho registra). Zvyšný kód Controllera v tejto fáze už IMO nepotrebuje popis. Všimnite si len, že obsah, ktorý "vylejeme" do layoutovej šablóny, získame od nášho MODELu.

Už z názvu triedy ste mohli odhadnúť meno a umiestnenie pre súbor MODELu:

lib/Models/Table.php

<?php
class Models_Table
{
    private $registry;
    public function __construct($registry)
    {
        $this->registry = $registry;
    }
    public function getTableMarkup()
    {
        $list = array(
            array('filename' => 'suzuki',  'filesize' => '500kB'),
            array('filename' => 'suzuki1', 'filesize' => '300kB'),
            array('filename' => 'suzuki2', 'filesize' => '5060kB'),
            array('filename' => 'suzuki3', 'filesize' => '507kB'),
        );
        $view = $this->registry['view'];
        $view->set('list', $list);
        return $view->fetch('Subparts/table.tpl');
    }
}

Náš MODEL pre nás vytvorí statické dvojrozmerné pole , v reálnej aplikácii údaje získate pravdepodobne z databázy. Pre tutoriál nám však postačí toto statické pole. Aby sme získali HTML markup (ako naznačuje meno metódy), pošleme pole na predbežné parsovanie do VIEW. Výstup z VIEW však nevypíšeme na stránku, ale vrátime ho Controlleru, nech s ním naloží podľa seba (nás). Šablóna na generovanie tabuľky má tento kód:

templates/Subparts/table.tpl

<table border="1">
  <tr>
    <th>Filename:</th>
    <th>Filesize:</th>
  </tr>
  {loop:list}
  <tr>
    <td>{tag:list[].filename /}</td>
    <td>{tag:list[].filesize /}</td>
  </tr>
  {/loop:list}
</table>

Knižnica bTemplate umožňuje takéto krásne iterovanie nad dvojrozmerným poľom, čo umožňuje vytváranie tabuliek (rovnomerná tabuľka je vlastne dvojrozmerné pole). Príklad spustíte zadaním URL
http://smvcfw/table

Last words
Hotovo. Framework je dokončený, dúfam že každému funguje. Kompletný zdrojový kód si môžete stiahnuť na tradičnom mieste. Postavili sme si naozaj malý a rýchly MVC framework, ktorý umožňuje, dúfam že, efektívne budovanie multijazyčných webových aplikácií.

Nabudúce sa pustíme do budovania jednoduchého multijazyčného CMS. V úvodnej časti som spomínal, že FW umožňuje jednoduché využívanie cudzieho kódu. Ukážeme si preto, ako CMS implementuje databázový layer Zend_Db. Použijeme aj nejaké ďalšie "Zendie" komponenty a knižnice (Zend_Acl, Zend_Registry). Takže sa je na čo tešiť. Zatiaľ experimentujte s frameworkom a konečne zahodte $_GET => include() metódu tvorenia webov.

Dúfam, že do skorého videnia :o)