Многие из вас знакомы с паттернами снижения “сильносвязанности” кода программного продукта, такими как внедрение зависимостей. В качестве простого примера – объект класса Logger, который не указан явно, а подсовывается в ходе выполнения кода. В зависимости от задач, условий, текущего окружения и так далее – конфигурирование позволяет подсунуть в нужное место тот или иной логгер – и писать хоть в файлы, хоть в базу, хоть в очередь rabbitmq. Вам достаточно делать Logger.write(data), и дальше алгоритм конкретного внедрённого обработчика будет применён к указанным данным.
Однако мы все привыкли обманываться, считая имеющееся у нас “dependency injection” отличным способом понизить связанность. Да, мы убрали алгоритмы во внедряемые компоненты, но мы по-прежнему задаём их либо в коде, либо в аннотациях (в случае PHP/Symfony), либо в конфигурационных файлах окружения. И мне не давала покоя мысль – как отдать такое конфигурирование в руки клиенту (другими словами – сделать его управляемым “на ходу”), при этом не применяя костылирование и не изобретая велосипед. В этом посте я хочу поделиться с вами паттерном, решающим эту задачу, который мне почему-то не встретился в литературе, но его удалось наработать в ходе локальных экспериментов.
Задача
Начнём с того, что у нас есть три вещи:
– некоторые данные (entity),
– набор различных алгоритмов их обработки (пишем в файл, в базу, в очередь),
– индивидуальная конфигурирация каждого алгоритма (в какой файл писать, реквизиты доступа к БД, …)
Нам нужно учесть взаимосвязи:
– к таким-то данным подходят вот эти алгоритмы, а другие не подходят;
– конфигурация алгоритма не валяется где попало, а привязана к алгоритму.
Нам нужно понимать, что:
– могут появляться новые алгоритмы;
– могут появляться новые конфигурации имеющихся алгоритмов.
И мы хотим конфигурирование зависимостей вида (типичный способ из PHP/Symfony):
$this->setLogger(MySuperCustomLogger);
$this->Logger->write(data);
— отдать в руки “клиенту”, чтобы тот мог выбирать среди логгеров конкретный нужный ему экземпляр MySuperCustomLogger.
Реализация
Удивительно, но решение достаточно долго лежало прямо перед глазами. Я делал свои наработки на ActiveRecord из Ruby on Rails, но естественно решение можно реализовать на любом достаточно взрослом ORM.
Мне помогло то, что я сторонник подхода “толстые модели – тонкие контроллеры”, который в отличие от противоположного – подразумевает богатую функциональность сущностей (моделей) MVC в противовес богатой функциональности внешних обработчиков (контроллеров). Это означает, что большая часть кода инкапсулирована в методы моделей и ориентирована на взаимодействие между данными, в противовес операциям “над данными”.
Взгляните на структуру данных: у вас есть сущности (экземпляры entity) и набор возможных обработчиков к ним. Что сделать, чтобы связать сущность с обработчиком? Да очень просто: one-to-many, или “один-ко-многим” в русскоязычной терминологии.
Создайте базовую модель Logger, имеющую кроме стандартных для ORM (new, create, update, destroy, …) пустой метод write. Создайте дочерную модель FileLogger extends Logger, воспользовавшись STI (однотабличным наследованием), и переопределите в её классе метод write, который будет писать данные в файл. Создайте ещё одну дочернюю модель DatabaseLogger и снова переопределите write – на этот раз для записи в БД. И так далее, до реализации всех необходимых алгоритмов.
— Что за бред, — скажете вы, — зачем наследовать функциональные классы от ORM?
Потерпите.
Во-первых, модели – это обычные классы языка, поэтому их нестандартное применение никак не нарушает ни один канон программирования. Во вторых, сделав эту схему, вы сможете создать необходимое количество сущностей “логгеров” с разными конфигурациями, описав их в БД, например:
id | type | filename |
1 | FileLogger | /var/log/something.log |
2 | FileLogger | /home/some/another/place/custom.log |
Затем, пользуясь штатным функционалом реляций ORM (has_many + has_one/belongs_to), вы связываете имеющиеся у вас классические модели (entity) с нужными сущностями логгеров. Когда из какой-то сущности потребуется что-то залогировать, вы просто напишете в коде привычный вам entity.logger.write(), и по реляции ORM будет “дёрнут” указанный объект класса и вызван соответствующий метод. Более того, поскольку “с другой стороны” реляции обычно можно достать вызывающую сущность (entity в данном примере), уменьшится головная боль и споры на тему “а что мне собственно передавать в метод логгера”, потому что решение однозначно: передавайте сам объект!
А потом вы даёте клиенту классическую CRUD-админку для управления всем этим. Бинго!
Пусть создаёт новые сущности, конфигурирует их как хочет, переключает на ходу. Сегодня пишет в один файл, завтра в другой, послезавтра – в rabbitmq. Всё без вашего участия. Если взять habtm вместо has_one, то можно присоединять объекты пачками. Естественно, вместо банального логгера может использоваться алгоритм любой сложности и любых задач, однако он точно так же будет “внедряться” (подключаться) путём манипуляций в админке.
Плюсы и минусы
Конечно, в реальности всё может быть не так радужно, иначе все бы так и делали, и об этом было написано в массе литературы.
Преимущества:
– клиент может конфигурировать систему без вашего участия;
– подход облегчает тестирование компонентов как чёрных ящиков с API;
– подход призывает работать с формализованными сущностями (моделями ORM) вместо “каких-попало” данных буквально везде, по принципу “под всякие данные делай модель”;
– повышается моральное удовольствие от разработки достаточно формализованной системы.
Недостатки:
– клиент будет конфигурировать систему без вашего участия, а это попахивает нездоровыми ассоциациями с 1С и прочим энтерпрайзом, в котором основная работа программиста сводится к разбирательству “да б..ть, что же они тут наконфигурили”;
– очевидно худшее быстродействие (инициализация объектов классов требует обращений к БД);
– практически полное отсутствие инструментов отладки и вообще поддержки в IDE;
– непривычность с точки зрения мировых традиций, несовместимость с чужими решениями, невозможность отдать ваш продукт “в мир” (если вы пишете опенсорс).
P.S.
Откуда вообще выросла такая задача? Взгляните, например, на инструменты A/B-тестирования. Во многих ли движках они есть из-коробки? Можем ли мы с одинаковой лёгкостью как показывать контрольной группе другую страницу, так и выполнять другую функцию? Нет, нет и нет.
А как в хайлоад-проектах тестируют новый функционал на 10% контрольной группе? А вот так: if (ID-шник пользователя оканчивается на ноль) then {выполняем другой кусок кода…} И так по всему коду продукта. Обоср..лись с фичей? Не беда, сейчас передеплоим заново..
Конечно, глубокий смысл этой затеи не в том, чтобы давать клиенту играться в программирование. Понятно, что такая игра либо надоест ему на второй день, либо он однажды приведёт её к полной неработоспособности и впредь будет бояться трогать – и довольные программисты скажут “ведь мы же вам говорили!”
Смысл – в том, чтобы строить систему, способную менять свои же собственные потоки выполнения с течением времени. Причём делать это не костылями, не путём внесения изменения в конфиги (и рестартом демона, ага) – а с той же гибкостью, которую даёт классический DI при написании кода.