Всем привет!
В этой статье я хочу рассказать о своем опыте переезда на “отвечающую современным трендам” платформу в одном legacy проекте.


Все началось примерно год назад, когда меня перекинули в “старый” (для меня новый) отдел.
До этого я работал с Symfony/Laravel. Перейдя на проект с самописным фреймворком количество WTF просто зашкаливало, но со временем все оказалось не так и плохо.
Во-первых, проект работал. Во-вторых, применение шаблонов проектирования прослеживалось: был свой контейнер зависимостей, ActiveRecord и QueryBuilder.
Плюс, был дополнительный уровень абстракции над контейнером, логгером, работе с очередями и зачатки сервисного слоя(бизнес логика не зависела от HTTP слоя, кое-где логика была вынесена из контроллеров).


Далее я опишу те вещи, с которыми трудно было мириться:


1. Логгер log4php


Сам по себе логгер работал и хорошо. Но были жирные минусы:


  • Отсутствие интерфейса
  • Сложность конфигурации для задач чуть менее стандартных (например, отправлять логи уровня error в ElastickSearch).
  • Подавляющее большинство компонентов мира opensource зависят от интерфейса Psr\Log\LoggerInterface. В проекте все равно пришлось держать оба логгера.

2-6. Контроллеры были вида:


<?php

class AwesomeController
{
   public function actionUpdateCar($carId)
   {
       $this->checkUserIsAuthenticated();
       if ($carId <= 0) {
           die('Машина не найдена');
       }
       $car = Car::findById($carId);
       $name = @$_POST['name'];
       container::getCarService()->updateNameCar($car, $name);
       echo json_decode([
           'message' =>'Обновление выполнено'
       ]);
   }
}

  • die посреди выполнения кода приложения
  • echo еще до выхода из контроллера.
  • Аутентификация пользователей подключалась в каждом контроллере отдельно
  • Классические $_POST и @.
  • Отсутствовала возможность внедрять сервисы в контроллер.

7. Отсутствие глобального логирования ошибок приложения


Максимум, что можно было найти в логах — это текст сообщения. Более подробную информацию об ошибке можно было получить после повторения на стенде разработки. Для дополнительного логирования на боевом окружении приходилось ставить блоки try/catch в нужном методе контроллера.


8. Конфигурирование контейнера зависимостей


Контейнер зависимостей напоминал Symfony контейнер времен 2.4. Каждый сервис требовал регистрации и описания как его собрать. Имея опыт работы с контейнером laravel, где максимально используется autowiring, хотелось избавиться от рутинных действий. Так же отсутствие autowiring снижало желание программистов писать отдельные сервисы (создавать новые классы) под отдельную бизнес задачу, так как это подразумевало необходимость править конфиг контейнера. При этом всегда есть вероятность ошибиться и потерять еще больше времени.


9. Роутинг


Роутинг был логичен и прост, по мотивам в Yii1.
Адрес вида www.carexchange.ru/awesome_controller/update_car означал выполнение контроллера AwesomeController и метода actionUpdateCar.
Но, к сожалению, были вложенные поддиректории сайта и приходилось создавать url’ы вида
www.carexchange.ru/awesome_controller_for_car_insite_settings_approve/update_car
Это не напрягает, но ограничение странное


10. Хардкод url’ов


Роутинг был простым, поэтому отсутствовала возможность генерации url автоматически (зачем усложнять). Это привело к тысячам ссылок, которые были захардкожены и в php и js. Мы, конечно, редко меняем url’ы, но иногда такое случается. И искать их по проекту сложно.


Пора что-то менять!


С приходом еще одного программиста стали подниматься вопросы о возможности рефакторинга, было желание сделать “по человечнее”. “По человечнее” — читай привычнее для современного разработчика. Читать и поддерживать существующий код было сложно->долго->дорого.


После нескольких обсуждений с руководством был получен зеленый флаг и началась работа над proof of concept.


