HTTP požadavky a odpovědi v Nette

Publikováno: 28.2.2019

Jaké jsou základní možnosti práce s HTTP protokolem v Nette Frameworku? Podíváme se detailněji na rozhraní IResponse. A na chvíli se zastavíme u cachování.

Celý článek

Text původně vyšel na blogu Nette.

Nette nabízí pro práci s HTTP dvě vrstvy abstrakce. První, nízkoúrovňovou, obstarávají třídy ze jmenného prostoru Nette\Http. Nabízejí příjemné API pro práci s HTTP hlavičkami, vyžádanou URL, příchozími parametry a soubory, nebo nastavení číselného kódu odpovědi. Od této vrstvy jsme v Nette aplikaci téměř odstíněni.

Druhá vrstva svým způsobem zapouzdřuje tu první. Třídy se nacházejí ve jmenném prostoru Nette\Application a využijí se, pokud pracujete s Nette aplikací (a teď myslím doslovně Nette\Application\Application) a jejími presentery a akcemi. Objekt třídy Nette\Application\Requestje výsledkem práce URL routeru. Router (Nette\Aplication\IRouter) vezme HTTP požadavek a podle definovaných rout ho přetaví na Nette\Application\Request. Ten nese informaci, jaký :modul:presenter:akce se má vykonat. Dále moc zajímavý není. Daleko zajímavější je Nette\Application\IResponse. Zejména té se bude věnovat tento článek.

HTTP odpovědi z aplikace

Než se k manipulaci s Nette\Application\IResponse dostaneme, shrnu nástroje pro ovládání HTTP protokolu, které máme při běhu presenteru k dispozici. Potomci třídy Nette\Application\UI\Presenter, pravděpodobně tedy všechny naše presentery, jich mají k dispozici opravdu hodně.

Metodu redirect() určitě znáte z tutoriálů. Co ale následující kód přesně udělá z pohledu HTTP protokolu?

public function actionDefault(): void
{
    $this->redirect(':Front:Homepage:help');
}

V konečném výsledku nastaví kód HTTP odpovědi na 302 (nebo 303), nastaví HTTP hlavičku Locationna URL, kterou vytvoří podle vzoru :Front:Homepage:help, ukončí běh presenteru a pošle HTTP odpověď klientovi. Klient, například webový prohlížeč, si odpověď přečte a načte novou stránku z URL, kterou dostal. Pro nás velmi pohodlné.

Dále tu máme metodu redirectPermanent(), která funguje stejně, ale jako kód odpovědi použije 301, tedy trvalé přesměrování. Vybrat ten správný kód je někdy věda.

A do třetice, metoda redirectUrl(). Metodě předáváme URL a volitelně kód přesměrování. Například:

public function actionDefault(): void
{
    $this->redirectUrl('https://my.new.web/');
}

Další pomocník, metoda error(). Vše se zase točí kolem návratového kódu HTTP odpovědi. Metoda má jako výchozí 404, tedy “stránka nebyla nalezena”. Kdy metodu error() použít? Záleží na vás. Dejme tomu, že máme blog a někdo si chce přečíst neexistující článek https://example.com/read/123456.

public function actionRead(int $id): void
{
    try {
        $article = $this->articles->get($id);
    } catch (ArticleNotFoundException $e) {
        # Článek neexistuje, co teď?

        # Můžeme uživatele upozornit a přesměrovat na seznam článků.
        $this->flashMessage("Sorry, article with ID '$id' does not exist. Try another one.", 'warning');
        $this->redirect('Blog:list');

        # Anebo použít HTTP kód 404 - Not Found
        $this->error("Article with ID '$id' was not found.");
    }
}

Největší rozdíl je v tom, co se zobrazí uživateli. V prvním případě seznam existujících článků, v případě volání error() se zobrazí ErrorPresenter a chybu 404 navíc zaloguje webserver. To se může hodit. Volání error() mi přijde korektnější, lépe vystihuje situaci.

Pozn.: Jakub Vrána v komentářích k původnímu textu připomněl, že přesměrování z neexistujícího článku je antipattern. Znepříjemní to ruční zadávání URL při překlepu a chybné odkazy vyhledávače zaindexují jako seznam článků. Jednoznačné argumenty pro použití 404.

