При разработке программного обеспечения программисты и архитекторы пользуются декомпозицией — представлением объектов и взаимосвязей между ними в виде классов, объектов, их свойств и методов.
Проводя декомпозицию, удается получить более точное представление объектов из реальной жизни в виде программного кода. Именно благодаря этому принципу, объектно-ориентированное программирование получило столь широкую популярность во всех языках программирования. Модель представления объектов реальной жизни в виде инстансов классов очень удобна: мы можем наделять класс набором методов и свойств, позволяя взаимодействовать с окружающей средой.
Но все ли так удобно?
Модель ООП позволяет отвечать на следующие вопросы: что или кто (объект), какого типа (класс), на что похож (наследование), что общего (абстракция), что может делать (методы), какими атрибутами обладает (свойства объекта или класса), какую функциональность реализует (интерфейсы). Вот примерный список вопросов, на которые мы постоянно даем ответы в виде нашего кода. Ответы на эти вопросы покрывают практически все те свойства и явления, которые происходят с объектами в реальной жизни.
Почему практически все?
В реальной жизни есть очень много нюансов во взаимодействии объектов, которые трудно представить в ООП: очередность выполнения взаимосвязанных действий у разных объектов, временная логика явлений, необходимость выполнять дополнительные действия при выполнении конкретного действия с объектом. В жизни это описывается в виде советов и рекомендаций: «мойте руки перед едой», «чистите зубы после еды», «перед выходом из дома — отключите свет» и других. Эти действия непросто описать с помощью методов: нужно использовать различные декораторы для классов, либо явно вносить логику взаимодействия в сам метод объекта. И в том, и в другом случае эти правила нельзя удобно формализовать в виде кода с помощью стандартных средств — и это приводит к усложнению системы и к более тесному связыванию компонентов.
Как можно решить данную проблему?
Решение этой проблемы было изобретено давно — дополнить существующую модель ООП некоторым расширением, которое позволит описывать такие взаимодействия формально. Было проведено исследование группой инженеров Xerox PARC, в результате которого они предложили новую парадигму - аспектно-ориентированное программирование. Суть идеи проста — позволить программе взглянуть на себя «со стороны» с помощью механизма рефлексии и при надобности — провести изменение конечного кода. Имея возможность изменять конечный код, АОП получает неограниченный доступ к созданию хуков в любом месте кода и к расширению этого кода с помощью советов.
Для того, чтобы описать это поведение были предложены следующие термины:
1. Advice — совет. Это действие, которое нужно выполнить. Для утверждения «мыть руки перед едой» советом будет «мыть руки». Как видите, советы описывают вполне реальные действия из реального мира, а значит, могут быть представлены в виде методов, анонимных функций и замыканий. Каждый совет знает, к какому именно месту он относится, поэтому в нем доступна почти вся информация о действии.
2. Joinpoint — точка внедрения. Данный термин определяет конкретное место в программном коде, в которое может быть добавлен совет. В AspectJ, который является первоисточником АОП, доступно большое количество точек внедрения: обращение к методу, выполнение метода, инициализация класса, создание нового объекта, обращение к свойству объекта. Библиотека Go! может работать с вызовами публичных и защищенных методов, как динамических, так и статических, в том числе, и в финальных классах и трейтах; поддерживается перехват защищенных и публичных свойств у объекта. Для примера, точкой внедрения для «мыть руки перед едой» будет «начало еды», или, говоря терминами ООП — выполнение метода «кушать()» у класса «человек».
3. Pointcut — срез точек внедрения. Срез задает некоторое множество точек внедрения, в которых нужно применить совет. В мире АОП это аналог SELECT из SQL. Синтаксис для задания среза точек может быть различным, как и сама реализация, но как правило, это фильтры, которые получают на вход множество точек внедрения, из которых нужно отобрать только подходящие. В библиотеке Go! для задания срезов в основном используются аннотации, но в будущем будет поддержка xml, yaml и с помощью кода. Срез точек может выглядеть так: все публичные методы в классе, все статические методы, имеющие название *init(), все методы с определенной аннотацией и т.д.
Помимо самой сигнатуры, срез определяет еще и относительное место для внедрения совета: перед, после, вокруг точки внедрения. Для нашего случая с мытьем рук срез точек будет определять единственную точку внедрения: «перед выполнением метода человек->кушать()». Если же мы хотим добиться идеальной чистоплотности, то можем определить точку внедрения «перед выполнением методов человек->*()», что позволит нам указать наше желание всегда мыть руки перед любым действием. Однако внимательный читатель может сообразить, что метод «человек->мытьРуки()» тоже попадает под этот срез. Чем это грозит и как этого избежать — останется на самостоятельное изучение, чтобы подогреть интерес.
4. Aspect — основная единица в рамках АОП, которая позволяет собрать воедино срезы точек с теми советами, которые нужно применить. Так как наш случай относится к здоровому образу жизни, то можно назвать его HealthyLiveAspect и внести туда наш срез и несколько советов: мыть руки перед едой и чистить зубы после еды. Давайте составим список того, что мы имеем на текущий момент. Мы имеем класс «человека» с понятными методами, в них нет дополнительной логики, которая смешалась бы с основным кодом метода. Мы имеем отдельный класс аспекта с необходимыми советами. Осталось сделать самую малость — соединить их в одно целое и получить готовый код. Этот процесс называется вплетением.
5. Weaving — процесс вплетения кода советов в исходный код. Этот механизм разбирает исходный код с помощью рефлексии и применяет советы в точках внедрения. Библиотека Go! использует уникальную для PHP технологию Load-Time Weaving, которая позволяет отследить момент загрузки класса и изменить этот код до его анализа парсером PHP. Это дает возможность динамически изменять код класса, без изменений в исходном коде со стороны разработчиков. Работает это все следующим образом: в начале программы мы инициализируем ядро АОП, добавляя туда наши аспекты, после этого передаем управление основной программе. При создании объекта человека сработает автоматическая загрузка класса, которая определит нужное имя файла и попытается его загрузить. В этот момент вызов будет перехвачен ядром, далее будет выполнен статический анализ кода и проверка текущих аспектов. Ядро обнаружит, что в коде есть класс «человек» и что нужно внедрить советы в этот класс, поэтому трансформеры кода в ядре изменят оригинальное имя класса, создадут прокси-класс с оригинальным названием класса и переопределенным методом «кушать», и передадут список советов для данной точки. Дальше парсер PHP разбирает этот код и загружает его в память, при этом по имени исходного класса уже будет находиться класс-декоратор, поэтому обычный вызов метода «кушать» будет обернут в точку соединения с подключенными советами.
Лучше один раз увидеть, чем сто раз услышать.
Давайте опишем все то, что мы обсудили с помощью кода. Реализуем первым делом класс человека-разумного, который будет уметь кушать, спать, работать, а также мыть руки и чистить зубы. Метод __clone пока делать не будем :)
/**
* Human class example
*/
class Human
{
/**
* Eat something
*/
public function eat()
{
echo "Eating...", PHP_EOL;
}
/**
* Clean the teeth
*/
public function cleanTeeth()
{
echo "Cleaning teeth...", PHP_EOL;
}
/**
* Washing up
*/
public function washUp()
{
echo "Washing up...", PHP_EOL;
}
/**
* Working
*/
public function work()
{
echo "Working...", PHP_EOL;
}
/**
* Go to sleep
*/
public function sleep()
{
echo "Go to sleep...", PHP_EOL;
}
}
На текущий момент все написано по фэн-шую: каждый метод занимается только своей работой, ничего лишнего в методах нет. Но у нас нет логики мытья рук перед едой, а также чистки зубов после еды и перед сном:
/**
* Human class example
*/
class Human
{
/**
* Eat something
*/
public function eat()
{
$this->washUp();
echo "Eating...", PHP_EOL;
$this->cleanTeeth();
}
// ....
/**
* Go to sleep
*/
public function sleep()
{
$this->cleanTeeth();
echo "Go to sleep...", PHP_EOL;
}
}
Типичный программист без особых раздумий сделает так, как приведено выше: внесет вызов нужных методов в код методов «кушать» и «спать», нарушив принцип единственной ответственности каждого из этих методов. Благо, условия простые, можно разобраться с тем, почему это было сделано именно здесь. В реальной же жизни все куда печальнее: как часто вам попадается код, который делает разные вещи в одном месте и нет никакого намека на то, что этот кусок кода должен быть тут? Это и есть та самая знаменитая метрика: WTF/строчку кода. Думаю, у каждого есть такие примеры в коде ).
Следующая категория программистов ощущает подвох в том, что нужно изменять логику метода и они идут в другую крайность: делают новые методы в классе, которые объединяют в себе логику нескольких методов. А вам знакомы методы вида «мытьРукиAndКушать()»?
С одной стороны, основные методы перестанут содержать эту логику, но наш класс начнет раздуваться, появится возможность вызвать метод «кушать()» напрямую, интерфейс класса будет завален ненужными методами и количество ошибок в коде начнет расти. Опять не вариант.
Другие варианты я уже рассматривал в своей статье на хабре по рефакторингу с АОП, поэтому перейду сразу к АОП.
Аспектно-ориентированный подход
Наверное, вы уже поняли, что АОП позволяет разделять на логические блоки то, что нельзя сделать с помощью ООП. Однако, эта техника довольно сложна, поэтому АОП предназначен прежде всего для тех, кто постиг все тонкости объектно-ориентированной парадигмы и желает открыть для себя что-то новое.
Основоположник аспектно-ориентированного программирования (АОП) Грегор Кикзалес в интервью поделился своим видением сущности АОП: "… АОП по существу – очередной этап в развитии механизмов структурирования. Сегодня понятно, что объекты не заменяют процедуры, а являются только способом создания механизма структурирования более высокого уровня. И аспекты тоже не заменяют объекты; они лишь предоставляют еще одну разновидность структурирования."
Давайте попробуем описать наш случай с позиции аспектно-ориентированной парадигмы. Для этого возьмем «чистый» класс «человека» с нашими «чистыми» методами и опишем советы для нужных методов с помощью класса аспекта:
namespace Aspect;
use GoAopAspect;
use GoAopInterceptMethodInvocation;
use GoLangAnnotationAfter;
use GoLangAnnotationBefore;
use GoLangAnnotationAround;
use GoLangAnnotationPointcut;
/**
* Healthy live aspect
*/
class HealthyLiveAspect implements Aspect
{
/**
* Pointcut for eat method
*
* @Pointcut("execution(public Human->eat(*))")
*/
protected function humanEat() {}
/**
* Method that recommends to wash up before eating
*
* @param MethodInvocation $invocation Invocation
* @Before(pointcut="humanEat()") // Short pointcut name
*/
protected function washUpBeforeEat(MethodInvocation $invocation)
{
/** @var $person Human */
$person = $invocation->getThis();
$person->washUp();
}
/**
* Method that recommends to clean teeth after eating
*
* @param MethodInvocation $invocation Invocation
* @After(pointcut="AspectHealthyLiveAspect->humanEat()") // Full-qualified pointcut name
*/
protected function cleanTeethAfterEating(MethodInvocation $invocation)
{
/** @var $person Human */
$person = $invocation->getThis();
$person->cleanTeeth();
}
/**
* Method that recommends to clean teeth before going to sleep
*
* @param MethodInvocation $invocation Invocation
* @Before("execution(public Human->sleep())")
*/
protected function cleanTeethBeforeSleep(MethodInvocation $invocation)
{
/** @var $person Human */
$person = $invocation->getThis();
$person->cleanTeeth();
}
}
Думаю, этот код достаточно понятный сам по себе, но лучше добавить некоторые комментарии.
Во-первых, мы определили срез точек — пустой метод humanEat()
, помеченный с помощью специальной аннотации @Pointcut("execution(public Human->eat(*))")
. В принципе, можно было не создавать отдельный срез, а указать его перед каждым советом отдельно, но так как у нас есть несколько советов для этого среза, то можно вынести его в отдельное определение. Сам метод и его код при определении среза не используются и служат лишь для указания и идентификации среза в самом аспекте.
Во-вторых, мы описали сами советы в виде методов аспекта, указав с помощью аннотации @Before
и @After
конкретное место внедрения для среза. Можно сразу задавать срезы в аннотации, как это сделано в методе cleanTeethBeforeSleep
: @Before("execution(public Human->sleep())")
. Каждый совет получает на вход нужную информацию о точке выполнения благодаря объекту MethodInvocation
, содержащему в себе вызываемый объект (класс для статического метода), аргументы вызываемого метода, а также информацию о точке в программе (рефлексия метода). Аналогичная информация может быть получена и для обращения к свойствам объектов.
Теперь запустим наш код:
include isset($_GET['original']) ? './autoload.php' : './autoload_aspect.php';
// Test case with human
$man = new Human();
echo "Want to eat something, let's have a breakfast!", PHP_EOL;
$man->eat();
echo "I should work to earn some money", PHP_EOL;
$man->work();
echo "It was a nice day, go to bed", PHP_EOL;
$man->sleep();
Если мы запустим данный код в браузере, то можно будеть увидеть следующий вывод:
Want to eat something, let's have a breakfast!
Washing up...
Eating...
Cleaning teeth...
I should work to earn some money
Working...
It was a nice day, go to bed
Cleaning teeth...
Go to sleep...
Для интересующихся прочими файлами:
/**
* Show all errors in code
*/
ini_set('display_errors', true);
/**
* Register PSR-0 autoloader for our code, any components can be used here
*/
spl_autoload_register(function($originalClassName) {
$className = ltrim($originalClassName, '\');
$fileName = '';
$namespace = '';
if ($lastNsPos = strripos($className, '\')) {
$namespace = substr($className, 0, $lastNsPos);
$className = substr($className, $lastNsPos + 1);
$fileName = str_replace('\', DIRECTORY_SEPARATOR, $namespace) . DIRECTORY_SEPARATOR;
}
$fileName .= str_replace('_', DIRECTORY_SEPARATOR, $className) . '.php';
$resolvedFileName = stream_resolve_include_path($fileName);
if ($resolvedFileName) {
require_once $resolvedFileName;
}
return (bool) $resolvedFileName;
});
ob_start(function($content) {
return str_replace(PHP_EOL, "<br>" . PHP_EOL, $content);
});
include '../src/Go/Core/AspectKernel.php';
include 'DemoAspectKernel.php';
// Initialize demo aspect container
DemoAspectKernel::getInstance()->init(array(
// Configuration for autoload namespaces
'autoload' => array(
'Go' => realpath(__DIR__ . '/../src'),
'TokenReflection' => realpath(__DIR__ . '/../vendor/andrewsville/php-token-reflection/'),
'Doctrine\Common' => realpath(__DIR__ . '/../vendor/doctrine/common/lib/')
),
// Default application directory
'appDir' => __DIR__ . '/../demos',
// Cache directory for Go! generated classes
'cacheDir' => __DIR__ . '/cache',
// Include paths for aspect weaving
'includePaths' => array(),
'debug' => true
));
use AspectHealthyLiveAspect;
use GoCoreAspectKernel;
use GoCoreAspectContainer;
/**
* Demo Aspect Kernel class
*/
class DemoAspectKernel extends AspectKernel
{
/**
* Returns the path to the application autoloader file, typical autoload.php
*
* @return string
*/
protected function getApplicationLoaderPath()
{
return __DIR__ . '/autoload.php';
}
/**
* Configure an AspectContainer with advisors, aspects and pointcuts
*
* @param AspectContainer $container
*
* @return void
*/
protected function configureAop(AspectContainer $container)
{
$container->registerAspect(new HealthyLiveAspect());
}
}
Что же у нас получилось? Во-первых, логика самих методов в классе «человека» не содержит никакого мусора. Уже отлично, потому что их будет легче поддерживать. Во-вторых, нет никаких методов, дублирующих основной функционал методов класса. В-третьих, логика представлена в виде понятных советов в аспекте, потому что советы имеют реальный смысл, а это означает, что мы только что сделали декомпозицию по функциональности и сделали наше приложение более структурированным! Более того, сам класс аспекта описывает вполне понятную цель — здоровый образ жизни. Теперь все на своих местах и мы можем легко изменять как дополнительную логику, так и работать с чистой логикой самих методов.
Надеюсь, этот нехитрый пример поможет вам лучше понять концепцию аспектной парадигмы и попробовать свои силы в описании аспектной составляющей процессов в реальном мире. Для интересующихся — этот пример доступен в исходном коде библиотеки в папке demos/life.php, его можно запустить и изучить )
Ссылки:
- Official site http://go.aopphp.com
- Source code https://github.com/lisachenko/go-aop-php
Автор: NightTiger