В этой статье я — Станислав Решетнев, Teamlead команды разработки Link Building в компании Sape — хочу рассказать об опыте нашей компании по избавлению от legacy. Многие компании сталкиваются с проблемой legacy-монолита, когда технический долг накапливается на протяжении долгих лет и разрыв по технологическому стеку становится всё больше похожим на пропасть. Нам удалось найти решение, позволившее провести полное обновление, и заодно выполнить другие бизнес-задачи.

Начну с описания технического стека (по той части, которая имеет значение для данной статьи). На момент старта преобразования системы мы располагали сервисами, написанными на:

  • Symfony 1 / Doctrine 1. Контент страниц генерируется на бэкенде, а в качестве шаблонизатора используется PHP;

  • процедурном PHP времён версии 5.3, но переведёнными на PHP 7.0. Шаблонизатор — Smarty;

  • Zend 1, который был с течением времени сильно доработан. Шаблонизатор — Smarty.

Нашей целью было создание объединённого приложения на основе современного стека. Бэкенд строился на Symfony 5 / 6, а используемые отдельные сервисы выносились за контуры приложения. Фронтенд представлял собой SPA, работающее с бэкендом по OpenApi.

План по модернизации в общих чертах был таким:

  1. Все legacy-сервисы получают интерфейс OpenAPI.

  1. Новое приложение использует legacy-сервисы в режиме клиента OpenAPI, в то же время предоставляя свой серверный интерфейс OpenAPI.

  1. Новая бизнес-логика пишется в новом приложении, а фоново происходит постепенный транзит бизнес-логики из legacy в новое приложение.

Работа ещё продолжается, но первый этап уже завершился и, на мой взгляд, успешно. Стартовали мы полтора года назад и уже запустили новое приложение.

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

Проблемные места

Не буду подробно останавливаться на проблемах, которые вызывает legacy. Запутанная логика, вследствие чего разработчик тратит больше времени на погружение и исследование, а не на реализацию. Любое изменение в коде похоже на хождение по минному полю, поскольку может сказаться на части системы, которая, казалось бы, не имеет к нему отношения. Да и сложно найти перспективного разработчика, готового провести лучшие годы своей жизни, копаясь в завалах старого и непонятного кода. 

Поговорим о том, насколько проблемной может стать переработка кода, написанного на Symfony 1.

Symfony — свободный фреймворк, написанный на PHP.

Версия 1.4 (финальная в ветке 1.xx). Появилась в ноябре 2009 г. Версия PHP: начиная с 5.2.4. Окончание поддержки: ноябрь 2012. ORM: Propel или Doctrine 1.

Выбор Symfony в качестве фреймворка казался настоящим прорывом. Он использовал передовые подходы к разработке. В состав поставки входила консольная утилита, выполнявшая ряд рутинных задач, в том числе миграцию СУБД. Symfony позволял легко написать свою команду, присутствовала система событий (Event Dispatcher). Была ORM — всемогущая Doctrine, были настройки в yaml-файлах. Админку можно было и вовсе генерировать на основе этих конфигураций. Существовали концепции форм и фильтров форм, что позволяло структурированно оперировать входными данными. Использовался шаблонизатор, дружелюбный для программиста — PHP. Казалось бы, всё замечательно! Но во что это превратилось с годами?

