В этой статье я — Станислав Решетнев, 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.
План по модернизации в общих чертах был таким:
Все legacy-сервисы получают интерфейс OpenAPI.
Новое приложение использует legacy-сервисы в режиме клиента OpenAPI, в то же время предоставляя свой серверный интерфейс OpenAPI.
Новая бизнес-логика пишется в новом приложении, а фоново происходит постепенный транзит бизнес-логики из 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.
Если есть интерес к этой и смежным темам, пишите в комментариях — буду делиться опытом.
glader
Простите за вопрос в сторону, просто любопытствую. Вас не смущает сомнительная этичность проекта?
fourfingers Автор
Линкбилдинг - популярный общепринятый метод продвижения сайтов, такой же как, например, платная реклама. Другое дело, что кто-то использует сервисы линкбилдинга для ссылочного спама, но на дворе не 2010 год, обойти всех и вывести нулевой сайт в топ просто закупая миллион ссылок уже не получится.