11 августа 2014 в 00:55

Dependency Injection. JavaScript

Понятия «инверсия управления» и «внедрение зависимостей» не являются новыми, но в сообществе JavaScript, несмотря на его бурный и продолжительный рост, почему-то встречаются довольно редко.

Независимо от контекста исполнения, расширяемое и поддерживаемое javascript-приложение, как и приложение, написанное на любом другом языке, должно соответствовать некоторым архитектурным принципам. Одним из которых является инверсия управления.

Что это?


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

Инверсия управления (англ. Inversion of Control, IoC) — это принцип объектно-ориентированного программирования, при котором объекты программы не зависят от конкретных реализаций других объектов, но могут иметь знание об их абстракциях (интерфейсах) для последующего взаимодействия.

Внедрение зависимостей (англ. Dependency Injection) — это композиция структурных шаблонов проектирования, при которой за каждую функцию приложения отвечает один, условно независимый объект (сервис), который может иметь необходимость использовать другие объекты (зависимости), известные ему интерфейсами. Зависимости передаются (внедряются) сервису в момент его создания.

Библиотеки, реализующие эти принципы часто называют IoC контейнерами (англ., Inversion of Control Container).

Пример из жизни


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

// Конструктор объекта локации
function Locator() {
	//
};

// Метод отображения пользователей
Locator.prototype.locateUsers = function(users) {
	var map;

	// Каким-то образом загружаем клиента сервиса карт
	// Загруженный клиент будет в глобальной переменной client
	loadScript("http://maps.com/client.js");

	// Инициализируем сервис
	client.init({
		token: "my-application-token"
	});

	// создаем карту
	map = new client.Map('my_dom_node_id', {
		center: [55.76, 37.64],
		zoom:   10
	});

	// Бежим по всем пользователям, создаем маркеры
	users.forEach(function(user) {
		var marker;
		marker = new client.Marker(user.geo.latitude, user.geo.longitude);
		map.addMarker(marker);
	});
}


Все работает, и вы переходите к следующим задачам. Но если со временем вдруг вы решите использовать другой сервис карт, вам придется переписать большую часть кода Локатора — от загрузки клиента и его инициализации, до общения с API нового клиента. Причина такой ситуации в том, в ответственности Локатора не только функция локации пользователей, но и функции создания, конфигурации и общения с клиентом карт.

Чтобы убрать из ответственности Локатора функции работы с картами, вынесем их в интерфейс, который будем реализовывать под конкретные карты. Реализацию такого интерфейса будем называть Картограф:

// Поскольку в javascript нет интерфейсов,
// Опишем полностью абстрактный класс Картографа
function AbstractMapService() {
	//
}

AbstractMapService.prototype = {
	createMap: function(id, options) {
		throw new TypeError("Method not implemented");
	},

	createMapMarker: function(map, options) {
		throw new TypeError("Method not implemented");	
	}
}

// Какая либо стратегия наследования
AbstractMapService.extend = function(prototypeProperties) {
	return inherits(this, prototypeProperties);
}


Имплементация такого интерфейса под конкретный сервис карт:

// Имплементация интерфейса
var MapService = AbstractMapService.extend({
	constructor: function(options) {
		loadScript("http://maps.com/client.js");

		client.init({
			token: options.token
		});

		this.client = client;
	},

	createMap: function(id, options) {
		return new this.client.Map(id, {
			center: [options.lat, options.long],
			zoom:   options.zoom
		});
	},

	createMapMarker: function(map, options) {
		var marker;

		marker = new this.client.Marker(options.lat, options.long);
		map.addMarker(marker);

		return marker;
	}
});


Теперь, если мы захотим поменять сервис карт, нам нужно будет просто создавать объект другой реализации внутри Локатора:

// Метод отображения пользователей
Locator.prototype.locateUsers = function(users) {
	var map, mapService;

	// создаем Картографа
	mapService = new MapService({
		token: "my-application-token"
	});

	// создаем карту
	map = mapService.createMap('my_dom_node_id', {
		lat:  55.76, 
		long: 37.64,
		zoom: 10
	});

	// Бежим по всем пользователям, создаем маркеры
	users.forEach(function(user) {
		mapService.createMapMarker(map, {
			lat:  user.latitude,
			long: user.longitude
		});
	});
}