Возьмём пример: создание заявки. Заявка (Advert) — это сущность, создаваемая заказчиком для передачи её исполнителю (вебмастеру). В процессе создания заявки мы проходим через следующие абстракции:

  • Шаблон на PHP — newSuccess.php, в котором мы корректируем цены с учётом дополнительных услуг, получаем информацию по площадкам, выводим настройки специфичные для пользователя. Как ни присматривай за изменениями в коде, сама возможность занести бизнес-логику в шаблон выглядит привлекательно. Из лучших побуждений, с целью экономии времени, фронтендер или бэкендер напрямую обращаются ко внутренним сервисам и начинают формировать свои структуры и переформатировать данные.

  • Действие в контроллере — \advertActions::executeCreate, в котором мы обработаем запрос для передачи в сервис создания. Если это — PHP-код, то почему бы не написать в нём логику преобразования каких-нибудь данных? Сформируем контент заявки. Подкорректируем свойства сущности заявки в соответствии со вновь открывшимися данными о пользователе и заполненных полях. Обработаем случай, если надо создать не заявку, а её черновик. В общем, действий на 400+ строк кода. А кто сказал, что нельзя сделать это тут?

  • Биндинг данных из запроса с формой в AdvertForm. Создадим виджеты, кое-что сделаем с оформлением формы — всё вполне легально и не рушит архитектуру. Но заодно и поменяем настройку сущности заявки на этапе биндинга, потому что тут же как раз всё под рукой.

  • Создадим заявку через сервис AdvertService. Тут самое раздолье в плане бизнес-логики: несколько десятков всяких проверок и преобразований. Ходим из метода в метод, что-нибудь меняем у сущности, пока не дойдём непосредственно до сохранения в БД. Сервис модели — это ведь самое подходящее место, чтобы написать много, очень много операций с данными?

  • Выполним сохранение сущности в \Advert::save. Тут разработчик понимает, что ещё не всё успел сказать не всю бизнес-логику успел описать. Метод сохранения в базу очень хорош, чтобы описать всё, что должно делаться автоматически при любом сохранении. Здесь мы можем обновить поля сущности как для существующей, так и для новой. Сходить в биллинг, во внешний партнёрский сервис, чтобы обновить там статус. Заодно обновить по условию ряд полей (и получить 200+ строк логики, не считая вложенных методов).

Стоит учитывать, что Symfony 1 использует подход богатой модели предметной области.

Богатая модель предметной области (БМПО, Rich Domain Model) — в ней классы, представляющие сущности предметной области, содержат в себе и данные, и всю бизнес-логику.

Такой подход, как показывает практика, провоцирует переплетение бизнес-логики и логики из инфраструктурного слоя. Рядом располагаются хуки для работы с БД и методы для всего, что связано с сущностью.

Итого получается 5 логических зон или слоёв, где может оказаться бизнес-логика, не считая косвенно связанных сервисов и моделей, на которые тоже оказывается влияние. При этом, данные оформлены в виде нетипизированной структуры — массива, который меняется и дополняется с помощью разных методов на манер матрешки. Выяснение, что же случилось с данными по ходу обработки, превращается в детектив. 

Глядя на всё это, наша команда решила не пытаться на данном этапе распутать логику, чтобы формализовать её и перенести в новое приложение. Вместо этого мы обернули код с уровня контроллера (уровень Controller из MVC) в дополнительный слой — внешний интерфейс на базе OpenAPI.

Коротко об OpenAPI

OpenAPI-спецификация — это контракт (соглашение) между фронтендом и бэкендом. В нём описываются адреса для обращения, форматы запросов и ответов, оперируемые сущности.

OpenAPI представляет собой формализованную спецификацию и полноценный фреймворк для описания, создания, использования и визуализации веб-сервисов REST.

OpenAPI имеет развитую экосистему, которая включает в себя множество инструментов для различных языков.

Благодаря формализации в общении между фронтом и бэком, появляются такие возможности как автогенерация кода и автотестов, создание имитаторов как бэкенда, так и фронтенда, автовалидация запросов и ответов, а также автоматизированное создание документации.

Преобразование наших legacy-сервисов в OpenAPI-сервисы позволило использовать их уже в качестве подсистем в новом приложении:

Добавляем OpenAPI поверх legacy

Мы выбрали слой Controller / Presenter в качестве места для создания обёртки вокруг legacy-логики (в Symfony и Zend это контроллеры). Далее разберём, как мы справились с этой задачей в Symfony 1.

Первым делом описали в спецификации на каждый сервис структуры запросов и ответов и вручную создали классы, отображающие эти объекты. Это было необходимо, чтобы максимально структурировать данные из legacy-action контроллеров. Новые контроллеры оперировали только объектами, принудительно преобразуя исходный запрос в приложение, а также получая ответ от старых контроллеров.

