Wpis z mikrobloga

#NieDlaKlepaczyKodu:Symfony: Różne sposoby na strony błędów w kontrolerach

#niedlaklepaczykodusymfony <<< Subskrybuj ten tag po więcej albo czarnolistuj, jeśli nie chcesz widzieć wpisów z tej serii. Więcej info na dole wpisu.

Symfonowe kontrolery działają w ramach abstrakcji HTTP: przyjmują „żądanie” (reprezentowane przez obiekt Request) i zwracają „odpowiedź” (obiekt Response).

Załóżmy, że chcemy odpowiedzieć klientowi (przeglądarce/użytkownikowi), że strona nie istnieje, czyli zwrócić odpowiedź ze statusem 404.
Najbardziej oczywistym sposobem będzie nadanie tego kodu obiektowi odpowiedzi:


#[1]
use Symfony\Component\HttpFoundation\Response; // dalej będę pomijał
``````
return new Response('Not found', 404);
```Ale kto by pamiętał te wszystkie kody numeryczne odpowiedzi? Lepiej użyć stałej:```
#[2]
return new Response('Not found', Response::HTTP_NOT_FOUND);
```Jeśli będziemy rzucali taki wyjątek często, pojawia się duplikacja domyślnej wiadomości. Na szczęście mamy ją w zmiennej:```
#[3]
return new Response(
    Response::$statusTexts[Response::HTTP_NOT_FOUND], 
    Response::HTTP_NOT_FOUND
);
```Zrobiło się sporo kodu, który znów możemy uznać za duplikację. Dodatkowo takie tworzenie stron błędów ma pewien minus: wyglądają kijowo w przeglądarkach.Dokumentacja Symfony [podpowiada](https://symfony.com/doc/current/book/controller.html#managing-errors-and-404-pages) więc, że możemy rzucić wyjątkiem [HttpExceptionInterface](https://github.com/symfony/http-kernel/blob/v3.0.6/Exception/HttpExceptionInterface.php), a Symfony wtedy zaprezentuje domyślną stronę błędów (którą możemy możemy zmodyfikować do swoich potrzeb):```
#[4]
throw $this->createNotFoundException();
```Owa metoda [kontrolera bazowego](https://github.com/symfony/framework-bundle/blob/v3.0.6/Controller/Controller.php#L238) nie robi nic więcej jak utworzenie obiektu wyjątku – możemy to zrobić sami:```
#[5]
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; // dalej będę pomijał
``````
throw new NotFoundHttpException();
```Co jednak, gdy chcemy, żeby programista miał więcej info dlaczego wystąpił wyjątek? Możemy przekazać wiadomość, która zostanie potem zapisana do logów i/lub wyświetlona na stronie błędów w środowisku developerskim:```
#[6]
throw new NotFoundHttpException('Article not found');
```Komunikat może być bardziej rozbudowany, tu przydaje się funkcja formatowania napisu:```
#[7]
throw new NotFoundHttpException(sprintf(
    'Article with id "%d" was not found', 
    $articleId
));
```Co jeśli w kontrolerze musimy tak zrobić w kilku miejscach? Wyciągamy metodę pomocniczą:```
#[8]        
public function showArticleAction($articleId)
{
    $article = $this->articleRepository->findById($articleId);
    
    if (null === $article) {
        throw $this->createArticleNotFoundHttpException($articleId);
    }
}
``````
private function createArticleNotFoundHttpException($articleId)
{
    return new NotFoundHttpException(sprintf(
        'Article with id "%d" was not found', 
        $articleId
    ));        
}
```W ten sposób unikamy duplikacji tworzenia odpowiedniego wyjątku i jego komunikatu.
Co jeśli chcemy tak zrobić w wielu kontrolerach? W OOP dziedziczenie idzie na ratunek:```
#[9]
abstract class ArticleController
{
    protected function createArticleNotFoundHttpException($articleId)
    {
        return new NotFoundHttpException(sprintf(
            'Article with id "%d" was not found', 
            $articleId
        ));        
    }        
}
``````
// i analogicznie np CreateArticleController czy SearchArticleController
class ShowArticleController extends ArticleController
{
    public function showArticleAction($articleId)
    {
        throw $this->createArticleNotFoundHttpException($articleId);
    }
}
```Inny sposób, który da nam więcej elastyczności, to skorzystać z dziedziczenia przy wyjątkach:```
#[10]
class ArticleNotFoundHttpException extends NotFoundHttpException
{
    public function __construct($articleId)
    {
        parent::__construct(sprintf(
            'Article with id "%d" was not found', 
            $articleId
        ));
    }
}
``````
class ArticleController
{
    public function showArticleAction($articleId)
    {
        throw new ArticleNotFoundHttpException($articleId);
    }        
}
```Dlaczego mówiłem, że da nam to więcej elastyczności? Bo teraz możemy rozpoznać rodzaj wyjątku i zrobić z nim coś więcej niż domyślnie robi Symfony z wyjątkami HTTP.Możemy przykładowo zaprezentować dedykowaną stronę błędów dla nieznalezionych artykułów (w odróżnieniu od „ogólnej“ strony błędów 404). [Poczytaj dokumentację](https://symfony.com/doc/current/cookbook/controller/error_pages.html).Jak już „robimy coś więcej“ z tym wyjątkiem, to zapewne przyda się też więcej informacji. Możemy rozbudować jego klasę:```
#[11]
class ArticleNotFoundHttpException extends NotFoundHttpException
{
    private $articleId;
``````
    public function __construct($articleId)
    {
        $this->articleId = $articleId;                
        
        parent::__construct(sprintf(
            'Article with id "%d" was not found', 
            $articleId
        ));
    }
``````
    public function getArticleId()
    {
        return $this->articleId;
    }
}

