Komunikacja z MongoDB (#3) – API w PHP z Laminas Mezzio

tytuł wpisu z grafiką kodu

Dane klientów będą przetrzymywane w nierelacyjnej bazie MongoDB. Czy jest to dobry wybór? Wszystko zależy od potrzeb. Nie ukrywam – wydaję mi się, że dla tej aplikacji sensowniejsza okazałaby się relacyjna baza jak MySQL czy PostgreSQL. Tak, czy inaczej to przecież aplikacja do nauki. Gdzie eksperymentować, jak nie w takich projektach. Stąd też mój wybór, chociaż i tak baza MongoDB jest bardzo wydajna, więc dla wielu obłożonych API okaże się nawet lepszym wyborem, niż relacyjna. W tej serii i tak nie będę jakoś znacząco rozbudowywał modelu klienta – tutaj chodzi o naukę Mezzio i kilku zewnętrznych komponentów, a nie ogarnianie logiki biznesowej.

Abstrakcja dla komunikacji z bazą danych

Zanim zainstaluję MongoDB i bibliotekę potrzebną do jej obsługi, stworzę odpowiednią abstrakcję. Odpowiedni moment na dodanie kolejnego modułu: Store. W poprzednim wpisie pokazałem dwa sposoby jak krok po kroku stworzyć nowy moduł w Laminas Mezzio.

Mając już pusty szablon modułu, czas zacząć implementację. Cały moduł nie będzie duży, bo podobnie jak ten odpowiedzialny za uwierzytelnianie, wykorzystuje zewnętrzne zależności. Na początek interfejs, który zdefiniuje podstawowe operacje do komunikacji z bazą danych. Normalnie dodaję metody w momencie, kiedy jest taka potrzeba. Tym razem jednak są to podstawowe metody, które na pewno będą potrzebne w dalszej części serii. To po prostu CRUD. Poniżej implementacja Store\Service\StoreInterface.

<?php

declare(strict_types=1);

namespace Store\Service;

use Store\Exception\InvalidDatabaseQueryException;

interface StoreInterface
{
    /**
     * @param string $storeName
     * @param array $filters
     * @param array $options
     * @return integer
     * @throws InvalidDatabaseQueryException
     */
    public function count(string $storeName, array $filters = [], array $options = []): int;

    /**
     * @param string $storeName
     * @param array $filters
     * @param array $options
     * @return array
     * @throws InvalidDatabaseQueryException
     */
    public function findAll(string $storeName, array $filters = [], array $options = []): array;

    /**
     * @param string $storeName
     * @param array $filters
     * @param array $options
     * @return array|null
     * @throws InvalidDatabaseQueryException
     */
    public function findOne(string $storeName, array $filters = [], array $options = []): ?array;

    /**
     * @param string $storeName
     * @param array $element
     * @param array $options
     * @return void
     * @throws InvalidDatabaseQueryException
     */
    public function insertOne(string $storeName, array $element, array $options = []): void;

    /**
     * @param string $storeName
     * @param string $elementId
     * @param array $changes
     * @param array $options
     * @return void
     * @throws InvalidDatabaseQueryException
     */
    public function updateOne(string $storeName, string $elementId, array $changes, array $options = []): void;

    /**
     * @param string $storeName
     * @param string $elementId
     * @param array $options
     * @return void
     * @throws InvalidDatabaseQueryException
     */
    public function deleteOne(string $storeName, string $elementId, array $options = []): void;
}

Taka abstrakcja na razie okaże się wystarczająca. Założyłem też, że w niektórych metodach można przekazać kilka dodatkowych informacji jak filtry, czy opcje. W zależności od tego, jaki magazyn na dane zostanie użyty, mogą okazać się potrzebne. Jak widzicie na tym etapie nie jest istotne, czego użyjecie do utrwalania stanu klienta. Poza tym, dzięki takiej abstrakcji, dużo prościej będzie dokonać zmiany sposobu przetrzymywania danych.

Od kiedy istnieje opcja jawnego określania typów w PHP, nie przepadam za komentarzami PHPDoc. Nie ma jednak innej opcji, żeby przemycić informację, że metoda może rzucić wyjątek. Skoro tak, to dodaję już wszystkie wymagane adnotacje. Swoją drogą, klasę wyjątku też należy utworzyć i wygląda ona jak poniżej.

<?php

declare(strict_types=1);

namespace Store\Exception;

class InvalidDatabaseQueryException extends \Exception
{

}

Instalacja MongoDB i klienta PHP

Teraz czas na wybór sposobu utrwalania stanu klientów. Mając odpowiednią abstrakcję, ten wybór można odłożyć w czasie. Ja zakładam, że będzie to niereleacyjna baza danych MongoDB. Nastał odpowiedni moment na instalację wymaganych zależności i konfigurację środowiska. Do plików konfiguracyjnych dockera trzeba dołożyć zaznaczone poniżej linie kodu. Ponowne uruchomienie środowiska pozwoli na instalację Mongo.

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 pecl install mongodb \
  && docker-php-ext-enable mongodb

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
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
  mongodb:
    image: mongo:4.4
    container_name: 'customer_api_mongo'
    environment:
      - MONGO_DATA_DIR=/data/db
      - MONGO_DATA_LOG=/var/log/mongo
      - MONGO_INITDB_DATABASE=customer_api
      - MONGO_NON_ROOT_USERNAME=customerapi
      - MONGO_NON_ROOT_PASSWORD=customerapi
    volumes:
      - ./.docker/mongo:/data/db
    networks:
      customer-api:
        aliases:
          - mongodb
        ipv4_address: 10.25.0.20
networks:
  customer-api:
    name: customer-api
    driver: bridge
    ipam:
      config:
        - subnet: 10.25.0.0/16

Mając tak skonfigurowane lokalne środowisko można już pracować z bazą NoSQL. Żeby z poziomu kodu móc się z nią komunikować, zainstaluję zewnętrzną bibliotekę.

composer require mongodb/mongodb

Od razu stworzę plik konfiguracyjny config/autoload/mongo.local.php, który za moment zostanie wykorzystany.

<?php

declare(strict_types=1);

return [
    'mongo' => [
        'server' => '10.25.0.20',
        'port' => '27017',
        'dbname' => 'customer_api',
        'options' => []
    ]
];

Spięcie interfejsu z faktyczną implementacją

Zostało tylko dostarczyć realną implementację poprzez interfejs. Oczywiście, zewnętrzny komponent go nie spełnia. W takim wypadku, pomocny okaże się wzorzec Adapter. Powstają więc klasy Store\Service\DatabaseAdapter oraz fabryka Store\Factory\DatabaseAdapterFactory. Odpowiednia dla nich konfiguracja prezentuje się następująco:

<?php

declare(strict_types=1);

namespace Store;

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

    public function getDependencies(): array
    {
        return [
            'aliases' => [
                Service\StoreInterface::class => Service\DatabaseAdapter::class
            ],
            'factories' => [
                Service\DatabaseAdapter::class => Factory\DatabaseAdapterFactory::class
            ]
        ];
    }
}