С самого начала было принято решение придерживаться современных стандартов. Это и рекомендации PSR, и следование стандартному поведению других фреймворков. Новые разработчики работавшие на любом современном фреймворке должны были понять где найти контроллеры, как собираются сервисы и где писать бизнес логику.


Если внимательнее посмотреть на озвученные претензии, то мы заметим: страдает код уровня приложения (контроллеры) и инфраструктурный слой (контейнер).
Бизнес логика была написана отдельно и не зависела от уровня HTTP — ее оставляем как есть. Active Record и QueryBuilder также не трогаем, так как они работали и не сильно отличались от той же doctrine/dbal.


Выбор фреймворка


На самом деле выбор тут был не велик. Тащить весь laravel или symfony ради слоя над HTTP нет смысла. А нужные компоненты всегда можно подключить через composer.
Серьезный выбор был между двумя микро-фреймворками: Slim и Zend.
Оба этих фреймворка полностью поддерживают PSR-7 и PSR-11.


Почему не Lumen? Главная причина конечно же в том, что Lumen сложно назвать “микро” вкупе со всем этим добром. Встроить Lumen в существующий проект сложно. Контейнер зависимостей легко не подменишь (необходимо соблюдение контракта illuminate). Контракт PSR-7 фреймворк поддерживает, но все равно зависит от symfony/http-foundation.


Сначала я всерьез взялся за Zend. Но потратив 2 дня, посмотрев на реализацию приложения в идеологии "все middleware", увидев как формируется конфиг контейнера, я с ужасом представил как буду объяснять менее опытным разработчикам чем invokables отличается от factories, и когда писать aliases. Перфекционистам и академикам Zend должен прийтись по нраву. Приложение работает через pipeline и middleware. Но я испугался более высокого порога входа, в то время как переезд должен был быть легким, в идеале незаметным.


Затем я переключился на Slim. Его внедрение в проект заняло меньше дня. Выбор контроллеров (старого и новго образца) был реализовано через middleware. На Slim и остановился. В далеких планах перейти на pipeline с PSR-15 middleware.


Выбор контейнера


Здесь я просто скажу что остановился на league/container, и попытаюсь объяснить свой выбор.


  1. Это поддержка PSR-11.
    Сейчас большинство контейнеров уже поддерживают PSR-11, но год назад лишь малая часть поддерживала container/interop интерфейс.
  2. Autowiring.
  3. Синтасис довольно прост, в противопоставление тому же zend-servicemanager.
  4. Сервис провайдеры, позволяющие писать модули еще более изолированно.
    В illuminate/container провайдеры регистрируются на уровне приложения, а в league/container провайдеры регистрируются на уровне контейнера. Таким образом приложение зависит только от контейнера, а контейнер зависит от сервис провайдеров.
  5. Делегирование контейнеров. Эта ”фича” оказалась решающей для этапа замены контейнера, поэтому раскрою ее подробнее.
    При желании внутри league/container может быть несколько PSR-11 совместимых контейнеров.
    Возможный сценарий: вы решили сменить ваш старый контейнер на symfony/dependency-injection. Чтобы переходить постепенно вы можете подключить league/container и в делегаты поместить и ваш старый контейнер и контейнер symfony. При поиске сервиса ваш старый контейнер будет опрашиваться самым первыми, затем будет поиск в контейнере symfony. На следующем этапе вы сможете перенести описания всех сервисов в контейнер symfony и оставить только его. Так как код зависит от PSR-11 интерфейса — изменения минимальны.

Выбор абстракции над HTTP


Тут всего 3 варианта:



Кстати Slim движется к выделению реализации HTTP в отдельный пакет(ожидается в ветке 4.0).
Symfony bridge использовать не хотелось по причине лишнего кода и лишней зависимости. Так как Slim ни в чем нас не ограничивает, предпочтение было отдано реализации Zend. Это только увеличило независимость кода приложения от HTTP слоя.


