Nedostatky Nette při přechodu ze Symfony2
Úkol zněl jasně:
Na dalším projektu použij Nette, protože ...
- Ale já jsem v Nette nedělal
- Ale já jsem v Nette nedělal
To nějak zvládneš
Předchozí projekty, na kterých jsem pracoval, byly v Symfony. Jakmile potom přecházíte z jednoho do druhého, tak se vždycky najde pár věcí, které jsou v jednom či druhém řešeny lépe. Framework nikdy nevyhoví všem (takový ten tweet: “Máte problém? Napíšete framework, teď mají problém i všichni ostatní”), tak jsem sepsal, s čím bojuji v Nette, resp. co by mohlo být řešeno lépe.
1. Magický production a development mód
Symfony má explicitně oddělený produkční
/
a development /app_dev.php/
. Nette jede development mód na localhostu, na produkci jede produkční mód. Development mód jde vypnout, ale ne zapnout (následky viz konzole). Mít zároveň produkční i development mód na localhostu je oříšek.Produkční mód na localhostu? Hlavní výhoda Nette je Tracy!
Možná to platí při ladění webů prohlížením stránek na webu (prohlížeč je král). Při TDD vývoji chyby radši vidím v testech, ještě než vůbec zobrazuji něco na webu. Navíc pokud akceptační test jde přes web, tak chci nastolit stejný stav jako u produkčního uživatele (nechci tracy, nechci buildovat kontejner při každém requestu, ...). Zkoušeli jste někdy napsat UI test pro chybovou stránku v Nette? Můj pokus dopadl následovně:
# Then I should see "Neautorizovaný přístup" # nette is in dev mode, errors are not shown
Then I should see "Nette\Application\BadRequestException #401"
Then I should see "Nette\Application\BadRequestException #401"
Pro odladění chybové stránky v prohlížeči je potřeba vypnout debug mode:
// app/boostrap.php
if (in_array('enableProduction', $config)) {
$configurator->setDebugMode(Nette\Configurator::NONE);
}
if (in_array('enableProduction', $config)) {
$configurator->setDebugMode(Nette\Configurator::NONE);
}
Tak si napiš mechanismus pro zobrazení produkčního módu...
Jestliže aplikace používá framework, tak očekávám stejné chování při přechodu z aplikace A do aplikace B. Když si to každý napíše sám, tak bude mít každá aplikace vlastní řešení (viz https/http web). Proto si myslím, že zobrazení produkce na localhostu by měl řešit samotný framework. Symfony to řeší, Silex to neřeší, ...
2. Konzole
Nette nemá vůbec konzoli (prohlížeč je král), ale existuje integrace pro Symfony konzoli. Ale jak konzoli spustit? Jako Symfony uživatel jsem hledal
app/console
, bin/console
, ale kde nic tu nic. Nakonec Google poradil php www/index.php
. Chybějící dokumentace se dá lehce doplnit, s použitelností to bude horší, dokud nebudou autoři používat konzoli. Konzoli jsem dokázal spustit, ale tím boj teprve začíná. Následuje push na CI server.Logování chyb
CI server rychle spadnul, protože:
ERROR: application encountered an error and can not continue. Error was logged.
Je potřeba vypnout logování chyb, protože v konzoli chci hned vidět chybu ihned bez procházení log adresářů. Stejně tak chci chybu vidět při spuštění testů. Opět s takovou situací dokumentace nepočítá, nakonec rada zní nevolat
enableDebugger
, aby se neaktivovala Tracy: if (in_array('enableDebugger', $config)) {
$configurator->enableDebugger(__DIR__ . '/../var/log');
}
$configurator->enableDebugger(__DIR__ . '/../var/log');
}
Produkční mód
CI server funguje, ale při další feature nastává problém:
$ bin/console
PHP Catchable fatal error: Argument 2 passed to App\Model\SettingsRepository
::__construct() must be an instance of stdClass, none given,
called in /.../var/temp/cache/Nette.Configurator/Container_ce71183ad7.php on line 1796
and defined in /.../app/Model/SettingsRepository.php on line 11
PHP Catchable fatal error: Argument 2 passed to App\Model\SettingsRepository
::__construct() must be an instance of stdClass, none given,
called in /.../var/temp/cache/Nette.Configurator/Container_ce71183ad7.php on line 1796
and defined in /.../app/Model/SettingsRepository.php on line 11
Magický localhost mód je zpět na scéně. Konzole běží v produkčním módu, takže se použije starý nacachovaný kontejner. Teď by to chtělo explicitně zapnout development mód z konzole. Po nahlédnutí do Nette\Configurator jsem to vzdal, protože jsou dostupné módy:
const AUTO = TRUE,
NONE = FALSE;
// setDebugMode
$this->parameters['debugMode'] = $value;
$this->parameters['productionMode'] = !$this->parameters['debugMode']; // compatibility
$this->parameters['environment'] = $this->parameters['debugMode'] ? 'development' : 'production';
NONE = FALSE;
// setDebugMode
$this->parameters['debugMode'] = $value;
$this->parameters['productionMode'] = !$this->parameters['debugMode']; // compatibility
$this->parameters['environment'] = $this->parameters['debugMode'] ? 'development' : 'production';
Evidentně development mód z konzole je aktuálně nemožné nastavit, nebo ne?
Tak si poděď Configurator, zkopíruj setDebugMode a nastav dle potřeby
Skvělá rada, ale já nechci řešit implementační detaily frameworku (co znamenají parametry, co znamená komentář compatibility, ...). Vyřeším si to zatím jinak.
promazat cache
Konfigurací to nejde, tak nastává čas pro hrubou sílu, tj. promazání celého
temp
adresáře a opětovné založení. Zrovna by se hodil integrovaný příkaz pro mazání cache, nechci mazat složku v průzkumníku :)// bin/console
#!/usr/bin/env php
<?php
exec('(rm -rf var/temp) && (mkdir var/temp) && (touch var/temp/.gitkeep)');
require __DIR__ . '/../vendor/autoload.php';
getService('application')->run();
#!/usr/bin/env php
<?php
exec('(rm -rf var/temp) && (mkdir var/temp) && (touch var/temp/.gitkeep)');
require __DIR__ . '/../vendor/autoload.php';
getService('application')->run();
3. Routování
https/http web
Vyvíjíte web, nasadíte ho na HTTPS web a najednou nic nefunguje, protože dojde k přesměrování na HTTP verzi. Náhodou jsem někde viděl Route::SECURED, takže jsem znal řešení. Ale nechápu, proč Nette přesměrovává HTTP schéma, ať se o to stará webserver. Při přesunu web na HTTPS nechci upravovat všechny aplikace. Co když chci kvůli kompatibilitě nechat API běžet i na HTTP verzi a vypnout ji až po 2 měsících po přechodu na HTTPS? Zbývá jen doufat, že to půjde.
Když už se tak Nette chová, tak by měl alespoň existovat standardní způsob, jak řešit web běžící na HTTP i HTTPS. Nastává zde situace, že si to každý musí napsat sám (někdo pracuje se
$_SERVER
, někdo si předá parametr do factory, ...). Pro naše účely stačil tento router:use Nette\Application\Routers\RouteList;
use Nette\Application\Routers\Route;
class RouterFactory
{
/** @return \Nette\Application\IRouter */
public static function createRouter()
{
$router = new RouteList;
$router[] = self::route('<presenter>/<action>[/<id>]', 'Homepage:default');
return $router;
}
/** @SuppressWarnings(PHPMD.Superglobals) */
private static function route($mask, $metadata)
{
$isSecured = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off';
$flags = $isSecured ? Route::SECURED : Route::$defaultFlags;
return new Route($mask, $metadata, $flags);
}
}
use Nette\Application\Routers\Route;
class RouterFactory
{
/** @return \Nette\Application\IRouter */
public static function createRouter()
{
$router = new RouteList;
$router[] = self::route('<presenter>/<action>[/<id>]', 'Homepage:default');
return $router;
}
/** @SuppressWarnings(PHPMD.Superglobals) */
private static function route($mask, $metadata)
{
$isSecured = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] != 'off';
$flags = $isSecured ? Route::SECURED : Route::$defaultFlags;
return new Route($mask, $metadata, $flags);
}
}
seznam rout
Nejvíc ze Symfony mi chybí
bin/console debug:router
. I u úplně cizího projektu mi jediný příkaz odpoví na základní otázky. Jak je projekt velký? Co vlastně dělá? Jaké URL jsou dostupné? Jaké routy existují pro článek?$ app/console d:r | grep article
Name Method Scheme Host Path
detail GET ANY ANY /article/{id}
edit ANY ANY ANY /article/{id}/edit
Name Method Scheme Host Path
detail GET ANY ANY /article/{id}
edit ANY ANY ANY /article/{id}/edit
Zatímco po příchodu do Nette v nejhorším případě v routeru najdu jenom
new Route('<presenter>/<action>[/<id>]', 'Homepage:default')
. Abych mohl aplikaci proklikat, tak ji musím nejprve zprovoznit (debug:router
by měl fungovat i bez databáze, ne?). Najít routy v kódu taky není snadné. Může to být metoda v presenteru začínající na action
, render
, handle
a pak ještě routa může existovat bez metody jen na základě latte šablony. Ještě předtím je ale třeba najít všechny presentery, ne každý totiž dodržuje cestu app/presenters
. Práce s HTTP request/response
Symfony má jasně danou komponentu pro zpracování webových requestů. Např. API vrát response se status kódem 201 a pár dodatečnými informacemi:
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
public function jsonAction(Request $request)
{
return new JsonResponse(
[
'scheme' => $request->getScheme(),
'time' => $request->server->get('REQUEST_TIME', '')
],
201,
['Content-Language' => $request->headers->get('Accept-Language', 'default')]
);
}
use Symfony\Component\HttpFoundation\JsonResponse;
public function jsonAction(Request $request)
{
return new JsonResponse(
[
'scheme' => $request->getScheme(),
'time' => $request->server->get('REQUEST_TIME', '')
],
201,
['Content-Language' => $request->headers->get('Accept-Language', 'default')]
);
}
Response můžu vytvořit mimo Controller a rovnou ji vrátit jako výsledek. Dokonce nemusím ani použít framework, v souboru
web/api.php
použiji stejný kód a front controller. Tomu říkám interoperabilita, nezáleží mi na tom, zda je HttpFoundation samostatné repository anebo monorepo s git subtree. V Nette je situace trochu komplikovanější:use Nette\Application\Responses\JsonResponse;
/** @SuppressWarnings(PHPMD.Superglobals) */
public function actionJson()
{
$request = $this->getHttpRequest();
$this->getHttpResponse()
->setCode(201)
->addHeader('Content-Language', $request->getHeader('Accept-Language', 'default'));
$this->sendResponse(new JsonResponse([
'scheme' => $request->getUrl()->getScheme(),
'time' => array_key_exists('REQUEST_TIME', $_SERVER) ? $_SERVER['REQUEST_TIME'] : ''
])); // $this->sendJson([]);
}
/** @SuppressWarnings(PHPMD.Superglobals) */
public function actionJson()
{
$request = $this->getHttpRequest();
$this->getHttpResponse()
->setCode(201)
->addHeader('Content-Language', $request->getHeader('Accept-Language', 'default'));
$this->sendResponse(new JsonResponse([
'scheme' => $request->getUrl()->getScheme(),
'time' => array_key_exists('REQUEST_TIME', $_SERVER) ? $_SERVER['REQUEST_TIME'] : ''
])); // $this->sendJson([]);
}
Následující připomínky jsou psané z pohledu DX, když jsem vytvářel první API endpoint s Basic autentizací. Práce s response jde proti Command Query Separation, protože
JsonResponse
vytvořím, ale HttpResponse
modifikuji. Musím pracovat s globálním polem. IDE nenapoví, že metody zavolané na getHttpResponse
mají fluent interface. Když jsem poprvé metodu neznal, tak jsem hlavičky nastavil přes funkci header
, protože JsonResponse
obsahuje jen payload a content-type (ale vypne i expiraci).4. Latte
Latte je mocnější než Twig, ale pár drobností se najde:
Globální proměnné pro všechny šablony
Globální proměnné jsou zlo nebo ne? Jak kdy, pro mě jsou větší zlo duplicity. V Symfony/Twigu lze lehce přidat globální proměnné pro použití ve všech šablonách. Typický příklad je číslo verze aplikace:
// app/config/config.yml
twig:
globals:
appVersion: v1.0
// app/Resources/views/base.html.twig
<title>{{ appVersion }}</title>
twig:
globals:
appVersion: v1.0
// app/Resources/views/base.html.twig
<title>{{ appVersion }}</title>
V Nette to už jde trochu hůře:
// app/config/config.neon
parameters:
globals:
appVersion: v1.0
// app/presenters/templates/@layout.latte
<title>{$presenter->context->parameters['globals']['appVersion']}</title>
parameters:
globals:
appVersion: v1.0
// app/presenters/templates/@layout.latte
<title>{$presenter->context->parameters['globals']['appVersion']}</title>
Šahat na context je prasárna, udělej BasePresenter a přidej metodu v beforeRender
Ok,
BaseCokoliv
je taky prasárna. Ne že by kompozice byla vždy nejlepší řešení, ale tady baseNěco
nepomůže. Globální proměnnou totiž nemusím použít jen na webu, co třeba v odeslaném e-mailu?// Symfony: {{ appVersion }}
$this->twig->render('email.twig');
// Nette: {$appVersion}
$latte->renderToString(
__DIR__ . "/templates/email.latte",
['appVersion' => $this->context->parameters['globals']['appVersion']]
)
$this->twig->render('email.twig');
// Nette: {$appVersion}
$latte->renderToString(
__DIR__ . "/templates/email.latte",
['appVersion' => $this->context->parameters['globals']['appVersion']]
)
A už má Nette duplicitu, navíc ješte tahání dat z interního kontextu. Přitom přidání globálních proměnných je změna na pár řádek. Může si to každý napsat sám? Může, ale pak musí proměnné nějak pojmenovat a definovat je v
parameters
, systémové řešení by je definovalo v sekci latte
.Nastavování parametrů v presenteru
Nette\Application\UI\ITemplate
definuje metody render
, setFile
, getFile
. Přitom jediná implementace rozhraní Nette\Bridges\ApplicationLatte\Template
má i užitečnou metodu setParameters
pro nastavení většího počtu proměnných jedním voláním: $this->template->anyVariable = 'any value';
$this->template->setParameters([
'title' => '...',
'articles' => ['...']
]);
'title' => '...',
'articles' => ['...']
]);
Ten kód samozřejmě funguje, jenom IDE nedokáže napovědět, že ta metoda existuje.
5. Ostatní
.htaccess/web.config v newebových složkách
Spíš bych podporoval správný přístup, tj. z webu se dostat přímo do složky
www
. A ne tam dát celou aplikaci a do všech ostatních složek dávat deny from all
pro všechny existující webservery. Tohle je určitě hodně subjektivní a není to chyba v Nette, takže já si je smažu a u potřebných složek je nahradím .gitkeep
.Převod php chyb na výjimky
PHP má sice výjimky, ale interní funkce používají vlastní mechanismus chyb přes
Notice
, Warning
, ..., které se špatně zachytávají. Například cizí API vrátí strukturu dat, která je špatně nebo není vůbec zdokumentovaná. Kód je čitelnější, když se soustředí na happy path a ne když dopředu ověřuje všemožné chyby:$externalData = [];
try {
$id = $externalData['id']; // $id = array_key_exists('id') ? $externalData['id'] : null
$name = $externalData['nested']['level']['name'];
} catch (\ErrorException $e) {
$this->logger->invalidExternalData($externalData, $e);
}
try {
$id = $externalData['id']; // $id = array_key_exists('id') ? $externalData['id'] : null
$name = $externalData['nested']['level']['name'];
} catch (\ErrorException $e) {
$this->logger->invalidExternalData($externalData, $e);
}
PHPUnit testy můžou projít díky
convertNoticesToExceptions="true"
, ale Nette web mi vždy skončil chybou Notice Undefined index: id
. Tracy sice nastavuje set_error_handler, ale docela složitě, takže jsem přešel na jednoduchý handler z tipů Jakuba Vrány:// app/bootstrap.php
set_error_handler('convertPhpErrorsToExceptions'); // zavolat až po $configurator->enableDebugger
function convertPhpErrorsToExceptions($severity, $message, $filename, $lineno)
{
if (error_reporting() & $severity) {
throw new ErrorException($message, 0, $severity, $filename, $lineno);
}
}
set_error_handler('convertPhpErrorsToExceptions'); // zavolat až po $configurator->enableDebugger
function convertPhpErrorsToExceptions($severity, $message, $filename, $lineno)
{
if (error_reporting() & $severity) {
throw new ErrorException($message, 0, $severity, $filename, $lineno);
}
}
formulářová komponenta s NETTE inputy
Zprovoznit formulářový prvek nad zvolenou entitou je boj. Příklady počítají s tím, že definuji renderování HTML, ale já přitom chci použít normální inputy, selecty, validace a HTML vzhled definovat na jednom místě. V Symfony to není problém, v Nette bylo potřeba hodně hledání a experimentování (kód je zjednodušen):
// app/forms/AddressControl.php
class AddressControl extends \Nette\Forms\Controls\BaseControl
{
// hack, abych v form.latte zjistil, že getControl nevrátí HTML, ale pole komponent
public $hasContainer = true;
/** @var Adresa */
private $adresa;
public function setValue($a = null)
{
$this->adresa = $a ? $a : new Adresa;
}
public function getValue()
{
return $this->adresa;
}
// BaseControl @return Html|string Generates control's HTML element
public function getControl()
{
$this->container = $this->form->addContainer($this->getName() . 'Container');
$this->container->addText('psc', 'PSČ')
->setValue(
->addCondition(Form::FILLED)
->addRule(Form::MIN_LENGTH, 'PSČ má mít minimálně %s číslic', 5)
->endCondition();
$this->container->addSelect('obec', 'Obec')->setPrompt('--vyberte--');
$this->container->addSelect('castObce', 'Část obce')->setPrompt('--vyberte--');
$this->container->addSelect('ulice', 'Ulice')->setPrompt('--vyberte--');
$this->container->addText('cisloPopisne', 'Číslo popisné');
$this->container->addText('cisloOrientacni', 'Číslo orientační');
return $this->container->getComponents();
}
}
class AddressControl extends \Nette\Forms\Controls\BaseControl
{
// hack, abych v form.latte zjistil, že getControl nevrátí HTML, ale pole komponent
public $hasContainer = true;
/** @var Adresa */
private $adresa;
public function setValue($a = null)
{
$this->adresa = $a ? $a : new Adresa;
}
public function getValue()
{
return $this->adresa;
}
// BaseControl @return Html|string Generates control's HTML element
public function getControl()
{
$this->container = $this->form->addContainer($this->getName() . 'Container');
$this->container->addText('psc', 'PSČ')
->setValue(
->addCondition(Form::FILLED)
->addRule(Form::MIN_LENGTH, 'PSČ má mít minimálně %s číslic', 5)
->endCondition();
$this->container->addSelect('obec', 'Obec')->setPrompt('--vyberte--');
$this->container->addSelect('castObce', 'Část obce')->setPrompt('--vyberte--');
$this->container->addSelect('ulice', 'Ulice')->setPrompt('--vyberte--');
$this->container->addText('cisloPopisne', 'Číslo popisné');
$this->container->addText('cisloOrientacni', 'Číslo orientační');
return $this->container->getComponents();
}
}
Dokumentace
Jediný problém v dokumentaci vidím ve velké roztříštěnosti informací. Třeba psaní vlastních formulářových prvků jsem našel na blogu, na nette.org jsem odkaz ani zmínku nezahlédl. Člověk nečetl blog a na githubu se dozví For the details you can have a look at the diff...
“They cannot assume that the user has read or will read any and every blog entry, press release and tweet issued by a company”
The Principled Documentation Manifesto
The Principled Documentation Manifesto
Závěr
Můj boj se točil hlavně okolo
app/bootstrap.php
, takže v gistu je k dispozici kód, jak zkrotit módy v konzoli, testech a na webu. Je možné, že popsané nedostatky jsou způsobené mojí neznalostí. V tom případě se rád přiučím, pokud jsem něco řešil zbytečně složitě.