Избавляемся от дублирования сквозного кода в PHP: рефакторинг кода с АОП

в 9:40, , рубрики: aop, aspect oriented programming, php, Веб-разработка, Программирование, метки: , ,

Думаю, каждому программисту знаком принцип единственной ответственности, Избавляемся от дублирования сквозного кода в PHP: рефакторинг кода с АОП ведь не зря он существует: соблюдая его, можно написать код лучше, он будет более понятным, его будет легче дорабатывать.

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

Эта статья о том, как можно избавиться от дублирования сквозного кода, и как сделать его чуточку лучше с помощью АОП.

Сквозная функциональность или «мокрый» код

С вероятностью около 95% в любом приложении можно найти куски сквозной функциональности, которые прячутся в коде под видом кэширования, логирования, обработки исключений, транзакционного контроля и разграничения прав доступа. Как вы уже догадались из названия, эта функциональность живет на всех слоях приложения (у вас же есть слои?) и вынуждает нас нарушать несколько важных принципов: DRY и KISS. Нарушая принцип DRY, вы автоматически начинаете использовать принцип WET и код становится «мокрым», что отражается в виде увеличения метрик Lines of Code (LOC), Weighted Method Count (WMC), Cyclomatic Complexity (CCN).

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

Сначала пишется логика самого метода, которая содержит необходимую и достаточную реализацию:

/**
 * Creates a new user
 *
 * @param string $newUsername Name for a new user
 */
public function createNewUser($newUsername)
{
    $user = new User();
    $user->setName($newUsername);

    $this->entityManager->persist($user);
    $this->entityManager->flush();
}

… после этого добавляем еще 3 строчки кода на проверку прав доступа

/** ... */
public function createNewUser($newUsername)
{
    if (!$this->security->isGranted('ROLE_ADMIN')) {
        throw new AccessDeniedException();
    }
    
    $user = new User();
    $user->setName($newUsername);

    $this->entityManager->persist($user);
    $this->entityManager->flush();
}

… потом еще 2 строчки для логирования начала и конца выполнения метода

/** ... */
public function createNewUser($newUsername)
{
    if (!$this->security->isGranted('ROLE_ADMIN')) {
        throw new AccessDeniedException();
    }

    $this->logger->info("Creating a new user {$newUsername}");

    $user = new User();
    $user->setName($newUsername);

    $this->entityManager->persist($user);
    $this->entityManager->flush();

    $this->logger->info("User {$newUsername} was created");
}

Узнаете свой код? Еще нет? Тогда давайте добавим туда еще 5 строчек обработки возможной исключительной ситуации с нескольким разными обработчиками. В методах, возвращающих данные, может добавиться еще 5 строчек для сохранения результата в кэше. Таким образом, из 4 строчек кода, которые реально имеют ценность, может получиться порядка 20 строк кода. Чем это грозит, думаю понятно — метод становится сложнее, его труднее читать, дольше приходится разбираться с тем, что реально он делает, сложнее тестировать, потому что приходится подсовывать моки для логера, кэша и т.д. Так как пример был для одного из методов, то логично предположить, что утверждения относительно размера метода справедливы и для класса, и для всей системы в целом. Чем старше код системы, тем больше он обрастает подобным мусором и становится все тяжелее следить за ним.

Давайте посмотрим на существующие решения проблем сквозной функциональности.

Чистота — залог здоровья! Здоровье прежде всего!

Заголовок я рекомендую читать в контексте разработки приложений так: «Чистота кода — залог здоровья приложения! Здоровье приложения прежде всего!». Было бы неплохо повесить такую табличку перед каждым разработчиком, чтобы всегда об этом помнить :)

Итак, мы решили всеми силами содержать код в чистоте. Какие решения у нас есть и что мы можем использовать?

Декораторы

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

Когда разговор заходит об АОП, первый вопрос, который обычно задают ООП-программисты — почему не использовать обычный декоратор? И это правильно! Потому что декоратором можно сделать почти все то, что делается с помощью АОП, но… Контр-пример: что если мы сделаем LoggingDecorator поверх CachingDecorator, а последний, в свою очередь, поверх основного класса? Сколько однотипного кода будет в этих декораторах? Сколько различных классов декораторов будет во всей системе?