Jako druhý parametr metody error() můžeme uvést číslený kód chyby. Chybových HTTP kódů jsou mraky. Tak například “forbidden”:

public function actionRead(int $id): void
{
    try {
        $article = $this->articles->get($id);
    } catch (PermissionDeniedException $e) {
        $this->error('You Shall Not Pass!', Nette\HTTP\IResponse::S403_FORBIDDEN);
    }
}

Další metoda presenteru slouží pro odeslání dat ve formátu JSON. Jmenuje se sendJson() a použití nemůže být intuitivnější:

public function actionGetUserInfo(string $username): void
{
    $this->sendJson([
        'username' => ...,
        'firstName' => ...,
        'lastName' => ...,
    ]);
}

Nemusíme se o nic starat. Pole se správně převede do JSON formátu, nastaví se správné HTTP hlavičky Content-Type a Content-Length, klient bude spokojený.

Další dvě metody presenteru, getHttpRequest() a getHttpResponse(). Názvy trochu napoví. Vracejí objekty Nette\Http\IRequest a Nette\Http\IResponse, ono nízkoúrovňové HTTP API, o kterém jsem se zmínil v úvodu. Díky všem těm metodám, které jsem výše popsal, je skoro nepotřebujeme. Ale jeden zjednodušený příklad bych měl.

Představte si, že náš blog, naše Nette aplikace, má poskytnout jeden REST API endpoint. Bude vracet jméno autora, nadpis článku a čas publikování. Nebude ale pro každého, jen pro klienty s tajuplným tokenem.

public function actionArticleData(int $id): void
{
    $request = $this->getHttpRequest();
    if ($request->getHeader('Token') !== 'eb0f21d63594e58d6b9995a7d2ac156c') {
        $this->error('Invalid or missing access token.', Nette\HTTP\IResponse::S403_FORBIDDEN);
        die();
    }

    try {
        $article = $this->articles->get($id);
    } catch (ArticleNotFoundException $e) {
        $this->error('Article not found.');
        die();
    }

    $this->sendJson([
        'id' => $id,
        'author' => $article->author->fullName,
        'title' => $article->title,
        'publishedAt' => $article->publishedAt->format(\DateTime::ISO8601),
    ]);
}

Asi jste si všimli volání die() po error(). Není nutné. Všechny metody o kterých jsem dosud psal ukončí zpracování a die() se ani nezavolá. Používám to, protože při prvním pohledu do kódu metody vidím, kde její běh může skončit.

Tolik k úvodu. Nyní konečně přejdeme k rozhraní Nette\Application\IResponse, které umožní HTTP odpovědi formovat velmi přesně.

Nette\Application\IResponse

Metoda presenteru sendResponse() přijímá jediný argument. Objekt implementující rozhraní Nette\Application\IResponse. Než si napíšeme vlastní implementaci, pojďme si prohlédnout ty hotové ze jmenného prostoru Nette\Application\Responses.

Nejpoužívanější, nejužitečnější, ale zároveň nejméně známá je TextResponse. Pokud presenter vykoná nějakou akci a z Latte se vykreslí HTML obsah, je to právě ona, které se použije. Ponechme ji v klidu v zapomnění.

Kdo znáte Nette podrobněji, mohli jste si všimnout, že jsem v první části vůbec nezmínil metodu presenteru forward(). To proto, že s HTTP nemá nic společného. Metoda sice vytvoří objekt třídy ForwardResponse, tváří se jako HTTP odpověď, odešle se tak, ale aplikace odpověď zachytí, HTTP přesměrování neodešle a místo toho jen zavolá nový cíl přesměrování. Prohlížeč nic neví, URL se nezmění.

Oproti tomu, třídu RedirectResponse používají všechny redirect*() metody presenteru. Tady už jde o přesměrování HTTP protokolem, prohlížeč načítá novou URL.

Zajímavější se může zdát JsonResponse, ale opravdu se to jen zdá. Tuto třídu využívá metoda presenteru sendJson() a tím bych její popis uzavřel.

