Прочитав несколько статей на тему DI мне захотелось использовать его в Node.js; после недолгих поисков оказалось, что модулей для этого не так много, из них самый интересный — di.js от Angular, но он мне не подошел и я решил написать свой.


Почему не di.js?


  • он написан с использованием ES6, т.е. нуждается в предварительной компиляции в ES5, а так как он использует декораторы, то и ваш код должен быть скомпилирован в ES5
  • давно не поддерживается
  • использует старый компилятор (es6-shim)
  • нет возможности создавать несколько инстансов одного класса

Пишем свою реализацию


Самой интересной в написании модуля оказалась задача динамической передачи в конструктор аргументов.
Наиболее очевидное решение — использовать apply, но это не сработает, так как он взаимодействует с методами, а не конструкторами.


Для нашей цели можно воспользоваться spread operator:


class Test {
    constructor(a, b) {

    }
}

let args = [1, 2]
let test = new Test(...args)

В остальном реализация довольно скучна и не содержит ничего интересного.


Используем модуль


Я решил отказаться от декораторов di.js в пользу конфиг-файла. Допустим, мы описываем архитектуру компьютера, тогда в конфиге нам нужно описать наши классы, пути к ним и их аргументы:


{
  "Computer": {
    "class": "./Computer.js", // Путь к классу 
    "args": ["RAM", "HDD", "CPU", "GPU"] // Зависимости
  },
  "RAM": {
    "class": "./RAM.js",
    "args": []
  },
  "HDD": {
    "class": "./HDD.js",
    "args": []
  },
  "CPU": {
    "class": "./CPU.js",
    "args": ["RAM"]
  },
  "GPU": {
    "class": "./GPU.js",
    "args": []
  }
}

Класс Computer, как и все остальные, довольно простой:


'use strict'

class Computer {
    constructor(ram, hdd, cpu, gpu) {
        this._cpu = cpu
        this._gpu = gpu
        this._hdd = hdd
        this._ram = ram
    }
}

module.exports = Computer

Теперь в точке входа нашего приложения используем модуль:


const Injector = require('razr')(__dirname, './path/to/your/config.json') // передаем текущий путь и путь к конфигу

const computer = Injector.get('Computer') // тут мы молучим инстанс Computer

Стоит заметить, что в конфиг-файле нужно указывать пути к классам относительно точки входа приложения.


На этом все. Спасибо всем, кто дочитал. А вот ссылочка на GitHub — https://github.com/Naltox/razr и NPM — http://npmjs.com/package/razr

