Пример создания приложения

Пример создания приложения на Micro Framework.

В данной статье я расскажу вам как создать простое приложение, которое с помощью Packagist API выполняет поиск библиотек и выводит результаты в браузер и консоль.

Пример составлен на базе предустановленных компонентах из micro/micro.

Установка

Установите локальное рабочее окружение с помощью composer, либо воспользуйтесь заранее подготовленной конфигурацией Docker.

После установки, нас особенно будут интересовать следующие каталоги и файлы

src - исходный код приложения

public - точка входа в приложение по протолоку HTTP

assets - исходный код front (css/js/statics)

bin/console - утилита для работы с командной строкой

etc/ - место хранения служебных файлов. Например, etc/plugins.php содердит список подключенных плагинов по умолчанию.

.env (env.{APP_ENV}, env.{APP_ENV}.php) - - конфигурация нашего приложения.

После установки приложения по умолчанию, давайте убедимся, что мы все сделали верно.

Если мы устанавливали приложение с помощью docker окружения

Давайте просто запустил приложение

$ make up

после чего перейдем https://localhost

Если установка была произведена с помощью composer

$ cd public
$ php -S localhost:8000

И просто перейдите по ссылке http://localhost:8000

Если в браузере мы увидели приветственное сообщение - подздравляю, вы все сделали правильно!

Первый плагин

Для начала, давайте удалим App/Acme плагин т.к. он является демонстрационным и никакой ценности из себя не представляет.

$ rm -rf src/Acme

И создадим свой собственный src/Demo/DemoAppPlugin.php

class DemoAppPlugin
{
}

После чего включим его в список плагинов для инициализации etc/plugins.php.

<?php

return [
    // Separate system plugins from appliction plugins.
    Micro\Plugin\Configuration\Helper\ConfigurationHelperPlugin::class,
    Micro\Plugin\Console\ConsolePlugin::class,
    Micro\Plugin\Http\HttpPackPlugin::class,
    Micro\Plugin\Logger\Monolog\MonologPlugin::class,
    Micro\Plugin\Doctrine\DoctrinePlugin::class,
    Micro\Plugin\Twig\TwigPlugin::class,
    OleksiiBulba\WebpackEncorePlugin\WebpackEncorePlugin::class,
    
     //App plugin(s)
    App\Demo\DemoAppPlugin::class,
    // App\Acme\AcmePlugin::class <-- remove redundant plugin
];

Бизнес-слой

Давайте наделим наше приожение логикой. Для начала, определим интерфейс взаимодейстаия с API Packagist.org для поиска. Создадим мы его в src/Demo/Business/Packagist/PackagistSearchInterface.php

<?php

declare(strict_types=1);

namespace App\Demo\Business\Packagist;

interface PackagistSearchInterface
{
    /**
     * @return array<string, mixed>
     */
    public function search(string $query): array;
}

После того как интерфейс готов, имплементируем его логику.

<?php

declare(strict_types=1);

namespace App\Demo\Business\Packagist;

use App\Demo\DemoAppPluginConfiguration;

readonly class PackagistSearch implements PackagistSearchInterface
{
    public function __construct(
    ) {
    }

    public function search(string $query): array
    {
        $query = trim($query);
        if (!$query) {
            return [];
        }

        $url = sprintf(
            'https://packagist.org/search.json?q=%s',
            $this->pluginConfiguration->getPackagistUrl(),
            urlencode($query)
        );

        $results = file_get_contents($url);
        if (false === $results) {
            throw new \RuntimeException(sprintf('Fetching query from `%s` can not be processed right now.', $url));
        }

        return json_decode($results, true);
    }
}

Казалось бы, все хорошо, однако Packagist может иметь зеркала и нам необходимо иметь возможность конфигурировать его HTTP эндпоинт адрес. Давайте для этого создадим конфигурационный класс плагина. Конфигурация приложения - это key/value хранилище, а значит мы можем определить ключ необходимого нам параметра для получения его значения.

