JavaScript — заполняем нишу между микросервисами и объектами — «нано-сервисы»

    Кто из нас не хочет сделать большое приложение с правильной архитектурой? Все хотят.
    Чтобы была гибкость, переиспользуемость и четкость логики. Чтобы были домены, сервисы, их взаимодействие.


    И даже иногда хочется чтобы было почти как в Erlang.


    Идея создания фреймворка для микросервисов для NodeJs удачно воплощалась не единожды — так у нас как минимум есть Seneca и Studio.js, которые безусловно хороши, но они определяют большие логические единицы. С другой стороны у нас есть обычные объекты, разделяемые в системе посредством Dependency Injection или подобной техники, но они не дают должной четкости границ.


    Иногда нужны "нано-сервисы".


    За термином "нано-сервис" закрепим такое определение - "самостоятельные компоненты, взаимодействующие друг с другом в пределах одного процесса по контракту, при этом не использующие сеть".


    Фактически это самые обычные объекты, однако отличия все же есть — компонент "нано-сервиса" явно описывает какие именно функции других сервисов ему требуются и однозначно перечисляет все экспортируемые функции. Компонент не может запросить у другого компонента выполнить что-то вне контракта.


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


    " — Стоп! — скажете вы. — А что не так с микросервисами?"


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


    Далее — ситуации недоступности сервиса требуют обработчиков в каждом потребителе. Кроме того сразу же встает вопрос кто будет обслуживать инфраструктуру из десятков сервисов (процессов), отслеживать их статусы, каков сценарий их перезапуска, утилизации и т.п.


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


    " — Хорошо, тогда почему не классическая DI?"


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


    " — И альтернатива — нано-сервисы?"


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


    Схематически концепция выглядит следующим образом:



    Имеются компоненты Foo и Bar, которые соответственно расположены в доменах Services и Shared. Компоненты экспортируют методы doFoo и getBar, которые могут быть запрошены другими компонентами.


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


    Кроме того фреймворк предоставляет метод для доступа к компонентам "извне", позволяя центральной точке запуска приложения взаимодействовать с компонентами.


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


    Код данного приложения:
    // first service file aka 'foo_service'
    class FooService {
      getServiceConfig () {
        return ({
          require: {
            BarService: ['getBar']
          },
          export: {
            execute: ['doFoo']
          },
          options: {
            injectRequire : true
          }
        })
      }
    
      doFoo (where) {
        let bar = this.BarService.getBar()
    
        return `${where} ${bar} and foo`
      }
    }
    
    export default FooService

    // first layer file aka 'services_layer'
    import { Layer } from 'antinite'
    import FooService from './foo_service'
    
    const LAYER_NAME = 'service'
    const SERVICES = [
      {
        name: 'FooService',
        service: new FooService(),
        acl: 711
      }
    ]
    
    let layerObj = new Layer(LAYER_NAME)
    layerObj.addServices(SERVICES)

    // second service file aka 'bar_service'
    class BarService {
      getServiceConfig () {
        return ({
          export: {
            read: ['getBar']
          }
        })
      }
    
      getBar () {
        return 'its bar'
      }
    }
    
    export default BarService

    // second layer file aka 'shared_layer'
    
    import { Layer } from 'antinite'
    import BarService from './bar_service'
    
    const LAYER_NAME = 'shared'
    const SERVICES = [
      {
        name: 'BarService',
        service: new BarService(),
        acl: 764
      }
    ]
    
    let layerObj = new Layer(LAYER_NAME)
    layerObj.addServices(SERVICES)

    // main start point aka 'index'
    import { System } from 'antinite'
    
    // load layers, in ANY orders
    import './services_layer'
    import './shared_layer'
    
    let antiniteSys = new System('mainSystem')
    antiniteSys.onReady()
      .then(function() {
        let res = antiniteSys.execute('service', 'FooService', 'doFoo', 'here')
    
        console.log(res) // -> `here its bar and foo`
      })

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


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


    В репозитории имеются дополнительные примеры сервисов.


    " — А что с накладными расходами?"


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


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


    Теперь о механизмах прав доступа. Во-первых компонент, экспортируя методы, явно указывает категорию экспорта — 'read', 'write', 'execute', таким образом можно разделить их по степени воздействия на систему. Во-вторых слой, регистрируя компонент, указывает маску доступа к компоненту, например '174' — говорит о том, что системным вызовам доступны только методы категории 'execute', компонентам, находящимся в том же домене — полный набор прав 'read', 'write', 'execute' и компонентам из других доменов — только методы категории 'read'.


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


    Фреймворк имеет помощник для legacy-кода, который может облегчить процесс переноса кода.


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


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


    И в дополнению ко всему система может предоставить текущий граф зависимостей, который несложно визуализировать.



    Есть простейший помощник для визуализации, Antinite Visual Toolkit. Данная библиотека была сделана как пример возможной визуализации, возможно не самый удачный.


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


    Если у вас есть вопросы, пожелания, дополнения, критика и предложения — пожалуйста отразите это в комментариях. Кроме того у проекта имеется gitter-чат. На данном моменте мне очень нужна обратная связь для улучшения прототипа.


    Antinite доступен на github, и для установки через npm под лицензией MIT. У проекта имеется подробная документация и набор тестов.


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


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

    Поделиться публикацией
    Комментарии 30
      +1

      Нет, не убедили.


      Да, инверсия зависимостей позволяет переложить работу по созданию объектов на фреймворк, но никак не может ограничить их использование.

      А как ограничиваеся использование в вашем решении?


      getServiceConfig () {
        return ({
          export: {
            read: ['getBar']
          }
        })
      }

      Интерфейсы, контракты — нет, не слышали.


      А то, что библиотека патчит исходные объекты — это вообще за гранью.


      Советую посмотреть на Typescript и библиотеки вроде InversifyJS.

        0
        Интерфейсы, контракты — что-то такое слышали, но в JS, уж если у нас как-то получен объект — то ВСЕ его методы доступны. Все варианты сделать методы как бы приватными — черная магия чистой воды.
        А как ограничиваеся использование в вашем решении?

        Пример маловат, из него не все очевидно, мой просчет.
        Все методы, объявленные в `export` конечно же будут доступны компонентам, которые имеют на это права. Но! Обычно объект имеет и приватные методы, которые используются для вынесения кода в аккуратные логичные сущности. И как мы помним, приватны они только на уровне договоренности (см первый абзац ответа). В случае использования Antinite экспортируется ТОЛЬКО то, что описано в экспорте. К приватным методам (не объявленным в экспорте) доступа нет. Вообще нет. Совсем. Никак.
        Ровно как у компонентов с правами на методы уровня «чтение» не будет доступа к методам других уровней. Совсем.
        И да, библиотека патчит исходные объекты. Потому что она ими владеет по праву использования. Как по мне — грех тянущий максимум на написание хорошего комментария ошибки.
        Советую посмотреть на Typescript и библиотеки вроде InversifyJS.

        Что именно в Typescript позволяет реализовать хотя бы DI? В Typescript есть что-то для контроля доступа к синглтонам? Аудит их использования?
        InversifyJS — забавная вещица, но вот вы мне патчить исходные объекты запрещаете, а там во весь рост Proxy используются, которы мало того что пока в драфте, так еще и, положа руку на сердце, делают тоже самое, по сути.
          +1

          Если у вас большой размер кода и нужно грамотно разделять зоны ответственностей модулей, то самое время внедрить Typescript, а не устраивать валидацию в рантайме.


          Про Proxy: во-первых, это уже часть стандарта языка, поддерживается в stable версии Node. Во-вторых, несмотря на использование Proxy, код остаётся типизированным и предсказуемым. В нем всегда можно проследить где метод декларируется и где вызывается. А в вашем подходе найти концы не представляется возможным

            0
            Если у вас большой размер кода и нужно грамотно разделять зоны ответственностей модулей, то самое время внедрить Typescript, а не устраивать валидацию в рантайме.

            Вероятно я плохо себе представляю возможности Typescript. Каким образом в нем можно разделить видимость методов, кроме как написав кучу оберток интерфейсов?
            Про Proxy: во-первых, это уже часть стандарта языка, поддерживается в stable версии Node. Во-вторых, несмотря на использование Proxy, код остаётся типизированным и предсказуемым. В нем всегда можно проследить где метод декларируется и где вызывается. А в вашем подходе найти концы не представляется возможным

            И что нам дает типизированность кода в вопросе доступа к методам? И видимо у меня не получилось донести идеи. Первое — концы конечно же можно найти — система в любой момент может отдать граф зависимостей. Приспособить его к тому же Atom — дело техники. Далее — приведенная Вами библиотека — классический вариант DI, разве что с TS интерфейсами, которые существуют только до момента транспилинга. И сам автор указывает на то, что извлечь из контейнера объект следует в самом верху дерева, после чего приходится носиться с этим объектом как с писаной торбой и прокидывать его дальше вглубь. Или прокинуть контейнер и получить -500 к карме. В этом отношении даже древнючий intravenous и то лучше, правда с ним мы так же в итоге не понимаем что откуда берется и что куда девается.
              +1
              Каким образом в нем можно разделить видимость методов, кроме как написав кучу оберток интерфейсов?

              Вы пишете ровно столько же интерфейсов, сколько кода в методе getServiceConfig вашей имплементации. Только с Typescript и интерфейсами вы получаете все преимущества статической типизации.


              И что нам дает типизированность кода в вопросе доступа к методам?

              private поля и методы.


              система в любой момент может отдать граф зависимостей

              Зачем городить велосипеды и пытаться интегрировать их в редактор, если уже есть готовые?

                0
                Хорошо, положим в TS у нас вроде бы есть приватные методы и поля. Пусть с ними.

                Давайте зайдем все же рассмотрим вариант интеграции с DI.
                В любом варианте мы в итоге имеем что-то типа
                class Foo {
                  constructor(dependency1, dependency2, dependency3) {
                    this.ext1 = dependency1
                    this.ext2 = dependency2
                    this.ext3 = dependency3
                  }
                 doFoo1 (dataIn) {
                     this.ext1.callFn(dataIn)
                  }
                }
                

                так? Если не брать совсем экстремальные варианты то вроде бы так. А значит при необходимости сделать тест нашего Foo нам надо на минуточку — найти что за объекты пробрасываются в конструктор (а если это довольно далеко от корня — могут быть нюансы), далее — найти методы этих зависимостей, которые используются (по всему коду), далее — сделать на них моки.
                Или, используя велосипед — просто посмотреть в перечисление require и дать именно те методы, которые там перечислены. Все! Больше никаких зависимостей у класса нет и быть не может.
                Статически объявленная гранулярность зависимостей на уровне методов.
                Нет, не нужно? Точно?
                  0
                  просто посмотреть в перечисление require и дать именно те методы, которые там перечислены

                  Не так уж просто. Вам нужно будет поддерживать моки в будущем, при изменении require. С интерфейсами тоже не сложно: пока интерфейс (контракт) не меняется, то и мок может оставаться прежним. Немного больше работы на старте, зато проще потом.

                    0

                    Апдейт: попробовал реализовать разделение ответственности на Typescript, используя typedi:


                    import 'reflect-metadata';
                    import {Inject, Service} from 'typedi';
                    import {Readable, Writable} from './interfaces';
                    
                    // Создаем сервис "книга", в который можно читать и писать
                    @Service("book")
                    class Book implements Readable, Writable {
                      private content: String = 'hello!';
                    
                      read() {
                        return this.content;
                      }
                    
                      write(value) {
                        this.content = value;
                      }
                    
                    }
                    
                    // сервис "человек" использует книгу только для чтения
                    class Person {
                      @Inject("book") private book: Readable;
                    
                      read() {
                        console.log(this.book.read())
                      }
                    }
                    
                    // сервис "писатель" может писать в книгу
                    class Writer {
                      @Inject("book") private book: Writable;
                    
                      write() {
                        this.book.write('New content');
                      }
                    }

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

                      0
                      Получилось красиво, не спорю.
                      Но у Вас сервис-потребитель ограничивает себя сам, а у меня сервис-поставщик и домен, в рамках которого он (поставщик) заявлен, контролирует доступность ресурса. Т.е. никто не берет чужого не потому что все честные, а потому что замок висит. В этом вся разница.
          0
          в Javascript только что были изобретены функции с сайд-эффектами и приватные поля?
            0
            Давайте по существу, что там у нас с «функциями с сайд-эффектами и приватными полями»?
              0
              что «самостоятельные компоненты, взаимодействующие друг с другом в пределах одного процесса по контракту, при этом не использующие сеть» это классы любого обьектно ориентированного языка, взаимодействующие через интерфейсы, ну ещё и переменной (полем) в этом языке может быть функция, что придумано давным- давно, а если какой-то язык не в полной мере соответствует концепции ОО, получается нечитабельная и тормозящая в рантайме хрень.
                0
                странно, а вот википедия говорит что
                Класс — абстрактный тип данных в объектно-ориентированном программировании, задающий общее поведение для группы объектов; модель объекта.

                Ну и да, что-то по вашему получается «не читал, но осуждаю». Почитайте, бывает интересно.
            +2

            следующими видимо будут фемтосервисы.

              +1

              Пикосервисы пропустили

              0
              Кажется, ваша модель безопасности вполне позволяет один раз залинковать друг с другом все сервисы при запуске. Почему было выбрано именно динамическое решение?
                0
                Не совсем понимаю вопрос.
                Если он в том «почему не все со всеми» — так это и не нужно и неудобно будет, здесь четкое понимание зависимостей прямо в конфигурации видно.
                Если «динамическое разрешение доступа» — то по факту-то оно как раз статическое, при сборке всех зависимостей и разрешается.
                  0

                  Ну смотрите, вот вы делаете присваивание this.service[service][funcName] = this.proxyUpcomingExecute.bind(this, service, funcName). Но в функции proxyUpcomingExecute первые два параметра при этом используются вот так:


                  let grantedItem = this.requireDict[serviceName][action] // action = funcName
                  return grantedItem.layer.executeAction(grantedItem.ticket, serviceName, action, ...args)

                  Это означает, что можно было бы биндить непосредственно grantedItem.layer.executeAction вместо proxyUpcomingExecute. Более того, у вас requireDict заполняется в той же функции где и биндится proxyUpcomingExecute — то есть можно было бы вообще использовать непосредственно переменную lookUpRes (которая после становится grantedItem) в той ветке где она уже имеется (а там где ее еще нет — можно отложить заполнение словаря service, избавившись тем самым от requireDict полностью).


                  Теперь разберемся что такое ваш grantedItem, он же lookUpRes. А это структура из двух свойств, которая формируется вот так: { ticket, layer: this }, при этом единственным ее предназначением является вызов вида x.layer.executeAction(x.ticket, serviceName, action, ...args). Но в таком случае можно было вместо структуры возвращать простую функцию, this.executeAction.bind(this, ticket)!


                  Это бы одновременно избавило вас от проверки this.grantedTickets.has(ticket) внутрях executeAction — потому что вызов этой функции с невалидным тикетом стал бы невозможным в принципе — вы же this нигде в явном виде сервисам не раскрываете (а еще можно перейти на Typescript и объявить ее приватной).


                  Продолжаем копать дальше. Первой же строчкой в функции executeAction идет let service = this.registeredWorkers[serviceName] — тот самый который был найден внутри serviceLookup. Его тоже можно забиндить.


                  Наконец, doExecute. Все что делает этот метод — вызывает this.service[action]. Поскольку action — опять константа, его тоже можно упростить.

                    0
                    Ох, немного неудобно так код обсуждать, в отрыве от него самого.
                    Но давайте попробую общё — во-первых я не могу себе позволить упрощать код, выкидывая сообщение об ошибках. А так как у нас много ленивых вычислений — верить нельзя никому.
                    Например — нельзя упросить `proxyUpcomingExecute`, потому что в момент его бинда к `doRequireCall` вполне возможно что `grantedItem.layer` просто отсутствует.
                    И так много где.
                    И это мы еще не вспоминаем об асинхронной инициализации сервисов.
                      0
                      Но давайте попробую общё — во-первых я не могу себе позволить упрощать код, выкидывая сообщение об ошибках.

                      Те сообщения об ошибках, который я предлагаю выкинуть — никогда не будут происходить в принципе.


                      Конкретно doRequireCall, конечно же, требует всей той логики которая уже написана — за счет своей "динамической" природы. Но вот второй способ обращения к зависимостям (судя по истории — более новый) вполне поддается упрощению, половины ошибок там просто не могут произойти.


                      А так как у нас много ленивых вычислений [...]
                      И это мы еще не вспоминаем об асинхронной инициализации сервисов.

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

                        0
                        Те сообщения об ошибках, который я предлагаю выкинуть — никогда не будут происходить в принципе.

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

                        Сложность в том что этих этапов нет, оно начинает разрешаться при импорте домена и как-то там себе разрешается или нет, новый импорт домена — новый цикл разрешений с учетом «ждущих» зависимостей. И если вы 100% уверены что дерево зависимостей корректно и нет асинхронных инициализаций — не нужно даже дожидаться `. onReady()`. Для введения этапов придется жестко задавать момент старта всего процесса, что выглядит не так изящно, и я сейчас не уверен что это сработает всегда. При написании проекта я ориентировался на нативный стиль того, как нода поступает с разрешением зависимостей.
                        Но мысли интересные, я подумаю.
                          0
                          Да. Но, в теории, они могут появится при внесении изменений. Поэтому моя практика подсказывает проверять то, что может упасть, даже если пока оно падать в общем-то не должно.

                          Каких таких изменений?


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

                            0

                            По поводу асинхронных инициализаций. Загрузка модулей в ноде — операция синхронная и блокирующая. Она будет мешать выполняться асинхронным операциям. Поэтому одновременная работа одних сервисов и дозагрузка остальных нормально работать не будет. Все сервисы надо грузить сразу, в самом начале.


                            Второе соображение: во время выполнения мы, конечно, потенциально можем загрузить сервис (с учетом просадки производительности) — но мы не можем его надежно выгрузить. А значит, изменение списка сервисов во время работы может работать только в одном направлении — то есть является довольно странной фичей.


                            Для введения этапов придется жестко задавать момент старта всего процесса

                            У вас в примерах уже есть строчка antiniteSys.ensureAllIsReady(), можно старт процесса повесить на этот вызов. Или на первый вызов execute если почему-то решили ensureAllIsReady не вызывать.

                              0
                              Загрузка модулей в ноде — операция синхронная и блокирующая.

                              Да, синхронная, нет, не блокирующая. Она начинает проходить по дереву зависимостей и тому, что недорезолвилось — ставит флаг. Именно поэтому в принципе возможны циркулярные зависимости (хоть они и зло). Кроме того новая реализация, которая ES6 modules import — вообще асинхронная.
                              У вас в примерах уже есть строчка antiniteSys.ensureAllIsReady(), можно старт процесса повесить на этот вызов.

                              Нет, `.ensureAllIsReady()` это проверка, которая в случае фейла бросает эксепшен. Потому что лучше совсем не взлететь, чем взлететь непонятно на чем, в смысле что не все разрезолвилось.
                              Или на первый вызов execute если почему-то решили ensureAllIsReady не вызывать.
                              Некрасиво.
                              Если вопрос стоит так — сделать что-то сложно выглядящее по-простому или что-то просто выглядящее, но сложное внутри — ИМХО нужно выбирать последнее.
                              Какая, в сущности разница, как устроен черный ящик, если он работает как надо?
                                0
                                Да, синхронная, нет, не блокирующая.

                                Проверим?


                                const fs = require('fs');
                                const process = require('process');
                                
                                {
                                  const line = '*'.repeat(100);
                                  const file = fs.openSync('bigmodule.js', 'w');
                                  fs.writeSync(file, '/' + '*'.repeat(99));
                                  for (let i=1; i<999999; i++)
                                    fs.writeSync(file, line);
                                  fs.writeSync(file, '*'.repeat(99) + '/');
                                  fs.closeSync(file);
                                }
                                
                                const timestamp = process.hrtime();
                                setTimeout(() => {
                                  var elapsed = process.hrtime(timestamp);
                                  console.log(`Time passed ${elapsed[0] * 1000 + elapsed[1] / 1000000}ms`);
                                }, 100);
                                
                                require('./bigmodule.js');

                                На моем компьютере таймер срабатывает как ему и положено через 100 миллисекунд… а потом еще 400 миллисекунд ждет пока ему освободят поток. Любой синхронный вызов в ноде — блокирующий.


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


                                Нет, .ensureAllIsReady() это проверка, которая в случае фейла бросает эксепшен.

                                Ну так прекрасно же! Это именно тот момент который нужен — после этой строчки добавления новых слоев или сервисов уже не будет гарантированно.


                                сделать что-то сложно выглядящее по-простому

                                Вот не вижу ничего "сложно выглядящего" в методе start() или run(), это общепринятый подход.

                              0
                              Да. Но, в теории, они могут появится при внесении изменений.

                              Лучший код — не написанный.
                                0
                                Да я вот так тоже нашему PM говорил, а он что-то там про сроки, заказчиков и контракты талдычит :)
                                Лучший код — это понятный любому, который делает то что надо и не делает того, чего не надо. И отгруженный вовремя.
                                  +2
                                  Я имел ввиду, что не стоит проверять то, что не может произойти и предполагать как может измениться система в своих попытках перестраховаться.
                                  В большинстве случаев в итоге выходит просто куча никому не нужного кода, который все равно нужно поддерживать.
                        0

                        В целом, если хорошо по-упрощать ваш код, его можно привести вот примерно к такому виду.


                        processImports(lookupFn) {
                            for (const [serviceName, actions] of Object.entries(workerConfig.require || {})) {
                                const imports = this.service[service] = {}
                        
                                for (const action of actions) {
                                    const resolved = lookupFn(serviceName, action)
                                    if (resolved.kind == "success")
                                        imports[action] = resolved.fn
                                }
                            }
                        }

                        И это вся необходимая обработка импортов!


                        При этом ту часть что лежит в layer.js можно написать как-то так:


                        serviceLookup (callerLayer, callerName, callerGroup, serviceName, action) {
                            const ticket = { callerGroup, callerName, callerLayer }
                            const service = this.registeredWorkers[serviceName]
                            if (!service) {
                                //  логи, логи
                                return
                            }
                        
                            const { code, fn } = service.tryResolve(callerGroup, action)
                            if (code != "success") {
                                //  логи, логи
                                return
                            }
                        
                            return (...args) => {
                                if (!this.isReady()) /* Ошибочка */;
                                if (/*включен аудит*/) /* аудитим */;
                                fn(...args);
                            }
                        }
                          0
                          Спасибо за ваш интерес к коду и приведеные примеры, но я не уверен в корректности его поведения. Если проект Вас заинтересовал — приглашаю присоединится на github. Я всегда очень лоялен к мердж-реквестам, проходящим тесты и соблюдающим стилистику кода, благо standart — очень простой стиль.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое