пятница, 20 мая 2011 г.

Применение 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/Событийно-ориентированное_программирование


Источник: Хабрахабр - JavaScript
Оригинальная страница: Применение Event-driven модели в веб-приложении

Комментариев нет:

Отправить комментарий