Szkielet aplikacji (#1) – API w PHP z Laminas Mezzio

tytuł wpisu z grafiką kodu

Laminas Mezzio jest swego rodzaju klejem dla innych komponentów. Dzięki temu, że jest on zgodny ze standardami, dużo łatwiej można te klocki wymieniać. Standardy PSR to zbiór dobrych praktyk kodowania oraz interfejsów ułatwiających komunikację między zewnętrznymi bibliotekami. Żeby zacząć przygodę z Mezzio wystarczy zaznajomić się z jego 4 głównymi konceptami.

Pierwszym i najważniejszym, który determinuje sposób tworzenia aplikacji jest podejście do obsługi żądań i odpowiedzi serwera. Głównymi bohaterami są tutaj: pośrednik (middleware) i obsługa żądania (request handler). Cała komunikacja jest zgodna z PSR-7 i PSR-15. Pozostałe idee są już analogiczne jak w większości frameworków. Kontener Dependency Injection zgodny z PSR-11 i Router to dwa wymagane komponenty. Ostatnim opcjonalnym jest silnik szablonów, który przyda się w aplikacjach z widokami.

Instalacja Laminas Mezzio

Oczywiście, przygodę z tym microframeworkiem można rozpocząć od pliku composer.json i samodzielnie skonfigurować cały projekt. W większości nowoczesnych rozwiązań istnieje jednak możliwość skorzystania ze szkieletu aplikacji. Tak właśnie uczynię. Jest to szybsze i prostsze. Jeśli w aplikacji planowane jest coś niestandardowego to łatwiej jest dostosować gotowy szablon, niż startować od zera. Ewentualnie można samodzielnie przygotować repozytorium z własnym uniwersalnym szkieletem, ale mój nie odbiega aż tak bardzo od oryginalnego, więc szkoda zachodu.

Instalacja zależności Laminas Mezzio to odpalenie jednej komendy za pomocą composera. Ja jeszcze wcześniej przygotuję sobie obraz dockera, w którym to mam właśnie serwer Apache2, PHP8 i Composer. Dla zainteresowanych poniżej zamieszczam przykładową konfigurację. Oprócz tego warto dodać sobie w lokalnych hostach wpis customer-api.local 10.25.0.10 tak by móc się łączyć z API po domenie, a nie przez adres IP.

#Dockerfile
FROM php:8.0-apache

RUN apt-get update \
  && apt-get install -y libzip-dev zip \
  && docker-php-ext-install zip \
  && a2enmod rewrite ssl \
  && curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer

RUN mkdir /etc/apache2/ssl \
 && openssl req \
              -new \
              -newkey rsa:4096 \
              -days 365 \
              -nodes \
              -x509 \
              -subj "/C=/ST=/L=/O=/CN=customer-api.local" \
              -keyout /etc/apache2/ssl/apache.key \
              -out /etc/apache2/ssl/apache.crt

RUN echo "ServerName customer-api" >> /etc/apache2/apache2.conf

RUN service apache2 restart

WORKDIR /var/www
#docker-compose.yml
version: '3.7'
services:
  server:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: 'customer_api'
    restart: 'on-failure'
    tty: true
    volumes:
      - .:/var/www
      - ./000-default.conf:/etc/apache2/sites-enabled/000-default.conf
    networks:
      customer-api:
        ipv4_address: 10.25.0.10
networks:
  customer-api:
    name: customer-api
    driver: bridge
    ipam:
      config:
        - subnet: 10.25.0.0/16
#000-default.conf
<VirtualHost *:443>
    ServerName customer-api.local
    DocumentRoot /var/www/public
    <Directory /var/www/public>
        DirectoryIndex index.php
        AllowOverride All
        Order allow,deny
        Allow from all
        <IfModule mod_authz_core.c>
        Require all granted
        </IfModule>
    </Directory>
	SSLEngine on
    SSLCertificateFile	/etc/apache2/ssl/apache.crt
    SSLCertificateKeyFile /etc/apache2/ssl/apache.key
</VirtualHost>

Jeśli preferujecie inne środowisko to nie ma problemu. W każdym razie mając już przygotowane technologie wystarczy uruchomić w terminalu polecenie.

composer create-project mezzio/mezzio-skeleton customer-api

W tym momencie uruchomi się interaktywna instalacja. Dzięki temu można w łatwy sposób rozpocząć projekt. Nie trzeba przywiązywać się do dokonanych wyborów, łatwo można je zmienić zaraz po instalacji. Biblioteki, na które się zdecydujecie zostaną dociągnięte za pomocą composera i wprowadzą odpowiedni wpis w pliku konfiguracyjnym. Cały proces dostępny na poniższym zdjęciu. Jak widzicie, instalacja to zaledwie 5 kroków, które w znacznej mierze odnoszą się do podstawowych konceptów, o których wspomniałem wcześniej.

instalacja laminas mezzio

W pierwszym kroku należy wybrać typ instalacji. Ja zawsze decyduję się na opcje numer 3, czyli podział modularny. W skrócie każdy moduł jest osobnym bytem i jest zgodny z PSR-4. Z łatwością można go użyć w innym projekcie, wymienić na inny lub pozbyć się go z aplikacji. Pozostałe dwie opcje to minimalna instalacja bez narzuconej struktury katalogów i plików oraz domyślny wybór, czyli cały kod w jednym katalogu src. Główna różnica między opcjami 2 i 3 jest taka, że w opcji flat tak naprawdę wszystko jest jednym modułem (src/App). Za to w modular można ich tworzyć więcej (src/App/{src|test|templates}, src/Log/{src|test|templates}).

Drugi krok to wybór kontenera DI. Jeśli znacie Symfony to może dobrym wyborem będzie właśnie ten komponent. Ewentualnie każdy inny, który Wam odpowiada. Ja znam jedynie te od Symfony i Laminasa. Naturalnie jest opcja skorzystania z bibliotek niewystępujących na tej liście. Wówczas trzeba samodzielnie zainstalować go za pomocą Composera. Jeden warunek – zgodność z wcześniej wymienionymi standardami. Ja skorzystam z laminas-servicemanager, dlatego że dobrze go znam, ale co ważniejsze bardzo lubię bazować na fabrykach.

Trzecim krokiem jest wybór Routera. Tutaj jest już znacznie mniej opcji. Dobrze znam ten od Laminasa, ale sięgam po znacznie szybszy i łatwy w użyciu, czyli FastRoute. Jak widzicie jest to opcja domyślna, co sugeruje dojrzałość rozwiązania. Twórcy nie wpychają na siłę własnych bibliotek.

Do stworzenia API nie będę potrzebował silnika szablonów. Rezygnuję więc z jego instalacji. Normalnie sięgnąłbym raczej po Twiga. Z Plates nie miałem do czynienia, za to z laminas-view działam na co dzień. Jest w porządku, bo wystarczy znajomość PHP, ale jednak Twig wygrywa.

Obsługa błędów w API wygląda nieco inaczej, dlatego nie instaluję Whoops. Dodam tylko, że jest to rozwiązanie mocno ułatwiające development. W przyjaznej formie prezentuje występujący błąd oraz podgląd kodu, w którym wystąpił. Warto rozważyć jego użycie – oczywiście tylko na środowiskach deweloperskich.

I to wszystko, instalacja zakończona. Teraz przejdę przez najważniejsze pliki i krótko o nich opowiem oraz dokonam kilku zmian względem domyślnej struktury plików i katalogów.

Główne pliki/katalogi oraz ich odpowiedzialność

Po instalacji, wewnątrz projektu można znaleźć sporo plików i folderów. Zacznę od tych drugich:

  • bin – w tym miejscu znajdą się skrypty konsolowe. Na ten moment jest tam wyłącznie clear-config-cache.php, który jak sama nazwa wskazuje, odpowiedzialny jest za wyczyszczenie cache plików konfiguracji. Pliki te nie zmieniają się aż tak często więc dobrze, że są cachowane. Przy wdrożeniu można więc usunąć je ręcznie albo właśnie wywołać owy skrypt wpisując w terminalu: php bin/clear-config-cache.php. W procesie developmentu dla ułatwienia pliki konfiguracyjne nie są cachowane.
  • config – ten folder to wszystkie pliki konfiguracyjne aplikacji. Te zawierające w sobie local.php powinny być wykluczone z repozytorium i często zawierają dane wrażliwe. Te z global.php to ogólna konfiguracja dla całego projektu, która trafia do repozytorium. Niektóre z nich zostaną jeszcze dokładniej opisane poniżej.
  • data – to kontener na pliki wynikające z projektu. Wspomniany powyżej cache, różnego rodzaju raporty, pliki tekstowe i graficzne, logi i tak dalej. Większość z nich najczęściej nie trafia do repozytorium.
  • public – tutaj umiejscowione są pliki z dostępem publicznym. Większość dotyczy front-endu. Oprócz tego .htaccess odpowiedzialny za konfigurację serwera i przede wszystkim Front Controller, czyli plik od którego zaczyna się każdy request: index.php.
  • src – i wreszcie miejsce na faktyczny kod aplikacji, czyli moduły stanowiące projekt.
  • vendor – wszystkie zewnętrzne zależności zarządzane za pomocą composera. Kod umiejscowiony wewnątrz jest tylko do podglądu i nie należy wykonywać w nim żadnych zmian.

Zastosuję kilka zmian w stosunku do pierwotnej wersji. Po pierwsze czyszczę plik composer.json ze zbędnych skryptów, których nie używam oraz definiuję w nim właściwe dla aplikacji własności. Kolejna rzecz to zmiana nazwy domyślnego modułu. App zdecydowanie mi nie odpowiada, w przypadku tej aplikacji zmieniam na Api. Oprócz zmiany nazwy folderu należy podmienić namespace dla istniejących wewnątrz plików oraz ścieżkę autoloadingu. Zostawię tylko jeden przykładowy HomePageHandler i jego fabrykę. Jeszcze czyszczenie komentarzy (mogą być pomocne przy poznawaniu narzędzia) oraz konfiguracja phpunit i phpcs. Z grubsza to tyle zmian.

Teraz zostają do omówienia główne pliki. Jeśli chodzi o Handler (przykładowy już istnieje) oraz Middleware (stworzę w przyszłości) to ich szczegółowe omówienie nastąpi w kolejnych wpisach. Na ten moment warto zahaczyć o index.php, ConfigProvider.php oraz pozostałe pliki konfiguracyjne: config.php, container.php, pipeline.php i routes.php.

Najważniejsze co dzieje się w głównym pliku index.php to uruchomienie aplikacji: $app->run(). Zanim jednak do tego dojdzie najpierw dołączony jest autoloader oraz wszystkie wyżej wymienione pliki konfiguracyjne (niewidoczny config.php dołączany jest w kontenerze).

<?php

declare(strict_types=1);

use Mezzio\Application;
use Mezzio\MiddlewareFactory;
use Psr\Container\ContainerInterface;

if (PHP_SAPI === 'cli-server' && $_SERVER['SCRIPT_FILENAME'] !== __FILE__) {
    return false;
}

chdir(dirname(__DIR__));
require 'vendor/autoload.php';

(function () {
    /** @var ContainerInterface $container */
    $container = require 'config/container.php';

    /** @var Application $app */
    $app = $container->get(Application::class);
    $factory = $container->get(MiddlewareFactory::class);

    (require 'config/pipeline.php')($app, $factory, $container);
    (require 'config/routes.php')($app, $factory, $container);

    $app->run();
})();

ConfigProvider.php to klasa odpowiedzialna za konfigurację zależności. Oczywiście można tam dorzucić również inne szczegóły specyficzne dla tego modułu. Każdy moduł ma swoją klasę, której główne założenia mówią, że nie przyjmuje żadnych argumentów w kontruktorze i można się do niej odwołać poprzez __invoke(). Metoda ta zwraca całą konfigurację w postaci tablicy.

<?php

declare(strict_types=1);

namespace Api;

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

    public function getDependencies(): array
    {
        return [
            'factories'  => [
                Handler\HomePageHandler::class => Factory\HomePageHandlerFactory::class
            ]
        ];
    }
}

