Jak na API (v PHP) bez controllerů

Skoro každá backendová aplikace má nějaké to API. Mnoho backendových aplikací dnes už komunikuje jen přes něj. Server-side rendering je na ústupu a frontendu vládnou aplikace v browseru a v mobilních aplikacích.

Jak tedy napsat dobře (v PHP) API? Tradiční způsob je použít důvěrně známé controllery/presentery a upravit routování. Proč to není nejlepší přístup a jak to zmáknout lépe.

cs 12. 1. 2017
API PHP

Zadání našeho ukázkového API

Mějme následující problém. Chceme dva endpointy pro vytvoření a čtení objednávky:

  • POST /orders,
  • GET /orders/e31f9d69-6bce-4552-a1dc-52282914a7f9.
Zároveň chceme tyto endpointy (a všechny další expointy v aplikaci!) zabezpečit nějakým ověřením klienta.

Controller/presenter approach

Controller/presenter je tu v roli pomyslného manažera, který deleguje práci na své podřízené. Každý takový manažer tedy:
  • Ví, že je potřeba ověřit, zda odesílatel požadavku má práva jej žádat, a ví kdo toto ověří.
  • Pokud dostane informaci, že je požadavek nevalidní, odpoví klientovi s chybou.
  • Ví, že požadavek je třeba validovatkterému podřízenému má dát tento úkol.
  • Pokud dostane informaci, že data, která s požadavkem dostal, jsou špatná, odpoví klientovi s chybou.
  • Ví, že požadavek je třeba zpracovatkdo jej ve finále zpracuje.
  • Výsledek secvakne sešívačkou, dá do desek a předá klientovi.
  • Ví, co všechno se může pokazit a umí na to reagovat.
  • Pamatuje na všechny krizové situace a informuje o nich klienta.

Tak, jdeme to naprgat. :)

Schválně jsem se v následujícím příkladu pokusil zmínit všechny „problémy“, které se v takovýchto implementacích objevují. Ne u všech se nutně vyskytují všechny, avšak všechny plynou ze stejného návrhového problému. A tím je controller – přetížený manažer, který musí komunikovat se všemi svými podřízenými.

Ověření klienta si vytáhneme do společného předka všech controllerů:

abstract class RestController extends Controller 
{
    private $authorizer;

    protected $clientId;

    public function __construct(Authorizer $authorizer) 
    {
        $this->authorizer = $authorizer;
    }

    public function beforeAction()
    {
        parent::beforeAction();

        // Hrozba číslo 1: Tato funkce a celá abstraktní třída bude časem bobtnat.

        $token = $this->getHeaderToken();
        if (!$this->authorizer->isTokenValid($token)) {
            throw new UnauthorizedException('Token is not valid.');
        }

        // Hrozba číslo 2: Třída má mutable stav.
        $this->clientId = $this->authorizer->getClientId($token);
    }

    private function getHeaderToken() 
    {
        return $this->request->getHeader['x-token'];
    }
}

A podědíme si controller pro práci s objednávkou:

class OrderController extends RestController // Hrozba číslo 3: Bude vznikat mnoho vrstev dedičnosti. 
{

    private $orderCreator;
    private $orderRepository;                 
    private $orderCreateValidator;
    private $orderViewConverter;

    public function __construct(
        Authorizer $authorizer, 
        OrderCreator $orderCreator, 
        OrderRepository $orderRepository,
        PostCreateValidator $orderCreateValidator,
        PostViewConverter $orderViewConverter
    ) {
        parent::__construct($authorizer); // Hrozba číslo 4: Constructor-Hell

        $this->orderCreator = $orderCreator;
        $this->orderRepository = $orderRepository;
        $this->orderCreateValidator = $orderCreateValidator;
        $this->orderViewConverter = $orderViewConverter;
    }

    public function postAction() 
    {
        $body = $this->request->getBody();
        $errors = $this->validate->getErrors($body);
        if (count($errors) > 0) {
            throw new BadReqestException($errors);
        }

        // Zodpovědnost A: Zpracovat request pro vytvoření objednávky
        try {
            $this->orderCreator->create($this->clientId, Json::decode($body)); 
            $this->response->setStatus(201);

        } catch (SomeUnexpectedException $e) {
            $resposne->setBody(['error' => 'Some unexpected error message.']);
            $response->setStatus(500);
        }

        $this->response->send();
    }

    public function getAction($id) 
    {
        $order = $this->orderRepository->findById($id);

        // Zodpovědnost B: Zpracovat request pro čtení objednávky
        $viewObject = $this->orderViewConverter->getViewObject($order);

        $this->response->setBody(Json::encode($viewObject));
        $this->response->send();
    }

    // Hrozba číslo 5: Třída controlleru bude mít více zodpovědností a bude bobtnat.
}

Chain-of-responsibility approach

Představte si, že místo toho, aby se o zpracování požadavku staral jeden manažer, by si požadavek předávala celá řada lidí. Každý jeden člověk je zodpovědný za svou část úkolu. A když ho dokončí, předá práci dalšímu v řadě.

Ve chvíli, kdy zná výsledek požadavku, jej vrátí (předá svému předchůdci). To se může stát ve dvou případech:

  • Sám se rozhodne ukončit řetěz (například když selže validace),
  • nebo vrací výsledek od svého následovníka (ten může ještě dodatečně zmodifikovat).

Manažer tu úplně chybí. Jeho zodpovědnosti jsou rozloženy mezi všechny články řetězu.