Таким образом мы решили проблему избытка ответственности Локатора, но появилась зависимость от конкретной реализации Картографа.

Если мы захотим перенести Локатор в другой проект, мы не сможем перенести только его реализацию. В другом проекте используется другие карты и, соответственно, другой клиент карт. Поэтому, разработчикам другого проекта придется править код Локатора — инстанцировать внутри уже свою реализацию Картографа.

Другими словами, пока что мы не можем использовать Локатор как плагин:



Именно подобного рода проблемы помогает решить инверсия контроля.

В контексте нашего примера — Локатор оставит знание лишь об интерфейсе Картографа, и позволит внедрять в себя любую его имплементацию, тем самым исчезнет зависимость от конкретной реализации:



При такой организации Локатор весьма независим и его легко использовать как плагин и переносить между проектами:

// Метод внедрения Картографа
Locator.prototype.setMapService: function(mapService) {
	if (!(mapService instanceof MapService)) {
		throw new TypeError("MapService is expected");
	}

	this.mapService = mapService;
}

// Теперь наш локатор выглядит так:
// Метод отображения пользователей
Locator.prototype.locateUsers = function(users) {
	var self = this,
		map;

	// создаем карту
	map = this.mapService.createMap('my_dom_node_id', {
		lat:  55.76, 
		long: 37.64,
		zoom: 10
	});

	// Бежим по всем пользователям, создаем маркеры
	users.forEach(function(user) {
		self.mapService.createMapMarker(map, {
			lat:  user.latitude,
			long: user.longitude
		});
	});
}


Теперь можно беспокоиться лишь о том, чтобы картограф в другом проекте поддерживал интерфейс нашего Картографа (обычно такие задачи решает шаблон Адаптер).

Вот так выглядит использование Локатора в итоге:

// Где-то уровнем выше:
var locator, mapService;

// Создадим Картографа
mapService = new MapService({
	token: "my-application-token"
});

// Создадим Локатор
locator = new Locator();

// Вндерим зависимость Локатора
locator.setMapService(mapService);

// Вызовем метод локации
locator.locateUsers(users);


Остается один вопрос — на каком уровне должно происходить создание сервисов и внедрение в них зависимостей, и можно ли как-то автоматизировать такую «сборку»?

dm.js


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

В мире JavaScript таких библиотек меньше, и далеко не все из них полностью автоматизируют создание и внедрение объектов. Так же далеко не многие могут работать независимо от среды исполнения.

Поэтому мне захотелось (и понравилось) написать свою имплементацию — dm.js.

При ее использовании, пример с Локатором мог бы выглядеть следующим образом:

// Создание DM опущена
// Полный пример есть на страничке github

// Установим конфигурацию сервисов
dm.setConfig({
	// конфигурация локатора
	"locator": {
		// путь к конкретной имплементации конструктора
		path: "path/to/locator/implementation",
		// вызовы, которые нужно сделать на созданном экземпляре
		calls: [
			["setMapService", ["@maps"]]
		]
	},

	// конфигурация картографа
	"maps": {
		// путь к конкретной имплементации конструктора
		path: "path/to/map/service/implementation",
		// аргументы, которые нужно передать при создании экземпляра
		arguments: [{
			id: "my-app-id"
		}]
	}
});

// Запросим у dm локатор пользователей
// dm
//   - загрузит конструктор по указанному в конфигурации пути
//   - получит все зависимости (картографа)
//   - сконфигурирует созданный экземпляр локатора (сделает вызов setMapService)
//   - посокольку загрузка модуля может быть асинхронной - вернет обещание на создание локатора
dm
    .get("locator")
    .then(function(locator) {
        locator.locateUsers(users);
    });


Весь процесс создания, конфигурации и внедрения объектов dm.js берет на себя. Для описания зависимостей используется объект с определенной структурой и синтаксисом.

Dm.js создает сервисы асинхронно, возвращая Promises/A+ обещания. При помощи адаптеров поддерживаются любые загрузчики модулей и библиотеки Promises.