Sam adapter pozwala na implementację wszystkich metod interfejsu przy użyciu klienta PHP Mongo. Oczywiście, żeby móc na nim działać, musi mieć do niego dostęp. Kompozycja rozwiąże problem i przez konstruktor dostarczony zostanie obiekt reprezentujący bazę danych. Pozostała część klasy to już konkretne wywołania metod zewnętrznej biblioteki. Więcej o samym komponencie można poczytać w dokumentacji.

Warto jeszcze zwrócić uwagę na zamianę parametru _id na id i odwrotnie. MongoDB jako identyfikator wykorzystuje właśnie pole _id natomiast w aplikacji będę opierał się o id. Aplikacja powinna być niezależna od szczegółów Mongo, dlatego trzeba to obsłużyć na etapie adaptera. Co więcej, prawdopodobnie skorzystam z własnego identyfikatora, a nie tego generowanego przez bazę.

<?php

declare(strict_types=1);

namespace Store\Service;

use MongoDB\Database;
use Store\Exception\InvalidDatabaseQueryException;
use Webmozart\Assert\Assert;

class DatabaseAdapter implements StoreInterface
{
    private Database $database;

    public function __construct(Database $database)
    {
        $this->database = $database;
    }

    public function count(string $storeName, array $filters = [], array $options = []): int
    {
        try {
            $filters = $this->adaptFilters($filters);

            return $this->database
                ->selectCollection($storeName)
                ->countDocuments($filters, $options);
        } catch (\Throwable $throwable) {
            throw new InvalidDatabaseQueryException($throwable->getMessage());
        }
    }

