Применение Event-driven модели в веб-приложении

    Взаимодействие частей приложения друг с другом — важная часть архитектуры любой программы.
    И существует немало паттернов для их реализации. Я бы хотел на примере веб-приложения показать применение одного из них, а именно — Event-driven модели.
    Она хорошо известна любому frontend-разработчику — всякий раз, работая с событиями DOM, вы используете эту модель. Давайте попробуем построить на ней не маленькое веб-приложение — файловый менеджер.



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

    Итак, приступим!

    Из чего состоит наше приложение?
    1. Ядро. Хранит данные о файлах и взаимодействует с серверной частью
    2. Вид. Отрисовывает список файлов и реагирует на действия пользователя
    3. Команды. Совершают определенные действия с файлами
    Теперь опишем, как наши компоненты взаимодействуют друг с другом.

    А чтобы наше просветление обрело истинную силу Дао, сначала мы опишем неудачный способ взаимодействия модулей — «прямой контакт» друг с другом.

    Ядро получает от серверной части список файлов и отдает его виду. Вид показывает пользователю, что содержится в текущей директории и ждет его действий.
    Пользователь кликает по файлу. Вид обращается к ядру за разъяснением происходящего. Ядро успокаивает вид, говоря, что ничего не случилось — просто файл выбран, и оно теперь в курсе.
    Пользователь дважды кликает по папке. Вид в панике молит ядро о помощи. Ядро хладнокровно сообщает виду, что он не по адресу обратился, и посылает его к команде «open»

    — Сложновато?

    Но пока терпимо. А теперь добавим еще один вид — дерево директории. Теперь ядро должно помнить, что и этому виду надо передать список полученных файлов.
    И еще добавим вид — «favorites».
    Ядро в шоке — «а этому-то что нужно?».

    Продолжать не буду — и так ясно, что никакой возможности для расширений у нас нет. Для реализации каждой новой команды нам придётся постоянно дописывать ядро.

    Идеология событий


    А теперь будем медитировать над задачей, пока в результате просветления (я же обещал, что оно наступит) мы не изречем:

    — Любое изменение — есть событие!

    А чтобы завтра не забыть, что же мы имели ввиду, запишем некоторые детали.
    1. В приложении есть объект, в нашем случае — ядро, которое принимает подписку на события.
    2. Все желающие (и ядро в том числе) подписываются на важные для них события.
    3. При наступлении события ядро оповещает о нем всех подписчиков.
    4. Сгенерировать событие может любой объект.
    5. Список событий не ограничен.


    Вернемся к нашему примеру и убедимся, насколько все стало проще.
    Ядро получает от серверной частей список файлов и генерирует событие «open». Все виды рисуют то, что должны.
    Пользователь кликает по файлу. Вид генерирует событие «select». Ядро запоминает, какой файл выбран.
    Пользователь дважды кликает по папке. Вид генерирует событие «dblclick». Ни секунды не медля, команда «open» бросается в бой и заставляет ядро совершить запрос к серверу.

    — Стало проще?

    Несомненно. Но кроме того, наше приложение приобрело два важных свойства.
    1. Слабая связанность. Модулям больше не надо ничего знать о друг о друге.
    2. Второе свойство не так очевидно, и для тех, кто его не заметил, я объясню в конце.


    А теперь перейдем к тому, что мы любим больше всего — писать код!

    Реализация будет выглядеть так же, как в jQuery, за одним исключением —
    данные, связанные с конкретным вызовом события будут передаваться в поле самого объекта события.

    "use strict";
    
    // ядро приложения
    window.elFinder = function(node, options) {
    	var self = this,
    		// слушатели событий
    		listeners = {};
    	
    	/**
    	 * Тут храним id выделеных в данный момент файлов
    	 * 
    	 * @type Array
    	 */
    	this.selected = [];
    	
    	/**
    	 * Объект для хранения видов
    	 * 
    	 * @type Object
    	 */
    	this.ui = {};
    	
    	/**
    	 * Объект для хранения комманд
    	 * 
    	 * @type Object
    	 */
    	this.commands = {};
    	
    	/**
    	 * Регистрируем слушателей событий.
    	 * 
    	 * @param  String  имя события, для подписки на несколько событий их имена разделяются пробелом
    	 * @param  Object  обработчик события
    	 * @return elFinder
    	 */
    	this.bind = function(event, callback) {
    		var i;
    		
    		if (typeof(callback) == 'function') {
    			event = ('' + event).toLowerCase().split(/\s+/);
    			
    			for (i = 0; i < event.length; i++) {
    				if (listeners[event[i]] === void(0)) {
    					listeners[event[i]] = [];
    				}
    				listeners[event[i]].push(callback);
    			}
    		}
    		return this;
    	};
    	
    	/**
    	 * Удаляем слушателя.
    	 * 
    	 * @param  String  имя события
    	 * @param  Object  обработчик события
    	 * @return elFinder
    	 */
    	this.unbind = function(event, callback) {
    		var l = listeners[('' + event).toLowerCase()] || [],
    		      i = l.indexOf(callback);
    
    		i > -1 && l.splice(i, 1);
    		return this;
    	};
    	
    	
    	/**
    	 * Рассылаем оповещение всем слушателям события.
    	 * 
    	 * @param  String  имя события
    	 * @param  Object  данные для передачи слушателям
    	 * @return elFinder
    	 */
    	this.trigger = function(event, data) {
    		var event    = event.toLowerCase(),
    			handlers = listeners[event] || [], 
    			i;
    		
    		if (handlers.length) {
    			event = $.Event(event);
    			for (i = 0; i < handlers.length; i++) {
    				// чтобы один обработчик не мог попортить данные в других обработчиках
    				// делаем их копию
    				event.data = $.extend(true, {}, data);
    				try {
    					if (handlers[i](event, this) === false 
    					|| event.isDefaultPrevented()) {
    						break;
    					}
    				} catch (ex) {
    					window.console && window.console.log && window.console.log(ex);
    				}
    				
    			}
    		}
    		return this;
    	}
    	
    	/**
    	 * Делаем запрос к серверной части.
    	 * 
    	 * @param  Object  данные для сервера
    	 * @return jQuery.Deferred
    	 */
    	this.ajax = function(data) {
    		var self = this,
    			dfrd = $.Deferred()
    				.fail(function(error) {
    					self.error({error : error});
    				})
    				.done(function(data) {
    					// вызываем событие с именем команды
    					self.trigger(data.cmd, data);
    				});
    				
    			
    		$.ajax({
    			// ... опции запроса
    			data : data
    		}).error(function() {
    			dfrd.reject('Unable to connect to backend');
    		}).success(function(data) {
    			if (!this.validData(data)) {
    				dfrd.reject('Invalid data from backend');
    			} else if (data.error) {
    				dfrd.reject(data.error);
    			}
    			
    			dfrd.resolve(data);
    		})
    			
    		return dfrd;
    	}
    	
    	// Создаем объекты-виды и объекты-команды
    	// ..............
    	
    	
    	// Для некоторых "встроенных" событий создадим 
    	// методы-алиасы для подписки/вызова событий.
    	$.each(['open', 'select', 'error'], function(i, name) {
    		self[name] = function() {
    			var arg = arguments[0];
    			return arguments.length == 1 && typeof(arg) == 'function'
    				? self.bind(name, arg)
    				: self.trigger(name, $.isPlainObject(arg) ? arg : {});
    		}
    	});
    	
    	// Ядро само подписывается на некоторые события
    	this.open(function(e) {
    		// сохраняем инфо о файлах
    	})
    	.select(function(e) {
    		// обновляем список выбранных файлов
    		this.selected = e.data.selected;
    	})
    	.error(function(e) {
    		// показываем сообщение об ошибке
    		alert(e.data.error);
    	});
    	
    }
    
    elFinder.prototype = {
    	// виды
    	views : {
    		// текущая директория
    		cwd : function(fm, node) {
    			var self = this,
    				view = $('<div/>')
    					.appendTo(node)
    					.delegate('div[id]', 'click', function() {
    						view.find('div[id]').removeClass('selected');
    						$(this).addClass('selected');
    						// клик по файлу вызывает событие "select"
    						fm.select({selected : [this.id]});
    					})
    					.delegate('div[id]', 'dblclick', function() {
    						fm.trigger('dblclick', {target : this.id});
    					})
    				;
    			// цепляемся за событие "open"
    			fm.open(function(e) {
    				var files = e.data.files;
    				// рисуем файлы в текущей директории
    			});
    		}
    	},
    	
    	// команды
    	commands : {
    		open : function(fm) {
    			this.enabled = false;
    			
    			// цепляемся за событие "dblclick"
    			fm.select(function(e) {
    				// необходимо, чтобы можно было открыть папку/файл 
    				// не только по двойному клику, а и по нажатию Enter
    				this.enabled  = e.data.selected.length > 0;
    			})
    			bind('dblclick', function(e) {
    				// инициируем запрос к серверу
    				fm.ajax({cmd : 'open', target : e.data.target});
    			});
    		}
    	}
    }
    


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

    А теперь подведем итоги.


    Какую пользу мы получили, использовав event-driven модель?

    1. Слабую связанность компонентов приложения и, как следствие, — хорошие возможности для его дальнейшего расширения.
    2. Открытый API. Это не очевидно сначала, но, ничего специально не сделав,
      мы создали API, который позволит другим разработчикам создавать расширения без вмешательства в основной код проекта. И любой внешний скрипт сможет взаимодействовать с нашим приложением.

    Недостатки:

    1. «Широковещательность». Нельзя отправить сообщение конкретному слушателю. В нашем случае это не важно.
    2. «Незащищенность». Любой объект может слушать любое событие и любой объект может сгенерировать событие. В нашем случае важно второе. То есть слушатели не могут доверять данным, которые им передаются. Решается проверкой данных в слушателе.
    3. Кто первый — того и тапки. Любой слушатель может прекратить дальнейшее оповещение о событии. В теории не решается никак. Частично решается контролем очередности подключения модулей/подписки на события. Но мы не первый год работаем с DOM событиями и этот недостаток нам хорошо знаком.

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

    Ссылки по теме:
    en.wikipedia.org/wiki/Coupling_(computer_science)
    en.wikipedia.org/wiki/Event-driven_architecture
    ru.wikipedia.org/wiki/Событийно-ориентированное_программирование
    Share post

    Comments 15

      +8
      Помимо вышеуказаных плюсов: легкость в тестировании. Из-за того, что модули связаны только событиями, для их тестирования достаточно тригеррить нужные события с нужными данными, без зависимостей от работы сторонних модулей
        0
        Да, насчет тестирования это я упустил. Спасибо!
        +2
        Фактически приходим к истокам. Событийная модель, как известно, используется во всех фреймворках для написания GUI desktop-приложений, будь то qt, wxWidgets, vcl… Теперь с усложнением веба и переходом от «веб-сайтов» до «веб-приложений» эта модель прекрасно себя чувствует и в разработке веб-фронтендов.
          0
          Но писать событийную модель на JQ — это еще то извращение. Это полностью искажает весь смыл и логику JQ — сначала находим объект, с которым будем работать, потом работаем с каждым из них. Я уже как 4 года пользуюсь своей поделкой, которая построена только на event-based модели, и скажу вам, что это небо и земля. Мыслить событиями гораздо удобнее, чем мыслить функциями. Да и повторное использование кода колоссальное.
            +1
            Что-то я не помню, чтобы я что-то говорил о JQuery )))

            Да лално… В защиту подхода автора: 4 года назад в jquery еще не было deferred'ов (да что там, jquery только-только появился тогда), они вообще только недавно появились, а вот эти вот объекты отложенного выполнения как раз хорошо решают проблему построения event-based модели.
              0
              ;) Я в рамках статьи говорил.
          0
          Использую такую же модель, только не в файловом менеджере, а в похожей схеме. И вот мне интересно, возникла ли у вас проблема зацикливания событий и, если да, какой вариант её решения вы выбрали? Опишу на примере: есть блок с деревом папок и есть блок с иконками папок и файлов. Если открывать папку в одном из блоков, то тот генерирует событие «открыть папку», которое приходит во второй блок, а тот открывает папку в себе и, если нет специального условия, может сгенерировать такое же событие от себя — это и приведёт к «зацикливанию». У вас есть «специальное условие» (разделение обработчиков действий пользователей и реакции на события) или какая-то другая обработка?
            +1
            У нас немного другая схема не так, как вы описали. Когда происходит клик по файлу (неважно где) вид поднимает событие клик, по которому ядро обращается к серверу. И собственно событие «open» им же и вызывается, когда получены корректные данные. Так, что ваш пример у нас просто невозможен.
            Хотя в теории возможна ситуация зацикливания — в обработчике события вызываем другое событие, а в его обработчике снова первое событие. Наверно абсолютного способа избежать этого — нету. Это скорее вопрос конкретной реализации.
            Наверно стоит отметить это, как еще один недостаток метода.
              +1
              По поводу разделения. Оно у нас есть, хотя и не в том смысле, как вы спрашиваете. Можно говорить о разных уровнях приложения где эта модель применяется. То, что я описал в примере можно считать «главным циклом» приложения — основная его логика и взаимодействие с пользователем. Кроме него есть еще «внутренние» слои приложения — например взаимодействие интерфейса (кнопки, контекстное меню) с командами. Что бы не «хардкодить» кнопки в самих командах, они у нас являются отдельными объектами и подписываются на событие «изменение» в командах.
              Наверно в статье я допустил неточность — объектов, принимающих подписку может быть любое количество
              +1
              Частично решить проблему генерации событий недоверенным кодом можно привязав observable объект к нашему объекту как «private» поле, выдав наружу интерфейс вида:
              var observable = new Observable();
              this.onReady = function(callback){
                  observable.bind('ready', callback);
              };


              В нужном месте внутри объекта вызывается
              observable.trigger('ready', {…});

              Таким образом препятствуется возможность триггерить некоторые события извне.

              Можно разрешить вызовы некоторых событий извне, опять же через интерфейс, который может дополнительно заведовать, что надо передать обязательно и что можно получить у вызывающей стороны
              this.doSomething = function(var1, var2) {
                  observable.trigger('something', {var1: var1, var2: var2, urgentDefault: 'blabla'});
              };


              Конечно, это плодит некоторое количество писанины, которую можно и нужно оптимизировать.
                0
                Это возвращает нас к тому, с чего мы начали. При добавлении новых модулей придется добавлять новые проверки — усиливается связанность. Имхо, контроль данных в самих слушателях — более гибкое решение
                  0
                  Мы похоже друг друга не поняли. Какие именно модули и какие именно проверки? Все проверки идут прямо в модуле, в функциях, открытых наружу. Контроль данных в слушателях нарушает логику — слушателю должны быть переданы заведомо корректные данные. Или мы говорим о разных вещах?

                  В идеале, по описанной схеме, все триггеры будут выглядеть как obj.doSomething(); — либо вообще без параметров, либо с каким-то минимальным набором, который будет в этом самом методе проверен и отдан слушателям. Таким образом компонент сам внутри себя знает, что передать и как нормализовать данные. И ситуация obj.trigger('event', 'rubbish'); вообще не появится. Бинд при этом может быть какой угодно — obj.onSomething(function(var1, var2) { … }); как раз таки потому, что сам триггер спрятан.
                    0
                    На всякий случай уточню, что каждому модулю принадлежит ровно один observable-объект, который скрыт и с которым напрямую общается только модуль, все остальное взаимодействие идет с модулем через методы, которые он предоставляет.
                  0
                  Да, похоже мы друг друга не поняли:( Извиняюсь
                    0
                    Советую посмотреть на библиотеку js-signals, предназначенную как раз для работы с событиями (подпиской на них и вызова).

                    Only users with full accounts can post comments. Log in, please.