Поделиться с друзьями
-->

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


  1. Large
    23.07.2016 18:57
    +1

    Вообще di очень удобно делать через декораторы, они конечно пока stage1 и stage0, но скорей всего появятся рано или поздно в стандарте.


  1. samizdam
    23.07.2016 19:10
    +1

    Насколько я понял из исходного кода, вы реализовали Service Locator, который инстанцирует сервисы-одиночки на основе захардкоженной в виде строк конфигурации.
    Аналогичного результат можно было бы достигнуть в объекте конфига сразу произведя инстанцирование.
    Имхо декораторы выглядят перспективнее для решения задачи DI. А в чём проблема компилировать свой node.js код в ES5?


    1. Altox
      23.07.2016 19:17
      +1

      Я согласен, что декораторы выглядят перспективнее, но мне не очень нравится идея компилировать серверный код ради пары фич.

      А еще с декораторами нельзя будет сделать несколько инстансов одного класса (разные коннекты к дб, например)


      1. AndreyRubankov
        24.07.2016 00:01
        +1

        Через декораторы несколько разных инстансов сделать не получится, да.
        Но в контексте DI, если вам нужны разные инстансы коннекторов — они описываются в конфиге, а в сервисы вы уже подтягиваете нужный инстанс:

        function Inject(field, instanceName) {
          return function(target) {
            target[field] = CONFIG[instanceName];
          }
        }
        
        @Inject("pgConnection", "pgConnector")
        @Inject("mongoConnection", "mongoConnector")
        class MyService {
          var pgConnection;
          var mongoConnection;
        }
        


  1. AndreyRubankov
    23.07.2016 23:23

    Идея интересная, но содержит неприятную проблему: в одном файле может быть несколько классов и ни один из них не будет называться по имени файла (файл — это модуль, внутри множество классов).

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

    Пока в JS | ES не будет полноценной поддержки метаданных (декораторов, аннотаций), реализовать полноценный DI будет практически невозможно. А при отсутствии типов поддерживать и отлаживать такой DI контейнер будет сложно.


  1. AndreyRubankov
    23.07.2016 23:30

    Вы как-то сами себе противоречите:

    Почему не di.js?
    • он написан с использованием ES6, т.е. нуждается в предварительной компиляции в ES5



    И следом:
    Для нашей цели можно воспользоваться spread operator


    Spread operator — это ES6 стандарт, как и Классы и let, которые идут в примерах.


    1. Altox
      23.07.2016 23:37
      +1

      И Spread operator и классы и let поддерживаются последней нодой, их не нужно компилировать.


  1. AxVPast
    24.07.2016 12:23

    1. есть вариант с применением requirejs — очень не плохой (правда там не решают проблему №2), кстати очень напоминает spring.
    2. оно не работает с аснихронным кодом — верно? То есть для примитивных поделок сойдет, но как только Вам потребуется получить объект который что-то читает из БД (для инициализации) или тянет с файловой системы файл (не через readFileSync) то тут будет проблема.

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


    1. symbix
      24.07.2016 13:52

      А зачем там работа с асинхронным кодом? Контейнер ничего, кроме вызова конструкторов, делать не должен. Если конструктор возвращает promise — это какой-то очень странный дизайн :)


      1. AxVPast
        24.07.2016 15:31

        Простой понятный пример — рисуем баннеры на которых написаны буквы. При этом код должен работать всегда, а не в некоторых теплично-лабораторных условиях. Другими словами у «тормоза»-разработчика все работает, так как он не умеет сразу (время порядка 10мс и меньше) после listen сделать запрос к баннеру. Понятное дело, что в продакшине, под нагрузкой, все крешится прямо на старте.

        Для того чтобы это сделать — вам нужно 3 гарантированно «живых» объекта — объект доступа к БД, обьект поднявший файл-подложку с диска (одноразово) и контроллер который прицепит это все к вебсерверу.
        Если эдак сотную файлов с диска через readFileSync поднять получится (что вобщем-то уже не совсем корректно по идеологическим соображениям), то вот гарантированно «живой» коннекшин к базе данных уже проблема. А если еще и информация о том, что рисовать на баннерах (мета информация — куда «положить» статический текст и какой, а еще веселее — какие файлы-подложки поднять с диска) — содержится в БД — то вообще «потребуется особое техническое решение».

        Другими словами dependency injection это только часть старта системы. Мне больше нравится вариант в котором после завершения dependency injection части старта системы я вижу в логах сообщение SUCCESS, что одначает, что все требуемые файлы на диске были найдены, все соединения к БД и другим системам прошли успешно. То есть приложение гарантированно работоспособное и самое последнее действие типа .listen(3493,...) произошло без приключений. Если в ходе инициализации что-то пошло «не так» то лучше чтобы приложение сразу завершилось с вменяемой ошибкой.

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

        P.S. почитайте про Spring из Java там есть много чего, чего не хватает в nodejs.


        1. Lisio
          24.07.2016 18:55

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


          1. AxVPast
            24.07.2016 19:58

            Нет не поняли важного. После SUCCESS в логах я на 100% уверен, что система сконфигруирована правильно как минимум на момент старта. Если я ошибся — система не стартует вообще. Это рально удобно для админов системы.

            «Ваш» пример — стартуем систему. Есть ошибка в конфигурации базы данных, причем firewall съедает пакеты (то есть destination host uncharitable вы не получите никогда — поищите, кстати в доках, насколько это долго ;) ). То есть факт неработоспособности системы вы получите в виде вылета процесса по OOM :) Почему так — Ваш модуль работы с БД шибко умный и все попытки работать с базой тщательно складывает во внутреннюю очередь без контроля размера этой очереди (ждет момент, когда соединение реально появится). Это типовое поведение драйверов БД в ноде когда при инициализации вы делаете что-то типа require('redis').createClient(...).hget('НеважноЧто','неважноОткуда', function (x){…

            Второй пример — пишем сетевой модуль — пару клиент и сервер. Для теста нам нжуно поднять сервер, прицепить к нему клиента, проверить, что клиент-сервер протокол работает (логично после этого и клиент и сервер опустить). Потом тест проверяет реконнект клиента (тоже старт сервера/коннект/убийство коннекта (со стороны сервера) и проверка наличия реконнекта). Следующий тест — прицепляет к серверу бизнес логику и к клинету тоже реальну реализацию. Делаем smoke тест. После этого нужно тоже все опустить (корректно). Вот и вопрос — нужен promise на инициализации обоих компонент и promise для того, чтобы все погасить.
            В спринге для этого обычно заводится отдельный инициализационный файл и вся «магия» поднятия/опускания делается без наприсания кода.

            Вот как обычно это выглядит в Java: http://howtodoinjava.com/spring/spring-core/spring-bean-life-cycle/
            Упрощения подобной модели имеют место до определенного момента, а потом все выглядит как последний старт Челенджера.


        1. symbix
          24.07.2016 20:32

          Понятно. Я не считаю, что это ответственность DI, тут нужен еще один слой абстракции.
          Ну и с вечной асинхронкой мне кажется более надежным CQRS/EventSourcing-подход (модные флюксы-рефлюксы это, кстати, оно и есть в упрощенной форме).


  1. elmigranto
    24.07.2016 20:14
    +2

    Что-то мне неясно, чего такое получилось в итоге. Зачем нужен JSON на 20 строчек, если можно всё это напрямую в JS писать?


  1. ktretyak
    24.07.2016 20:15

    В di.js есть пример для node.js, который не нужно компилить: github.com/angular/di.js/tree/master/example/node


  1. gobwas
    25.07.2016 11:02

    … из них самый интересный — di.js от Angular, но он мне не подошел и я решил написать свой.
    =)

    Если по теме – когда-то пилил тоже "свой", с асинхронной загрузкой, ленивым инстанцированием и прочими прелестями:
    https://github.com/gobwas/dm.js


  1. bromzh
    25.07.2016 11:42

    В качестве DI в js/ts мне понравилась эта штука: http://inversify.io/


  1. raveclassic
    25.07.2016 21:35

    Не по теме DI —
    использовать apply, но это не сработает, так как он взаимодействует с методами, а не конструкторами
    Вообщем-то apply можно, вот так:
    class Foo {
    constructor(a, b) {
    this.c = a + b;
    }
    }

    const args = [2, 3]
    const foo = new (Function.prototype.bind.apply(Foo, [null, ...args]));
    console.log(foo.c) //5


    1. raveclassic
      25.07.2016 22:07

      Ну вот, тэги вырезались, разметка побилась.
      А вообще забавно, посмотрел, во что транспайлится spread в конструкторе — как раз вот в это.


  1. MarcusAurelius
    26.07.2016 10:31

    DI для классов практически не нужен. В ноде есть DL для модулей, это require, а вот простая реализация DI для модулей: https://github.com/HowProgrammingWorks/InversionOfControl/blob/master/sandboxedModule/ru/framework.js
    А тут пример с декларативным описанием зависимостей модулей: https://github.com/HowProgrammingWorks/InversionOfControl/tree/master/dependencyInjection/ru
    Это все только примеры использования, вся реализация уже встроена в ноду, только мало кто знает.


    1. symbix
      26.07.2016 11:06

      Это не DI, это сервис-локатор.


      1. MarcusAurelius
        26.07.2016 11:27

        Сервис-локатор это когда программный компонент запрашивает у локатора ссылку на другой программного компонент (модуль, класс, интерфейс). А тут все иначе, служебный код (управляющий код, среда запуска или фреймворк) незаметно для управляемого кода (библиотеки, прикладной программы) создает контекст и внедряет в него нужные зависимости. Из контекста теперь зависимости не нужно брать через require (DL).


        1. symbix
          26.07.2016 11:44

          Не суть. DI — это когда конкретная реализация:
          1) ничего не знает об «особом» способе получения зависимостей (и вообще о каком-то способе это делать), просто получает все, что надо, в конструктор;
          2) не может сама «взять» то, чего ей не давали, не меняя конструктор.

          Сервис-локатор же, напротив, это когда
          1) реализация знает, что зависимости надо получать особым образом;
          2) может взять из контейнера все, что там есть, в любой момент.

          А уж что там будет написано, serviceLocator.get('logger') или context.logger — это не имеет значения.

          При этом любой DI неизбежно содержит SL внутри. Чтобы это стало DI, надо добавить инстанциацию с constructor injection.


          1. MarcusAurelius
            26.07.2016 12:05

            DI можно применять не только к ООП, а к классическим модулям. Вот в ноде модули это не классы, а контексты. В контексте зависимость используется не как

            context.logger
            а как
            logger.write('string');


            serviceLocator.get('logger')
            и
            logger.write('string');
            отличаются существенно, первый получает ссылку на зависимость, а второй использует зависимость.


            1. symbix
              26.07.2016 12:27

              А, понял вас. И посмотрел внимательнее код, извините, что сразу не сделал этого.

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

              И, кстати, разве vm.createContext — не тяжелая операция?


              1. MarcusAurelius
                26.07.2016 12:53

                Почти ни кто на node.js не используют ООП, а управление зависимостями происходит на уровне модулей. vm.createScript тяжелая, а vm.createContext не очень тяжелая, а в этом случае скорость исполнения вообще не важна, потому, что связывание зависимостей происходит при старте приложения.


                1. symbix
                  26.07.2016 13:03

                  Ну, не знаю насчет «почти никто». Я использую (хотя я на ноде мало пишу), мои коллеги используют, в том числе TypeScript.


                  1. MarcusAurelius
                    26.07.2016 13:48

                    В npm почти нет пакетов с использованием ООП и на TypeScript, а среди распространенных библиотек их вообще нет. Есть так же мнение, что ООП провалилось, http://blogerator.ru/page/oop_why-objects-have-failed Я же думаю, что его просто не умеют правильно использовать. ООП прекрасно подходит для GUI и для обертки объектов операционной системы, как то сокеты, файлы и т.д. Но вот для моделирования предметной области ООП нужно по возможности избегать и использовать структуры данных и функции, к сожалению так нельзя сделать везде, вот в Java придется все писать на ООП, а в ноде лучше писать на мультипарадигменном подходе, применять функционально и реактивное программирование, прототипное наследование, data-driven программирование, событийное и асинхронное программирование, а не лепить объекты куда угодно к месту и не к месту.


                    1. symbix
                      26.07.2016 14:16

                      > для моделирования предметной области ООП нужно по возможности избегать и использовать структуры данных и функции

                      Это очень спорное мнение. Eric Evans с вами не согласен.

                      Вообще, про «провалилось» — это какой-то холивар, и обычно с основной мотивацией «не осилили». Оба подхода имеют право на жизнь. То, что перечисляют в статье — это неправильное использование ООП.


                      1. MarcusAurelius
                        26.07.2016 14:34

                        Согласен, в статье некомпетентно нагоняют на ООП, у меня к ООП совсем другие претензии, в основном связанные с тем, что модель предметной области в информационных системах не может иметь методов, потому, что моделируются не объекты реального мира, а лишь их информационная составляющая, например, vehicle.driveTo(address) имеет смысл только если у нас система, приводящая в действие автомобиль, но если мы работаем только с данными, и не занимаемся автоматизацией железа, то нам нужно job = { vehicle, address }. Тут vehicle и address это только только данные, практически стракты, а вот у logisticsService могут быть методы, в которые передается job, например: logisticsService.execute(job). Грубо говоря, за неимением страктов, в некоторых языках vehicle, address нужно делать классами и объектами. Для этого даже есть название «анемичные классы» и «анемичные объекты», которые не имеют поведения, только данные.


                        1. symbix
                          26.07.2016 22:00

                          Что ж это за предметная область, где нет бизнес-логики, а только данные? Коли так, в программе нет никакого смысла и достаточно использовать СУБД.

                          Анемичная модель — это вообще не ООП, это эмуляция процедурного программирования средствами объектного языка. К ООП отношения по сути своей не имеет (от ключевого слова class программа не становится объектной). Anemic models и бездумное вынесение бизнес-логики в сервисы — это как раз антипаттерн в DDD. Очень, правда, популярный с распространением Active Record (там так проще).

                          Может быть, вы путаете anemic models и value objects? Value objects — это нормально, там максимум инварианты. А логика будет как минимум в aggregate root. Если последние два слова непонятны… Эванса читать советовать не буду (там много букв), лучше посоветую Domain Driven Design Quickly, она короткая, и pdf-ку раздают бесплатно.


                          1. MarcusAurelius
                            30.07.2016 04:03

                            Я имел в виду именно анемичные модели. Что же делать, если язык только ООП поддерживает, но программисту в задаче ООП не нужен. А бизнес-логика предметной области может находиться не в классах, если использовать не ООП. Это же не серебрянная пуля. Вот недавно с медиума статья по ООП:

                            https://medium.com/@cscalfani/goodbye-object-oriented-programming-a59cda4c0e53#.1ox7s5qd2

                            Автор резок, а мое мнение, что самое здоровое — это мультипарадигменное программирование с элементами процедурного, функционального, реактивного, асинхронного, событийного, декларативного и управляемого данными, объектного, структурного, обобщенного и метапрограммирования.


                            1. symbix
                              30.07.2016 04:32

                              Статью надо назвать «goodbye inheritance». Наследование — зло, я предпочитаю делегирование и композицию, а когда (иногда) наследование уместно, придерживаюсь принципа abstract or final. ООП вообще не про наследование, и Страуструповская мантра — это про С++, а не про ООП. Алан Кей вообще говорил, что когда говорил об ООП, вот то, что в С++, никак не имел ввиду :)

                              Бизнес-логика может находиться где угодно, но это «где угодно» должно находиться в отдельном слое domain model — в том или ином смысле. :) Иначе она будет размазана по всему коду, и при внесении изменений в бизнес-логику будет очень «весело».

                              Противопоставлять причин не вижу, все перечисленные вами парадигмы прекрасно уживаются вместе.


  1. aretmy
    26.07.2016 21:16

    Для себя использовал bottlejs. Зависимости описываются как в ангуляре, через .service, .factory, .constant и т.д. (что мне не сильно нравится). Пользовался в основном объявлением сервиса, куда передаешь конструктор класса и имена модулей, которые надо заинжектить. Мне понравилось – можно было использовать несколько инстансов одного класса как разные сервисы.