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


И даже иногда хочется чтобы было почти как в 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. В настоящее время активно разрабатывается рабочий проект с использованием данного фреймворка, подробности, увы, разгласить не могу, но проблем на данном этапе не выявлено.


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

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


  1. justboris
    19.01.2018 00:10

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


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

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


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

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


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


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


    1. meettya Автор
      19.01.2018 10:53

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

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

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

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


      1. justboris
        19.01.2018 12:33

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


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


        1. meettya Автор
          19.01.2018 13:08

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

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

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


          1. justboris
            19.01.2018 16:37

            Каким образом в нем можно разделить видимость методов, кроме как написав кучу оберток интерфейсов?

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


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

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


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

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


            1. meettya Автор
              19.01.2018 16:58

              Хорошо, положим в 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 и дать именно те методы, которые там перечислены. Все! Больше никаких зависимостей у класса нет и быть не может.
              Статически объявленная гранулярность зависимостей на уровне методов.
              Нет, не нужно? Точно?


              1. justboris
                19.01.2018 17:35

                просто посмотреть в перечисление require и дать именно те методы, которые там перечислены

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


              1. justboris
                21.01.2018 01:10

                Апдейт: попробовал реализовать разделение ответственности на 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');
                  }
                }

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


                1. meettya Автор
                  22.01.2018 11:22

                  Получилось красиво, не спорю.
                  Но у Вас сервис-потребитель ограничивает себя сам, а у меня сервис-поставщик и домен, в рамках которого он (поставщик) заявлен, контролирует доступность ресурса. Т.е. никто не берет чужого не потому что все честные, а потому что замок висит. В этом вся разница.


  1. smer44
    19.01.2018 10:30

    в Javascript только что были изобретены функции с сайд-эффектами и приватные поля?


    1. meettya Автор
      19.01.2018 10:31

      Давайте по существу, что там у нас с «функциями с сайд-эффектами и приватными полями»?


      1. smer44
        20.01.2018 05:51

        что «самостоятельные компоненты, взаимодействующие друг с другом в пределах одного процесса по контракту, при этом не использующие сеть» это классы любого обьектно ориентированного языка, взаимодействующие через интерфейсы, ну ещё и переменной (полем) в этом языке может быть функция, что придумано давным- давно, а если какой-то язык не в полной мере соответствует концепции ОО, получается нечитабельная и тормозящая в рантайме хрень.


        1. meettya Автор
          22.01.2018 11:25

          странно, а вот википедия говорит что

          Класс — абстрактный тип данных в объектно-ориентированном программировании, задающий общее поведение для группы объектов; модель объекта.

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


  1. pyur
    19.01.2018 19:11

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


    1. alhimik45
      19.01.2018 21:10

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


  1. mayorovp
    22.01.2018 16:25

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


    1. meettya Автор
      22.01.2018 17:02

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


      1. mayorovp
        22.01.2018 21:21

        Ну смотрите, вот вы делаете присваивание 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 — опять константа, его тоже можно упростить.