Когда разработчик планирует архитектуру своего будущего веб-приложения, полезно подумать о его расширяемости заранее. Модульная архитектура приложения может обеспечить хорошую степень расширяемости. Существует довольно много способов, как такую архитектуру реализовать, но все они сходны в своих фундаментальных принципах: разделение понятий, самодостаточность, взаимная сочетаемость всех компонентов.
Однако есть один подход, который именно в PHP можно встретить довольно редко. Он включает использование нативного наследования и позволяет патчить код «более лучше»(с). Мы называем этот способ “Forwarding Decorator”. Нам он представляется достаточно эффективным, и, кстати, эффектным тоже, хотя последнее не так важно в продакшене.
Как автор оригинальной англоязычной статьи "Achieving Modular Architecture with Forwarding Decorators", опубликованной на SitePoint, я представляю вам авторскую версию перевода. В ней я сохранил изначально задуманный смысл и идею, но постарался максимально улучшить подачу.
В данной статье мы рассмотрим реализацию подхода с использованием Forwarding Decorator, а также его плюсы и минусы. Сравним данный подход с другими хорошо известными альтернативами, а именно — с использованием хуков, патчингом кода или DI (dependency injection). Для наглядности есть демо-приложение вот в этом репозитории GitHub.
Основная идея состоит в том, чтобы рассматривать каждый класс как сервис, и модифицировать этот сервис посредством наследования и реверсирования цепочки наследников при компиляции кода.
В системе, основанной на такой идее, в любом модуле можно создать специальный класс-декоратор (отмеченный особым образом). Такой класс получит поля и методы другого класса через механизм наследования, но после компиляции будет везде использоваться вместо оригинального класса.
Собственно, поэтому мы и называем такие классы Forwarding decorators: эти декораторы являются надстройкой над исходной реализацией, однако выдвигаются вперед в местах использования.
Преимущества такого подхода очевидны:
Однако свои недостатки у этого подхода тоже есть:
Подобный способ расширения системы — в некотором смысле промежуточное решение между прямым патчингом кода (low-level, никаких правил игры, god mode, greatest power but with greatest responsibility и т.д.) и архитектурой на основе плагинов, с четким определением того, каким может быть плагин, какие подсистемы и как он может изменять\предоставлять. Система декораторов позволяет хорошо решать некоторый диапазон задач, но это вовсе не серебряная пуля и не идеальный способ организовать модульность.
Вот пример:
Как так вышло? Это уличная магия ) Мы разворачиваем цепочку наследования вспять. Исходный класс — без внутреннего кода. В результате компиляции мы препроцессим код так, что содержимое исходного класса уходит в отдельный класс, который будет новым родителем для цепочки:
Если кратко, то в приложение встраивается компилятор, который строит промежуточные классы, и autoloader, который будет загружать эти промежуточные классы вместо исходных.
А теперь немного подробнее. Компилятор строит список всех классов, используемых в системе, и для каждого класса, который не является декоратором, находит все подклассы, которые будут его декорировать с помощью DecoratorInterface. Он создает дерево декораторов, проверяет, нет ли там циклов, сортирует декораторы по их приоритету об этом подробнее далее) и строит промежуточные классы, где цепочка наследования будет развернута в обратную сторону. Исходный код преобразуется в новый класс, который станет новым родительским классом для цепочки наследования.
Звучит сложно. Так оно и есть, это действительно сложная комплексная система. Однако она позволяет очень гибко комбинировать модули, и с помощью этих модулей вы можете модифицировать абсолютно любую часть вашего приложения.
В случае, если в игру вступает несколько декораторов одновременно, они попадают в цепочку декорирования согласно их приоритету. Приоритет можно задать с помощью аннотаций (мы пользуемся Doctrine\Annotations) или конфигов.
Рассмотрим пример:
В данном примере аннотация Decorator\After используется, чтобы поставить декоратор другого модуля Module 1 перед модулем Module 2. Компилятор проанализирует файлы, учтет аннотации и построит промежуточный класс с такой цепочкой наследования:
Также можно использовать такие аннотации:
Данного набора аннотаций (Before, After, Depend) абсолютно достаточно для построения любой комбинации модулей и классов.
Есть! Для наглядности я подготовил демку приложения, она находится вот в этом репозитории GitHub. Это написанное на PHP приложение имеет модульную архитектуру, и модули могут подмешивать код без рекомпиляции. При этом модули можно добавлять и удалять, но в этом случае рекомпиляция уже понадобится. Более детально все это описано в readme файле.
Есть и совсем «боевые» примеры. На рынке уже есть несколько программных продуктов, которые используют такой подход. В частности, нечто очень похожее используется в OXID eShop. Кстати, у них прикольный стиль изложения в блоге. Еще в одной платформе, X-Cart 5, данный подход реализован именно в той форме, в которой я его описал — код X-Cart 5 даже был взят за основу для этой статьи. Это позволило создать очень гибкое решение для электронной коммерции, которое можно расширять настолько, насколько хватит фантазии разработчика (или денег заказчика =)), и при этом не ломать последующие апгрейды ядра.
Как и подход с Forwarding Decorators, использование хуков и патчинг “в лоб” имеют свои плюсы и минусы.
Forwarding-декораторы — это подход, по меньшей мере заслуживающий внимания. Он может использоваться для решения проблемы разработки расширяемой модульной архитектуры приложений на языке PHP. При этом будут использоваться знакомые конструкции, такие как наследование или область видимости полей/методов/классов.
Реализация такого концепта — задача нетривиальная, возможны сложности с отладкой, но они преодолимы при условии, что вы потратите некоторое время на должную настройку компилятора.
Если будет интерес к данному материалу, в следующей статье я напишу, как сделать оптимальный компилятор с автозагрузчиком и использовать потоковые фильтры (PHP Stream filters), чтобы включить пошаговый дебаггинг исходного кода через XDebug. Интересно? Дайте об этом знать в комментариях. А еще я буду рад вашим вопросам, советам и конструктивной критике.
Однако есть один подход, который именно в PHP можно встретить довольно редко. Он включает использование нативного наследования и позволяет патчить код «более лучше»(с). Мы называем этот способ “Forwarding Decorator”. Нам он представляется достаточно эффективным, и, кстати, эффектным тоже, хотя последнее не так важно в продакшене.
Как автор оригинальной англоязычной статьи "Achieving Modular Architecture with Forwarding Decorators", опубликованной на SitePoint, я представляю вам авторскую версию перевода. В ней я сохранил изначально задуманный смысл и идею, но постарался максимально улучшить подачу.
Введение
В данной статье мы рассмотрим реализацию подхода с использованием Forwarding Decorator, а также его плюсы и минусы. Сравним данный подход с другими хорошо известными альтернативами, а именно — с использованием хуков, патчингом кода или DI (dependency injection). Для наглядности есть демо-приложение вот в этом репозитории GitHub.
Основная идея состоит в том, чтобы рассматривать каждый класс как сервис, и модифицировать этот сервис посредством наследования и реверсирования цепочки наследников при компиляции кода.
В системе, основанной на такой идее, в любом модуле можно создать специальный класс-декоратор (отмеченный особым образом). Такой класс получит поля и методы другого класса через механизм наследования, но после компиляции будет везде использоваться вместо оригинального класса.
Собственно, поэтому мы и называем такие классы Forwarding decorators: эти декораторы являются надстройкой над исходной реализацией, однако выдвигаются вперед в местах использования.
Преимущества такого подхода очевидны:
- Любая часть системы может быть расширена с помощью модуля — любой класс, любой public/protected метод. Не нужно заранее отмечать точки расширения специальным кодом.
- Одна подсистема может модифицироваться несколькими модулями одновременно.
- Подсистемы слабо связаны между собой, поэтому могут обновляться по-отдельности, независимо друг от друга.
- Вы можете ограничить расширяемость, используя привычные конструкции: приватные (private) методы и закрытые (final) классы.
Однако свои недостатки у этого подхода тоже есть:
- В первую очередь — это отсутствие четких интерфейсов взаимодействия c расширяемой системой. Мы можем расширять все, что не запрещено явно через private, но система может не ожидать, что в нее зашли не с того конца и будет работать неадекватно в случаях, о которых не задумывался разработчик модуля. Нужно тщательно инспектировать код на наличие нежелательных побочных эффектов.
- Вам придется реализовать своего рода компилятор (подробности ниже).
- При разработке модулей нужно четко соблюдать публичный интерфейс подсистем и не нарушать принцип подстановки Лисков, иначе эти модули сломают систему.
- Наличие дополнительного компилятора усложняет отладку кода. Вы не сможете запускать XDebug на исходном коде напрямую, любое изменение кода сначала требует запуска компилятора. Однако эту проблему можно решить, используя хитрые PHP-трюки так, что запускаться будут скомпилированные файлы, но при этом в дебаггере вы будете видеть исходный код.
Подобный способ расширения системы — в некотором смысле промежуточное решение между прямым патчингом кода (low-level, никаких правил игры, god mode, greatest power but with greatest responsibility и т.д.) и архитектурой на основе плагинов, с четким определением того, каким может быть плагин, какие подсистемы и как он может изменять\предоставлять. Система декораторов позволяет хорошо решать некоторый диапазон задач, но это вовсе не серебряная пуля и не идеальный способ организовать модульность.
Как можно использовать такую систему?
Вот пример:
class Foo {
public function bar() {
echo 'baz';
}
}
namespace Module1;
/**
*
Это класс особого декоратора, его отметкой служит DecoratorInterface (примечание переводчика: также можно использовать аннотации, конфиги и проч)
*/
class ModifiedFoo extends \Foo implements \DecoratorInterface {
public function bar() {
parent::bar();
echo ' modified';
}
}
// ... где-то в коде приложения
$object = new Foo();
$object->bar(); // will echo 'baz modified'
Как так вышло? Это уличная магия ) Мы разворачиваем цепочку наследования вспять. Исходный класс — без внутреннего кода. В результате компиляции мы препроцессим код так, что содержимое исходного класса уходит в отдельный класс, который будет новым родителем для цепочки:
// пустой код исходного класса, который будет использоваться, чтобы инстанцировать новые объекты
class Foo extends \Module1\ModifiedFoo {
// move the implementation from here to FooOriginal
}
namespace Module1;
// Здесь мы создаем особый класс, который будет расширять другой класс с исходным кодом
abstract class ModifiedFoo extends \FooOriginal implements \DecoratorInterface {
public function bar() {
parent::bar();
echo ' modified';
}
}
// Новый родительский класс с исходным кодом. Все цепочки наследования будут начинаться с него
class FooOriginal {
public function bar() {
echo 'baz';
}
}
Если кратко, то в приложение встраивается компилятор, который строит промежуточные классы, и autoloader, который будет загружать эти промежуточные классы вместо исходных.
А теперь немного подробнее. Компилятор строит список всех классов, используемых в системе, и для каждого класса, который не является декоратором, находит все подклассы, которые будут его декорировать с помощью DecoratorInterface. Он создает дерево декораторов, проверяет, нет ли там циклов, сортирует декораторы по их приоритету об этом подробнее далее) и строит промежуточные классы, где цепочка наследования будет развернута в обратную сторону. Исходный код преобразуется в новый класс, который станет новым родительским классом для цепочки наследования.
Звучит сложно. Так оно и есть, это действительно сложная комплексная система. Однако она позволяет очень гибко комбинировать модули, и с помощью этих модулей вы можете модифицировать абсолютно любую часть вашего приложения.
А если один класс переписывается несколькими модулями?
В случае, если в игру вступает несколько декораторов одновременно, они попадают в цепочку декорирования согласно их приоритету. Приоритет можно задать с помощью аннотаций (мы пользуемся Doctrine\Annotations) или конфигов.
Рассмотрим пример:
class Foo {
public function bar() {
echo 'baz';
}
}
namespace Module1;
class Foo extends \Foo implements \DecoratorInterface {
public function bar() {
parent::bar();
echo ' modified';
}
}
namespace Module2;
/**
* @Decorator\After("Module1")
*/
class Foo extends \Foo implements \DecoratorInterface {
public function bar() {
parent::bar();
echo ' twice';
}
}
// ... где-то в коде приложения
$object = new Foo();
$object->bar(); // вывод 'baz modified twice'
В данном примере аннотация Decorator\After используется, чтобы поставить декоратор другого модуля Module 1 перед модулем Module 2. Компилятор проанализирует файлы, учтет аннотации и построит промежуточный класс с такой цепочкой наследования:
Также можно использовать такие аннотации:
- Decorator\Before (чтобы поместить декоратор перед декораторами другого модуля или выше по весу)
- Decorator\Depend (чтобы включить декоратор, только если указанный модуль включен в системе)
Данного набора аннотаций (Before, After, Depend) абсолютно достаточно для построения любой комбинации модулей и классов.
А есть прямо рабочие примеры?
Есть! Для наглядности я подготовил демку приложения, она находится вот в этом репозитории GitHub. Это написанное на PHP приложение имеет модульную архитектуру, и модули могут подмешивать код без рекомпиляции. При этом модули можно добавлять и удалять, но в этом случае рекомпиляция уже понадобится. Более детально все это описано в readme файле.
Есть и совсем «боевые» примеры. На рынке уже есть несколько программных продуктов, которые используют такой подход. В частности, нечто очень похожее используется в OXID eShop. Кстати, у них прикольный стиль изложения в блоге. Еще в одной платформе, X-Cart 5, данный подход реализован именно в той форме, в которой я его описал — код X-Cart 5 даже был взят за основу для этой статьи. Это позволило создать очень гибкое решение для электронной коммерции, которое можно расширять настолько, насколько хватит фантазии разработчика (или денег заказчика =)), и при этом не ломать последующие апгрейды ядра.
Привычные хуки и патчинг лучше! Или нет?
Как и подход с Forwarding Decorators, использование хуков и патчинг “в лоб” имеют свои плюсы и минусы.
- Хуки (или какая-либо реализация шаблона Observer ) широко используются во многих популярных приложениях, например в Wordpress. Среди плюсов — четко определенный API, прозрачный способ регистрации Наблюдателя. Самый большой недостаток — ограниченное количество точек входа для встраивания расширений, также неудобством является порядок выполнения (сложно полагаться на результат работы других хуков)
- Патчинг “в лоб” — самый тривиальный и очевидный способ расширения, однако он нам представляется достаточно рисковым. Во-первых, он существенно затрудняет чтение и анализ кода, во-вторых — усложняет откат изменений в случае их неправильности. Также, осложняется и наложение нескольких патчей одновременно так, чтобы они не противоречили друг другу и не ломали функционал. Другими словами, это наименее контролируемый и управляемый способ, и если в простых решениях он себя оправдывает, то с усложнением системы эти минусы растут пропорционально ее комплексности.
- Dependency Injection — код в системе с DI строится вокруг понимания, что необходимые зависимости не получаются вручную, а поставляются откуда-то извне или к ним доступ осуществляется опосредованно — опять же через некоего поставщика (чаще всего это какой-либо IoC-контейнер).
Зависимости удовлетворяют некому интерфейсу и являются законченной реализацией некой функциональности. Через систему расширения можно подменять одну реализацию зависимости на другую исходя из текущей конфигурации системы.
Реализации могут быть наследованными от базовых или же декорированными в классическом смысле декоратора — как в Symfony 2, например, как описано здесь. Проблема такой архитектуры в том, что весь код должен строиться c использованием DI-style получения зависимостей. Отличие от описанной в статье системы в том, что forwarding decorator позволяет подменять классы абсолютно прозрачно во всех точках использования.
Помимо этого, непонятно, как организовать композицию нескольких модулей, расширяющих один и тот же сервис — придется писать отдельную систему, т. к. популярные IoC-контейнеры никак не разрешают данную проблему (это находится вне области ответственности таких библиотек).
Заключение
Forwarding-декораторы — это подход, по меньшей мере заслуживающий внимания. Он может использоваться для решения проблемы разработки расширяемой модульной архитектуры приложений на языке PHP. При этом будут использоваться знакомые конструкции, такие как наследование или область видимости полей/методов/классов.
Реализация такого концепта — задача нетривиальная, возможны сложности с отладкой, но они преодолимы при условии, что вы потратите некоторое время на должную настройку компилятора.
Если будет интерес к данному материалу, в следующей статье я напишу, как сделать оптимальный компилятор с автозагрузчиком и использовать потоковые фильтры (PHP Stream filters), чтобы включить пошаговый дебаггинг исходного кода через XDebug. Интересно? Дайте об этом знать в комментариях. А еще я буду рад вашим вопросам, советам и конструктивной критике.