Jednotlivé články se často pojmenovávají middlewary. Tento název se hodně zpopularizoval v Node.js. Něco se dá dočíst napříkad zde.

Tak, jdeme to naprgat znovu a lépe. ;)

Pro každou routu připravíme stack middlewarů – jednotlivých článků řetězu. V tomto případě uvažujeme FIFO architekturu. První kdo tedy dostane požadavek do ruky, bude v obou případech Authorization middleware.

Super věc je, že takový middleware může být služba se závislostmi zaregistrovaná v DI Containeru.

$stack = new Stack();
$stack->add($container->getByType(CreateOrderAction::class);
$stack->add($container->getByType(CreateOrderValidator::class);
$stack->add($container->getByType(Authorization::class);

$router->map('POST', '/api/2.0/orders', $stack);
$stack = new Stack();
$stack->add($container->getByType(GetOrderAction::class);
$stack->add($container->getByType(Authorization::class); 
$router->map('GET', '/api/2.0/orders/{orderId}', $stack); 

Všechny middlewary implementují společné rozhraní Middleware.

interface Middleware
{
    /**
     * @param RequestInterface $request
     * @param ResponseInterface $response
     * @param callable $next
     * @return ResponseInterface
     */
    public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next);
}

Implementace autorizátoru pak vypadá takto. Služba s jedinou a jasnou zodpovědností. Nikdy nebude potřeba ji dědit. V případě potřeby se rozšiřuje kompozicí (například dekorátorem).

final class Authorization implements Middleware
{
    private $authorizer;

    public function __construct(Authorizer $authorizer) 
    {
        $this->authorizer = $authorizer;
    }

    public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next) 
    {
        $token = $request->getHeader['x-token'];
        if (!$this->authorizer->isTokenValid($token)) {
            // Pokud už víme výsledek, nepředáváme požadavek dál a rovnou vracíme response
            return $response->withCode(401);
        }

        // Pošleme svému následovníkovi a to, co nám vrátí, vrátíme také.
        return $next($request, $response);
    }
}

Validace požadavku je pak zase jedinou zodpovědností jiné třídy.

final class CreateOrderValidator implements Middleware
{
    private $orderCreateValidator;

    public function __construct(PostCreateValidator $orderCreateValidator) 
    {
        $this->orderCreateValidator = $orderCreateValidator;
    }

    public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next) 
    {
        $body = $request->getBody();
        $errors = $this->orderCreateValidator->getErrors($body);
        if (count($errors) > 0) {
            return $response->withJson($errors, 400);
        }

        return $next($request, $response);
    }
}

A nakonec poslední middleware, který se postará o business logiku daného požadavku. Důležité je, že umí zpracovat jen jeden typ požadavku – má jen jednu zodpovědnost.

final class CreateOrderAction implements Middleware
{
    private $orderCreator;

    public function __construct(OrderCreator $orderCreator) 
    {
        $this->orderCreator = $orderCreator;
    }

    public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next) 
    {
        $body = $request->getBody();

        try {
            $this->orderCreator->create($request->getAttribute('clientId'), Json::decode($body));

        } catch (SomeUnexpectedException $e) {
            return $response->withJson(['error' => 'Some unexpected error message'], 500);
        }

        return $response->withCode(201);
    }
}

Pro každý endpoint tedy nadefinujeme (například v konfiguraci v neon-u) stack middlewarů, který v případě, že se matchne routa, spustíme. O vše ostatní se už postarají jednotlivé články řetězu samy. Na konci se vrátí response, kterou jen odešleme uživateli.

Čeho jsme dosáhli

  • SRP: Rozbili jsme jednu třídu Controller na více menších.
  • Immutability: Žádná z tříd, které zpracovávají požadavek, nemění svůj stav. Všechna data jsou zapouzdřena v RequestResponse value-objectech.
  • No-inheritance: Odstranili jsme nešikovnou dědičnost, zabránili contructor-hell.
  • Reusability: Odstraněním dědičnosti mezi middlewary a aplikací SRP jsme usnadnili znovupoužitelnost jednotlivých komponent.

Technické tipy

  1. Používejte PSR-7. Udělejte si vlastní RequestInterfaceResponseInterface odvozené od těch z PSR-7. Framework, který používáte, bude mít skoro jistě vlastní RequestResponse objekty (implementující PSR-7). Odekorujte je vašimi vlastními objekty, které implementují vaše rozhraní. Usnadní to případný přechod na jiný framework a umožní vám to rozšiřovat RequestResponse objekty o vaše tool metody.

  2. Nadefinujte si vlastní Middleware interface. Opět z toho důvodu, abyste nebyli vendor-locked na daný framework. Případnou nekompatibilitu tohoto interfacu s tím, jak bude middleware vypadat ve frameworku, řešte opět dekorováním.

  3. Error handler vrstva: Handlování errorů se dá delegovat ještě na další vrstvu. Pak v middlewarech stačí vyhodit správnou výjimku a o přebalení na chybový response se postarají error handlery. Například se to dá udělat takto.

Pár přikladů na middlewary

Závěrem

Celý článek jsem se snažil psát obecně. Nicméně inspirací byl framework Slim, který rozhodně doporučuji. Příkladem middlewaru pro tento framework muže být implementace OAuth.

Každopádně tento middlewarový přístup je mezi frameworky na API velmi populární a pravděpodobně skoro každý bude mít pro psaní middlewarů podporu.

A jak řešíte API vy? Tweetněte mi o tom. ;)