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

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

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


  1. oxidmod
    18.05.2017 13:10

    Подобным образом строится система модулей в системе OXID eShop.

    Компилятором там является обычный eval ))


    1. Daemos
      18.05.2017 14:06

      Ага, я о вас тоже в статье написал, в пункте про боевые примеры) Вам самим, кстати, нравится эта система, или, если бы можно было все отмотать назад и переделать, вы бы от нее ушли и сделали, например, на хуках?


      1. oxidmod
        18.05.2017 14:22

        Точно, а я просмотрел что-то. Я не имею никакого отношения к разработчикам OXID eShop, но года два назад доводилось писать модули под эту платформу. С точки зрения разработчика это очень удобно и просто. Ты вроде просто расширяешь класс и понеслась. Но это легко и просто до тех пор пока ты не подтягиваешь модули сторонних разработчиков. Их нужно очень внимательно просматривать и анализировать. Много раз ловили баги, когда сторонние модули не вызывали родительскую реализацию и в итоге твой код просто не выполнялся. Решается это изменением приоритета модулей в админке, так чтобы проблемный модуль работал последним и парент вызывать не было нужды. Но если таких модулей два, то все, пиши пропало.
        Круто что таким образом можно расширить практически любой класс в системе, а не только в тех местах куда можно вклинится хуком/евент листенером. Не круто, что все это надо контролировать вручную))
        Я правда давно уже неработаю с магазинами вообще, возможно в свежих версиях что-то у них поменялось))


        1. Daemos
          19.05.2017 15:22

          Понятно, я думал, что тут как в реддите, username checks out)


          Их нужно очень внимательно просматривать и анализировать. Много раз ловили баги, когда сторонние модули не вызывали родительскую реализацию и в итоге твой код просто не выполнялся.

          Очень знакомо и проблемы у нас те же. Действительно, нужно очень аккуратно привносить изменения и следить за побочными эффектами, зато можно это сделать куда угодно. Бывают даже кейсы, когда модуль изменяет сам себя несколько раз в зависимости от состава включенных\выключенных модуле, вот тут вообще мозг взрывается.


          Правда, проблема с вызовом парента у нас довольно успешно решается конвенцией "всегда вызывай парент".


  1. hanovruslan
    19.05.2017 12:57

    Статья, содержащая в себе фразы типа "нативное наследование", изначально настраивает на недоверие.


    • Вмешиваться в иерархию типов кажется очень сомнительной идеей. Более того, определение кто кого расширяет противоречит идее использования интерфейсов. как самих по себе так и в составе SOLID.
    • Не являюсь большим знатоком ворпресса, но коль уж система имеет вполне определенное назначение, что упрекать её в ограниченном наборе хуков, наверное, неуместно.
    • минусы использования DI надуманные. Его (DI) в хороших реализация работа заканчивается на этапе компиляции зависимостей. А также под DI стилем имеется в виду что? Конфиги? или код? Если конфиги, то тут объяснение простое — явное указание зависимостей — это хорошая практика. Да, часто разработчики испытывают соблазн использовать магию в виде автовайринга но мы же знаем, что явное лучше неявного. Если код, то как правило нет никакого стиля, это просто обычные конструкторы, сеттеры или публичные методы с определенными сигнатурами. Это обычные пользовательские типы и DI тут просто как потребитель а не "требователь" архитектуры.

    Это навскидку.


    1. Daemos
      19.05.2017 14:52

      Статья, содержащая в себе фразы типа "нативное наследование", изначально настраивает на недоверие

      Здесь имелось ввиду использование языковой конструкции, а не какой-либо другой связи двух классов. Наверное, слово "нативное" действительно стоит опустить.


      Вмешиваться в иерархию типов кажется очень сомнительной идеей. Более того, определение кто кого расширяет противоречит идее использования интерфейсов. как самих по себе так и в составе SOLID.

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


      Не являюсь большим знатоком ворпресса, но коль уж система имеет вполне определенное назначение, что упрекать её в ограниченном наборе хуков, наверное, неуместно.

      Никто не упрекает Вордпресс в ограниченности их набора хуков, речь лишь об ограничениях самого подхода. Я очень часто встречался с ситуациями, когда необходимо расширить какое-то поведение тем образом, который не предусмотрел создать оригинальной системы. В бизнесе невозможно предугадать все потенциальные точки расширения.


      Выбор подхода всецело зависит от назначения системы и здесь нет идеального решения. Вордпресс такое устроило, нашу систему — нет.


      А также под DI стилем имеется в виду что? Конфиги? или код? Если конфиги, то тут объяснение простое — явное указание зависимостей — это хорошая практика.

      Конфиги\код — неважно, имеется ввиду использование единого Composition Root и предоставления зависимостей через конструктор или, на худой конец, сеттеры во всех классах системы.


      Проблемы начинаются тогда, когда вам необходимо добавить новую зависимость в конструктор класса через модуль или же заменить реализацию сервиса. Особенно, когда одновременно несколько модулей стремятся подменить сервис своей реализацией. Популярные DI-контейнеры данную проблему абсолютно никак не решают, как и упомянул в статье. Так или иначе придется изобретать свою систему, у пресловутой Magento 2, например, также производится компиляция промежуточного класса с учетом всех модулей, которая доступна через DI и заменяет оригинальный сервис.


      P.S. В статье идет речь о системах с динамическим набором модулей — плагинов, т.е. исполнение кода и функциональность системы очень сильно зависит от текущей конфигурации. Речь не о конкретном приложении, а о платформе, отсюда и вытекают все проблемы и приходится выдумывать, как скрещивать независимые куски кода правильно.


  1. kraso4niy
    19.05.2017 14:25

    Хороший пример «как лучше не делать»


    1. Daemos
      19.05.2017 14:56
      +1

      Да, система далеко не идеальная. Зато с помощью дискуссии мы вместе сможем придумать что-то более хорошее. Вы могли бы поделится примером "как лучше делать"?


  1. sspat
    20.05.2017 22:50

    У меня один вопрос напрашивается, а как на это все тесты писать?


    1. Daemos
      22.05.2017 08:29

      С тестами действительно есть проблема, но она скорее вытекает из-за того, что мы в коде X-Cart не получаем зависимости чисто. Если скрестить этот подход с получением зависимостей строго через сеттеры (через конструктор нельзя, т.к. модули могут потребовать лишние зависимости, а в PHP не получится красиво сделать несколько конструкторов) — можно писать обыкновенные тесты.


      Проблему с наличием разных наборов модулей мы решаем разными сборками и параметром --group в PHPUnit. Безусловно, покрыть все комбинации не получается, так что работаем с наиболее популярными.


  1. flancer
    25.05.2017 18:19

    Интересный способ. В Magento 2 используется генерация промежуточного кода для плагинов и использования нагенеренного вместо оригинала в собственном IoC-контейнере. Но если использовать оригинал напрямую (через new), а не через DI, то будет задействован оригинальный код. Мне кажется, что это достаточно удачный компромисс. Я во главу угла ставлю удобство отладки, особенно, когда имеешь дело с незнакомым кодом (а незнакомым становится даже собственный код, спустя какое-то время). Поэтому мне бы хотелось как можно меньше иметь подобного нагенеренного кода в приложении. Но так как подобный подход (плагины в Magento 2 или описанные Forwarding decorators) дает очень хорошую гибкость при независимой модульной разработке сложных систем, то, похоже, что так или иначе он будет использоваться.


    Существующие в Magento 2 плагины позволяют оборачивать (before/after/around) любой публичный метод любого класса, создаваемого в IoC-контейнере, и выстраивать конвейеры из подобных «оберток» на основании зависимостей между модулями, в которых эти «обертки» объявлены. При удалении модуля соответствующая обертка выбывает из конвейера, при добавлении модуля — встраивается в соотв. место конвейра. Мне кажется это более удачный подход, чем выстраивание цепочек наследования (особенно, если зависимости между модулями не линейные, а древовидные), т.к. «обертки» полностью независимы друг от друга с точки зрения наследования. Если бы я обдумывал архитектуру приложения, я бы шел по этому пути (оборачивание отдельных методов и использование через DI, а не forwarding decorators).


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