С помощью библиотеки можно описывать зависимость не только от сервисов, но и ресурсов — например, шаблонов или json файлов. Синтаксис конфигураций так же позволяет строить рекурсивные зависимости.

Подробная документация и описание конфигурации библиотеки представлены на странице проекта на github.

P.S. или инверсия в контексте Веб


Приложение, архитектура которого следует принципу инверсии управления, имеет ряд положительных особенностей, которые облегчают его модификации и увеличивают жизненный цикл. Централизованное управление зависимостями позволяет описывать различные конфигурации приложения, тем самым, в совокупности сервисов, меняя его поведение. Например, приложение на тестовом сервере может использовать другие сервисы и/или их конфигурации, чем то же приложение использует на боевом. Внедрение зависимостей облегчает юнит тестирование, позволяя подменять зависимости тестируемого сервиса заглушками. Использование интерфейсов при проектировании позволяет описывать новые функции приложения более изолированно, ограничивая ответственность сервисов.

Помимо внедрения зависимостей, библиотеки (dm.js не исключение) часто реализуют и другой вид инверсии управления, известный как паттерн Сервис Локатор. При таком подходе зависимости не только внедряются в сервис, но и сам сервис может запрашивать объекты у IoC контейнера, который выполняет роль локатора сервисов.

В некоторых статьях была высказана мысль, что такой вид инверсии управления является антипаттерном. С этой мыслью можно согласиться по нескольким причинам. Во-первых, при таком использовании информация о зависимостях размывается и переносится в код сервисов, которые запрашивают у контейнера нужные им объекты. Такое поведение не позволяет контролировать все конфигурации сервисов централизованно. Во-вторых, каждый сервис получает дополнительную зависимость от сервис-локатора. Аналогичное мнение сложилось у меня и про внедрение типов по интерфейсам (Interface Injection в статье Мартина Фаулера) — информация о зависимостях переносится в реализуемые сервисом интерфейсы.

Однако, в контексте веб-разработки в браузере, использовать паттерн Сервис Локатор в целях оптимизации оправданно — ведь далеко не всегда нужно загружать сразу все сервисы приложения и грузить тем самым много килобайт кода.

Спасибо за внимание, вопросы и комментарии приветствуются!

Полезные ссылки:

Сергей Камардин @gobwas
карма
13,0
рейтинг 0,0
Похожие публикации
Самое читаемое Разработка

