HTTP požadavky a odpovědi – Část 2.

před 6 lety od Miloslav Hůla  

V první části popisuji metody presenteru, kterými můžeme ovládat HTTP odpovědi aplikace. V této části se budu věnovat rozhaní Nette\Application\IResponse, se kterým lze 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 jmené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 této minisérie 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.

Tím jsem shrnul téměř vše, co jsem chtěl o HTTP v Nette aplikaci napsat. Třetí díl už bude jen taková třešnička.

Komentáře

  1. Při přesměrování na přihlašovací formulář je vhodné mu předat adresu, na kterou se po přihlášení má vrátit. Jinak uživatel stránku musí znovu hledat.

    před 6 lety
  2. Ano. To se v Nette řeší uložením požadavku a persistentním parametrem. https://pla.nette.org/…jnou-stranku

    před 6 lety

Chcete-li odeslat komentář, přihlaste se