Opravdu zajímavá je až třída FileResponse. Odešle do prohlížeče soubor a nabídne ho ke stažení. Představte si aplikaci s účetnictvím. Spousta faktur ve formátu PDF. Ty ale nesmí být přístupné jen tak někomu! Přístup je řízen aplikací, adresářová struktura je následující:

app/
invoices/    <-- spousta faktur v PDF, sem se prohlížeč nedostane
www/         <-- document root

A nyní presenter, který umožní stahování faktur pouze přihlášeným uživatelům.

use Nette\Application\Responses\FileResponse;

final class DownloadPresenter extends Nette\Application\UI\Presenter
{
    private $invoices;


    public function __construct(InvoiceRepository $invoices)
    {
        $this->invoices = $invoices;
    }


    public function startup(): void
    {
        parent::startup();
        if (!$this->getUser()->isLoggedIn()) {
            $this->redirect(':Admin:Sign:in');
            die();
        }
    }


    public function actionInvoice(int $id): void
    {
        try {
            $invoice = $this->invoices->get($id);
        } catch (InvoiceNotFoundException $e) {
            $this->error("Faktura s ID $id neexistuje.");
            die();
        }

        $response = new FileResponse($invoice->file, "Faktura $invoice->number.pdf", 'application/pdf');
        $this->sendResponse($response);
    }
}

Presenter dostane v konstruktoru repozitář. V metodě startup() zajistíme přístup pouze pro přihlášené uživatele. A pokud faktura existuje, akce invoice ji odešle jako PDF soubor do prohlížeče.

Třída FileResponse za nás myslí na spoustu věcí. Správně formátovaný název souboru při použití diakritiky, nebo navázání přerušeného stahování. Není úplně snadné to vyřešit korektně. Také celý soubor nenačítá do paměti, ale odesílá ho po blocích. Nutnost pro velké soubory.

Třída FileResponse je nenahraditelná. Téměř. Znáte modul mod_xsendfile pro Apache? Pokud ne, krátce ho představím.

Mějme adresář se soubory mimo document root. Stejně umístěný, jako ten s fakturami z příkladu výše. Webový prohlížeč se do něj nedostane. A vy přesto chcete, aby šlo soubory volně stahovat a navíc, abyste si mohli přístupy logovat. Můžete použít logiku stejnou jako v příkladu s PDF fakturami a použít FileResponse. Má to ale jednu nevýhodu. Pokud jsou soubory velké a klienti přistupují po pomalém připojení, může stažení souboru trvat několik hodin. Mohou se nám nashromáždit třeba i stovky paralelních stahování. A pro každé spojení běží jeden PHP proces. Máte-li Apache a modul mod_xsendfile, stačí odeslat HTTP hlavičku X-SendFile a běh PHP aplikace ukončit. O stažení souboru se postará web server. Úvahy o tom, jak moc je to užitečné, ponechám na vás. Mně se to hodí do příkladu na implementaci Nette\Application\IResponse. A třeba anonymní třídou, když už je v PHP 7 máme. Následující útržek kódu je akce presenteru, která stažení obslouží.

public function actionDownload(string $path): void
{
    # $this->basePath  - například /var/data/download
    # $this->logger    - smyšlený logovací nástroj

    $file = $this->basePath . '/' . $path;
    if (!is_file($file)) {
        $this->error('File not found.');
        die();
    }

    # Trocha té bezpečnosti
    $file = realpath($file);
    if (strpos($file, $basePath . '/') !== 0) {
        $this->error(null, Nette\HTTP\IResponse::S403_FORBIDDEN);
        die();
    }

    # Implementace IResponse
    $response = new class($file) implements Nette\Application\IResponse
    {
        private $file;


        public function __construct(string $file)
        {
            $this->file = $file;
        }


        public function send(Nette\Http\IRequest $request, Nette\Http\IResponse $response)
        {
            $response->setHeader('X-SendFile', $this->file);
        }
    };

    $this->logger->notice("File download from $_SERVER[REMOTE_ADDR]: $path");
    $this->sendResponse($response);
}

