Кто из нас не хочет сделать большое приложение с правильной архитектурой? Все хотят.
Чтобы была гибкость, переиспользуемость и четкость логики. Чтобы были домены, сервисы, их взаимодействие.
И даже иногда хочется чтобы было почти как в 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)
smer44
19.01.2018 10:30в Javascript только что были изобретены функции с сайд-эффектами и приватные поля?
meettya Автор
19.01.2018 10:31Давайте по существу, что там у нас с «функциями с сайд-эффектами и приватными полями»?
smer44
20.01.2018 05:51что «самостоятельные компоненты, взаимодействующие друг с другом в пределах одного процесса по контракту, при этом не использующие сеть» это классы любого обьектно ориентированного языка, взаимодействующие через интерфейсы, ну ещё и переменной (полем) в этом языке может быть функция, что придумано давным- давно, а если какой-то язык не в полной мере соответствует концепции ОО, получается нечитабельная и тормозящая в рантайме хрень.
meettya Автор
22.01.2018 11:25странно, а вот википедия говорит что
Класс — абстрактный тип данных в объектно-ориентированном программировании, задающий общее поведение для группы объектов; модель объекта.
Ну и да, что-то по вашему получается «не читал, но осуждаю». Почитайте, бывает интересно.
mayorovp
22.01.2018 16:25Кажется, ваша модель безопасности вполне позволяет один раз залинковать друг с другом все сервисы при запуске. Почему было выбрано именно динамическое решение?
meettya Автор
22.01.2018 17:02Не совсем понимаю вопрос.
Если он в том «почему не все со всеми» — так это и не нужно и неудобно будет, здесь четкое понимание зависимостей прямо в конфигурации видно.
Если «динамическое разрешение доступа» — то по факту-то оно как раз статическое, при сборке всех зависимостей и разрешается.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
— опять константа, его тоже можно упростить.
justboris
Нет, не убедили.
А как ограничиваеся использование в вашем решении?
Интерфейсы, контракты — нет, не слышали.
А то, что библиотека патчит исходные объекты — это вообще за гранью.
Советую посмотреть на Typescript и библиотеки вроде InversifyJS.
meettya Автор
Интерфейсы, контракты — что-то такое слышали, но в JS, уж если у нас как-то получен объект — то ВСЕ его методы доступны. Все варианты сделать методы как бы приватными — черная магия чистой воды.
Пример маловат, из него не все очевидно, мой просчет.
Все методы, объявленные в `export` конечно же будут доступны компонентам, которые имеют на это права. Но! Обычно объект имеет и приватные методы, которые используются для вынесения кода в аккуратные логичные сущности. И как мы помним, приватны они только на уровне договоренности (см первый абзац ответа). В случае использования Antinite экспортируется ТОЛЬКО то, что описано в экспорте. К приватным методам (не объявленным в экспорте) доступа нет. Вообще нет. Совсем. Никак.
Ровно как у компонентов с правами на методы уровня «чтение» не будет доступа к методам других уровней. Совсем.
И да, библиотека патчит исходные объекты. Потому что она ими владеет по праву использования. Как по мне — грех тянущий максимум на написание хорошего комментария ошибки.
Что именно в Typescript позволяет реализовать хотя бы DI? В Typescript есть что-то для контроля доступа к синглтонам? Аудит их использования?
InversifyJS — забавная вещица, но вот вы мне патчить исходные объекты запрещаете, а там во весь рост Proxy используются, которы мало того что пока в драфте, так еще и, положа руку на сердце, делают тоже самое, по сути.
justboris
Если у вас большой размер кода и нужно грамотно разделять зоны ответственностей модулей, то самое время внедрить Typescript, а не устраивать валидацию в рантайме.
Про Proxy: во-первых, это уже часть стандарта языка, поддерживается в stable версии Node. Во-вторых, несмотря на использование Proxy, код остаётся типизированным и предсказуемым. В нем всегда можно проследить где метод декларируется и где вызывается. А в вашем подходе найти концы не представляется возможным
meettya Автор
Вероятно я плохо себе представляю возможности Typescript. Каким образом в нем можно разделить видимость методов, кроме как написав кучу оберток интерфейсов?
И что нам дает типизированность кода в вопросе доступа к методам? И видимо у меня не получилось донести идеи. Первое — концы конечно же можно найти — система в любой момент может отдать граф зависимостей. Приспособить его к тому же Atom — дело техники. Далее — приведенная Вами библиотека — классический вариант DI, разве что с TS интерфейсами, которые существуют только до момента транспилинга. И сам автор указывает на то, что извлечь из контейнера объект следует в самом верху дерева, после чего приходится носиться с этим объектом как с писаной торбой и прокидывать его дальше вглубь. Или прокинуть контейнер и получить -500 к карме. В этом отношении даже древнючий intravenous и то лучше, правда с ним мы так же в итоге не понимаем что откуда берется и что куда девается.
justboris
Вы пишете ровно столько же интерфейсов, сколько кода в методе
getServiceConfig
вашей имплементации. Только с Typescript и интерфейсами вы получаете все преимущества статической типизации.private поля и методы.
Зачем городить велосипеды и пытаться интегрировать их в редактор, если уже есть готовые?
meettya Автор
Хорошо, положим в TS у нас вроде бы есть приватные методы и поля. Пусть с ними.
Давайте зайдем все же рассмотрим вариант интеграции с DI.
В любом варианте мы в итоге имеем что-то типа
так? Если не брать совсем экстремальные варианты то вроде бы так. А значит при необходимости сделать тест нашего Foo нам надо на минуточку — найти что за объекты пробрасываются в конструктор (а если это довольно далеко от корня — могут быть нюансы), далее — найти методы этих зависимостей, которые используются (по всему коду), далее — сделать на них моки.
Или, используя велосипед — просто посмотреть в перечисление require и дать именно те методы, которые там перечислены. Все! Больше никаких зависимостей у класса нет и быть не может.
Статически объявленная гранулярность зависимостей на уровне методов.
Нет, не нужно? Точно?
justboris
Не так уж просто. Вам нужно будет поддерживать моки в будущем, при изменении require. С интерфейсами тоже не сложно: пока интерфейс (контракт) не меняется, то и мок может оставаться прежним. Немного больше работы на старте, зато проще потом.
justboris
Апдейт: попробовал реализовать разделение ответственности на Typescript, используя typedi:
Получилось декларативно и компактно, я думаю.
meettya Автор
Получилось красиво, не спорю.
Но у Вас сервис-потребитель ограничивает себя сам, а у меня сервис-поставщик и домен, в рамках которого он (поставщик) заявлен, контролирует доступность ресурса. Т.е. никто не берет чужого не потому что все честные, а потому что замок висит. В этом вся разница.