Итак, создаем новый класс в src/Demo/DemoAppPluginConfiguration.php

<?php

declare(strict_types=1);

namespace App\Demo;

use Micro\Framework\Kernel\Configuration\PluginConfiguration;

class DemoAppPluginConfiguration extends PluginConfiguration
{
    public const CFG_PACKAGIST_URL = 'PACKAGIST_URL';

    public function getPackagistUrl(): string
    {
        return rtrim($this->configuration->get(self::CFG_PACKAGIST_URL, 'https://packagist.org/'), '/');
    }
}

Сделующая задача - сделать доступным эту конфигурацию в нашем плагине. Для этого мы имплементируем в плагин Micro\Framework\Kernel\Plugin\ConfigurableInterface и чтобы каждый раз не писать рутинные методы setConfiguration, configuration(), просто добавим трейт Micro\Framework\Kernel\Plugin\PluginConfigurationTrait.

<?php

declare(strict_types=1);

namespace App\Demo;
use Micro\Framework\Kernel\Plugin\ConfigurableInterface;
use Micro\Framework\Kernel\Plugin\PluginConfigurationTrait;

/**
 * @method DemoAppPluginConfiguration configuration()
 */
class DemoAppPlugin implements ConfigurableInterface
{
    use PluginConfigurationTrait;
}
Обратите внимание, чтобы конфигурационный класс плагина нашелся корректно, он должен иметь имя {PluginClassName}Configuration
Аннотация @method имеет исключительно вспомогательную функцию для IDE.

Когда конфигурация определена, мы можем предоставить ее в сервис поиска PackagistSearch

<?php

declare(strict_types=1);

namespace App\Demo\Business\Packagist;

use App\Demo\DemoAppPluginConfiguration;

readonly class PackagistSearch implements PackagistSearchInterface
{
    public function __construct(
        private DemoAppPluginConfiguration $pluginConfiguration
    ) {
    }

    public function search(string $query): array
    {
        $query = trim($query);
        if (!$query) {
            return [];
        }

        $url = sprintf(
            '%s/search.json?q=%s',
            $this->pluginConfiguration->getPackagistUrl(),
            urlencode($query)
        );

        $results = file_get_contents($url);
        if (false === $results) {
            throw new \RuntimeException(sprintf('Fetching query from `%s` can not be processed right now.', $url));
        }

        return json_decode($results, true);
    }
}

Фасады - Контракт работы с бизнес-слоем.

Мы не хотим предоставлять все классы в сервис-контейнер. Важно иметь контроль над тем, что мы регистрируем в качестве сервиса, однако важно, чтобы сторонние сервисы, которые используют наш фасад могли работать с заранее определенным контрактом. Для этого к нам приходит на помощь такой паттерн проектирования как Фасад. Фасады нужны для того, чтобы агрегировать и определить контракт работы с бизнес-логикой плагина и последующей регистрации его в DI.

Давайте создадим наш первый фасад - src/Demo/Facade/DemoAppFacadeInterface.php

<?php

declare(strict_types=1);

namespace App\Demo\Facade;

use App\Demo\Business\Packagist\PackagistSearchInterface;

interface DemoAppFacadeInterface extends PackagistSearchInterface
{
}

Обратите внимание, данный фасад имплементирует интерфейс App\Demo\Business\Packagist\PackagistSearchInterface, однако он может имплементировать множесто внутренних интерфейсов. Это позволяет предоставить единую точку входа к функционалу плагина, при этом иметь четкое понимание того, какие задачи он сопосбен решать. И теперь давайте сделаем реализацию данного контракта src/Demo/Facade/DemoAppFacade.php

<?php

declare(strict_types=1);

namespace App\Demo\Facade;

use App\Demo\Business\Packagist\PackagistSearchInterface;

readonly class DemoAppFacade implements DemoAppFacadeInterface
{
    public function __construct(private PackagistSearchInterface $packagistSearch)
    {
    }

    public function search(string $query): array
    {
        return $this->packagistSearch->search($query);
    }
}