Myslím, že nic složitého. Odesláním odpovědi se pouze nastaví zmiňovaná HTTP hlavička. Vše ostatní řeší Apache.

Podobnou funkcionalitu má i Nginx. Řízení se předává hlavičkou X-Accel-Redirect a její smysl je trochu odlišný. Neposíláte absolutní cestu ve filesystému, ale URI, jejíž mapování na konkrétní adresář nastavíte v konfiguraci.

Použití anonymní třídy je sice cool, ale kód není znovupoužitelný. Napíšeme si teď znovupoužitelnou třídu, response pro odesílání CSV, populární tabulkový export dat.

final class CsvResponse implements Nette\Application\IResponse
{
    private $fileName;
    private $rows;
    private $delimiter;


    public function __construct(string $fileName, iterable $rows, string $delimiter = ',')
    {
        $this->fileName = $fileName;
        $this->rows = $rows;
        $this->delimiter = $delimiter;
    }


    public function send(Nette\Http\IRequest $request, Nette\Http\IResponse $response)
    {
        $response->setContentType('text/csv', 'utf-8');
        $response->setHeader('Content-Description', 'File Transfer');

        # Trochu vykrademe Nette\Application\Responses\FileResponse
        $tmp = str_replace('"', "'", $this->fileName);
        $response->setHeader(
            'Content-Disposition',
            "attachment; filename=\"$tmp\"; filename*=utf-8''" . rawurlencode($this->fileName)
        );

        $bom = true;
        $fd = fopen('php://output', 'wb');

        foreach ($this->rows as $row) {
            if ($bom) {
                # Aby MS Excel správně zobrazil diakritiku. Ale jen pokud existují nějaké řádky.
                fputs($fd, "\xEF\xBB\xBF");
                $bom = false;
            }

            $row = $row instanceof \Traversable ? iterator_to_array($row) : (array) $row;
            fputcsv($fd, $row, $this->delimiter);
        }

        fclose($fd);
    }
}

Kód komentovat nebudu, věřím, že je srozumitelný. Použití je snadné. Vytvoříme instanci třídy a předáme ji metodě presenteru sendResponse().

Poslední připravenou implementací Nette\Application\IResponse je CallbackResponse. Je to univerzální třída a v konstruktoru vyžaduje callback, který zavolá ze své send() metody. Všechny příklady uvedené výše lze přepsat pomocí CallbackResponse. S anonymní třídou trochu ztrácí na kouzlu, ale pro nějaké very lazy odpovědi se stále hodí.

Ať už vám přišly uvedené implementace užitečné, nebo ne, rozhodně doporučuji podívat se na jejich kód, než začnete psát implementace vlastní. Je se čím inspirovat a lépe se vám to dostane pod kůži.

HTTP cache a Nette

Pokud jste se někdy o HTTP protokolu zajímali trochu do hloubky, mohli jste narazit na téma kešování. Tedy jak ušetřit nějaká ta přenášená data. Názorně je to vidět v “Developer tools” v prohlížečích. Otevřte si je na záložce “Síť” a načtěte si nějaký web, třeba GitHub. Poté klikněte na refresh. Hlavní stránka se pravděpodobně načte s kódem 200, ale spousta ostatních souborů, styly, skripty a ikony, bude zašedlých s kódem 304. Byly načteny z keše prohlížeče a 304 znamená “Not Modified”.

HTTP kešování je složitá problematika, takže pod pokličku pouze nahlédneme. Hrají zde roli HTTP hlavičky Last-ModifedETagPragmaCache-ControlIf-Modified-Since a If-None-Match. Ty jsou na úvod nejdůležitější, ale existuje jich více. Princip ukážu na tom, jak si povídá webový klient s webovým serverem:

  • Client: Ahoj.
  • Server: Čau.
  • Client: Mám prý od tebe stáhnout soubor main.css, If-Modified-Since 11.11.2018.
  • Server: 304, nic nestahuj, soubor se od té doby nezměnil.
  • Client: Díky (a soubor si vezme ze své keše).