Легко прикинуть, что если у нас 100 классов, реализующих 100 интерфейсов, то добавление кэширующих декораторов добавит нам в систему еще 100 классов. Конечно, это не проблема в современном мире (загляните в папку cache любого большого фреймворка), но зачем нам нужны эти 100 однотипных классов? Непонятно, согласитесь?

Тем не менее, умеренное использование декораторов полностью оправданно.

Прокси-классы

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

Не очень хорошее решение с моей точки зрения, но у всех разработчиков на слуху кэширующие прокси, поэтому их так часто можно встретить в приложениях. Основные недостатки: падение скорости работы (часто используется __call, __get, __callStatic, call_user_func_array), а также ломается тайпхинтинг, потому что вместо реального объекта приходит прокси-объект. Если попытаться завернуть кэширующий прокси поверх логирующего, а тот, в свою очередь, поверх основного класса, то скорость упадет на порядок.

Но есть и плюс: в случае 100 классов мы можем написать один кэширующий прокси на все классы. Но! Ценой отказа от тайпхинтинга по 100 интерфейсам, что категорически неприемлемо при разработке современных приложений.

События и паттерн Наблюдатель

Трудно не вспомнить такой замечательный паттерн, как Наблюдатель. Наблюдатель (Observer) — поведенческий шаблон проектирования. Также известен как «подчинённые» (Dependents), «издатель-подписчик» (Publisher-Subscriber).

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

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

Тем не менее, несмотря на все плюсы, есть несколько больших минусов, которые иногда перевешивают плюсы. Первый минус заключается в том, что вы должны заранее знать, что и где может расширяться в вашей системе. К сожалению, зачастую это неизвестно. Второй минус заключается в том, что необходимо писать код особым образом, добавляя шаблонные строки генерации события и его обработки (пример из Symfony2):

class Foo
{
    // ...

    public function __call($method, $arguments)
    {
        // create an event named 'foo.method_is_not_found'
        $event = new HandleUndefinedMethodEvent($this, $method, $arguments);
        $this->dispatcher->dispatch('foo.method_is_not_found', $event);

        // no listener was able to process the event? The method does not exist
        if (!$event->isProcessed()) {
            throw new Exception(sprintf('Call to undefined method %s::%s.', get_class($this), $method));
        }

        // return the listener returned value
        return $event->getReturnValue();
    }
}

Сигналы и слоты

Этот паттерн, по своей сути, является реализацией паттерна Наблюдатель, но позволяет сократить количество повторяющегося кода.

Из самых интересных реализаций этого паттерна я бы отметил ядро фреймворка Lithium, изучение которого может дать много нового даже продвинутым разработчикам. Если вкратце, то Lithium позволяет навешивать функции-фильтры обратного вызова на любые важные методы в системе и проводить дополнительную обработку. Желаете писать лог запросов к базе в файл в отладочном режиме — нет ничего проще:

use lithiumanalysisLogger;
use lithiumdataConnections;

// Set up the logger configuration to use the file adapter.
Logger::config(array(
    'default' => array('adapter' => 'File')
));

// Filter the database adapter returned from the Connections object.
Connections::get('default')->applyFilter('_execute', function($self, $params, $chain) {
    // Hand the SQL in the params headed to _execute() to the logger:
    Logger::debug(date("D M j G:i:s") . " " . $params['sql']);

    // Always make sure to keep the filter chain going.
    return $chain->next($self, $params, $chain);
});

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

Аспектно-ориентированное программирование

Итак, мы подошли к самому интересному — к использованию аспектно-ориентированного программирования для борьбы с дублированием сквозного кода. На хабре уже были статьи по АОП, в том числе и для PHP, поэтому я не буду повторять этот материал и давать определения тех терминов и те приемы, которые использует АОП. Если вы не знакомы с терминами и понятиями АОП, то перед дальнейшим чтением можно ознакомиться со статьей про АОП на Википедии.