Регистрация сервиса в DI

Разумеется, мало написать сервис. Нужно предоставить его в сервисный контейнер. Давайте немного расширим наш плагин.

<?php

declare(strict_types=1);

namespace App\Demo;

use App\Demo\Business\Packagist\PackagistSearch;
use App\Demo\Business\Packagist\PackagistSearchInterface;
use App\Demo\Facade\DemoAppFacade;
use App\Demo\Facade\DemoAppFacadeInterface;
use Micro\Component\DependencyInjection\Container;
use Micro\Framework\Kernel\Plugin\ConfigurableInterface;
use Micro\Framework\Kernel\Plugin\DependencyProviderInterface;
use Micro\Framework\Kernel\Plugin\PluginConfigurationTrait;

/**
 * @method DemoAppPluginConfiguration configuration()
 */
class DemoAppPlugin implements DependencyProviderInterface, ConfigurableInterface
{
    use PluginConfigurationTrait;

    public function provideDependencies(Container $container): void
    {
        $container->register(DemoAppFacadeInterface::class, fn () => $this->createFacade());
    }
    
    protected function createFacade(): DemoAppFacadeInterface
    {
        return new DemoAppFacade($this->createPackagistSearchService());
    }

    protected function createPackagistSearchService(): PackagistSearchInterface
    {
        return new PackagistSearch($this->configuration());
    }
}

Что у нас получилось - мы создали и зарегистрировали в контейнере сервис с контрактом App\Demo\Facade\DemoAppFacadeInterface. Для того, чтобы ядро MF понимало, что плагин предоставляет в контейнер сервисы, он должен имплементировать интерфейс Micro\Framework\Kernel\Plugin\DependencyProviderInterface.

Давайте попробуем пообщаться с нашим сервисом.

Cli интерфейс.

Здесь все будет предельно просто. Мы просто возьмем и создадим класс взаимодействия I/O (Input/Output) Сli и в зависимости от переданных параметров через консольную строку будем выдавать ответ в нужном формате. Для этого создадим новый класс src/Demo/Communication/Command/PackagistSearchCommand.php

<?php

declare(strict_types=1);

namespace App\Demo\Communication\Command;

use App\Demo\Facade\DemoAppFacadeInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class PackagistSearchCommand extends Command
{
    public function __construct(private readonly DemoAppFacadeInterface $facade)
    {
        parent::__construct('packagist:search');
    }

    public function configure(): void
    {
        $this->addArgument('q', InputArgument::REQUIRED, 'Search query string.', null);
    }

    public function execute(InputInterface $input, OutputInterface $output): int
    {
        $query = $input->getArgument('q');
        $queryResult = $this->facade->search($query);
        $tableHeaders = [
            'name',
            'description',
            'favers',
        ];
        $tableRows = [];
        foreach ($queryResult['results'] as $result) {
            $row = [];
            foreach ($tableHeaders as $key) {
                $row[] = $result[$key];
            }

            $tableRows[] = $row;
        }

        $table = new Table($output);
        $table
            ->setHeaders($tableHeaders)
            ->setRows($tableRows)
        ;
        $table->render();

        return self::SUCCESS;
    }
}

В качестве рендера ответов в CLI мы решаем, что хватит такой информации как:

  • имя пакета
  • его описание
  • количество “звезд” на гитхабе.

Сделано это для того, чтобы выводимая информация без труда поместилась на экран.

Давайте попробуем что у нас получислось. В корне проекта выполним команду:

$ bin/console packagist:search "micro kernel app"

Если мы используем docker окружение, то

$ make micro c="packagist:search 'micro kernel app'"

И получим примено похожие результаты

