Использование аннотаций в PHP 5.4 для АОП и не только

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

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

    Технологии реализации AOP в PHP


    Волшебные методы

    Самое простое решение — использование «волшебных методов» __call и __callStatic. Эти методы вызываются (если они определены в классе) при обращении к несуществующему методу класса. В качестве аргументов они получают имя несуществующего метода и переданные ему параметры.
    В данном случае, приложение строится таким образом, что реальные методы имеют имя отличное от имени указанном в вызывающих их конструкциях. Сквозной функционал реализуется в «волшебных методах», которые, при необходимости, передают управление реальным методам классов.

    Плюсы:
    • Легко начать использовать;
    • Реализация не требует дополнительных модулей (нативный PHP).

    Минусы:
    • Не удобно использовать при большом количестве сквозного функционала;
    • Т.к. имена методов в определении и в вызовах различаются, создаются трудности при использовании автодополнения кода в IDE.

    Предварительный разбор кода

    Этот способ подразумевает наличие посредника, позволяющего использовать «синтаксический сахар». Необходимый функционал описывается вспомогательным синтаксисом (xml/json конфигурация, дополнительные php-классы или аннотации в коде), который разбирается посредником. На основе разбора генерируется результирующий код, который содержит вставки сквозного функционала в необходимые места.

    Плюсы:
    • Работает быстро, т.к. на выходе это обычный PHP-код, просто сгенерированный за Вас автоматически.

    Минусы:
    • Сложно внедрить в большой проект;
    • Требуется разбор кода после каждого изменения, для внесения корректировок в результирующий код.

    Замена кода приложения во время выполнения

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

    Встречайте: Annotator.

    Возможности Annotator


    Библиотека реализует 4 типа обработчиков:

    Info

    Обработчик типа Info получает информацию о методе во время обработки класса. Это позволяет «зарегистрировать» метод для дальнейшего использования в приложении. Например с его помощью можно назначить метод на обработку определённого URL и таким образом реализовать роутинг в приложении.

    Before

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

    After

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

    Around

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

    Установка


    Для работы Annotator требуется PHP 5.4 и модуль runkit.

    1. Качаем расширение отсюда: https://github.com/zenovich/runkit;
    2. Собираем и устанавливаем его:
      phpize && ./configure && make && sudo make install
      
    3. Если всё прошло успешно, подключаем модуль runkit.so в conf.d или php.ini;
    4. Качаем класс Annotator.php и подключаем его к проекту.

    Примеры использования


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

    Info

    <?php
    
    require_once __DIR__ . '/Annotator.php';
    
    class Advice {
    	public static function infoStatic($point, $options) {
    		var_dump($point);
    		var_dump($options);
    	}
    }
    
    class Test {
    	/**
    	 * @info Advice::infoStatic hello world
    	 */
    	public static function testInfoStatic() {
    		return 'info';
    	}
    }
    
    Annotator::compile('Test');
    

    Уже во время вызова Annotator::compile('Test'); будет вызван обработчик infoStatic класса Advice, который получит инфрмацию о методе testInfoStatic класса Test и параметры обработчика в качестве массива array('hello', 'world').

    Before

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

    <?php
    
    require_once __DIR__ . '/Annotator.php';
    
    class Advice {
    	public function before($point, $params, $options) {
    		$params['string'] = 'bar';
    	}
    }
    
    class Test {
    	/**
    	 * @registered_before
    	 */
    	public function testBefore($string) {
    		return $string;
    	}
    }
    
    $advice = new Advice();
    
    Annotator::register('registered_before', array($advice, 'before'), Annotator::BEFORE);
    
    Annotator::compile('Test');
    
    $test = new Test();
    
    echo $test->testBefore('foo');
    

    Методом Annotator::register мы создали аннотацию @registered_before, ассоциированную с методом before объекта $advice. При вызове testBefore управление будет передано в обработчик, который заменит параметр $string и вместо ожидаемого «foo» в результате работы скрипта будет выведено «bar».

    After

    <?php
    
    require_once __DIR__ . '/Annotator.php';
    
    class Advice {
    	public function power($point, $params, $options, $result) {
    		return pow($result, $options[0]);
    	}
    }
    
    class Test {
    	/**
    	 * @power 4
    	 */
    	public function testAfter($number) {
    		return $number + 1;
    	}
    }
    
    $advice = new Advice();
    
    Annotator::register('power', array($advice, 'power'), Annotator::AFTER);
    
    Annotator::compile('Test');
    
    $test = new Test();
    
    echo $test->testAfter(1);
    

    В этом примере результат работы метода testAfter будет возведён в степень 4. Скрипт выведет значение 16.

    Around

    <?php
    
    require_once __DIR__ . '/Annotator.php';
    
    class Advice {
    	private static $cache = array();
    
    	public function cache($point, $params, $options, $proceed) {
    		if (array_key_exists($options[0], self::$cache)) {
    			// Если значение ключа содержится в кеше - возвращаем его
    			return self::$cache[$options[0]];
    		} else {
    			// Если значения в кеше нет - выполняем функцию
    			$result = $proceed();
    			// Перед возвращением значения не забываем положить его в кеш
    			self::$cache[$options[0]] = $result;
    			return $result;
    		}
    	}
    }
    
    class Test {
    	/**
    	 * @cache around_cache_key
    	 */
    	public function testAround($string) {
    		return $string;
    	}
    }
    
    $advice = new Advice();
    
    Annotator::register('cache', array($advice, 'cache'), Annotator::AROUND);
    
    Annotator::compile('Test');
    
    $test = new Test();
    
    echo $test->testAround('foo') . PHP_EOL;
    echo $test->testAround('foo') . PHP_EOL;
    echo $test->testAround('foo') . PHP_EOL;
    

    Этот пример представляет реализацию простого механизма кеширования. Метод testAround вызывается 3 раза подряд, но будет выполнен только 1 раз. Остальные 2 раза значение будет взято из статической переменной $cache класса Advice, куда оно будет сохранено после первого вызова.

    Использование нескольких обработчиков

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

    <?php
    
    require_once __DIR__ . '/Annotator.php';
    
    class Advice {
    	public static function before1($point, $params, $options) {
    		$params['string'] .= 'before1';
    	}
    
    	public static function before2($point, $params, $options) {
    		$params['string'] .= ' before1';
    	}
    
    	public static function after1($point, $params, $options, $result) {
    		return $result . ' after1';
    	}
    
    	public static function after2($point, $params, $options, $result) {
    		return $result .= ' after2';
    	}
    
    	public static function around1($point, $params, $options, $proceed) {
    		return $proceed() . ' around1';
    	}
    
    	public static function around2($point, $params, $options, $proceed) {
    		return $proceed() . ' around2';
    	}
    }
    
    class Test {
    	/**
    	 * @before Advice::before1
    	 * @after Advice::after1
    	 * @around Advice::around1
    	 * @before Advice::before2
    	 * @after Advice::after2
    	 * @around Advice::around2
    	 */
    	public function testMulti($string) {
    		return $string;
    	}
    }
    
    Annotator::compile('Test');
    
    $test = new Test();
    
    echo $test->testMulti('');
    

    В результате работы этого примера будет выведена строка «before1 before1 around1 around2 after1 after2».

    Обработчики


    Каждый тип обработчика имеет свой набор параметров:

    Info
    • $point — метод на который навешан обработчик;
    • $options — массив параметров, указанных в аннотации.


    Before
    • $point — метод на который навешан обработчик;
    • $params — массив параметров, переданных в метод при вызове;
    • $options — массив параметров, указанных в аннотации.


    After
    • $point — метод на который навешан обработчик;
    • $params — массив параметров, переданных в метод при вызове;
    • $options — массив параметров, указанных в аннотации;
    • $result — переменная содержащая результат метода, на который навешан обработчик.


    Around
    • $point — метод на который навешан обработчик;
    • $params — массив параметров, переданных в метод при вызове;
    • $options — массив параметров, указанных в аннотации;
    • $proceed — функция передающая управление назад в метод, на который навешан обработчик.

    Послесловие


    На основе Annotator можно очень просто и быстро реализовать удобные механизмы, позволяющие сильно сократить код и улучшить структуру приложения, упрощая его поддержку. Например, кроме реализации АОП с его помощью можно довольно легко реализовать паттерн Dependency injection.

    Необходимо помнить, что замена методов во время выполения скрипта требует некоторого времени. Не стоит вызывать Annotator::compile для классов, которые не будут использоваться в данном запросе. Проще всего это реализуется чрез автоматическую загрузку классов php.

    Для долгоживущих приложений (простые демоны или приложения на основе phpDaemon) вносимый overhead практически не будет влиять на производительность, т.к. классы будут загружены и обработаны всего 1 раз.
    Поделиться публикацией

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

      +1
      Отличная идея и реализация. Вопрос только в том, а можно ли как-то кешировать результаты, вместо того, чтобы каждый раз в рантайме гонять аннотатор с runkit? Например, если файл не поменялся, вгружать уже сгенерированый класс со всеми зарегистрированными обработчиками.
        +1
        Над ускорением думаю. Первое, что можно сделать — закешировать результат обработки php-doc и методов заменителей в apc. Это снизит время разбора. Как сохранить уже готовые классы пока не придумал.
        Относительно производительности данные появятся через некоторое время, когда интегрирую решение в проект, на котором можно будет оценить скорость работы и необходимость в оптимизации.
        +1
        Чтение аннотаций хорошо реализовано в Doctrine 2, от туда можно взять все необходимое для работы с ними + новые аннотации так же элементарно создаются. Не требуют компиляции доп. модулей, да и кэширование, по-моему, так уже реализовано. Не смотрели в эту сторону?
          0
          Хотя кеширование и runkit все равно, наверное, придется использовать в Вашем случае.
            0
            Аннотации сейчас практически все читают через PHP Reflection, Doctrine не исключение. С этим проблем нет. Модуль runkit в данном случае используется для замены кода «на ходу». Основное время уходит на замену кода и его построение. Построение можно закешировать в APC. На продакшене, где код меняется не часто это позволит не парсить аннотации до сброса кеша.
              0
              Да, с этим понятно. А если не менять код на ходу, а использовать прокси-классы, как делает опять тот же Doctrine? Т.е. генерировать их заранее, класть в папочку и использовать уже от туда. Просто библиотека Ваша конечно же будет многим полезна, но очень сильно мешает компиляция сторонних модулей, в мире shared-хостингов использовать ее будет очень трудно.
                0
                Такие решения уже есть. Например FLOW3. Другое дело, что интегрировать целый фреймворк в уже готовый проект довольно сложно. Ещё как вариант — phpAspect. Он тоже генерирует прокси-классы.
                В данном случае было интересно сделать решение работающее именно на лету и сравнить с существующими.
                  0
                  Было бы интересно посмотреть сравнительную статейку этих решений с Вашими, в том числе с тестами производительности.
                    +1
                    Я планирую заняться тестированием производительности в течении нескольких ближайших дней, когда интегрирую решение в проект. Больше всего интересует будет ли разница и насколько значительная с phpAspect. Он ближе всего по духу (в плане синтаксиса).
            0
            if (isset(self::$cache[$options[0]])) — лучше array_key_exists. Метод может вернуть null, который игнорит isset.
              0
              Спасибо, исправил.
              +1
              Честно говоря, немного запутался, когда увидел «Around». Вызов аннотатора «около» функции. Всё-таки это замена, наверное лучше что-то вроде instead использовать. Вообще, интересная штука. Можно примеры практического использования? Событийную модель на основе такого неплохо реализовывать, но не мешает ли код, например, юнит-тестам?
                +1
                Названия советов before, after и around вместе с их поведением заимствованы из AspectJ. Везде, где я встречал АОП, оперировали именно этими названиями. Примеры применения я привёл в статье: кеширование, логирование, проверка прав доступа, реализация роутинга. Есть мысли реализовать Dependency injection. События, кстати, тоже хорошая идея.
                По поводу unit-тестов. А как оно должно помешать? Естественно тесты нужно писать с учётом влияния советов на результат работы функции. В остальном никаких отличий нет. Это же всего лишь способ сделать из много скучного кода мало скучного кода.
                0
                В примерах не показано про $this, работает он в обработчиках?
                  0
                  Внутри обработчика $this ссылается на объект класса в котором определён обработчик. Получить доступ к объекту на метод которого навешан обработчик можно через параметр $point. Это массив. Для статических методов он имеет вид array('ClassName', 'methodName'); для методов объектов он имеет вид array(&$this, 'methodName'); Но в данном случае обращение к объекту идёт «снаружи». Т.е. доступа к приватным свойствам и методам не будет.
                  0
                  АОП интересная тема. Года 2 назад увлекался этой идеей в рамках PHP. Из современного:
                  1) Недавно вышел экстеншен для aop
                  2) Для PHP есть очень неплохой sf2 бандл (который в общем-то и отдельно можно попробовать использовать )
                  3) Аннотации все таки лучше использовать в DoctrineStyle
                    0
                    Сейчас, кстати, снова появилась мысль собрать это все, чтобы оно выглядело примерно так (3я часть доклада)

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

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