Итак, фильтры в Lithium позволяют подключать дополнительные обработчики почти куда угодно, что дает возможность вынести код кэширования, логирования, проверки прав доступа в отдельные замыкания. Казалось бы, вот она, серебряная пуля. Но все не так уж гладко. Во-первых, для использования фильтров нам нужно подключить весь фреймворк, так как отдельной библиотеки для этого нет, а жаль. Во-вторых, фильтры-замыкания (в терминах АОП — советы) разбросаны повсюду и за ними очень сложно следить. В-третьих, код должен быть написан определенным образом и реализовывать специальные интерфейсы, чтобы можно было использовать фильтры. Эти три минуса значительно ограничивают возможность использовать фильтры в качестве АОП в других приложениях и фреймворках.

Вот здесь у меня и появилась идея — написать библиотеку, которая дала бы возможность использовать АОП в любом приложении на PHP. Дальше была битва с PHP, изучение техник ускорения кода, борьба с багами опкод-ускоритиелей и много-много интересного. В результате родилась библиотека Go! AOP PHP, которая может внедриться в существующее приложение, перехватить доступные методы во всех классах и вынести из них сквозную функциональность на несколько тысяч строк кода в пару десятков строк советов.

Библиотека Go! AOP PHP

Основные отличия от всех существующих аналогов — это библиотека, не требующая никаких расширений PHP, не призывающая на помощь черную магию runkit-a и php-aop. Она не использует eval-ов, не завязана на DI-контейнер, не нуждается в отдельном компиляторе аспектов в конечный код. Аспекты представляют собой обычные классы, органично использующие все возможности ООП. Формируемый библиотекой код с вплетенными аспектами — очень чистый, его можно легко отлаживать с помощью XDebug-а, причем как сами классы, так и аспекты.

Самое ценное в этой библиотеке то, что теоретически ее можно подключить в любое приложение, потому что для добавления новой функциональности с помощью АОП не нужно менять код приложения вообще, аспекты вплетаются динамически. Для примера: с помощью десяти-двадцати строк кода можно перехватить все публичные, защищенные и статические методы во всех классах при запуске стандартного ZF2-приложения и выводить при вызове метода на экран имя этого метода и его параметры.

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

Рефакторинг сквозного кода с использованием АОП

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

Выносим логирование из кода

Итак, представим, что у нас есть логирование всех выполняемых публичных методов в 20 классах, находящихся в неймспейсе Acme. Выглядит это как-то так:

namespace Acme;

class Controller
{

    public function updateData($arg1, $arg2)
    {
        $this->logger->info("Executing method " . __METHOD__, func_get_args());
        // ...
    }    
}

Давайте возьмем и отрефакторим этот код с использованием аспектов! Легко заметить, что логирование выполняется перед кодом самого метода, поэтому сразу выбираем тип совета — Before. Дальше нам нужно определить точку внедрения — выполнение всех публичных методов внутри неймспейса Acme. Это правило задается выражением execution(public Acme*->*()). Итак, пишем LoggingAspect:

use GoAopAspect;
use GoAopInterceptMethodInvocation;
use GoLangAnnotationBefore;

/**
 * Logging aspect
 */
class LoggingAspect implements Aspect
{
    /** @var null|LoggerInterface */
    protected $logger = null;
    
    /** ... */
    public function __construct($logger) 
    {
        $this->logger = $logger;
    }
    
    /**
     * Method that should be called before real method
     *
     * @param MethodInvocation $invocation Invocation
     * @Before("execution(public Acme*->*())")
     */
    public function beforeMethodExecution(MethodInvocation $invocation)
    {
        $obj    = $invocation->getThis();
        $class  = is_object($obj) ? get_class($obj) : $obj;
        $type   = $invocation->getMethod()->isStatic() ? '::' : '->';
        $name   = $invocation->getMethod()->getName();
        $method = $class . $type . $name;
        
        $this->logger->info("Executing method " . $method, $invocation->getArguments());
    }
}    

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

Более того, теперь мы легко можем менять формат логирования, потому что теперь он задается в одном месте.

Прозрачное кэширование

Думаю, всем знаком шаблонный код метода с использованием кэширования:

    /** ... */
    public function cachedMethod()
    {
        $key = __METHOD__;
        $result = $this->cache->get($key, $success);
        if (!$success) {
            $result = // ...
            $this->cache->set($key, $result);
        }
        return $result;
    }