+-----------------------------+---------------------------------------------+--------+
| name                        | description                                 | favers |
+-----------------------------+---------------------------------------------+--------+
| micro/kernel-app            | Micro Framework: App Kernel component       | 2      |
| symlex/di-microkernel       | Micro-Kernel for PHP Applications           | 6      |
| phonetworks/pho-microkernel | Social-Enabled App Infrastructure           | 23     |
| lastzero/di-microkernel     | Micro-Kernel for PHP Applications           | 6      |
| jmleroux/symfony-micro-rest | Symfony micro kernel application for rest   | 1      |
| lou117/wake                 | Web-Application KErnel, PHP micro-framework | 0      |
+-----------------------------+---------------------------------------------+--------+

Это может свидетельствует о том, что мы все делали правильно:

  • В PackagistSearchCommand была внедрена зависимость нашего бизнес-слоя
  • Сервис DemoAppFacadeInterface независимо от I/O способен выдавать необходимый результат для дальнейшего его представления во View.

HTTP

Давайте отобразим наши данные в браузере. Чтобы сделать это в красивом виде, мы будем использовать

  • В качестве шаблонизатора Twig. Он интегрирован в MF с помощью компонента micro/plugin-twig
  • Для стилизации мы будем использовать Bootstrap
  • Чтобы связать стили и JS мы будем использовать уже предустановленный плагин micro/plugin-twig-webpack-encore.
  • Чтобы выполнять сборку frontend - нам понадобится nodejs и менеджер зависимостей (yarn/npm) последних версий.** Но об этом мы расскажем немного позже.

Давайте приступим к созданию контроллера. Расположим его в src/Demo/Communication/Controller/PackagistSearchController.php

<?php

declare(strict_types=1);

namespace App\Demo\Communication\Controller;

use App\Demo\Facade\DemoAppFacadeInterface;
use Micro\Plugin\Twig\TwigFacadeInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;

readonly class PackagistSearchController
{
    public function __construct(
        private DemoAppFacadeInterface $packagistFacade,
        private TwigFacadeInterface $twigFacade
    ) {
    }

    public function search(Request $request): Response
    {
        $query = (string) $request->get('q');
        $exception = null;
        $results = null;
        try {
            $results = $this->packagistFacade->search($query);
        } catch (\RuntimeException $exception) {
        }

        $rendered = $this->twigFacade->render('@DemoAppPlugin/Packagist/search.html.twig', [
            'query' => $query,
            'results' => $results,
            'exception' => $exception,
        ]);

        return new Response($rendered);
    }
}

Обратите внимание, в контроллере нам требуются сервисы App\Demo\Facade\DemoAppFacadeInterface у которого есть одна задача - генерировать из Twig шаблона контент, в нашем случае, HTML, а так же, как вы уже узнали, сервис нашего бизнес-слоя DemoAppFacadeInterface. Осталось сообщить ядру то, что наш плагин является еще и провайдером маршрутов и шаблонов Twig. Для этого вернемся в код нашего плагина src/Demo/DemoAppPlugin.php и имплементируем в него интерфейсы

  • Micro\Plugin\Twig\Plugin\TwigTemplatePluginInterface - контракт, сообщающий о том, откуда и с каким неймспейсом забирать шаблоны.
  • Micro\Plugin\Http\Plugin\RouteProviderPluginInterface - контракт предоставления маршрутов плагина.

Давайте добавим в наш плагин эту функциональность.

<?php

declare(strict_types=1);

namespace App\Demo;

use App\Demo\Business\Packagist\PackagistSearch;
use App\Demo\Business\Packagist\PackagistSearchInterface;
use App\Demo\Communication\Controller\PackagistSearchController;
use App\Demo\Facade\DemoAppFacade;
use App\Demo\Facade\DemoAppFacadeInterface;
use Micro\Component\DependencyInjection\Container;
use Micro\Framework\Kernel\Plugin\ConfigurableInterface;
use Micro\Framework\Kernel\Plugin\DependencyProviderInterface;
use Micro\Framework\Kernel\Plugin\PluginConfigurationTrait;
use Micro\Plugin\Http\Facade\HttpFacadeInterface;
use Micro\Plugin\Http\Plugin\RouteProviderPluginInterface;
use Micro\Plugin\Twig\Plugin\TwigTemplatePluginInterface;
use Micro\Plugin\Twig\Plugin\TwigTemplatePluginTrait;

