Хук ООП не друг или Динамическое автонаследование классов

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

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

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

    Но когда движок написан с использованием ООП и все разложено на классы, то использование хуков – как это чужеродно и «костыльно», и хочется более чистого и более простого ООП-подхода, когда в создаваемом расширении просто расширяется «коробочный» класс с перекрытием родительских методов.

    Вот для решения таких задач и был придуман способ, который я назвал «Динамическое автонаследование».

    И объяснять этот способ я буду на примере того, как это реализовано в системе поддержки плагинов в Alto CMS.

    Допустим, есть исходный «коробочный» класс:
    class ModuleUser {
    
    	public function Init() {
    		// Init code here
    	} 
    
    	public function GetRecord() {
    		// Some code here
    		return $oRecord;
    	}
    }


    И у сторонних разработчиков возникает необходимость расширить класс ModuleUser, причем, один хочет изменить метод Init(), другой – метод GetRecord(), а третий – добавить своей логики в оба метода. И при этом надо обеспечить работоспособность всех трех расширений на любом сайте и в любой комбинации (т.е. где-то стоит одно расширение, где-то – другое, где-то – два из них, а где-то – и все три).

    Итак, пусть сторонние разработчики независимо друг от друга пишут плагины, которые будут называться незатейливо First, Second и Third, и в каждом из них требуется класс-наследник от ModuleUser. В Alto CMS такие классы-наследники оформляются следующим образом:
    class PluginFirst_ModuleUser extends PluginFirst_Inherits_ModuleUser {
    
    	public function Init() {
    		parent::Init();
    		// New code here
    	} 
    }
    

    class PluginSecond_ModuleUser extends PluginSecond_Inherits_ModuleUser {
    
    	public function GetRecord() {
    		$oRecord = parent::GetRecord();
    		// Some code with $oRecord here
    		Return $oRecord;
    	}
    }
    

    class PluginThird_ModuleUser extends PluginThird_Inherits_ModuleUser {
    
    	public function Init() {
    		parent::Init();
    		// Init code here
    	} 
    
    	public function GetRecord() {
    		// Yet another code here
    		return parent::GetRecord();
    	}
    }
    


    Вы, конечно же, обратили внимание, что классы наследуются не напрямую от родителя ModuleUser, а через классы-посредники — PluginFirst_Inherits_ModuleUser и т.д. Вот в этих классах-посредниках и заложена вся соль.

    Кроме того, в плагинах в специальных свойствах указывается, что в них используется динамическое автонаследование от класса ModuleUser:
    class PluginFirst extends Plugin {
        /** @var array $aInherits Объявление переопределений (модули, мапперы и сущности) */
        protected $aInherits = array(
            'module' => array(
                'ModuleUser',
            ),
        );
        // Plugin code here
    }
    


    Теперь при каждой загрузке ядра и инициализации подключенных плагинов будет создаваться стек наследований класса ModuleUser (пусть в нашем примере порядок будет такой: PluginFirst_ModuleUser, PluginSecond_ModuleUser, PluginThird_ModuleUser). И как только будет обращение к классу ModuleUser (создание экземпляра объекта выполняется через вызов специального метода), то автозагрузчик будет сначала проверять стек наследований и подгружать последний зарегистрированный там класс (в нашем примере — PluginThird_ModuleUser). При этом, разумеется, проверяется наличие класса-родителя PluginThird_Inherits_ModuleUser и т.к. его нет (а такого класса действительно нет), то делается попытка загрузить и его. И вот тут и начинается «магия».

    Автозагрузчик анализирует имя родительского класса и понимает, что это – класс-посредник, его на самом деле не существует, а он является лишь алиасом предыдущего класса в стеке наследований, и сей факт закрепляет с помощью PHP-функции:

     class_alias('PluginThird_Inherits_ModuleUser', 'PluginSecond_ModuleUser');
    


    И теперь вместо класса-посредника PluginThird_Inherits_ModuleUser выполняется загрузка реального класса PluginSecond_ModuleUser. Его родитель – тоже класс-посредник, и он становится алиасом предыдущего класса из стека PluginFirst_ModuleUser. А вот PluginFirst_ModuleUser – это последний класс в стеке, поэтому его класс-родитель становится алиасом уже исходного «коробочного» класса ModuleUser.

    В итоге цепочка наследований в системе получается такая:


    Теперь, каждый раз, когда в системе будет запрос на создание экземпляра класса ModuleUser, то на самом деле объект будет создаваться от класса PluginThird_ModuleUser, и, с учетом цепочки наследований, он наследует все то, что было задумано разработчиками всех трех плагинов.

    Преимущества принципа динамического автонаследования
    • Никаких костыльных хуков, чистый ООП со всеми вытекающими
    • Плагины (и классы-наследники) могут разрабатываться независимо друг от друга, нет необходимости знать, как в точности будет называться класс-родитель
    • В цепочке наследования может быть неограниченное число классов, которые автоматически будут наследоваться друг от друга


    Недостатки:
    • Как верно было замечено в комментах, IDE таких «фокусов» не поймут, ибо наследование «динамическое»
    • … — а вот иных существенных недостатков я, честно говоря, не вижу.

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

    Так же будет интересно узнать, встречал ли кто подобный способ расширения функционала в других CMS (мне до сих пор такого не попадалось).

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

    PS На всякий случай для перфекционистов от кодирования — да, я знаю про неймспейсы и понимаю, что все может быть оформлено с их помощью (и даже соглашусь, что желательно их тут использовать), но пост все же о другом, поэтому предлагаю не зацикливаться на этой стороне вопроса.
    Поделиться публикацией

    Комментарии 36

      +4
      То, что вы изобрели, называется «декоратор» и является стандартным паттерном в программировании. Удачи в изобретении «адептера», «компоновщика», «заместителя», а также других типов шаблонов.
        0
        Ненавижу слово «хук», лаконичние использовать observer или «наблюдатель» по нашему…
          +1
          Не-не-не, не надо мне приписывать чужих подвигов. Я не изобретал ни декораторов, ни CMS, ни ООП вообще. Я пишу о вполне конкретной реализации расширения функционала.

          К тому же в описанном способе есть некоторое отличие от того, что обычно называют «декоратором». Во всяком случае в тех реализациях, которые я видел на PHP (реализацию на других языках не смотрел) декоратор, как правило, расширяет функциональность объекта, а не класса. Т.е. что-то типа того:

          $oUser = new DecoratorUser(new ModuleUser());
          


          И сравните с моим вариантом (код условный):

          $oUser = E::ModuleUser();
          
            0
            А, вот вы чего сделали. Действительно, это не декоратор.

            Ну, тогда перечисляю недостатки вашего подхода, раз уж в статье вы их не нашли:
            1. IDE такое не поймет. Я не пишу на PHP, поэтому не в курсе, какие IDE тут есть и что они умеют — но невозможность любого статического анализа иерархии не есть хорошо в любом случае.

            2. Новый человек в проекте такого не поймет и долго будет искать класс PluginFirst_Inherits_ModuleUser. Впрочем, эта проблема решается правильными комментариями.

            3. Невозможно построить две разные цепочки обработчиков (с разными списками включенных плагинов) в рамках одного запроса.

            4. Непригодность архитектуры для долгоживущих процессов (невозможно динамически включать и выключать плагины). В этом плане вам несколько повезло с языком программирования, поскольку PHP «рожден умирать». Но всегда попадаются исключения.
              0
              По пунктам:

              1. Это абсолютно верно. Поскольку речь именно о «динамическом» наследовании, то ни одна IDE этого не поймет. В лучшем случае — это если какое-то специальное расширение для IDE писать (где это возможно). Принимается.

              2. Объяснить такие вещи человеку гораздо проще, чем IDE, и грамотная документация решает проблему.

              п.3 и п.4 — речь идет о расширение функционала в CMS, написанной на PHP. Само это понятие — CMS на PHP — уже накладывает такое количество ограничений на сферу применения, что эти два пункта, ИМХО, просто тонут в общем потоке.
          +2
          Здесь есть проблема. Ваш класс ModuleUser — это в чистом виде Public API. Поправьте если не я не прав, но для меня, как для стороннего разработчика плагина, очевидно, что метод Init абстрактный. А это значит что в его реализации я не буду вызывать «parent::Init();», и буду прав. А теперь, мы подключаем сразу 3 плагина, и мой становится последним. И все — инициализация чужих всех остальных плагинов никогда не будет вызвана.
            –1
            Я так понимаю, что Вы уже глянули, как класс ModuleUser реализован конкретно в Alto CMS? ;)

            Если с этой точки зрения смотреть, то да, есть такая проблема. Это «болезнь роста», которая требует решения в последующих версиях.
              0
              Ну, это — меньшая из проблем. Если с самого начала понимать, что ты пишешь именно плагин, который может оказаться в любом месте цепочки — то такую ошибку просто не допустишь.
              0
              За class_alias большое спасибо, сколько этих функций уже в памяти, а вот такой не припоминаю, полезная.
                0
                Хорошо помню, что когда наткнулся на нее в первый раз, то был несколько удивлен: «Нафига?» Т.е. с певого взгляда она мне показалась совершенно бесполезной. Но, как оказалось, на ней целую концепцию выстроить можно :)
                  0
                  У меня сейчас автозагрузчик при загрузке класса cs\Core проверяет есть ли cs\custom\Core и так далее. Но это не позволяет сделать множественного переопределение функциональности. Но сохраняется работоспособность автодополнения в любой IDE, так как вы всё ещё вызываете cs\Core::instance().
                  А с class_alias() можно придумать что-то ещё более веселое и менее ограниченное) При этом хочется обойтись без eval()
                +3
                Не понимаю. Какой смысл использовать наследование для задачи которая решается композицией? Наследование это зло в большинстве случаев.
                  0
                  В целом очень сложное решение. Явная нехватка явности и гибкости. Думаю, все-таки система событий и слушателей здесь подошла бы намного лучше.
                    –1
                    А если попробовать абстрагироваться от тезиса, что «наследование это зло» (сегодня я не готов к халивару «композиция vs наследование»), то в чем конкретно ощущается нехватка явности и гибкости?

                    Ведь по сути своей «хуки» — это та же система «событий и слушателей», и вот ее мне было однозначно мало. Мне, например, не хватало того, что код, исполняемый во время вызова, был жестко изолирован от той среды, в которой выполнялся весь остальной код класса. А здесь — все нативно и органично. Т.е. для разработчика, пишущего расширение — ООП в самом чистом виде без всяких извращений.
                      +1
                      Не совсем понимаю, как сам по себе подход с событиями связан с проблемами изоляции. В любом подходе можно сделать любую степень изоляции, но используя наследование ты создаешь проблемы с которыми сам будешь бороться. В целом я не хочу сказать, что наследование вообще неприменимо и уж тем более холиварить на эту на эту тему, но именно в этом случае это не дает ничего кроме проблем. вроде вызова перентовой реализации и неявности вроде наследования от какого-то непонятного класса.
                        +1
                        Возможно, проблемы с изоляцией это вопрос инкапсуляции. Вроде отсутствия хорошо продуманных интерфейсов для работы с системами цмс из плагинов и как следствие необходимость работать с внутренним состоянием классов.
                        0
                        Система событий и слушателей рискует выродиться в систему костылей и подпорок — я видел кучу проектов, где так и произошло. А автору статьи подошел бы в чистом виде паттерн «декоратор» — он очень похож на решение автора, но при этом куда проще реализуется.
                          0
                          Да собственно, те самые «хуки» — это и есть система «событий и слушателей», которая активно использовалась в PHP-проектах еще до прихода туда нормального ООП, и, наверное, любая более-менее популярная CMS их использует. Но, как Вы верно отметили, нередко это вырождается в жуткую систему костылей и подпорок.

                          Да и вообще, ни один паттерн не гарантирован от «грязной» реализации, к сожалению.
                        +2
                        А что произойдет, если в модуле PluginFirst будет объявлен вспомогательный метод HandleStuff и член класса $someData, а в модуле PluginSecond будет объявлен другой метод HandleStuff, и в модуле PluginThird будет объявлен (и будет вовсю использоваться) член класса $someData? Коллизия имен и вызов неверных методов с неверными параметрами? Непонятное изменение члена класса с тяжелой отладкой? А что, если $someData был сначала объявлен как public:, а потом был объявлен как private:?
                          –4
                          Т.к. тут все строится на ООП, то подразумевается, что разработчики, использующие данный подход, понимают, что это такое и как работает. Объявление любого метода или свойства как public или protected означает, что эти методы и свойства доступны в классах-наследниках. И если не задумываться об этом, то да, вполне может возникнуть коллизия. Но ровно это же может случиться, если не касаться описанных в статье подходов, а просто, работая в каком-то проекте на ООП, расширять цепочку классов, создавая свой дочерний класс.
                            –1
                            Поправка, тут от ООП только наследование… и все. Ни нормальной инкапсуляции, ни вообще ничего…
                              +3
                              «Работая в каком-то проекте и создавая свой дочерний класс», такая ошибка будет отловлена в два счета, на этапе разработки.
                              А вот ваша реализация породит кучу «Гейзенбагов», которые отлавливаются только при каком-то определенном наборе модулей, когда любой из модулей работает правильно, но их одновременное включение порождает баги.
                                –4
                                Хочу акцентировать внимание: речь идет о CMS, и о возможности разрабатывать плагины, расширяющие любую сущность движка, независимыми друг от друга разработчиками.

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


                                  Конечно, найдется. Но при вашей реализации таких способов уже слишком много.
                                +2
                                Наверняка понимают. Только это не повод создавать им дополнительные проблемы. Ogra задает верный вопрос о проблемах конфликтов. И подобных проблем с наследованием много, именно по-этому его используют с большой осторожностью. Ваш подход пытается использовать наследование в том месте, где достаточно было бы использовать имплементацию интерфейсов, что на порядок снизило бы количество вероятных проблем.
                              +6
                              слишком абстрактный и от того бесполезный пример. Приведите более явные юзкейсы когда необходимо вводить такую дикую схему наследования объекта на примере реализации конкретного функционала. Так сложно спроецировать вашу реализацию на здравый смысл.

                              Вообще наследование зло, особенно в этом контексте. Нет возможности задавать приоритеты, какие-то вещи скажем должны быть последними в цепочке а какие-то первыми а какие-то последними (кеширование в цепочке должно быть последним скажем, а замена доступа к хранилищу данных первым звеном).

                              Вообще все эти CMS в стиле PHP4 как-то… печально сейчас выглядят… Можно сделать на интерфейсах, композиции и декораторах, с DI, минимумом бойлерплейта и компиляцией контейнера зависимостей и все такое… получится профит для всех. И вам поддерживать систему, и разработчиком проще разобраться и поддерживать и покрывать тестами свои решения, и производительность не будет так сильно страдать… И можно много чего еще придумать…

                              Грусть тоска по неймспейсам, PSR-4 и вообще всему тому что PHP комьюнити наработало за последние пару тройку лет…
                                0
                                Извините за сумбур и оговорки, вечер понедельника сказывается.
                                +1
                                vshemarov
                                И как только будет обращение к классу ModuleUser (создание экземпляра объекта выполняется через вызов специального метода), то… начинается «магия».
                                Как я понимаю — для того чтобы все работало нельзя создавать объект как обычно:
                                $object = new ModuleUser();
                                а надо использовать некий специальный метод.
                                Могли бы вы немного подробнее описать и показать — как конкретно это будет выглядеть в вашем коде?
                                  0
                                  Выше в комментах я приводил пример, как может выглядеть метод для создания объекта:

                                  $object = E::ModuleUser();
                                  

                                  Где E — это класс (singleton), выполняющий функции ядра, и там есть __callStatic, который получает управление и понимает, что нужно создать объект класса ModuleUser. И вот он уже смотрит стек наследования, определяет, нужно ли непосредственно класс ModuleUser брать (если наследников нет), либо кого-то, кто его переопределяет.
                                    0
                                    vshemarov
                                    Где E — это класс (singleton), выполняющий функции ядра, и там есть __callStatic, который получает управление и понимает, что нужно создать объект класса ModuleUser
                                    Ясно. Но почему вы не хотите использовать для этого отдельную функцию? Например:

                                    $object = newObject('ModuleUser');

                                    чтобы хоть немного «помочь» IDE и разработчику. Вы конечно можете возразить, что в вашем случае есть возможность передать в конструктор аргументы, типа:

                                    $object = E::ModuleUser($arg1, $arg2);

                                    но, при вызове __callStatic все равно все аргументы будут «собраны» в массив $arguments:

                                    public static function __callStatic($name, $arguments) { ... }

                                    и если я не ошибаюсь, то вы не сможете использовать их явно в конструкторе плагина:

                                    class PluginFirst_ModuleUser extends PluginFirst_Inherits_ModuleUser
                                    {
                                        public function __construct($arg1, $arg2)
                                        {
                                            parent::__construct($arg1, $arg2);
                                            // New code here
                                        } 
                                    }
                                    

                                    все равно придется использовать func_get_args в этих конструкторах. Я думаю, что использование отдельной функции для создания объекта с динамическим автонаследованием классов вместо __callStatic будет «понятнее» и IDE и разработчику, например:

                                    $object = newObject('ModuleUser', array($arg1, $arg2));
                                      0
                                      Вообще говоря, если действовать в духе статьи, то проще всего сделать алиас ModuleUser_AllPlugins, указывающий на конец цепочки, и вызывать конструктор напрямую…
                                        0
                                        Ну, это опять же уже детали реализации. Развивая мысль, можно, при желании, и как-то так сделать:

                                        $object = new InstanceOfModuleUser($arg1, $arg2);
                                        

                                        Но это, опять же, кому-то понравится, кому-то нет, дело вкуса
                                          0
                                          Вы написали абсолютно то же самое, что и я.
                                        0
                                        Я слегка видоизменю код, чтоб уровнять визуально:
                                        $object = E::newObject('ModuleUser', array($arg1, $arg2));
                                        

                                        $object = E::newModuleUser($arg1, $arg2);
                                        

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

                                        Например, первый вариант в теории должен работать чуть-чуть быстрее, т.к. идет явный вызов существующего метода, в то время как во втором какие-то доли секунды будут тратиться на вызов __callStatic. Насколько это существенно? Не знаю.

                                        Зато во втором случае можно хоть небольшую, но дать подсказку IDE:
                                        /**
                                         * @method ModuleUser ModuleUser()
                                         */
                                        class E {
                                            // ...
                                        }

                                        Понятно, что это не даст IDE знаний о динамически подключаемых классах, но хотя бы про базовый «коробочный» класс она знать будет.

                                        Так что из этих двух вариантов я предпочту второй. Но, повторюсь, не потому что он объективно лучше, а потому что мне он больше нравится.

                                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                                Самое читаемое