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

Однако есть один подход, который именно в PHP можно встретить довольно редко. Он включает использование нативного наследования и позволяет патчить код «более лучше»(с). Мы называем этот способ “Forwarding Decorator”. Нам он представляется достаточно эффективным, и, кстати, эффектным тоже, хотя последнее не так важно в продакшене.

Как автор оригинальной англоязычной статьи "Achieving Modular Architecture with Forwarding Decorators", опубликованной на SitePoint, я представляю вам авторскую версию перевода. В ней я сохранил изначально задуманный смысл и идею, но постарался максимально улучшить подачу.

Введение


В данной статье мы рассмотрим реализацию подхода с использованием Forwarding Decorator, а также его плюсы и минусы. Сравним данный подход с другими хорошо известными альтернативами, а именно — с использованием хуков, патчингом кода или DI (dependency injection). Для наглядности есть демо-приложение вот в этом репозитории GitHub.

Основная идея состоит в том, чтобы рассматривать каждый класс как сервис, и модифицировать этот сервис посредством наследования и реверсирования цепочки наследников при компиляции кода.

В системе, основанной на такой идее, в любом модуле можно создать специальный класс-декоратор (отмеченный особым образом). Такой класс получит поля и методы другого класса через механизм наследования, но после компиляции будет везде использоваться вместо оригинального класса.
image
Собственно, поэтому мы и называем такие классы 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. Компилятор проанализирует файлы, учтет аннотации и построит промежуточный класс с такой цепочкой наследования:
image
Также можно использовать такие аннотации:

  • 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. Интересно? Дайте об этом знать в комментариях. А еще я буду рад вашим вопросам, советам и конструктивной критике.