Понятия «инверсия управления» и «внедрение зависимостей» не являются новыми, но в сообществе 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 в статье Мартина Фаулера) — информация о зависимостях переносится в реализуемые сервисом интерфейсы.

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

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

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