Комментарии (12)

  • +1
    Помимо внедрения зависимостей, библиотеки (dm.js не исключение) часто реализуют и другой вид инверсии управления, известный как паттерн Сервис Локатор. При таком подходе зависимости не только внедряются в сервис, но и сам сервис может запрашивать объекты у IoC контейнера, который выполняет роль локатора сервисов.

    Простыми словами мы заменяем зависимость от конкретных сервисов на зависимость от Сервис Локатора. При этом он сам по себе должен быть легко переносимым.
    Однако, в контексте веб-разработки в браузере, использовать паттерн Сервис Локатор в целях оптимизации оправданно — ведь далеко не всегда нужно загружать сразу все сервисы приложения и грузить тем самым много килобайт кода.

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

    Есть еще комбинированный вариант:
    1. Плагин знает только о конкретных сервисах.
    2. А вот инициализация плагина происходит при помощи Сервис Локатора. В данном конкретном приложении подключаем к плагину только нужные сервисы.
    var locator, mapService, serviceLocator;
    //Создаем Сервис Локатор
    serviceLocator = new ServiceLocator();
    
    
    // Создадим Картографа
    mapService = new MapService({
        token: "my-application-token"
    });
    
    // Создадим Локатор
    locator = new Locator();
    
    // Вндерим зависимость Локатора при помощи Сервис Локатора
    locator.setMapService(serviceLocator.getMapService());
    
    // Вызовем метод локации
    locator.locateUsers(users);
    

    Таким образом, плагин становится чище, а централизованность обращения к сервисам сохранится.
    • 0
      Простыми словами мы заменяем зависимость от конкретных сервисов на зависимость от Сервис Локатора. При этом он сам по себе должен быть легко переносимым.


      Не совсем так. Зависимость от сервисов все равно остается, при этом добавляется еще одна зависимость от Сервис Локатора.

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


      Здесь немного не понял. Мы не привязываем инициализацию, мы делегируем создание объектов IoC контейнеру, вместо того, чтобы создавать объекты самим.
  • +1
    Можно просто взять AMD (require.js тот же), и имена модулей прописать константами (или в конфигурации require проставить).

    Будет что-то типа:
    var config = {
      logger: 'myApp/myLogger'
    }
    
    define('myApp/myModule', [config.logger], function(logger) { 
      ... 
    })
    


    Как по мне — это более JS-way: пользоваться гибкостью JS, а не тащить непонятные штуки из мира java enterprise.
    • +2
      В вашем примере вы инкапсулировали только реализацию логгера, но его создание и конфигурация лежит в ответственности модуля myModule. myModule должен знать о конфигурации логгера? Или в myApp/myLogger уже создается и конфигурируется экземпляр? ) Как можно поменять конфигурацию логгера?

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

      Однако, стоит помнить, что jQuery спагетти-код тоже совсем недавно считался «JS-way». =)
      • 0
        Как раз в методе инициализации require.js можно создать и реализовать что угодно, если это необходимо. В частности управлять версиями загруженных модулей и т.п. Это не DI, но все еще IoC. Далее работают сами синтаксические конструкции языка, где необходимо.

        Что касается необходимости, то IoC с DI это еще тот антипаттерн в плане поддержки кода. JS и так ужасно поддерживается IDE по сравнению с .Net/Java. Молчу про то, что нет никаких способов нормально осуществлять Go to declaration нет, так еще и вводим паттерн, который намеренно усложняет эти связи. Представьте, что у вас пара сотен тысяч строк кода в проекте и как вы это будете поддерживать.
        • 0
          Согласен с тем, что, например AMD или CJS модули могут быть контейнерами сервисов. Но считаю, что это скорее побочный эффект оптимизаций загрузки, который использовать в архитектурных целях не очень правильно. Про синтаксические конструкции — можно подробнее? =)

          Со вторым абзацем не согласен. На мой взгляд использование DI упрощает поддержку системы. «Go to declaration» поддерживается в CJS модулях, например Idea, а паттерн никак не влияет на связи в расположении классов.
  • 0
    Что-то сложно. Кажется, это слишком сложное решение, чтобы абстрагироваться от провайдеров карт — приходится думать больше над тем, как использовать этот dm, вместо того, как же подключить карту (непосредственно задача).
    Вариант использовать веб-компонент, скажем,
    <users-map provider="google"></users-map> 
    

    выглядит в разы проще.
    Этот-же компонент и из js элементарно вызывается new UsersMap({options});

    Ведь есть-же чудесный xtags, polymer, или на худой конец их аналоги bosonic, mod.js. Сложно представить, что из подобных задач лучше решается в JS, чем через веб-компоненты.
    • 0
      Провайдеры карт — это лишь более наглядный пример. Представьте, что у вас есть сервис хранения кэшированных данных. И, например, две его имплементации — хранение в cookies, хранение в localStorage.

      dm.get("cache").then(function(cacheService) {
          cacheService.store("my_key", {my_data: 1});
      });
      


      При этом можно сделать третью имплементацию, которая, в зависимости от браузера, будет определять, куда сохранять кэш. Такое можно реализовать на веб компонентах? На мой взгляд, это просто разные слои абстракций. В вашем примере вы передаете параметр provider, который означает, если я не ошибаюсь, что в компонент user-map содержит в себе все реализации провайдеров.

      Более того, как быть со слоем DI в node.js?
  • –1
    Чем инверсия управления отличается от полиморфизма?
    • 0
      Странный вопрос. Инверсия управления, как принцип, в первую очередь, объектно-ориентированного программирования, безусловно включает в себя и понятие полиморфизм. Ровно, как и наследование и инкапсуляцию =)
      • –1
        Тогда, вообще, запутанно. Что там инвертируется и что управляется?)
        • –1
          Чтобы не играть в испорченный телефон, предлагаю прочитать материалы из ссылок в конце статьи, мне кажется, они лучше внесут ясность чем я в двух строках комментариев =)

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.