Być może zauważyłeś już pewien problem: NotFoundHttpException ma też swoje pola, które warto uzupełnić. Do tego być może dorobiłeś już własną obsługę wyjątków o braku artykułu (np strona błędów z dedykowaną grafiką czy linkami) => a jednak niekoniecznie chcesz mieć zawsze taki sam komunikat. Mógłbyś chcieć z kontrolera przekazać własny. Pełna więc wersja klasy z wyjątkiem:


#[12]
class ArticleNotFoundHttpException extends NotFoundHttpException
{
    private $articleId;
``````
    public function __construct(
        $articleId, 
        $message = null, 
        Exception $previous = null, 
        $code = 0
    ) {
        $this->articleId = $articleId;
                
        if (null === $message) {
            $message = sprintf(
                'Article with id "%d" was not found', 
                $articleId
            );
        }
        
        parent::__construct($message, $previous, $code);
    }
``````
    public function getArticleId()
    {
        return $this->articleId;
    }
}
```No i już możemy go rzucić bezpośrednio:```
#[13]
$article = $this->articleRepository->findOneById($articleId);
``````
if (null === $article) {
    throw new ArticleNotFoundHttpException($articleId);
}
```Albo przechwycić wyjątek domenowy i dać własny komunikat:```
#[14]
try {
    $article = $this->articleRepository->findOneById($articleId);
} catch (ArticleNotFoundException $ex) {
    throw new ArticleNotFoundHttpException(
        $articleId, 
        sprintf(
            'Article with id "%d" was not found by ArticleRepository', 
            $articleId
        ),
        $ex
    );
}

-----------------

To pierwszy wpis z serii #niedlaklepaczykodusymfony – będzie o tym jak pisać i nie pisać webaplikacji z wykorzystaniem Symfony.

Jeśli chcesz dostawać powiadomienia o nowych wpisach, zapraszam do subskrypcji taga.

Jeśli nie chcesz widzieć takich wpisów, wrzuć ten tag na #czarnolisto – zwłaszcza, że będę „spamował“ wieloma innymi tagami, jak #programowanie #php #symfony #symfony2 #symfony3 ;)

Zapraszam do komentowania, code review, pytań, sugestii, propozycji nowych wpisów!
  • 10
@MacDada: ja bym chyba polecaił w traity i z metody createArticleNotFoundHttpException zrobił createNotFoundHttpException gdzie przekazywałbym dwa parametry(co jako string i id), tj zatrzymał się na punkcie 9 i trochę go "ulepszył" aby móc skorzystać z tego w każdym kontrolerze

ewentualnie aby w samym traitcie pobierało prefix kontrolera(o ile w ogóle jest coś takiego możliwe), ale to raczej głupie rozwiązanie bo nie zawsze jakiś Controller będzie rzucał not found bo nie znaleziono
ja bym chyba polecaił w traity i z metody createArticleNotFoundHttpException zrobił createNotFoundHttpException gdzie przekazywałbym dwa parametry(co jako string i id), tj zatrzymał się na punkcie 9 i trochę go "ulepszył" aby móc skorzystać z tego w każdym kontrolerze


@Jurigag: Yep, jest też taka opcja. Chociaż ja raczej mam awersję do traitów, co też w sumie mógłbym opisać w dedykowanym artykule ;)