Żeby włączyć moduł w aplikacji należy właśnie powyższą klasę zarejestrować poprzez jej dodanie w pliku config.php. Klasa ConfigAgregator.php odpowiedzialna jest za zebranie konfiguracji wszystkich modułów i ich odpowiednie połączenie. Odpowiednie pliki są łączone w takiej samej kolejności jak ich przekazywanie. Oczywiście każdy kolejny ma wyższy priorytet, więc mógłby nadpisać wcześniejsze wystąpienie tego samego klucza.

<?php

declare(strict_types=1);

use Laminas\ConfigAggregator\ArrayProvider;
use Laminas\ConfigAggregator\ConfigAggregator;
use Laminas\ConfigAggregator\PhpFileProvider;

$cacheConfig = [
    'config_cache_path' => 'data/cache/config-cache.php',
];

$aggregator = new ConfigAggregator([
    Mezzio\Router\FastRouteRouter\ConfigProvider::class,
    Laminas\HttpHandlerRunner\ConfigProvider::class,
    new ArrayProvider($cacheConfig),
    Mezzio\Helper\ConfigProvider::class,
    Mezzio\ConfigProvider::class,
    Mezzio\Router\ConfigProvider::class,
    Laminas\Diactoros\ConfigProvider::class,
    Api\ConfigProvider::class,
    new PhpFileProvider(realpath(__DIR__) . '/autoload/{{,*.}global,{,*.}local}.php'),
    new PhpFileProvider(realpath(__DIR__) . '/development.config.php'),
], $cacheConfig['config_cache_path']);

