СоХабр закрыт.

С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.

H IoC контейнер на javascript в 90 строк в черновиках Из песочницы

На прошлой неделе, я получил удовольствие, рассказывая ученикам пятого класса о том, что такое программирование и алгоритмы. За 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, но я не готов назвать их легковесными решениями. Они больше похожи на танки обвешанные межгалактическими ракетами.

Правда, в процессе написания статьи я нашел элегантное решение в 30 строк
Источник
Здесь нет: 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 = /^function\s*[^\(]*\(\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");
};


Исходный код IoC контейнера
"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 = /((\/\/.*$)|(\/\*[\s\S]*?\*\/))/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 контейнер
+7
~1600

комментарии (6)

0
lair ,  

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

+1
Drag13 ,  
Да, поэтому после минификации это сломается. Поэтому в ангуляре и появилось внедрение зависимостей через ведро массив вида ['service1', 'service2', (anyservice1, anyservice2)=>{...}]
0
redyuf ,  
По именам жестоко конечно. Без типобезопасности на DI сложный проект не напишешь, а в небольших проектах он и не нужен особо.

Типы принципиально не хотите использовать, ts, flow?

@Inject()
class A {
  constructor(private service1: Service1, service2: Service2) {}
}


Можно использовать рефлексию, например ts генерит у классов сигнатуру конструкторов.

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

class AbstractService {
    some(): void { }
}

@Inject() class A {
    constructor( service: AbstractService ) { }
}

//
register(AbstractService, MyService)
0
+1 –1
justboris ,  

Большинство ваших объявлений переменных через let можно заменить на const, потому что их значение не меняется далее по коду.


Раз уж пишете ES6 код, то можно заменить все function на стрелочные => и избавиться от self = this; потому что this будет ссылаться на одно и то же.

+1
justboris ,  

Использовать функцию getParamNames для выяснения зависимостей — плохая идея. Как уже указали выше, это ломается при минификации кода. То же самое и с самим service.name — оно тоже может быть переименовано.


В Angular на эти грабли уже наступили, и в версии Angular 2+ никаких магических операций в рантайме не делают, а всегда передают через строки. Для вашего IoC это будет смотреться как-то так:


function MainService(multiService, singletonService) {
  multiService().start();
  singletonService().start();
}
MainService.inject = ["multiService", "singletonService"];

container.register("mainService", MainService)

Все имена сервисов и зависимости хранятся в строках, никакие манипуляции с исходным кодом им не грозят.

0
AlexPTS ,   * (был изменён)
Для более простого разграничения приватных и публичных методов и свойств используйте паттерн модуль и паттерн открытия модуля, сделав все this.xxxPubllic методы тоже закрытыми в замыкании и в конце явно возвращая новый объект. Будет проще код и в 1 месте видно, что является открытым наружу