    public function findAll(string $storeName, array $filters = [], array $options = []): array
    {
        try {
            $filters = $this->adaptFilters($filters);

            $documents = $this->database
                ->selectCollection($storeName)
                ->find($filters, $options)
                ->toArray();

            $arrayDocuments = [];
            /** @var \Traversable $document */
            foreach ($documents as $document) {
                Assert::isInstanceOf($document, \Traversable::class);

                $documentData = iterator_to_array($document);
                $documentData['id'] = $documentData['_id'];
                unset($documentData['_id']);

                $arrayDocuments[] = $documentData;
            }

            return $arrayDocuments;
        } catch (\Throwable $throwable) {
            throw new InvalidDatabaseQueryException($throwable->getMessage());
        }
    }

    public function findOne(string $storeName, array $filters = [], array $options = []): ?array
    {
        try {
            $filters = $this->adaptFilters($filters);

            /** @var \Traversable|null $document */
            $document = $this->database
                ->selectCollection($storeName)
                ->findOne($filters, $options);

            if ($document === null) {
                return null;
            }

            Assert::isInstanceOf($document, \Traversable::class);

            $documentData = iterator_to_array($document);
            $documentData['id'] = $documentData['_id'];
            unset($documentData['_id']);

            return $documentData;
        } catch (\Throwable $throwable) {
            throw new InvalidDatabaseQueryException($throwable->getMessage());
        }
    }

    public function insertOne(string $storeName, array $element, array $options = []): void
    {
        try {
            $element['_id'] = $element['_id'] ?? $element['id'] ?? null;
            unset($element['id']);

            $this->database
                ->selectCollection($storeName)
                ->insertOne($element, $options);
        } catch (\Throwable $throwable) {
            throw new InvalidDatabaseQueryException($throwable->getMessage());
        }
    }

    public function updateOne(string $storeName, string $elementId, array $changes, array $options = []): void
    {
        try {
            $this->database
                ->selectCollection($storeName)
                ->updateOne(
                    ['_id' => $elementId],
                    ['$set' => $changes],
                    $options
                );
        } catch (\Throwable $throwable) {
            throw new InvalidDatabaseQueryException($throwable->getMessage());
        }
    }

    public function deleteOne(string $storeName, string $elementId, array $options = []): void
    {
        try {
            $this->database
                ->selectCollection($storeName)
                ->deleteOne(['_id' => $elementId], $options);
        } catch (\Throwable $throwable) {
            throw new InvalidDatabaseQueryException($throwable->getMessage());
        }
    }

    public function dropCollection(string $storeName): void
    {
        try {
            $this->database->dropCollection($storeName);
        } catch (\Throwable $throwable) {
            throw new InvalidDatabaseQueryException($throwable->getMessage());
        }
    }

    private function adaptFilters(array $filters): array
    {
        if (!empty($filters['id'])) {
            $filters['_id'] = $filters['_id'] ?? $filters['id'] ?? null;
            unset($filters['id']);
        }

        return $filters;
    }
}

Sama fabryka to utworzenie klasy i dostarczenie jej instancji bazy danych. W tym momencie przyda się konfiguracja przygotowana wcześniej.

<?php

declare(strict_types=1);

namespace Store\Factory;

use Interop\Container\ContainerInterface;
use MongoDB\Client;
use Store\Service\DatabaseAdapter;
use Webmozart\Assert\Assert;

class DatabaseAdapterFactory
{
    private const MONGO_DEFAULT_HOST = '127.0.0.1';
    private const MONGO_DEFAULT_PORT = '27017';

    public function __invoke(ContainerInterface $container): DatabaseAdapter
    {
        $mongoConfig = $container->get('config')['mongo'] ?? [];
        Assert::notEmpty($mongoConfig, 'MongoDB config not found.');

        $mongoClient = new Client(sprintf(
            'mongodb://%s:%s',
            $mongoConfig['server'] ?? self::MONGO_DEFAULT_HOST,
            $mongoConfig['port'] ?? self::MONGO_DEFAULT_PORT
        ));

        return new DatabaseAdapter(
            $mongoClient->selectDatabase($mongoConfig['dbname'])
        );
    }
}

I to już wszystko. Do obsługi żądań z API, tyle kodu powinno okazać się wystarczające. Jako, że z modułu skorzystam dopiero w kolejnym wpisie, żeby upewnić się, że działa – czas na kilka testów.

Testy modułu z bazą Mongo DB

Zdaję sobie sprawę, że napisane testy nie będą idealne. Zazwyczaj ciężko jest realizować testy z użyciem bazy danych. Jest to jednak konieczne, żeby upewnić się, że adapter odpowiednio realizuje swoje zadania. Do ich przeprowadzenia potrzebne będzie odseparowane miejsce. Należy stworzyć nową bazę danych. Ja skorzystam z bazy test, która już domyślnie jest tworzona podczas instalacji Mongo.

W folderze zawierającym testy dla modułu Store tworzę folder TestHelper z plikami pomocniczymi, które będą wykorzystane tylko na potrzeby testów. Konfigurację samej bazy można wynieść do pliku, w którym znajduje się też realna konfiguracja. Ja jednak chciałem, aby moduł był reużywalny i z powodzeniem można było go przenieść wraz z testami do innej aplikacji. Konfiguracja trafia do pliku mongo.test.local.php.

<?php

declare(strict_types=1);

return [
    'testMongo' => [
        'server' => '10.25.0.20',
        'port' => '27017',
        'dbname' => 'test',
        'options' => []
    ]
];

Dodatkowo tworzenie Store\Service\DatabaseAdapter na potrzeby testów realizować będzie osobna fabryka StoreTest\TestHelper\DatabaseAdapterFakeFactory, która dostarcza odpowiednią konfigurację.

<?php

declare(strict_types=1);

namespace StoreTest\TestHelper;

use MongoDB\Client;
use Store\Service\DatabaseAdapter;
use Webmozart\Assert\Assert;

class DatabaseAdapterFakeFactory
{
    private const MONGO_TEST_CONFIG_FILE_NAME = 'mongo.test.local.php';

    public function create(): DatabaseAdapter
    {
        $testMongoConfig = $this->getTestMongoConfig();
        $mongoClient = new Client(sprintf('mongodb://%s:%s', $testMongoConfig['server'], $testMongoConfig['port']));

        return new DatabaseAdapter(
            $mongoClient->selectDatabase($testMongoConfig['dbname'])
        );
    }

    private function getTestMongoConfig(): array
    {
        $testMongoConfig = require self::MONGO_TEST_CONFIG_FILE_NAME;
        Assert::notEmpty($testMongoConfig['testMongo'], 'Test MongoDB config not found.');

        return $testMongoConfig['testMongo'];
    }
}

Z takim setupem można przejść do samego testowania. Realizacja testów w kontrolowanym środowisku wymaga zapewnienia odpowiednich ustawień na wejściu oraz po wykonaniu pojedynczego testu, bądź całej grupy testów. W tej aplikacji wiąże się to ze stworzeniem Adaptera do komunikacji z testową bazą NoSQL i posprzątaniem kolekcji po uruchomieniu testów. W przypadku, gdyby to nie zaszło, testy przeszłyby za pierwszym uruchomieniem, a wraz z kolejnymi mogłyby kończyć się nieoczekiwanymi rezultatami. Za ustawienie startowe i końcowe odpowiadają metody setUp() i tearDown().

<?php

declare(strict_types=1);

namespace StoreTest\Service;

use PHPUnit\Framework\TestCase;
use Store\Service\DatabaseAdapter;
use StoreTest\TestHelper\DatabaseAdapterFakeFactory;

class DatabaseAdapterTest extends TestCase
{
    private const TEST_COLLECTION_NAME = 'test';

    private DatabaseAdapter $databaseAdapter;

    public function setUp(): void
    {
        $this->databaseAdapter = (new DatabaseAdapterFakeFactory())->create();
    }

    public function testCanCountElements(): void
    {
        $testElement = ['test' => 'test', 'id' => '75ae6d63-55fa-45df-b346-3be8eb921633'];

        $this->assertSame(0, $this->databaseAdapter->count(self::TEST_COLLECTION_NAME));

        $this->databaseAdapter->insertOne(self::TEST_COLLECTION_NAME, $testElement);

        $this->assertSame(1, $this->databaseAdapter->count(self::TEST_COLLECTION_NAME));
    }