Логированиe


Тут ничего кроме monolog в голову не приходит. Его и прикрутили. Во время разработки бывают полезны PHPConsoleHandler и ChromePHPHandler


Роутинг


Slim из коробки имеет FastRoute. На его основе появились именованные роуты. Генерация URL реализована через глобальный хелпер (Как здесь)


Ну и что изменилось?


Сейчас наш контроллер выглядит так:


<?php

namespace Controllers;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Zend\Diactoros\Response\JsonResponse;
use Domain\Car\Services\CarService;

class AwesomeController
{
   /**
    * @var CarService
    */
   private $carService;

   public function __construct(CarService $carService)
   {
       $this->carService = $carService;
   }

   public function actionUpdateNameCar(ServerRequestInterface $request, $carId): ResponseInterface
   {
       if ($carId <= 0) {
           throw new BadRequestException('Машина не найдена');
       }
       $car = $this->carService->getCar($carId);
       $name = $request->getParsedBody()['name'];
       $this->carService->updateNameCar($car, $name);

       return new JsonResponse([
           'message' => 'updateNameCar выполнено'
       ]);
   }
}

Разумеется, в реальном коде вещи вроде $request->getParsedBody()['name'] и new JsonResponse вынесены на еще один уровень абстракции с дополнительными проверками.


В качестве бонуса: в зависимости от окружения есть возможность подменять сервисы в контейнере и запускать функциональные тесты.


В заключение


Как вы видите, много практик с идеологией “так проще” позаимствовано из Laravel. Он действительно задает тренды.


Приложение получило новый фреймворк уже после того, как проработало 7 лет. Насколько я знаю, старый самописный фреймворк также появился не сразу. И никто не даст гарантий, что мы не захотим сменить фреймворк через 5 лет. Поэтому код писался максимально независимым от выбранного фреймворка. Бизнес логика и прежде не зависела от приложения, а теперь и контроллеры не зависят от фреймворка. Контроллеры зависят от PSR-7 совместимых запросов и возвращают PSR-7 ответы. А собираются контроллеры приложением, зависящим от PSR-11 совместимого контейнера.


Slim работает через middleware и добавлять общую логику стало проще (Логирование ошибок приложения, обработка ошибок пользовательского ввода). Autowiring контроллеров прекрасно работает, по сути контроллеры стали сервисами.


Кстати здесь можно подсмотреть пример включения autowiring в slim.


Конечно, приложение продолжает развиваться и уже перешло на стандартные интерфейсы кэширования и событий, но их внедрение было чуть менее кроваво:).

