Простой плагин для Twig или разворачиваем константы

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

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

    Сама проблема — в константах внутри шаблонов. Бывают такие задачи, когда в шаблоне необходимо зашиться на какие-нибудь идентификаторы. Цифрами расставлять их — не совсем хорошо, а если для них еще и существуют константы — грех не воспользоваться функцией constant. Но дело в том, что после компиляции из шаблона она все равно вычисляется в рантайме.

    И что же у нас может получиться? Мы на волне рефакторинга убиваем или переименовываем константу, а о шаблоне забываем. И IDE забывает, даже хваленый PHPStorm. Успешно компилируем перед деплоем всю нашу гору шаблонов, раскидываем на сервера. Ничего не упало, просто работает все не очень, а на нашу голову сваливается огромная простыня одинаковых ворнингов. Плохо? Отвратительно!

    Решение? Резолвить константы в процессе компиляции шаблона, на отсутствующие — ругаться.

    Тем, кто не знаком с Twig или знаком не очень хорошо, расскажем (очень кратко) что каждый шаблон парсится плагинами (даже базовые возможности реализованы в шаблонизаторе с помощью плагинов), обрабатывается и компилируется в php-класс, у которого потом дергается метод display. Для примера возьмем такой код шаблона, как раз с нашей константой:

    {% if usertype == constant('Users::TYPE_TROLL') %}
    	Давай, до свидания!
    {% else %}
    	Привет!
    {% endif %}

    Шаблон разберется в относительно большое дерево объектов.

    Здесь немного укороченный, но все равно большой вывод print_r представления нашего шаблона
    [body] => Twig_Node_Body Object (
    	[nodes:protected] => Array (
    		[0] => Twig_Node_If Object (
    			[nodes:protected] => Array (
    				[tests] => Twig_Node Object (
    					[nodes:protected] => Array (
    						[0] => Twig_Node_Expression_Binary_Equal Object (
    							[nodes:protected] => Array (
    								[left] => Twig_Node_Expression_Name Object (
    									[attributes:protected] => Array (
    										[name] => usertype
    									)
    								)
    								[right] => Twig_Node_Expression_Function Object (
    									[nodes:protected] => Array (
    										[arguments] => Twig_Node Object (
    											[nodes:protected] => Array (
    												[0] => Twig_Node_Expression_Constant Object (
    													[attributes:protected] => Array (
    														[value] => Users::TYPE_TROLL
    													)
    												)
    											)
    										)
    									)
    									[attributes:protected] => Array (
    										[name] => constant
    									)
    								)
    							)
    						)
    						[1] => Twig_Node_Text Object (
    							[attributes:protected] => Array (
    								[data] => Давай, до свидания!
    							)
    						)
    					)
    				)
    				[else] => Twig_Node_Text Object (
    					[attributes:protected] => Array (
    						[data] => Привет!
    					)
    				)
    			)
    		)
    	)
    )

    Оно дополнительно обрабатывается [сюда нам нужно вклиниться] и в итоге компилируется вот в такой файл (тоже слегка укороченный вариант):

    class __TwigTemplate_long_long_hash extends Twig_Template {
    
        protected function doDisplay(array $context, array $blocks = array()) {
            if (((isset($context["usertype"]) ? $context["usertype"] : null) == twig_constant("Users::TYPE_TROLL"))) {
                echo "Давай, до свидания!";
            } else {
                echo "Привет!";
            }
        }
    
    }

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

    Чтобы посмотреть на проблему своими глазами убираем константу из кода и на рендере ловим:
    PHP Warning: constant(): Couldn't find constant Users::TYPE_TROLL in vendor/twig/twig/lib/Twig/Extension/Core.php on line 1387

    Именно вызов twig_constant в компилированном варианте нам нужно заменить на значение константы.

    Для расширений в шаблонизаторе предусмотрен класс Twig_Extension, от которого мы и наследуем наше расширение. Расширение может предоставлять шаблонизатору наборы функций, фильтров и прочей ерунды, какой только можно придумать, через специальные методы, которые вы можете сами найти в интерфейсе Twig_ExtensionInterface. Нас интересует метод getNodeVisitors, который возвращает массив объектов, через которых будут пропущены все элементы распарсенного дерева шаблона перед его компиляцией.

    class Template_Extensions_ConstEvaluator extends Twig_Extension {
    
        public function getNodeVisitors() {
            return [
                new Template_Extensions_NodeVisitor_ConstEvaluator()
            ];
        }
    
        public function getName() {
            return 'const_evaluator';
        }
    
    }

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

    Вот таким наш node visitor и получается:

    class Template_Extensions_NodeVisitor_ConstEvaluator implements Twig_NodeVisitorInterface {
    
        public function enterNode(Twig_NodeInterface $node, Twig_Environment $env)
        {
            // ищем ноду-функцию с названием constant и 1 аргументом
            if ($node instanceof Twig_Node_Expression_Function
                && 'constant' === $node->getAttribute('name')
                && 1 === $node->count()
            ) {
                // получаем аргументы функции
                $args = $node->getNode('arguments');
    
                if ($args instanceof Twig_Node
                    && 1 === $args->count()
                ) {
                    $constNode = $args->getNode(0);
    
                    // 1 текстовый аргумент
                    if ($constNode instanceof Twig_Node_Expression_Constant
                        && null !== $value = $constNode->getAttribute('value')
                    ) {
                        if (null === $constantEvaluated = constant($value)) {
                            // не можем найти константу - ругаемся
                            throw new Twig_Error(
                                sprintf(
                                    "Can't evaluate constant('%s')",
                                    $value
                                )
                            );
                        }
    
                        // все нашлось, возвращаем вместо функции ноду со значением константы
                        // не введитесь в заблуждение названием класса :]
                        return new Twig_Node_Expression_Constant($constantEvaluated, $node->getLine());
                    }
                }
            }
    
            // все ок, возвращаем то, что получили, в целости и сохранности
            return $node;
        }
    
        public function leaveNode(Twig_NodeInterface $node, Twig_Environment $env) {
            return $node;
        }
    
    }

    Вот так мы и заменили чуть ли не треть нашего дерева шаблона обычным значением.

    На всякий случай покажу, что же получилось в компилированном варианте
    class __TwigTemplate_long_long_hash extends Twig_Template {
    
        protected function doDisplay(array $context, array $blocks = array()) {
            if (((isset($context["usertype"]) ? $context["usertype"] : null) == 2)) {
                echo "Давай, до свидания!";
            } else {
                echo "Привет!";
            }
        }
    
    }

    Просто, интересно, полезно.

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

    Подробнее
    Реклама
    Комментарии 37
    • +1
      А если используется встроенный в Twig кэш шаблонов, который сохраняет компиляцию шаблона в файл и далее просто «дёргает» этот файл. Используя ваш метод придётся заново генерировать весь кэш шаблонов, которые используют константу.
      • 0
        ЕМНИП Twig сам генерирует новый кэш при изменении кода шаблона
        • 0
          так изменится только константа, шаблон останется прежним
          • 0
            Как я понял автор захотел заменить вызов constant('Users::TYPE_TROLL') на просто Users::TYPE_TROLL, что является изменением кода шаблона.
          • +1
            при деплое кэш обычно сбрасывают.
          • 0
            Я файловый кеш и имею в виду. Для того и сделано чтобы при генерировании кеша он ругнулся если константы нет, а если есть — подставил значение.
            • 0
              в скомпилированном классе шаблона
              ...
              if (((isset($context["usertype"]) ? $context["usertype"] : null) == 2)) {
              ...
              


              2 — это же значение константы Users::TYPE_TROLL? если да, то это строка так и будет выглядеть в кэше шаблона. и если вдруг значение Users::TYPE_TROLL изменится или в обще убрать константу то придётся сбрасывать кэш.
              • 0
                Я в посте описал проблему в самом начале. При выкатке в бой кеш шаблонов компилируется полностью, снуля и целиком, чтобы в бою не пришлось. Именно на этом этапе хорошо было бы ругнуться, чтобы можно было поправить ошибку до того, как она попала в бой. А если ошибки нет, то бонусом значение запилить вместо вызова в рантайме.

                Надеюсь, теперь понятно расписал :]
              • +1
                У автора проблема довольно простая. Каждый раз когда кто-то забывает поправить шаблон после изменения констант в моделях, где-то умирает котенок во время сборки проекта в каком-нибудь CI сервере сброс и разогрев кэша не вызывает никаких ошибок, и о том что есть какие-то проблемы с шаблонами мы узнаем только после деплоя. Автор же хотел что бы на этапе сбоорки проекта ДО деплоя, любые проблемы с шаблонами вызванные изменением констант останавливали сборку и CI сервер слал бы уведомление что все плохо и сломано.
            • +1
              Нельзя так делать. Шаблоны перекомпилируются по времени их изменения, а не по изменению классов, которые использует шаблон.
              Я бы такой шаблон написал так:

              {% if user.troll %}
                  fuckoff
              {% else %}
                  ok
              {% endif %}
              

              И соответственно метод User::isTroll()
              А вот на счет проверки — да, хорошая штука. Вроде как уже PR есть скоро в ядре подобная проверка будет
              • +2
                В моем случае шаблоны перекомпилируются при выкатке, сделано для ругани на этом этапе, чтобы не допустить кривые константы в бой.
                • 0
                  Ну так у вас сейчас включен дебаг в Twig_Environment.
                  А когда Вы его отключите, то Вам верно выше указали. В случае чего придётся сбрасывать кэш.
                  Ну или держать постоянно на дебаге, но в таком случае смысла с кэширования нет.
                  • +2
                    Я даже не знаю, как мне еще объяснить свою проблему и ее решение :(
                    Прочитайте что-ли пост.
            • +1
              А стоит ли вообще логикой приложения грузить шаблон?

              Имхо, если у вас есть некая проверка, не является ли пользователь троллем, то правильнее было бы это обсчитать в ваших моделях, а шаблону просто передать флаг userIsTroll.
              • 0
                Это для наглядности. Бывают места, где таких завязок очень много и выносить это в контроллер или модель не выгодно — получится еще более трудно поддерживаемая простыня свойств и функций, используемых только в одном месте.
                • +2
                  Как раз правильно в модель выносить подобные проверки, чтобы не сверяться с константами. Как минимум это способствует сохранности чистоты в шаблонах и выносу логики. Оставили метод isBlabla и потом проверяете. Можете убрать константы, отрефакторить 10 раз, а isBlaBla всегда будет работать, а если нет — ошибку тут же увидите
                  • 0
                    Точно так же не увижу ошибки и точно так же про шаблон забудется :)
                    • 0
                      Омг. У вас будет ошибка, что twig не нашел свойства blabla, методов has, is.
                      Еще раз, логика модельки остается логикой модельки, а ее свойства узнаются через методы.
                      Вы пишите
                      {{ user.troll ? 'fuck off' : 'ok' }}
                      

                      А в модели это может быть хоть как:
                      public function isTroll()
                      {
                          return $this->status === self::TROLL_STATUS;
                      }
                      
                      public function isTroll()
                      {
                          return $this->hasStatus('troll');
                      }
                      
                      public function isTroll()
                      {
                          return 100 > count($this->getLastMessages(60*60*6));
                      }
                      
                      • 0
                        #!/usr/bin/env php
                        <?php
                        
                        require 'vendor/autoload.php';
                        
                        class JustForTest { }
                        
                        $loader = new Twig_Loader_String();
                        $twig = new Twig_Environment($loader);
                        
                        echo $twig->render('Hello, {{ test.notExists() }}', [
                        	'test' => new JustForTest()
                        ] );

                        даже ворнинга не дает, выводит «Hello, ».

                        И да, я согласен, что по возможности надо выносить такое дело в модели, но это не всегда выгодно, я уже писал выше.
                        • +1
                          Вы просто твиг готовить не умеете значит. twig.sensiolabs.org/doc/api.html#environment-options — обратите внимание на strict_variables.
                          Вот исключение:
                          Twig_Error_Runtime: Method "notExists" for object "JustForTest" does not exist in "Hello, {{ test.notExists() }}" at line 1 in /Users/hell0w0rd/Desktop/test/vendor/twig/twig/lib/Twig/Template.php on line 438
                          
                          • 0
                            Если вы умеете хорошо готовить, в том числе Twig, — приходите к нам на собеседование :)
                            Со strict_variables уже не так комфортно, мне кажется. Да и поздно уже его включать, когда шаблонов слишком много.

                            В любом случае это — уже отдельная тема для обсуждения.
                            • 0
                              Если принимаете стажеров-студентов, прийду) Для того чтобы было комфортно в симфони например есть тестовое окружение. Где все как на продакшене, но включен режим дебага, в тч strict_variables.
                              Или вы имеете ввиду что вам переодически нужно использовать несуществующие свойства объектов, а проверки писать не хочется? На мой взгляд выключить strict_vars, это как выключить варнинги и нотисы в php)
                              • –1
                                Да, я имею в виду что в существующих шаблонах сплошняком завязки на то, что отсутствующее свойство — аналог false. И, на самом деле, это слишком удобно чтобы от этого взять и отказаться.
                                Насчет стажеров-студентов не уверен, но вас никто не съест, если вы спросите это в отклике на наши вакансии.
                                • 0
                                  А можно пример? Вы бы кстати это в статье указали, что подобное нужно когда отключены strict_vars потому-то потому-то)
                                  • 0
                                    Да вот пример — {% if errors %}...{% endif %}.
                                    strict_variables не влияет на константы, а топик про них. Да и даже не особо про них, сколько поверхностно о системе расширений.
                                    • 0
                                      Ну тут опять же если везде в шаблонах возможно есть errors, а может и нет — я бы определил глобальную переменную, по умолчанию пустую, а если ошибки есть — переопределять ее в скопе шаблона при передаче аргументов.
                                      Но конкретно в таком виде мне не понятно как у вас собираются аргументы для шаблонов. Обычно это как-то так:
                                      $errors = [];
                                      foreach($form as $field) {
                                          $errors[] = $field->getError();
                                      }
                                      // Или
                                      $errors = $form->getErrors();
                                      

                                      То есть в шаблон и так и так передадутся errors, но в хорошем случае массив будет пуст, и проверка нормально отработает, или же там что-то будет и проверка опять же нормально отработает.
                                      Так что мое мнение уже озвучил — отключать strict равносильно отключению ошибок в php. Вы же вот так:
                                      foreach($foo as $bar) {
                                          $arr[] = $bar;
                                      }
                                      

                                      Не пишите? Вот в шаблонах тоже на мой взгляд так не стоит.
                                      • 0
                                        Такой путь ведёт к результирующей каше.

                                        Конечно, заманчиво ввести глобальный вывод ошибок, а для тех контроллеров, для которых это пока что не удалось внедрить, просто ничего не выводить. В итоге логика, которая должна везде работать одинаково, работает по-разному. Вывод ошибок нужен только там, где про ошибки вообще знают. А если про ошибки знают, то результат может быть только один — ошибок нет (errors = null, errors = [], etc.) или ошибки есть (errors = [...]). Это говорит в том числе и о том, что ошибки не забыли импортнуть в шаблон, ошибок действительно нет.

                                        Если бы интерпретатор php изначально шёл в strict-режиме без возможности устраивать хороводы, то никто бы не говорил, что «неинициализированная переменная равна null — это удобно». Нестрогий режим может использоваться только на продакшне (и то только из-за того, что PHP изначально допускает различные режимы). На девелопменте — только strict со всем вытекающим (написал шаблон, шаблон развалился — исправляй).
                                        • 0
                                          Это совершенно некорректно и ведет к тем же проблемам, что и в старых версиях php делал register_globals и не выставленный в E_ALL уровень репортинга. Умом понимаю, почему Twig по умолчанию не включает стрикт по умолчанию, но сердце отказывается верить. Да и согласитесь,

                                          {% if errors is defined %}

                                          выглядят куда круче. В крайнем случае это может быть

                                          {% if errors|default('') is empty %}

                                          Когда у нас логика сложнее/ненормальнее.
                                  • 0
                                    Тема-то не отдельная. Какой смысл предлагать велосипеды из-за плохо прочитанной документации?
                        • 0
                          Если у вас в отображении есть логика, не касающаяся самого отображения, то можете перестать употреблять слова вроде «модель», «контроллер», «отображение».

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

                          С этой точки зрения верстальщику нужно знать только некий «контракт», например:
                          user — объект, данные и параметры текущего пользователя;
                          user.firstName — строка, имя пользователя;
                          user.lastName — строка, фамилия пользователя;
                          user.troll — булевый флаг, является ли пользователь троллем;
                          users — список объектов user.

                          Как именно реализуется user.troll и остальные вещи верстальщику знать не обязательно. Для решения задачи: «Вывести в таблице список пользователей с указанием имени и фамилии. Строки пользователей-троллей помечать классом warning.» верстальщику вы не нужны.
                          • –1
                            Вы живете в идеальном мире, где все просто и удобно, но на самом деле все не так.

                            Это — действительно отдельная тема для обсуждения.
                            • 0
                              На самом деле, это всего лишь ваше неправильное мнение.

                              Нет никого идеального мира. Контроллер разделяет логику и отображение. К контроллеру нужно относиться так же, как к API сервиса — контекст, который он задаёт шаблону — это все данные, которые шаблону нужно и можно знать.

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

                              С этой точки зрения вообще сложно понять fabpot. Кто-кто, а он должен понимать, что предоставление таких расширений по умолчанию — это очень плохо.

                              В итоге получаем следующее: github.com/fabpot/Twig/issues/1149

                              Как результат, имеем аналог strict-mode для PHP — sandbox-mode. Это для тех, кто не привык использовать PHP в стиле «ай, пофиг, можно же», а привык думать и разделять.
                              • 0
                                Нет правильного или неправильного, есть задачи, которые нужно решать.
                                Если вы думаете что я внезапно решу переписать все шаблоны и контроллеры — вы ошибаетесь.

                                Предлагаю на этом дискуссию закончить.
                          • +1
                            Я могу представить только пару кейсов в которых нужно использовать константы в шаблонах:
                            проверка на то, может ли пользователь делать что-то (тут лучше сделать карту правил для пользователей и там определить что им можно а что нельзя и проверять в шаблоне без привязки к модели и константам)
                            в зависимости от типа контента нужно что-то другое вывести. тут опять же лучше уж использовать разные шаблоны и общие части вынести в базовый шаблон (слава наследованию шаблонов).

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

                              user.hasPermission(constant(User::PERM_KILL_CAT)) или acl.subjectHasPermission(user, constant(Acl::PERM_KILL_CAT'))

                              Вводить для каждого разрешения методы типа user.canKillCat (даже если использовать _call) по-моему избыточно.
                          • +1
                            Если тролль или не тролль пользователь определяется в результате анализа некоего статуса, представленного «перечислением», то для каждого статуса иметь флаг userIs<Status> (или метод user.Is<Status>()) несколько напряжно может быть. Использование констант в шаблоне тут вполне оправдано, если они используются для определения что пользователю показывать, а что нет (как инфу, так и интерфейсы). А собственно что ещё в шаблоне может быть :)

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