    public function testCanFindAllElements(): void
    {
        $testElement1 = ['test' => 'test', 'id' => '75ae6d63-55fa-45df-b346-3be8eb921633'];
        $this->databaseAdapter->insertOne(self::TEST_COLLECTION_NAME, $testElement1);
        $testElement2 = ['test' => 'test', 'id' => 'eb64512a-80fa-4487-a0a7-b47d7b8927cb'];
        $this->databaseAdapter->insertOne(self::TEST_COLLECTION_NAME, $testElement2);

        $elements = $this->databaseAdapter->findAll(self::TEST_COLLECTION_NAME);

        $this->assertCount(2, $elements);
        $this->assertContains($testElement1, $elements);
        $this->assertContains($testElement2, $elements);
    }

    public function testCanFindOneElement(): void
    {
        $testElement1Id = '75ae6d63-55fa-45df-b346-3be8eb921633';
        $testElement1 = ['test' => 'test', 'id' => $testElement1Id];
        $this->databaseAdapter->insertOne(self::TEST_COLLECTION_NAME, $testElement1);
        $testElement2 = ['test' => 'test', 'id' => 'eb64512a-80fa-4487-a0a7-b47d7b8927cb'];
        $this->databaseAdapter->insertOne(self::TEST_COLLECTION_NAME, $testElement2);
        $foundElement = $this->databaseAdapter->findOne(self::TEST_COLLECTION_NAME, ['id' => $testElement1Id]);

        $this->assertSame($testElement1, $foundElement);
    }

    public function testCanInsertOneElement(): void
    {
        $testElementId = '75ae6d63-55fa-45df-b346-3be8eb921633';
        $testElement = [
            'test' => 'test',
            'id' => $testElementId
        ];

        $this->databaseAdapter->insertOne(self::TEST_COLLECTION_NAME, $testElement);
        $foundElement = $this->databaseAdapter->findOne(self::TEST_COLLECTION_NAME, ['id' => $testElementId]);

        $this->assertSame($testElement, $foundElement);
    }

    public function testCanUpdateOneElement(): void
    {
        $testElementId = '75ae6d63-55fa-45df-b346-3be8eb921633';
        $testElement = [
            'test' => 'test',
            'id' => $testElementId
        ];
        $changedTestValue = 'changedTest';

        $this->databaseAdapter->insertOne(self::TEST_COLLECTION_NAME, $testElement);
        $this->databaseAdapter->updateOne(self::TEST_COLLECTION_NAME, $testElementId, ['test' => $changedTestValue]);
        $foundElement = $this->databaseAdapter->findOne(self::TEST_COLLECTION_NAME, ['id' => $testElementId]);

        $this->assertSame($changedTestValue, $foundElement['test']);
    }

    public function testCanDeleteOneElement(): void
    {
        $testElementId = '75ae6d63-55fa-45df-b346-3be8eb921633';
        $testElement = [
            'test' => 'test',
            'id' => $testElementId
        ];

        $this->databaseAdapter->insertOne(self::TEST_COLLECTION_NAME, $testElement);
        $this->databaseAdapter->deleteOne(self::TEST_COLLECTION_NAME, $testElementId);
        $foundElement = $this->databaseAdapter->findOne(self::TEST_COLLECTION_NAME, ['id' => $testElementId]);

        $this->assertNull($foundElement);
    }

    public function tearDown(): void
    {
        $this->databaseAdapter->dropCollection(self::TEST_COLLECTION_NAME);
    }
}

Po uruchomieniu testów widać, że można wykonać wszystkie metody dostarczone przez interfejs, a ich rezultaty są zgodne z oczekiwanymi. Tak przygotowany moduł nadaje się więc do użytku i zostanie wykorzystany w trakcie obsługi konkretnych endpointów API.

Podsumowanie warstwy komunikacji z MongoDB

Aby korzystać z nierelacyjnych baz danych trzeba w pełni zrozumieć jak działają i do czego służą. Ten wpis na pewno nie jest wystarczający do tego, żeby rozwiązywać produkcyjne problemy z użyciem MongoDB. Pozwala jednak zobaczyć, jak można taką komunikację zapewnić.

Ważniejsza w tym wszystkim okaże się jednak umiejętność tworzenia uniwersalnych modułów w Laminas Mezzio. Tak stworzony komponent może z powodzeniem zostać wykorzystany w innej aplikacji, a co więcej – prawdopodobnie wymiana sposobu utrwalania danych nie będzie aż tak bolesna, jak mogłoby się wydawać.

Kod dostępny w repozytorium jako gałąź step03.

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.