Комментарии (14)


  1. MetaDone
    29.09.2017 09:08

    В качестве di-контейнера не рассматривали https://github.com/auraphp/Aura.Di?


    1. Fantyk Автор
      29.09.2017 10:05

      Рассматривался, тем более это один из вариантов установки zend-expressive. Возможно, из-за его «не впечатляющей» документации он рассматривался не столь пристально. В целом его синтаксис выглядит менее приятно, чем в том же league/container (кажутся излишними lazy, lazyArray, lazyCallable, lazyGet, lazyGetCall, lazyNew, lazyValue...). Из коробки DelegateContainer может быть один(не массив). Также на беглый взгляд плохи дела с сервис провайдерами.


      1. MetaDone
        29.09.2017 10:18

        Документация вполне подробная, вроде все четко описано. lazy-функционал для экономии ресурсов очень полезен, особенно когда у вас куча сервисов с кучей зависимостей — инициализируете только то что нужно
        Так же можете создавать отдельные конфиги для каждого модуля/бандла/компонента приложения, есть некоторое сходство с сервис-провайдерами


        1. Fantyk Автор
          29.09.2017 10:46

          Сравните опыт работы c документацией League и Aura.Di. Я не спорю, что документация есть, я говорю о том, что она может быть в разы лучше. В League контейнере все из коробки lazy (думаю так обстоят дела во всех современных контейнерах), префикс lazy кажется атавизмом.
          В любом случае ваш выбор упадет на то, с чем вы раньше работали. И если у вас удачный опыт работы с Aura.Di — лучше работать с ним дальше.
          Статья еще раз убеждает следовать принципу: детали должны зависеть от абстракций. В нашем конкретном примере от интерфейса контейнера.


          1. MetaDone
            29.09.2017 10:51

            Да, оба контейнера используют container-interop, так что все утыкается в субъективное удобство в данном случае


          1. pbatanov
            29.09.2017 17:31

            Мне кажется, вы говорите про загрузку по требованию, а MetaDone про ленивую инъекцию.

            Ленивая инъекция невозможна по умолчанию, т.к. обычно она работает через наследование.


  1. uonick
    29.09.2017 09:37

    Серьезный выбор был между двумя микро-фреймворками: Slim и Zend.

    Zend микро-фреймворк, простите?


    1. Fantyk Автор
      29.09.2017 09:43

      Благодаря модульной архитектуре zend-expressive намного больше «Микро», чем тот же Lumen. Сравните его лист requirements github.com/zendframework/zend-expressive/blob/master/composer.json#L29-L35. В минимальной инсталляции в нем идет только контейнер, роутер и pipeline — не больше, чем в Slim.


  1. inververs
    29.09.2017 16:04

    Затем я переключился на Slim. Его внедрение в проект заняло меньше дня. Выбор контроллеров (старого и новго образца) был реализовано через middleware. На Slim и остановился. В далеких планах перейти на pipeline с PSR-15 middleware.

    Не могли бы показать, как было и как стало? Не очень понимаю, как у вас роутинг и на новые контроллеры и на старые сделан.


    1. Fantyk Автор
      29.09.2017 16:29

      Роутинг на новые контроллеры работает через FastRoute. Если роут в FastRoute не найден, тогда я отдаю выполнения приложения старому коду (который обрабатывал запросы раньше). Если роут найдет — выполняется Slim приложение.

      <?php
      
      // Middleware, решающее какой контроллер нужно выполнить.
      class ControllerDispatcherTypeMiddleware
      {
          // Была написана обертка над старым роутингом.
          public function __construct(LegacyControllerDispatcher $legacyControllerDispatcher)
          {
              $this->legacyControllerDispatcher = $legacyControllerDispatcher;
          }
      
          public function __invoke($request, $response, $next)
          {
              $routeInfo = $request->getAttribute('routeInfo');
      
              // Если роут не найдем в роутинге приложения Slim, тогда отправляем запрос в старый обработчик.
              if ($routeInfo[0] === \FastRoute\Dispatcher::NOT_FOUND) {
                  // Вызов "старых" контроллеров
                  $this->legacyControllerDispatcher->dispatch();
              }
      
              // Стандартная работа Slim приложения
              return $next($request, $response);
          }
      }
      
      //Регистрируем выбор типа контроллеров первым Middleware в приложении
      $app = new \Slim\App();
      $app->add(ControllerDispatcherTypeMiddleware::class);
      


  1. happyproff
    01.10.2017 14:56

    Серьезный выбор был между двумя микро-фреймворками: Slim и Zend.

    Silex не рассматривался?


    1. Fantyk Автор
      01.10.2017 19:29

      Silex полностью завязан на компоненты Symfony и Pimple, легко в нем компоненты не заменишь.


  1. Akhristenko
    01.10.2017 19:18

    Вопрос по поводу контейнера. А на php-di не смотрели? И если смотрели, то почему его не взяли?


    1. Fantyk Автор
      01.10.2017 19:37

      Php-Di рассматривался, но каких то киллер-фич в нем не вижу. А синтаксис League контейнера кажется приятнее (это, конечно же, мое субъективное мнение).