return $aggregator->getMergedConfig();

W konfiguracji konternera prawdpodobnie nie będzie potrzeby grzebać. Ja korzystam z service-managera, który jest tworzony w pliku container.php.

<?php

declare(strict_types=1);

use Laminas\ServiceManager\ServiceManager;

$config = require __DIR__ . '/config.php';

$dependencies = $config['dependencies'];
$dependencies['services']['config'] = $config;

return new ServiceManager($dependencies);

Z dwoma pozostałymi plikami będę pracował trochę więcej. Na razie wrzucam je bez opisu. Zostaną dokładniej wytłumaczone w kolejnych artykułach. Dodam tylko, że są one odpowiedzialne za konfiguracje middleware i routingu.

<?php

declare(strict_types=1);

use Laminas\Stratigility\Middleware\ErrorHandler;
use Mezzio\Application;
use Mezzio\Handler\NotFoundHandler;
use Mezzio\Helper\ServerUrlMiddleware;
use Mezzio\Helper\UrlHelperMiddleware;
use Mezzio\MiddlewareFactory;
use Mezzio\Router\Middleware\DispatchMiddleware;
use Mezzio\Router\Middleware\ImplicitHeadMiddleware;
use Mezzio\Router\Middleware\ImplicitOptionsMiddleware;
use Mezzio\Router\Middleware\MethodNotAllowedMiddleware;
use Mezzio\Router\Middleware\RouteMiddleware;
use Psr\Container\ContainerInterface;

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

    $app->pipe(ServerUrlMiddleware::class);
    $app->pipe(RouteMiddleware::class);

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

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

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

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

declare(strict_types=1);

use Api\Handler\HomePageHandler;
use Mezzio\Application;
use Mezzio\MiddlewareFactory;
use Psr\Container\ContainerInterface;

return static function (
    Application $app,
    MiddlewareFactory $factory,
    ContainerInterface $container
): void {
    $app->get('/', HomePageHandler::class, 'home');
};

Podsumowanie instalacji Laminas Mezzio

Myślę, że z taką wiedzą można już zaczynać budować aplikację w oparciu o Laminas Mezzio. Resztę istotnych według mnie kwestii będę oczywiście tłumaczył na bieżąco. W kolejnym wpisie zajmę się implementacją osobnego modułu odpowiedzialnego za uwierzytelnianie.

Kod dostępny na w repozytorium jako gałąź step01. Zachęcam do sprawdzenia procesu instalacji w oficjalnej dokumentacji Mezzio.

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.