Использование событий Events в Yii
При программировании на компонентном языке с поддержкой графического интерфейса часто приходится иметь дело с событиями. Любой визуальный объект в каждом из этих языков может обладать свойствами, методами и событиями. Но этими языками событийный подход не ограничивается. Попробуем по аналогии с реализацией в других языках разобраться с работой с ними в Yii.
Например, у кнопки Button есть свойства width
, height
, label
; методы move()
, setFocus()
, события click
, mouseMove
, keyPress
и подобные. События могут быть и у невизуальных объектов (например, события срабатывания таймера или прихода ответа в сокет).
Для знакомства нам нужен язык, который имеет уже готовую встроенную поддержку событий. Начнём с более привычного для веб-разработчика визуально-интерфейсного языка JavaScript.
Событийное программирование в JavaScript
Предположим, что нам нужно создать кнопку и повесить на неё обработчик щелчка мышью. В простейшем случае мы можем присвоить нужный нам обработчик свойству onclick
нашего элемента:
<button onclick="alert('Click!')">Button_1</button>
Программным путём это же можно реализовать так:
<script> var body = document.getElementsByTagName('body').item(0); var button = document.createElement('button'); button.innerText = 'Button_1'; button.onclick = "alert('Click!')"; body.appendChild(button); </script>
Здесь мы создаём HTML-элемент и передаём ему функцию-обработчик обратного вызова (callback), которую кнопка вызовет при наступлении события click
.
За этой записью скрывается не что иное, как встроенная возможность реализации шаблона проектирования «Наблюдатель».
Нам не нужно вручную проверять нажатие кнопки, не нужно лезть в исходный код класса DOMElement или перекрывать его методы, чтобы вставить свой обработчик щелчков или нажатий клавиш. Нам нужно только реализовать внешний обработчик и сообщить его имя (или предоставить ссылку на него) нашему объекту. При этом объект добавит переданный элемент в свой внутренний список. А при наступлении события пройдёт по нужному массиву и запустит каждую процедуру.
Чтобы добавить обработчик, нам нужно передать его и имя события в метод addEventListener
объекта:
function clickListener1(event){ alert('Click 1!'); }; var clickListener2 = function(event){ alert('Click 2!'); }; button.addEventListener('click', clickListener1); button.addEventListener('click', clickListener2); button.addEventListener('click', function(event){ alert('Click 3!'); });
Здесь мы добавили три обработчика, и при клике по кнопке сработают все.
В начальном примере
onclick
есть ни что иное, как сокращённый вариант передачи только одного обработчика для события'click'
. С помощью него нельзя добавить несколько обработчиков по очереди.
Перепишем наш пример так:
<html> <body> </body> <script> var body = document.getElementsByTagName('body').item(0); function clickListener(event){ alert('Click on ' + event.target.innerText); } var button = document.createElement('button'); button.innerText = 'Button_1'; button.addEventListener('click', clickListener); body.appendChild(button); </script> </html>
В паре с addEventListener
имеется метод removeEventListener
. Он с помощью аналогичной сигнатуры позволяет удалять обработчики. Про него мы скажем далее.
Заметим, что в функцию передаётся объект
event
. В нём содержится информация о самом событии (например, код нажатой клавиши мыши) и свойствоevent.target
, которое будет ссылаться на тот элемент, в котором сработало событие.
Это позволяет нам, например, создать в массиве десяток кнопок и навесить на все один и тот же обработчик:
function clickListener(event){ alert('Click on ' + event.target.innerText); } for (i=1; i<=10; i++) { var button = document.createElement('button'); button.innerText = 'Button_' + i; button.addEventListener('click', clickListener); body.appendChild(button); }
При этом не будет никаких конфликтов, так как event.target
будет указывать каждый раз на тот элемент, по которому кликнули.
Вместо этого мы можем даже использовать анонимные функции, но с ними нужно быть осторожными (если нам потребуется удалять обработчики). Например, добавим и удалим clickListener
:
function clickListener(event){ alert('Click!'); } button.addEventListener('click', clickListener); button.removeEventListener('click', clickListener);
После этого он уже не будет реагировать на щелчки по кнопке, так как clickListener
ссылается в памяти на одну и ту же функцию. А если мы напишем так:
button.addEventListener('click', function(event){ alert('Click!'); }); button.removeEventListener('click', function(event){ alert('Click!'); });
то первый обработчик не удалится, так как при такой записи в памяти создаются две функции с совершенно разными идентификаторами.
Если при создании нескольких элементов в массиве использовать анонимные функции вместо одной именованной, то их создастся уже десять штук вместо передачи по ссылке одной, что очень расточительно. Поэтому лучше их избегать.
В принципе, знать что у DOM элементов в JavaScript есть пара этих методов уже достаточно для реализации интерактивного пользовательского интерфейса. Но для более оптимального использования событий при разработке пользовательских интерфейсов лучше изучить их подробнее и ознакомиться с такими нюансами, как направления распространения событий и их прерывания.
Рассмотрим также использование событий в другом языке.
События в ActionScript3
Этот объектно-ориентированный язык обладает развитой системой работы с системными и пользовательскими событиями. Рассмотрим реализацию подписки на события кнопки:
package { import flash.display.*; import flash.events.*; class Application extends Sprite { public function Application():void { var button:Button = new Button(); button.label = 'Button_1'; // метод этого же класса (this. указывать не обязательно) button.addEventListener(MouseEvent.CLICK, clickListener); // метод другого объекта var listener:EventListener = new EventListener(); button.addEventListener(MouseEvent.CLICK, listener.clickListener); // статический метод класса StaticEventListener button.addEventListener(MouseEvent.CLICK, StaticEventListener.clickListener); addChild(button); } private function clickListener(event:MouseEvent):void { trace('Click on ' + event.target.label); } } class EventListener { public function clickListener(event:MouseEvent):void { trace('Click on ' + event.target.label); } } class StaticEventListener { public static function clickListener(event:MouseEvent):void { trace('Click on ' + event.target.label); } } }
Здесь мы определили три обработчика (в виде приватного метода текущего объекта this
, метода другого объекта listener
и статического метода класса StaticEventListener
). Можно использовать любой вариант.
Как видно, во многих языках работа с событиями происходит практически одинаково.
Событийный подход упрощает написание программ и игр с графическим интерфейсом. Достаточно написать класс мячика Ball, который «умеет» случайным образом изменять свои координаты по отсчёту таймера:
class Ball extends Sprite { protected var timer:Timer = new Timer(20); public function startMovie():void { timer.addEventListener(TimerEvent.TIMER, timerListener); timer.start(); } public function stopMovie():void { timer.removeEventListener(TimerEvent.TIMER, timerListener); timer.stop(); } protected function timerListener(event:TimerEvent):void { this.x += ...; this.y += ...; } }
и вбросить десяток шариков на экран, навесив на каждый удаление по щелчку и включив перемещение:
class Game extends Sprite { public function Game():void { for (i=0; i<10; i++){ // создаём шарик var ball:Ball = new Ball(); // запускаем движение ball.startMovie(); // следить за щелчками по нему будем мы сами ball.addEventListener(MouseEvent.CLICK, clickListener) // добавляем на сцену addChild(ball); } } private function clickListener(event:MouseEvent):void { if (event.target is Ball){ // приведение типа var ball:Ball = Ball(event.target); // останавливаем таймеры ball.stopMovie(); // перестаём следить за щелчками ball.removeEventListener(MouseEvent.CLICK, clickListener) // удаляем со сцены removeChild(ball); } } }
и игра заработает. Каждый шарик будет «жить своей жизнью», то есть перемещаться по экрану независимо от других. А при щелчке по шарикам мы будем их удалять.
Этот пример служит хорошей иллюстрацией важности использования метода removeEventListener
. Здесь при удалении шарика с экрана мы вручную отключили для него слежение за мышью и таймером. Это можно делать и автоматически в событиях Event.ADDED_TO_STAGE
и Event.REMOVED_FROM_STAGE
. Но если забыть это сделать, то таймер каждого удалённого шарика останется работать и загружать процессор в фоне (а сборщик мусора может вообще не придти, если потребление памяти не растёт).
Пользовательские события
Методы addEventListener
и removeEventListener
позволяют работать с уже готовым списком предопределённых событий в системе. Но что делать, если нам нужно реализовать свои события?
Представим другую игру, которая содержит игровую карту и табло с отсчётом времени. Научим компонент нашей карты принимать слушателей и оповещать их о наступлении какого-либо события.
Сначала создадим класс события:
package { import flash.events.*; public class MapEvent extends Event { public static const START:String = 'start'; public static const SUCCESS:String = 'success'; public static const ERROR:String = 'error'; public static const COMPLETE:String = 'complete'; public function MapEvent (type:String, bubbles:Boolean = false, cancelable:Boolean = false) { super(type, bubbles, cancelable); } public override function clone():Event { return new MapEvent(type, bubbles, cancelable); } public override function toString():String { return formatToString('ToggleEvent', 'type', 'bubbles', 'cancelable', 'eventPhase'); } } }
Методы в этом классе нам не интересны (это специфика определения события в ActionScript). Обратим внимание только на имена событий, заданных константами.
Свежеобъявленные события вида MapEvent.START
мы теперь можем использовать так же, как и использовали MouseEvent.CLICK
:
package { import flash.display.*; public class Game extends Sprite { private var map:Map; private var timerDisplay:TimerDisplay = new TimerDisplay(); private var soundSystem:SoundSystem = new SoundSystem(); private var messageBox:MessageBox = new MessageBox(); public function Game():void { map = new Map(); map.addEventListener(MapEvent.START, mapStartListener); map.addEventListener(MapEvent.SUCCESS, mapSuccessListener); map.addEventListener(MapEvent.ERROR, mapErrorListener); map.addEventListener(MapEvent.COMPLETE, mapCompleteListener); map.init(); addChild(map); } private function mapStartListener(event:MapEvent):void { addChild(timerDisplay); timerDisplay.startTimer(); } private function mapSuccessListener(event:MapEvent):void { soundSystem.playSuccess(); } private function mapErrorListener(event:MapEvent):void { soundSystem.playError(); } private function mapCompleteListener(event:MapEvent):void { timerDisplay.stopTimer(); addChild(messageBox); messageBox.showSuccess(); } } }
При запуске игры мы создаём экземпляр игровой локации, передаём ему наши обработчики для её событий, запускаем и добавляем карту на экран. Карта должна загрузить все ресурсы, расставить шарики и послать сообщение MapEvent.START
. Также она должна вызывать событие MapEvent.SUCCESS
при каждом попадании по шарику и MapEvent.ERROR
при щелчке по фону:
package { import flash.display.*; import flash.events.*; public class Map extends MovieClip { private var balls:Array = new Array(); private var background:Background = new Background(); public function Map:void {} public function init():void { // загружаем ресурсы ... // добавляем фон background.addEventListener(MouseEvent.CLICK, backgroundClickListener); addChild(ball); // добавляем шарики на карту поверх фона и запускаем их перемещение for (i=0; i<10; i++){ var ball:Ball = new Ball(); ball.startMovie(); ball.addEventListener(MouseEvent.CLICK, ballClickListener); addChild(ball); } // ...и сообщаем о готовности sendSrartNotify(); } private function backgroundClickListener(event:MouseEvent):void { if (event.target is Background){ // попали в фон, значит промахнулись мимо шарика sendErrorNotify(); } } private function ballClickListener(event:MouseEvent):void { if (event.target is Ball){ var ball:Ball = Ball(event.target); // удаляем шарик с экрана и из массива // не забыв всё отключить ball.stopMovie(); ball.removeEventListener(MouseEvent.CLICK, ballClickListener); removeChild(ball); balls.splice(balls.indexOf(ball), 1); // если шарики закончились if (balls.length == 0) { // сообщаем о завершении игры sendCompleteNotify(); } } } private function sendSrartNotify():void { dispatchEvent(new MapEvent(MapEvent.START, true, false)); } private function sendErrorNotify():void { dispatchEvent(new MapEvent(MapEvent.ERROR, true, false)); } private function sendCompleteNotify():void { dispatchEvent(new MapEvent(MapEvent.COMPLETE, true, false)); } } }
Строка
dispatchEvent(new MapEvent(MapEvent.START, true, false));
представляет собой создание экземпляра event
и передача его на генерацию методом dispatchEvent()
родительского класса:
var event:MapEvent = new MapEvent(MapEvent.START, true, false); this.dispatchEvent(event);
В ActionScript не обязательно указывать this.
перед методами и полями текущего класса, поэтому мы пишем просто dispatchEvent(event)
.
То есть мы создали свои события, «научили» наш объект их запускать, и теперь можем подписаться на них с помощью метода addEventListener
этого объекта.
В данном случае класс
Game
представляет из себя полноценный контроллер, так как загружает представления (карту и другие виджеты), подписывается на их события и командует их действиями. При этом ни карта, ни виджет таймера, ни шарики не догадываются о том, кто их использует и кто находится рядом. Они выполняют только свои встроенные обязанности и оповещают о своих событиях всех, кто на них подписался.
Теперь не надо в цикле или таймером проверять, готова карта или нет. Просто передаём функцию обратного вызова, и карта сама вызовом этого метода сообщит о своей готовности.
Использование событий в Yii
Язык PHP не имеет графического интерфейса, не работает с окнами, не способен отлавливать нажатия клавиш, события таймера и не способен что-то переспросить посреди программы с помощью STDIN (вне консольного режима). В отличие от других программ он запускается на один пролёт и должен вернуть текстовый результат на основе входных параметров $_REQUEST, $_SERVER и подобных. Он не может запуститься и ждать нажатия клавиши. Именно поэтому в нём неуместны примеры асинхронных событий вроде щелчков мыши и нет встроенных событий и методов для работы с ними.
Некоторые фреймворки и ORM, как более высокоуровневые программные системы, для универсализации разработки так или иначе предоставляют (эмулируют) этот удобный дизайн-паттерн «Наблюдатель» в дополнение к «Шаблонному методу».
При знакомстве с ActiveRecord
разработчик узнаёт, что в модель можно добавить некоторые «специальные» методы, например beforeSave()
и afterSave()
, которые будут вызываться автоматически до и после сохранения записи:
class User extends CActiveRecord { protected function beforeSave() { if (parent::beforeSave()) { echo 'Ещё не сохранили'; return true; } else { return false; } } protected function afterSave() { echo 'Уже сохранили'; parent::afterSave() } } class TestController extends Controller { public function actionEvents() { $user = new User(); $user->name = 'Вася'; $user->save(); } }
Изучим эти два метода.
Перейдёим в класс CActiveRecord
, а конкретнее в его метод save()
:
class CActiveRecord extends CModel { public function save($runValidation=true, $attributes=null) { if(!$runValidation || $this->validate($attributes)) return $this->getIsNewRecord() ? $this->insert($attributes) : $this->update($attributes); else return false; } }
Здесь в зависимости от того, новая это запись или нет, происходит вызов метода вставки либо обновления строки в базе данных. Рассмотрим теперь их:
class CActiveRecord extends CModel { public function insert($attributes=null) { ... if($this->beforeSave()) { ... if($command->execute()) { ... $this->afterSave(); ... return true; } } return false; } public function update($attributes=null) { ... if($this->beforeSave()) { ... $this->updateByPk(...); ... $this->afterSave(); return true; } return false; } }
Эти методы до выполнения команды вызывают шаблонный метод beforeSave()
, а после успешного сохранения запускают afterSave()
. Здесь же в классе есть заготовки этих методов:
class CActiveRecord extends CModel { protected function beforeSave() { if($this->hasEventHandler('onBeforeSave')) { $event = new CModelEvent($this); $this->onBeforeSave($event); return $event->isValid; } else return true; } protected function afterSave() { if($this->hasEventHandler('onAfterSave')) $this->onAfterSave(new CEvent($this)); } public function onBeforeSave($event) { $this->raiseEvent('onBeforeSave', $event); } public function onAfterSave($event) { $this->raiseEvent('onAfterSave', $event); } }
При вызове parent::beforeSave()
и parent::afterSave()
в переопределённых нами методах вызываются именно эти оригинальные методы.
Здесь и происходит самое интересное. Строкa
$event = new CModelEvent($this);
есть ни что иное, как ручное создание переменной event:
$event = new CModelEvent(); $event->sender = $this;
где $event->sender
повторяет event.target
из прошлых примеров. Таким образом, строки
$event = new CModelEvent($this); $this->raiseEvent('onBeforeSave', $event);
являются аналогом запуска события в нашей игре:
var event:MapEvent = new MapEvent('start', true, false); this.dispatchEvent(event);
Если на это событие кто-либо был подписан, то у всех подписчиков запустится переданная ими функция с параметром $event
.
Для подписки на события у класса CComponent
, соответственно, имеются методы attachEventHandler()
и detachEventHandler()
:
class TestController extends Controller { public function actionEvents() { $user = new User(); $user->name = 'Вася'; $user->attachEventHandler('onBeforeSave', array($this, 'userBeforeSaveListener')); $user->attachEventHandler('onBeforeSave', 'simpleBeforeSaveListener'); $user->attachEventHandler('onAfterSave', array('EventListener', 'userAfterSave')); $user->save(); } public function userBeforeSaveListener($event) { echo 'Ещё не сохранили пользователя ' . CHtml::encode($event->sender->name); } } function simpleBeforeSaveListener($event) { echo 'Ещё не сохранили пользователя ' . CHtml::encode($event->sender->name); } class EventListener { public static function userAfterSave($event) { echo 'Уже сохранили пользователя ' . CHtml::encode($event->sender->name); } }
Здесь мы навесили несколько обработчиков, оформленных различным образом.
Если Вам не нужно добавлять несколько обработчиков для одного и того же события, то можно использовать свойство вида on*
, действующее через магический сеттер __set()
:
$user->onAfterSave = array('EventListener', 'userAfterSave');
Используя этот вариант можно нечаянно перезаписать все предыдущие обработчики (например, пропадут подписки всех поведений модели User). В то же время такая запись позволяет задавать обработчики событий прямо в конфигурационном файле. Например, возьмём пример из рецепта по событиям:
return array( ... 'onBeginRequest' => function($event){ return ob_start('ob_gzhandler'); }, 'onEndRequest' => function($event){ return ob_end_flush(); } );
Здесь мы подписались на события onBeginRequest
и onEndRequest
самого приложения для gzip-сжатия всего HTML-кода. Если нужно подключить несколько обработчиков, то лучше эти две функции объединить в класс-поведение и указать его в списке behaviors
, как мы подключали DModuleUrlRulesBehavior.
Варианты передачи обработчиков событий
Метод attachEventHandler()
не очень привиредливый, так как может принимать либо строковое название функции, либо массив из класса и метода или массив из объекта и метода, либо анонимную функцию:
// передача имени обычной функции $user->onAfterSave = 'handleEvent'; // передача анонимной функции $user->onAfterSave = function($event){ ... }; $handler = function($event){...}; $user->onAfterSave = $handler; // статический метод класса $user->onAfterSave = array('MyEventHandler', 'handleEvent'); $user->onAfterSave = 'MyEventHandler::handleEvent'; // статический метод класса с namespace $user->onAfterSave = array('\MyLibrary\MyEventHandler', 'handleEvent'); $user->onAfterSave = '\MyLibrary\MyEventHandler::handleEvent'; // нестатический метод объекта $handler = new MyEventHandler(); $user->onAfterSave = array($handler, 'handleEvent'); $user->onAfterSave = array(new MyEventHandler(), 'handleEvent'); // метод текущего объекта $user->onAfterSave = array($this, 'handleEvent');
Пример использования пользовательских событий в Yii
Пусть в нашем интернет-магазине мы подключили сервис для приёма оплаты (например, мерчант Robokassa или Яндекс.Деньги) и настроили свой аккаунт так, чтобы при оплате мерчант посылал POST запрос на наш URL /payment/process.
Добавим в наш компонент оплаты вызов события 'onPaid'
при успешной оплате:
class PaySystem extends CAppicationComponent { public $client_id; public $client_password; ... public function process($order_id) { // запрашиваем статус заказа у мерчанта ... // если заказ оплачен, то запускаем наше событие if ($paid) { // если есть подписчики if($this->hasEventHandler('onPaid')){ // создаём объект события, передавая себя в качестве sender $event = new CEvent($this); // событию можно присвоить для передачи любые параметры // используя его свойство params $event->params = array( 'order_id' => $order_id, ); $this->onPaid($event); } return true; } return false; } public function onPaid($event) { $this->raiseEvent('onPaid', $event); } }
Теперь в конфигурационном файле вместе с другими параметрами укажем обработчик для события onPaid
:
return array( ... 'components' => array( ... 'paySystem' => array( 'class' => 'application.components.PaySystem', 'client_id' => 'myId', 'client_password' => 'qwer', 'onPaid' => array('Order', 'onOrderPaid'), }, }, ... );
Это указание статического метода. Создадим его прямо в модели Order
. Он будет помечать соответствующий заказ оплаченным:
class Order extends CActiveRecord { ... public static function onOrderPaid($event) { $order = Order::model()->findByPk($event->params['order_id']); $order->paid = 1; $order->save(); } }
В нашем контроллере просто примем пришедший от мерчанта номер заказа и передадим его компоненту оплаты:
class PaymentController extends Controller { public function actionProcess() { $order_id = Yii::app()->request->getParam('id'); Yii::app()->paySystem->process($order_id); } }
Теперь заказы будут помечаться оплаченными автоматически.
Это можно было бы сделать и без использования событий и производить изменение статуса заказа в контроллере или самом компоненте. Но сейчас можно легко поделиться этим компонентом с соседом, и он сам может вешать любой свой обработчик, не задумываясь, как устроен ваш компонент. Так что можно и не использовать свои события, полностью полагаясь на шаблонные методы моделей и поведений, но при желании можно пойти и этим путём.
Некоторые другие нюансы можно подсмотреть в рецепте о событиях на официальном сайте.
События в Yii используются и в поведениях. Про них есть отдельный пост.
На десерт рекомендую посмотреть классный доклад о событиях:
UPD: Провели недавно похожий вебинар по событиям в Yii2.
Классная тема. Спасибо. Очень познавательно!
Спасибо. Доступно и очень подробно. Помогло разобраться с событиями.
Хорошая статья, думаю полезно отметить что "события" это шаблон (паттерн) проектирования Observer (наблюдатель.
Что то мне тяжело даются события
Видео доклад ни о чем.
как-то запутанно
не пойму обработчики нужно вешать до или после вызова события?
До. Потом событие сработает и запустит все навешанные обработчики.
Добрый день, понравилась статья, начал эксперименты, интересует этот кусочек:
Вы указываете, что данный кусочек должен быть в файле конфигурации? Какой именной файл Вы имеете ввиду? Я попробовал вставить в config/main.php , но мне вышла ошибка, т.е. в массиве {} странно использовать. На просторах гугла нашел такой вариант:
Вроде рабочий, просто интересно, какой файл Вы имели ввиду?
Да, основной конфиг веб-приложения config/main.php.
Анонимные функции появились в PHP 5.3, так что ошибка может быть из-за старой версии.
> Но сейчас можно легко поделиться этим компонентом с соседом...
В данном случае компонентом является элемент
в конфиге и класс process или что-то другое?
Имеется в виду компонент PaySystem. Сосед может просто взять его у Вас и легко навесить на него любой свой обработчик в конфиге.
Спасибо! Вы не планируете написать книгу по Yii/Yii2? Я бы купил.
По Yii1 смысла уже особого нет. А по Yii2 вполне можно.
Добрый день. Подскажите, как лучше всего поступить с передаваемыми параметрами, если у события предполагается много обработчиков? Например, после успешной оплаты необходимо:
- отправить письмо админу
- отправить письмо клиенту
- добавить в лог отметку об оплате
В $event->params можно передавать вложенные массивы с парамсами, а каждый обработчик будет уже выгребать своё - насколько такой подход оптимален? Yii ведь не позволяет для каждого обработчика указывать свои парамсы...
Обычно вся информация берётся из самой модели заказа, доступной в $event->sender, и связанных с ней моделях. Так что необходимости в дополнительных параметрах у меня не возникало.