Obsługa błędów (#6) – API w PHP z Laminas Mezzio

tytuł wpisu z grafiką kodu

W każdej aplikacji potrzebne jest odpowiednie obsłużenie błędów. Niektóre z nich są przechwytywane i obsługiwane w blokach try catch. Skoro nie wszystkie, plus dochodzą jeszcze te niespodziewane, trzeba nad nimi zapanować i zadbać o ich odpowiednią formę. Wyświetlenie użytkownikowi szlaczków albo odpowiedź jako HTML w API to niezbyt praktyczne rozwiązania.

Mezzio dostarcza podstawową obsługę błędów opierająca się o standardowe funkcje i klasy PHP. Domyślnie, dba o to klasa Mezzio\Middleware\ErrorResponseGenerator, chociaż jak może pamiętać z instalacji, można dodatkowo dla wersji deweloperskich zainstalować Mezzio\Middleware\WhoopsErrorResponseGenerator. Dla aplikacji web opartej o widoki ta funkcjonalność może być wystarczająca. Dla mniej standardowych sytuacji trzeba napisać własną obsługę lub skorzystać z innych zewnętrznych bibliotek. Jako, że implementacja w tym projekcie nie jest skomplikowana i czasochłonna to przygotuję ją samodzielnie. Jednak całość oprze się o wyżej wymieniony middleware.

Moduł odpowiedzialny za obsługę błędów

Obsługa błędów zostanie wyniesiona do osobnego modułu. Tak będzie wygodniej – w razie czego łatwo go wymienić. Zresztą jak mogliście zauważyć, aplikacja jest modularna. Tak więc tworzony jest folder Error zawierający podstawową strukturę katalogów i klasę ConfigProvider.php z pustą metodą getDependencies(). Klasa Error\ConfigProvider rejestrowana jest w pliku config/config.php. Oczywiście nowy moduł trzeba uwzględnić w autoloadingu w pliku composer.json. Jeszcze tylko konfiguracja dla testów i wszystko gotowe. Ten proces był już kilkukrotnie pokazywany w tej serii, więc mam nadzieję, że nie wymaga dokładniejszego tłumaczenia.

Dobra, ale jak zabrać się za obsługę błędów w PHP w Mezzio? Tak jak wspomniałem na początku, w pewnym sensie ona już istnieje, bo została dostarczona wraz z instalacją szkieletu frameworka Mezzio. Poniżej prezentuję aktualnie zarejestrowane middleware i widać, że pierwszy z nich to Laminas\Stratigility\Middleware\ErrorHandler. Dla tej aplikacji nie jest jednak wystarczająca, ale komponent jest zrobiony w tak przyjazny sposób, że do tworzenia własnej obsługi można spokojnie z niego skorzystać. Dlaczego ta jest niewystarczająca? Zwraca błędy w postaci czystego tekstu. Ewentualnie jeśli dorzuci się szablon to uda się pokazywać błędy w bardziej przystępny sposób. Tylko że nadal będzie to zwrotka typu text/html.

return function (Application $app, MiddlewareFactory $factory, ContainerInterface $container): void {
    $app->pipe(ErrorHandler::class);

    $app->pipe(ServerUrlMiddleware::class);
    $app->pipe('/api/v1', AuthenticationMiddleware::class);
    $app->pipe('/api/v1', IpAccessControlMiddleware::class);
    $app->pipe(RouteMiddleware::class);

    $app->pipe(ImplicitHeadMiddleware::class);
    $app->pipe(ImplicitOptionsMiddleware::class);
    $app->pipe(MethodNotAllowedMiddleware::class);

    $app->pipe(BodyParamsMiddleware::class);
    $app->pipe(UrlHelperMiddleware::class);

    $app->pipe(DispatchMiddleware::class);

    $app->pipe(NotFoundHandler::class);
};

Jako, że ta aplikacja nie posiada warstwy widoku, a odpowiedzi zwracane są jako application/json, dla spójności komunikaty błędów warto zwrócić właśnie w tym formacie. Domyślna implementacja na to nie pozwala, ale w prosty sposób można ją nadpisać wymieniając pewne elementy układanki.

Na początku trzeba będzie stworzyć własną fabrykę dla klasy Laminas\Stratigility\Middleware\ErrorHandler po to by wstrzyknąć nieco inne zależności, niż są domyślnie. W Laminas Framework, czy Mezzio bardzo często działa się w ten sposób korzystając z dobrodziejstw Dependency Injection. Klasa Error\Factory\CustomErrorHandlerFactory prezentuje się następująco.

<?php

declare(strict_types=1);

namespace Error\Factory;

use Laminas\Stratigility\Middleware\ErrorHandler;
use Error\Service\JsonErrorResponseGenerator;
use Mezzio\Middleware\ErrorResponseGenerator;
use Psr\Container\ContainerInterface;
use Psr\Http\Message\ResponseInterface;

class CustomErrorHandlerFactory
{
    public function __invoke(ContainerInterface $container): ErrorHandler
    {
        $generator = $container->has(JsonErrorResponseGenerator::class)
            ? $container->get(JsonErrorResponseGenerator::class)
            : $container->get(ErrorResponseGenerator::class);

        return new ErrorHandler($container->get(ResponseInterface::class), $generator);
    }
}

Od domyślnej fabryki różni się ona tym, że najpierw jako generator próbuję z kontenera wyciągnąć Error\Service\JsonErrorResponseGenerator, który jeszcze nie istnieje. W kolejnym kroku zostanie stworzony. Mimo wszystko, nawet gdy nie istnieje, wyciągana jest domyślnie dostarczana klasa generatora, która funkcjonowała do tej pory. Własny generator nie ma w sobie nic skomplikowanego. Wymaga magicznej metody __invoke() i dzięki niej obiekt jest wywoływany przez zewnętrzny komponent. Poza tym posiada jeszcze konstruktor, gdyż do konkretyzacji wymagane jest ustawienie flagi $isDebug, która pozwoli zwrócić więcej informacji o błędzie. Jej wartość pochodzi z pliku konfiguracyjnego. Dla środowisk deweloperskich ustawiona jest na true, a dla produkcyjnych na false.

<?php

declare(strict_types=1);

namespace Error\Service;

use Laminas\Stratigility\Utils;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class JsonErrorResponseGenerator
{
    private bool $isDebug;

    public function __construct(bool $isDebug)
    {
        $this->isDebug = $isDebug;
    }

    public function __invoke(
        \Throwable $error,
        ServerRequestInterface $request,
        ResponseInterface $response
    ): ResponseInterface {
        $message = 'An unexpected error occurred.';
        if ($this->isDebug) {
            $message .= sprintf(
                ' %s raised in file %s in line %d with message: %s',
                get_class($error),
                $error->getFile(),
                $error->getLine(),
                $error->getMessage()
            );
        }

        $response = $response->withHeader('Content-Type', 'application/json');
        $response = $response->withStatus(Utils::getStatusCode($error, $response));
        $response->getBody()->write(
            json_encode(['message' => $message])
        );

        return $response;
    }
}

Treści nieoczekiwanych wyjątków nie warto pokazywać na produkcji – może to być niebezpieczne, a już na pewno jest niefunkcjonalne. W takim środowisku klient API otrzyma następującą treść błędu: An unexpected error occurred. Fajnie jednak mieć wgląd w błędy, które wystąpiły – dlatego w dalszej części zostanie zaimplementowana klasa odpowiedzialna za ich logowanie.

Wracając do głównej metody – pozwala ona zwrócić właściwą odpowiedź typu application/json. Wartość statusu HTTP ustawiana jest w taki sam sposób jak w domyślnym generatorze. Do tego jeszcze należy stworzyć fabrykę i całość można rejestrować w zależnościach modułu.

<?php

declare(strict_types=1);

namespace Error\Factory;

use Error\Service\JsonErrorResponseGenerator;
use Psr\Container\ContainerInterface;

class JsonErrorResponseGeneratorFactory
{
    public function __invoke(ContainerInterface $container): JsonErrorResponseGenerator
    {
        return new JsonErrorResponseGenerator($container->get('config')['debug'] ?? false);
    }
}

W tym momencie każdy błąd Throwable zostanie przechwycony i zwrócony w formacie odpowiednim dla tego projektu. Wspomniałem jednak wcześniej o kwestii logowania błędów. Oczywiście, często do tego celu zapina się osobne narzędzia gwarantujące szeroki monitoring albo po prostu można przejrzeć logi serwera. Wszystko zależy od wymagań.

Dla własnego loggera zostanie stworzony nowy interfejs. Na ten moment błędy będą logowane do pliku – najprostsza opcja. W późniejszym etapie, dzięki interfejsowi, będzie można zmienić tę implementację na inną. Nawet wpiąć inny komponent za pomocą wzorca Adapter spełniającego ten interfejs. Na ten moment interfejs, realizująca go klasa i jej fabryka wyglądają następująco.

<?php

declare(strict_types=1);

namespace Error\Service;

interface LoggerInterface
{
    public function log(string $message): void;
}
<?php

declare(strict_types=1);

namespace Error\Service;

class FileErrorLogger implements LoggerInterface
{
    private string $filePath;

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

    public function log(string $message): void
    {
        $log  = sprintf(
            'Date: %s, Message: %s',
            (new \DateTime())->format('d.m.Y H:i:s'),
            $message
        );

        file_put_contents($this->filePath, $log . PHP_EOL, FILE_APPEND);
    }
}
<?php

declare(strict_types=1);

namespace Error\Factory;

use Error\Service\FileErrorLogger;
use Psr\Container\ContainerInterface;

class FileErrorLoggerFactory
{
    public function __invoke(ContainerInterface $container): FileErrorLogger
    {
        return new FileErrorLogger(APPLICATION_PATH . '/../data/log/error.log');
    }
}

Jak można zauważyć, w ścieżce do pliku pojawiła się nowa stała APPLICATION_PATH. Do tej pory nie istniała, ale z doświadczenia wiem, że prędzej, czy później przyda się w projekcie. Zawsze bezpieczniej bazować na ścieżce bezwzględnej. Stałą trzeba zdefiniować w pliku głównym public/index.php za pomocą poniższego kodu.

define('APPLICATION_PATH', __DIR__);

Katalog, w którym zostanie zapisany plik z logami jeszcze nie istnieje. Trzeba więc go stworzyć, dodać plik .gitkeep i dorzucić do pliku .gitignore odpowiednie wykluczenia.

!log
!log/.gitkeep

Ścieżka do pliku jako argument konstruktora pomoże w łatwiejszym wykorzystywaniu tej klasy w innych miejscach. Dla każdego przypadku logi mogą być zapisywane do osobnego pliku. Także na potrzeby testów tworzony będzie odrębny. Klasa testowa wygląda następująco.

<?php

declare(strict_types=1);

namespace ErrorTest\Service;

use Error\Service\FileErrorLogger;
use PHPUnit\Framework\TestCase;

class FileErrorLoggerTest extends TestCase
{
    private const TEST_LOG_FILE_PATH = __DIR__ . '/../TestHelper/Log/test_error.log';

    public function testCanSaveLogIntoFile(): void
    {
        $testLog = 'Test Log Message';

        $logger = new FileErrorLogger(self::TEST_LOG_FILE_PATH);
        $logger->log($testLog);

        $logsContent = file_get_contents(self::TEST_LOG_FILE_PATH);

        $this->assertTrue(str_contains($logsContent, $testLog));
    }

    public function tearDown(): void
    {
        unlink(self::TEST_LOG_FILE_PATH);
    }
}

Dla pliku testowego też trzeba odtworzyć strukturę katalogów i skonfigurować .gitignore – tak by żadne pliki z owej ścieżki nie trafiły do repozytorium kodu. Plik i tak tworzony jest tymczasowo i usuwany po wykonaniu testów, ale dla pewności warto o to zadbać.

Mając tak przygotowany komponent, czas z niego skorzystać. Trzeba będzie wstrzyknąć go do middleware odpowiedzialnego za obsługę błędów. Tego, który został przygotowany wcześniej. Ale zaraz… jak wstrzyknąć interfejs loggera do klasy, która nie przyjmuje takiego parametru w konstruktorze? Do tego celu posłuży klasa obserwująca, która obsługiwana jest przez middleware za pomocą wzorca projektowego Obserwator. Oczywiście gdzieś trzeba dodać logger do subskrybentów.

<?php

declare(strict_types=1);

namespace Error\Listener;

use Error\Service\LoggerInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class ErrorLogListener
{
    private LoggerInterface $logger;

    public function __construct(LoggerInterface $logger)
    {
        $this->logger = $logger;
    }

    public function __invoke(\Throwable $error, ServerRequestInterface $request, ResponseInterface $response): void
    {
        $log = sprintf(
            '%s raised in file %s in line %d with message: %s',
            get_class($error),
            $error->getFile(),
            $error->getLine(),
            $error->getMessage()
        );

        $this->logger->log($log);
    }
}

Listener prezentuje się następująco, a jego rejestracja ma miejsce w Error\Factory\ErrorHandlerDelegatorFactory. Jest to kolejna z opcji do konfigurowania w zależnościach. Rozwiązanie bardzo pomocne w wielu przypadkach. Klasa delegatora pozwala zaraz po utworzeniu obiektu przez fabrykę, udekorować go w odpowiedni sposób. Tak jak w tym przypadku, gdzie rejestrowany zostaje subskrybent. Co ważne, delegatorów może być więcej niż jeden dla konkretnej klasy. Tym bardziej jest to świetna opcja.

<?php

declare(strict_types=1);

namespace Error\Factory;

use Error\Listener\ErrorLogListener;
use Error\Service\LoggerInterface;
use Laminas\Stratigility\Middleware\ErrorHandler;
use Psr\Container\ContainerInterface;

class ErrorHandlerDelegatorFactory
{
    public function __invoke(ContainerInterface $container, string $name, callable $callback): ErrorHandler
    {
        $listener = new ErrorLogListener($container->get(LoggerInterface::class));
        $errorHandler = $callback();
        $errorHandler->attachListener($listener);

        return $errorHandler;
    }
}

Finalna konfiguracja zależności dla tego modułu prezentuje się w ten sposób. Kodu nie powstało jakoś dużo, a implementacja jest gotowa i spełnia założenia.

<?php

declare(strict_types=1);

namespace Error;

use Laminas\Stratigility\Middleware\ErrorHandler;

class ConfigProvider
{
    public function __invoke(): array
    {
        return [
            'dependencies' => $this->getDependencies()
        ];
    }

    public function getDependencies(): array
    {
        return [
            'aliases' => [
                Service\LoggerInterface::class => Service\FileErrorLogger::class
            ],
            'factories' => [
                ErrorHandler::class => Factory\CustomErrorHandlerFactory::class,
                Service\JsonErrorResponseGenerator::class => Factory\JsonErrorResponseGeneratorFactory::class,
                Service\FileErrorLogger::class => Factory\FileErrorLoggerFactory::class
            ],
            'delegators' => [
                ErrorHandler::class => [
                    Factory\ErrorHandlerDelegatorFactory::class
                ]
            ]
        ];
    }
}

Podsumowanie – obsługa błędów w Mezzio

To oczywiście podstawowa obsługa, ale zdecydowanie wystarczająca. Gdyby była to aplikacja posiadająca zarówno API jak i widoki, należałoby pomyśleć o takiej obsłudze, by dla konkretnych żądań zwracany był błąd w oczekiwanym formacie. Można przecież w prosty sposób zarejestrować middleware dla konkretnej ścieżki.

Ważne żeby middleware odpowiedzialny za błędy był zarejestrowany jako pierwszy. Za to jako ostatni rejestrowany jest NotFoundHandler – wyświetlający strony 404 w przypadku braku dopasowania. Widać więc, że Mezzio dba o podstawowe elementy potrzebne do tworzenia funkcjonalnych aplikacji webowych.

Moduł do obsługi błędów w Mezzio, który powstał w tym wpisie z powodzeniem może zostać przeniesiony do innego projektu albo rozszerzony. Jeszcze raz powtórzę – to całe piękno tworzenia modularnych aplikacji.

Cały kod dostępny jako gałąź step06.

Krystian Żądło
Programista PHP i właściciel marki Koddlo. Pasjonat czystego kodu i dobrych praktyk programowania obiektowego. Prywatnie fan angielskiej piłki nożnej, dobrego humoru oraz podcastów.