Несомненно, все узнают этот шаблонный код, так как таких мест всегда достаточно. Если у нас большая система, то таких методов может быть очень много, вот бы сделать так, чтобы они сами кэшировались. А что, идея! Давайте помечать аннотацией те методы, которые должны кэшироваться, а в поинткате зададим условие — все методы, помеченные определенной аннотацией. Так как кэширование «оборачивает» код метода, то и нам нужен подходящий тип совета — Around, самый могущественный. Этот тип совета сам принимает решение о необходимости выполнения исходного кода метода. А дальше все просто:

use GoAopAspect;
use GoAopInterceptMethodInvocation;
use GoLangAnnotationAround;

class CachingAspect implements Aspect
{

    /**
     * Cache logic
     *
     * @param MethodInvocation $invocation Invocation
     * @Around("@annotation(AnnotationCacheable)")
     */
    public function aroundCacheable(MethodInvocation $invocation)
    {
        static $memoryCache = array();

        $obj   = $invocation->getThis();
        $class = is_object($obj) ? get_class($obj) : $obj;
        $key   = $class . ':' . $invocation->getMethod()->name;
        if (!isset($memoryCache[$key])) {
            $memoryCache[$key] = $invocation->proceed();
        }
        return $memoryCache[$key];
    }
}

В этом совете самое интересное — вызов оригинального метода, который осуществляется с помощью вызова proceed() у объекта MethodInvocation, содержащего информацию о текущем методе. Легко заметить, что если у нас есть данные в кэше, то мы не производим вызов оригинального метода. При этом, ваш код не изменяется никак!
Имея такой аспект, мы можем перед любым методом поставить аннотацию AnnotationCacheable и этот метод будет кэшироваться благодаря АОП автоматически. Проходимся по всем методам и вырезаем логику кэширования, заменяя ее на аннотацию. Теперь шаблонный код метода с использованием кэширования выглядит просто и изящно:

    /** 
     * @Cacheable
     */
    public function cachedMethod()
    {
        $result = // ...
        return $result;
    }

Этот пример можно также найти внутри папки demos библиотеки Go! AOP PHP, а также посмотреть на коммит, реализующий вышесказанное в действии.

Заключение

Аспектно-ориентированное программирование — довольно новая парадигма для PHP, но у нее большое будущее. Развитие метапрограммирования, написание Enterprise-фреймворков в PHP — все это идет по следам Java, а АОП в Java живет уже очень давно, так что нужно готовиться к АОП уже сейчас.

Go! AOP PHP — одна из немногих библиотек, которая работает с АОП и в некоторых вопросах она выгодно отличается от аналогов — возможность перехватывать статические методы, методы в финальных классах, обращения к свойствам объектов, возможность отладки исходного кода и кода аспектов. Go! использует массу техник для обеспечения высокого быстродействия: компиляция вместо интерпретации, отсутствие медленных техник, оптимизированный код выполнения, возможность использовать опкод-кэшер — все это дает свой вклад в общее дело. Одним из удивительных открытий было то, что Go! в некоторых аналогичных условиях может работать быстрее C-экстеншена PHP-AOP. Да-да, это правда, которая имеет простое объяснение — экстеншен вмешивается в работу всех методов в PHP в рантайме и делает небольшие проверки на соответствие поинткату, чем больше таких проверок, тем медленнее вызов каждого метода, тогда как Go! делает это один раз при компиляции кода класса, не влияя на скорость работы методов в рантайме.

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

Ссылки

  1. Исходный код https://github.com/lisachenko/go-aop-php
  2. Презентация SymfonyCampUA-2012 http://www.slideshare.net/lisachenko/php-go-aop
  3. Видео SymfonyCampUA-2012 http://www.youtube.com/watch?v=ZXbREKT5GWE
  4. Пример перехвата всех методов в ZF2 (после клонирования устанавливаем зависимости через composer) https://github.com/lisachenko/zf2-aspect
  5. Интересная статья по теме: Аспекты, фильтры и сигналы — о, боже! (en)

Автор: NightTiger

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js