Например, метод создания заявки в OpenAPI описан так (в сокращении):

    "/rest/Adverts/projectId/{projectId}": {
      "post": {
        "tags": [
          "Adverts"
        ],
        "summary": "Создание классической заявки",
        "operationId": "createAdvert",
        "parameters": [
          {
            "$ref": "#/components/parameters/projectId"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "sitesIds": {
                    "type": "array",
                    "items": {
                      "$ref": "#/components/parameters/siteId"
                    },
                    "minItems": 1
                  },
                  "links": {
                    "$ref": "#/components/schemas/AdvertLinks"
                  },
                  "description": {
                    "type": "string",
                    "title": "Комментарий вебмастеру",
                    "minLength": 3
                  }
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "description": "Результат создания классической заявки",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "advertIds": {
                      "type": "array",
                      "title": "Массив ID созданных заявок",
                      "items": {
                        "type": "integer",
                        "format": "int32",
                        "title": "ID созданной заявки",
                        "minimum": 0
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    }

При этом бэкенд оперирует ответом как объектом:

<?php

declare(strict_types=1);

/**
 * Объект ответа API-метода Adverts.createAdvert.
 */
class AdvertsCreateAdvertResponse
{
  /** @var int[] Массив ID созданных заявок */
  public $advertIds;
}

Преобразование ответов старых контроллеров происходит через метод-wrapper responseWrapper(), который:

  • собирает все входящие данные из GET и POST-параметров;

  • вызывает legacy-метод контроллера, перехватывая отображения flash с ошибками (это уведомления в Symfony1) и преобразуя их в структуры с ошибками в OpenAPI, а также, блокируя redirect, перехватывает возникающие исключения, преобразуя их в структуры ошибок, описанные в спецификации;

  • упаковывает ответ в JSON.

Также применяется метод для перехвата возвращаемых параметров из varHolder — getActionResultValues(). VarHolder — это способ Symfony 1 для передачи данных в шаблоны для рендеринга. Из контекста класса контроллера виртуальные переменные через $this выставляются вот так:

$this->project = $user->findProject($project_id);

Наш метод getActionResultValues() создаёт имитацию запроса из браузера для legacy-action, перехватывает любые исключения и преобразует их в ошибки, а также собирает все результаты из varHolder. И, разумеется, блокирует рендеринг.

В итоге обработка выглядит так:

1. В нашем методе-обёртке мы заполняем из параметров запроса данные, пригодные для legacy-контроллера:

$this->getRequest()->setParameter('form', $form_data);
$this->getRequest()->setParameter('type', $link_type);
$this->getRequest()->setParameter('project_id', $project_id);

2. При помощи вспомогательного метода нового контроллера $this->getActionResultValues('advert', 'create') получаем всё, что он в итоге собирался отрисовать в шаблоне.

3. Формируем объект ответа в соответствии с OpenAPI.

4. Передаём результат для дооформления в responseWrapper(), который может по ходу поймать исключения более высокого уровня.

Адаптация бэкенда в Zend 1 была сделана похожим образом, но несколько проще: там не пришлось перехватывать рендер в Smarty. В нашем распоряжении оказались пусть и плохо организованные, но сервисы, методы которых мы смогли использовать.

В заключение

Адаптация под OpenAPI старых сервисов позволила в краткие сроки и с наименьшими усилиями создать современный интерфейс для наших legacy-сервисов. Сервис на Symfony 1 мы смогли адаптировать за 14 человеко-дней (не считая решения предварительных организационных вопросов и обучения). В итоге получилось около 70 API-методов. Это открыло нам дорогу для дальнейшей модернизации системы. 

Команда Sape построила и успешно запустила новое приложение, объединившее логику из legacy-подсистем. При этом мы начали процесс переноса бизнес-логики в новое приложение. Благодаря этому, например, смогли привлечь новых разработчиков, которым не приходится сталкиваться с нашим legacy.

Если есть интерес к этой и смежным темам, пишите в комментариях — буду делиться опытом.

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


  1. glader
    08.11.2022 08:51

    Простите за вопрос в сторону, просто любопытствую. Вас не смущает сомнительная этичность проекта?


    1. fourfingers Автор
      08.11.2022 15:31

      Линкбилдинг - популярный общепринятый метод продвижения сайтов, такой же как, например, платная реклама. Другое дело, что кто-то использует сервисы линкбилдинга для ссылочного спама, но на дворе не 2010 год, обойти всех и вывести нулевой сайт в топ просто закупая миллион ссылок уже не получится.


  1. casnerano
    08.11.2022 22:16

    Есть точное определение этому подходу — замести под ковер.