На прошлой неделе, я получил удовольствие, рассказывая ученикам пятого класса о том, что такое программирование и алгоритмы. За 45 минут сложно рассказать много о такой широкой теме, моей целью было заинтересовать в игровой форме. Тема урока была выбрана «Программирование: как создаются игры».
Вашему вниманию представляется игра, реализованная для этого урока с использованием инверсии зависимости и IoC-контейнера:
Игра DiggerZ, исходный код.
Сразу предупреждаю, про контейнеры я детям не рассказывал.
Опыт я получил занимательный. Интересно наблюдать, как малые ребята используют смартфоны — устройства, по мощности превосходящие мой первый компьютер на несколько порядков. Они с радостью и азартом поиграли, а вам сегодня я расскажу, как написал IoC контейнер и почему его использовал в данном случае.
Контейнер я написал еще месяц назад из спортивного интереса.
а) Я никогда до этого не писал контейнеры. После прочтения отличной книги Симана "Внедрение зависимостей в .Net" осталось впечатление, что реализовать простейший контейнер не составит труда.
б) Интересно было реализовать на javascript. Особенности языка позволяют лаконично реализовывать достаточно сложные вещи. Например, удобно, что объекты — это сразу и словари, а функция легко возвращает другую функцию. Пример ленивой инициализации:
if (options.lifetime == "singleton") {
return function() {
return options.__instance || (options.__instance = new function() {
return service.apply(this, usedServices);
});
};
};
в) Некоторое время назад я искал контейнеры для js. Есть готовые решения, например inversify или cujojs, но я не готов назвать их легковесными решениями. Они больше похожи на танки обвешанные межгалактическими ракетами.
Здесь нет: lifetimes, валидации (проверки на циклы и отсутствие регистрации зависимости)
var Injector = {
dependencies: {},
add : function(qualifier, obj){
this.dependencies[qualifier] = obj;
},
get : function(func){
var obj = new func;
var dependencies = this.resolveDependencies(func);
func.apply(obj, dependencies);
return obj;
},
resolveDependencies : function(func) {
var args = this.getArguments(func);
var dependencies = [];
for ( var i = 0; i < args.length; i++) {
dependencies.push(this.dependencies[args[i]]);
}
return dependencies;
},
getArguments : function(func) {
//This regex is from require.js
var FN_ARGS = /^functions*[^(]*(s*([^)]*))/m;
var args = func.toString().match(FN_ARGS)[1].split(',');
return args;
}
};
Итак, контейнер был реализован за полдня субботы. Проверить работу можно было только в консоли. А вот пример использования:
// composition root:
let container = new IoC();
container.register(mainService, {
lifetime: "transient"
});
container.register(singletonService); // by default singleton
container.register(multiService, {
lifetime: "transient"
});
container.check();
// only one resolve:
let main = container.resolve("mainService");
// application run:
main();
Пример синтетический и пользы от него немного. И вот когда дело дошло до написания игры и продумывания структуры сущностей для нее, я решил использовать эту наработку.
Удобство использования IoC контейнера заключается в том, что добавляя новый сервис к существующему приложению, не надо задумываться, как поставлять ему зависимости, как они будут по цепочке собственных зависимостей разрешены. Также, не составляет труда к существующему сервису добавить новую зависимость.
Проектируя в стиле «инверсии зависимости» на выходе получаются небольшие изолированные сервисы, легкие в понимании и поддающиеся (при необходимости) тестированию.
В дальнейшем, любой сервис можно заменить. Например, в процессе разработки игра выглядела так:
В конечной реализации сервис, отвечающий за прорисовку, был заменен в течении пары часов.
Вот такой composition root в итоге получился:
let container = new IoC();
container.register(level);
container.register(player);
container.register(monsters);
container.register(mapStorage);
container.register(engine);
container.register(renderer);
container.register(messageBroker);
container.register(userInput);
container.check();
container.resolve("renderer")();
container.resolve("userInput")();
container.resolve("engine")().startNewGame();
Плюсом я также считаю проверку зависимостей до старта всего приложения (container.check). Подтверждение того, что все требуемые зависимости зарегистрированы и нет циклов, дают определенную уверенность, что с приложением порядок.
А вот пример реализации одного из ключевых сервисов, «движка»:
function engine(level, monsters, messageBroker) {
this.startNewGame = () => {
level().reset();
let i = 0;
setInterval(() => {
messageBroker().send("render");
if (level().getIsGameOver()) {
level().reset();
};
i++;
if (i % (20 - level().getIndex()) == 0) {
monsters().move();
};
}, 50);
};
};
Как оказалось на практике, все сервисы используются в режиме singleton. При развитии приложения могут пригодиться и другие жизненные циклы, например perGraph, perRequest или именованные экземпляры.
function userInput(player) {
let onkeydown = (e) => {
if (e.keyCode == 38) player().moveUp();
if (e.keyCode == 40) player().moveDown();
if (e.keyCode == 37) player().moveLeft();
if (e.keyCode == 39) player().moveRight();
e.preventDefault();
};
document.addEventListener("keydown", onkeydown, false);
};
Я в конечном итоге в этот же метод добавил поддержку touch устройств, а можно было добавить еще один сервис так, чтобы их обязанности были разделены:
function userInputForTouchDevices(player) {
let onClick = direction => {
if (direction === "top") player().moveUp();
if (direction === "bottom") player().moveDown();
if (direction === "left") player().moveLeft();
if (direction === "right") player().moveRight();
};
document.querySelector(".left .top").onclick = () => onClick("top");
document.querySelector(".right .top").onclick = () => onClick("top");
document.querySelector(".left .bottom").onclick = () => onClick("bottom");
document.querySelector(".right .bottom").onclick = () => onClick("bottom");
document.querySelector(".left .middle").onclick = () => onClick("left");
document.querySelector(".right .middle").onclick = () => onClick("right");
};
"use strict";
function IoC() {
let services = {}; // services registration options
let self = this;
this.register = function(service, options) {
if (typeof(service) !== "function")
throw new Error("Service is not a function: " + service);
if (typeof(options) === "undefined") {
options = {};
};
setDefaultOptions(options);
let name = service.name;
if (services.hasOwnProperty(name))
throw new Error("Service already has been registered: " + name);
options.__service = service;
services[name] = options;
return function() {
return self.resolve(name);
};
};
this.resolve = function(name) {
if (!services.hasOwnProperty(name))
throw new Error("Service can not be resolved: " + name);
let options = services[name];
let service = options.__service;
let usedServices = getParamNames(service).map(self.resolve);
if (options.lifetime == "transient") {
return function() {
return new function() {
return service.apply(this, usedServices);
};
};
};
if (options.lifetime == "singleton") {
return function() {
return options.__instance || (options.__instance = new function() {
return service.apply(this, usedServices);
});
};
};
throw new Error("Service can not be resolved: " + name);
};
this.check = function() {
Object.keys(services).forEach(checkUsedServicesExist);
Object.keys(services).forEach(checkCycle);
};
function checkUsedServicesExist(name) {
getUsedServices(name).map(self.resolve);
};
let getUsedServices = name => getParamNames(services[name].__service);
function checkCycle(name) {
let chains = [[name]];
let i = 0;
while (i < chains.length) {
let currentChain = chains[i];
let currentName = currentChain[currentChain.length - 1];
let currentUsed = getUsedServices(currentName);
currentChain.forEach(x => {
if (currentUsed.includes(x))
throw new Error("Cicle found : " + currentChain + " > " + x);
});
let newChains = currentUsed.map(x => currentChain.concat([x]));
chains = chains.concat(newChains);
i++;
}
};
function setDefaultOptions(options) {
options.lifetime = options.lifetime || "singleton";
};
// https://stackoverflow.com/questions/1007981/how-to-get-function-parameter-names-values-dynamically
const STRIP_COMMENTS = /((//.*$)|(/*[sS]*?*/))/mg;
const ARGUMENT_NAMES = /([^s,]+)/g;
function getParamNames(func) {
let functionString = func.toString().replace(STRIP_COMMENTS, '');
let result = functionString.slice(functionString.indexOf('(') + 1, functionString.indexOf(')')).match(ARGUMENT_NAMES);
if (result === null)
result = [];
return result;
}
};
///////////////// Services implementation /////////////////////
function multiService(singletonService) {
let index = 0;
this.start = function() {
index++;
console.log("multiService : " + index);
};
singletonService().start();
};
let singletonService = function() {
let index = 0;
this.start = function() {
index++;
console.log("singletonService : " + index);
};
};
let mainService = function(multiService, singletonService) {
multiService().start();
singletonService().start();
};
///////////////// IoC registration and check (Composition root) /////////////////////
let container = new IoC();
container.register(mainService, {
lifetime: "transient"
});
container.register(singletonService);
container.register(multiService, {
lifetime: "transient"
});
container.check();
///////////////// Run App /////////////////////
let main = container.resolve("mainService");
main();
Конечное приложение получилось небольшим (500 строк) и по большей части экспериментальным.
Если Вам понравилась статья, приглашаю к обсуждению. Расскажите о своем опыте использования IoC в javascript в больших приложениях. Какие есть плюсы и, возможно, минусы?
Статьи по данной теме:
Внедрение зависимости
Инверсия управления
IoC контейнер
Автор: pilyugin