IoC контейнер на javascript в 90 строк

в 15:04, , рубрики: dependency injection, ioc, ioc контейнеры, javascript

На прошлой неделе, я получил удовольствие, рассказывая ученикам пятого класса о том, что такое программирование и алгоритмы. За 45 минут сложно рассказать много о такой широкой теме, моей целью было заинтересовать в игровой форме. Тема урока была выбрана «Программирование: как создаются игры».

Вашему вниманию представляется игра, реализованная для этого урока с использованием инверсии зависимости и IoC-контейнера:

IoC контейнер на javascript в 90 строк - 1

Игра 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 = /^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 контейнера заключается в том, что добавляя новый сервис к существующему приложению, не надо задумываться, как поставлять ему зависимости, как они будут по цепочке собственных зависимостей разрешены. Также, не составляет труда к существующему сервису добавить новую зависимость.

Проектируя в стиле «инверсии зависимости» на выходе получаются небольшие изолированные сервисы, легкие в понимании и поддающиеся (при необходимости) тестированию.

В дальнейшем, любой сервис можно заменить. Например, в процессе разработки игра выглядела так:

IoC контейнер на javascript в 90 строк - 2

Можно поиграть с клавиатуры

В конечной реализации сервис, отвечающий за прорисовку, был заменен в течении пары часов.

Вот такой 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 = /((//.*$)|(/*[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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js