/**
 * @method DemoAppPluginConfiguration configuration()
 */
class DemoAppPlugin implements DependencyProviderInterface, TwigTemplatePluginInterface, ConfigurableInterface, RouteProviderPluginInterface
{
    use PluginConfigurationTrait;
    use TwigTemplatePluginTrait;

    public function provideDependencies(Container $container): void
    {
        $container->register(DemoAppFacadeInterface::class, fn () => $this->createFacade());
    }

    protected function createFacade(): DemoAppFacadeInterface
    {
        return new DemoAppFacade($this->createPackagistSearchService());
    }

    protected function createPackagistSearchService(): PackagistSearchInterface
    {
        return new PackagistSearch($this->configuration());
    }

    public function provideRoutes(HttpFacadeInterface $httpFacade): iterable
    {
        yield $httpFacade->createRouteBuilder()
            ->setUri('/')
            ->setController(PackagistSearchController::class)
            ->setName('search')
            ->build()
        ;
    }
}

Здесь стоит обратить внимание на метод provideRoutes. ОДнако, думаю, в излишних комментариях он не нуждается. А вот трейт Micro\Plugin\Twig\Plugin\TwigTemplatePluginTrait реализует сразу 2 метода:

  • TwigTemplatePluginTrait::getTwigTemplatePaths() - указывает массив с адресами каталогов где нужно искать шаблоны. По умолчанию это <PLUGIN ROOT DIR>/templates
  • TwigTemplatePluginTrait::getTwigNamespace - Указывает на неймспейс. По умолчанию это shortName класса плагина.

Шаблоны

Мы не будем останавливаться в данной статье на работе шаблонизатора, лучше всего об этом расскажет официальная документация. Но мы подгтовили для вас готоые шаблоны, чтобы вы смогли использовать их для демонстрационных целей. Просто разместите их в каталог src/Demo/templates/ А frontend часть разместите в assets/

Давайте соберем наш фронт

Для того, чтобы html контент выглядел презентаельно, вышеупомянутые шаблоны подготовлены для работы с CSS фреймворком Bootstrap И мы пришли к необходимости установки NodeJS.

Если вы используете docker, вы можете добавить в docker-compose.override.yml подходящий для этого контейнер, либо установить nodejs локально.

Oдин из вариантов добавления nodejs в docker-compose.yml. После чего, самый простой вариант установки нужного контейрера make down && make up

services:
# other services
    node:
        image: node:lts
        working_dir: /app
        volumes:
          - - ./:/app
Если вы не используете Docker, то можете просто использовать интерфейс командной строки для работы.
$ yarn install

Давайте установим Bootstrap и требуемый для него jQuery.

$ yarn add bootstrap jquery
# or
$ npm i bootstrap jqeury --save

После чего давайте запустим сборку

yarn build

Завершение

Давайте проверим то, как работает наше приложение

Если мы используем Docker

Если наше приложение не запущено, то давайте стартуем его

$ make up

Откроем в браузере https://localhost

Если мы используем локальный php-cli
$ cd public
$ php -S localhost:8000 

Откроем в браузере [http://localhost:8000]

Поздравляю, мы собрали первое приложение на MicroPHP.

Надеемся, данный пример раскрыл часть возмодностей MicroPHP и вам понравилось на нем работать.

Послесловие

  • Исходный код готового приложения можно найти на GitHub
  • В демонстрационных целях мы показали как можно работать с интерфейсами компонентов MF, однако, мы придерживаемся правила, что каждый плагин должен выполнять только свою функцию: I/O, Business-Layer, View и т.д., однако, вам решать как обустраивать экосистему своего приложения.
  • Спасибо, что используете нас. Это очень мотивирует делать продукт более удобным, качественным и придерживаться высоких стандартов в его разработке.