anebo:

  • Client: Ahoj.
  • Server: Čau.
  • Client: Mám prý od tebe stáhnout soubor selfie.jpg, If-None-Match akjJ54sd
  • Server: 200, jo, ten už má zase jiný hash, tady ho máš, jeho ETag je teď bfhd54se
  • Client: Ach jo, už zase? (a začne stahovat soubor a uloží si ho do keše s novým hashem)

Otázka zní, jak nám může Nette s HTTP kešováním pomoct? Pomůže nám Nette\Http\Context. Metoda isModified() podle HTTP hlaviček požadavku rozhodne, jestli se má soubor znovu odeslat anebo ne. A také nastaví potřebné hlavičky a kód odpovědi.

Ukážeme si, poněkud zjednodušenou, verzi implementace FileResponse, která bere v potaz kešování.

final class FileCachedResponse implements Nette\Application\IResponse
{
    private $file;

    public function __construct(string $file)
    {
        $this->file = $file;
    }


    public function send(Nette\Http\IRequest $request, Nette\Http\IResponse $response)
    {
        $response->setContentType('...');
        $response->setHeader('Content-Description', '...');
        $response->setHeader('Content-Disposition', '...');
        $response->setHeader('Content-Length', filesize($this->file));

        $response->setHeader('Pragma', null);
        $response->setHeader('Cache-Control', null);

        $context = new Nette\Http\Context($request, $response);

        $mTime = filemtime($this->file);
        if ($context->isModified($mTime)) {
            readfile($this->file);
        }
    }
}

Hlavičky Pragma a Cache-Control nastavuje PHP. Zjednodušeně řečeno, je potřeba je z HTTP odpovědi odebrat, jinak kešování nebude fungovat.

Metodě Nette\Http\Context::isModified() jsme jako parametr předali časové razítko změny stahovaného souboru. Metoda porovná časové razítko s hlavičkou If-Modified-Since, pokud existuje, a vrátí true/false. Metoda má ještě druhý parametr, hash odesílaného obsahu. Ten porovnává s hlavičkou ETag. Hash se hodí v případě, kdy neznáme čas modifikace odesílaných dat. Nejedná se o hashování bezpečnostního rázu, MD5 suma postačí, a asi i prostší CRC32 nebo CRC64. Pokud by vás zajímala validace hashe na základě významu odesílaného obsahu (například XML, ve kterém někdy nezáleží na pořadí tagů) a ne jeho přesného bitového obsahu, najděte si něco o “weak ETag” validaci.

Jestli použít časové razítko, hash, nebo oboje je pouze na vás. Oba dva parametry metody isModified() jsou volitelné a nullable.

Použití kešování pomocí Last-Modified a ETag hlaviček sníží datové toky mezi HTTP klientem a serverem, ale nesníží počet HTTP dotazů. HTTP hlavičky dotazu a odpovědi se přenesou vždy, uspoří se přenos těla odpovědi. Pokud jste si jisti, že se odesílaná data určitě nezmění, dejme tomu, dalších 10 minut, můžete jejich expiraci klientovi sdělit pomocí Nette\Http\IResponse::setExpiration(). Například:

$response->setExpiration(60 * 10);  # 600 vteřin

V tom případě si klient následujících 10 minut o danou URL vůbec nepožádá, nebude se vůbec dotazovat, jestli se obsah nějak změnil a vždy použije svou keš. Metoda setExpiration() nastavuje hlavičky Cache-Control a Expires.

Při kešování myslete hlavně na největší problém keše a to její invalidaci. Já osobně jsem radši, když mi obsah dorazí o něco málo později správně, než okamžitě, ale už neplatný.

Doplnění

Adam Zemek mi na Twitteru připomněl, odkud se vlastně HTTP hlavičky Pragma a Cache-Control, a k nim ještě Expires, odesílané automaticky z PHP, berou.

Hlavičky se začnou automaticky odesílat, pokud nastartujete session. Jak jejich odesílání upravit, nebo vypnout, najdete v PHP manuálu u funkce session_cache_limiter().

Nahoru
Tento web používá k poskytování služeb a analýze návštěvnosti soubory cookie. Používáním tohoto webu s tímto souhlasíte. Další informace