Comments 30
Нет, не убедили.
Да, инверсия зависимостей позволяет переложить работу по созданию объектов на фреймворк, но никак не может ограничить их использование.
А как ограничиваеся использование в вашем решении?
getServiceConfig () {
return ({
export: {
read: ['getBar']
}
})
}
Интерфейсы, контракты — нет, не слышали.
А то, что библиотека патчит исходные объекты — это вообще за гранью.
Советую посмотреть на Typescript и библиотеки вроде InversifyJS.
А как ограничиваеся использование в вашем решении?
Пример маловат, из него не все очевидно, мой просчет.
Все методы, объявленные в `export` конечно же будут доступны компонентам, которые имеют на это права. Но! Обычно объект имеет и приватные методы, которые используются для вынесения кода в аккуратные логичные сущности. И как мы помним, приватны они только на уровне договоренности (см первый абзац ответа). В случае использования Antinite экспортируется ТОЛЬКО то, что описано в экспорте. К приватным методам (не объявленным в экспорте) доступа нет. Вообще нет. Совсем. Никак.
Ровно как у компонентов с правами на методы уровня «чтение» не будет доступа к методам других уровней. Совсем.
И да, библиотека патчит исходные объекты. Потому что она ими владеет по праву использования. Как по мне — грех тянущий максимум на написание хорошего комментария ошибки.
Советую посмотреть на Typescript и библиотеки вроде InversifyJS.
Что именно в Typescript позволяет реализовать хотя бы DI? В Typescript есть что-то для контроля доступа к синглтонам? Аудит их использования?
InversifyJS — забавная вещица, но вот вы мне патчить исходные объекты запрещаете, а там во весь рост Proxy используются, которы мало того что пока в драфте, так еще и, положа руку на сердце, делают тоже самое, по сути.
Если у вас большой размер кода и нужно грамотно разделять зоны ответственностей модулей, то самое время внедрить Typescript, а не устраивать валидацию в рантайме.
Про Proxy: во-первых, это уже часть стандарта языка, поддерживается в stable версии Node. Во-вторых, несмотря на использование Proxy, код остаётся типизированным и предсказуемым. В нем всегда можно проследить где метод декларируется и где вызывается. А в вашем подходе найти концы не представляется возможным
Если у вас большой размер кода и нужно грамотно разделять зоны ответственностей модулей, то самое время внедрить Typescript, а не устраивать валидацию в рантайме.
Вероятно я плохо себе представляю возможности Typescript. Каким образом в нем можно разделить видимость методов, кроме как написав кучу оберток интерфейсов?
Про Proxy: во-первых, это уже часть стандарта языка, поддерживается в stable версии Node. Во-вторых, несмотря на использование Proxy, код остаётся типизированным и предсказуемым. В нем всегда можно проследить где метод декларируется и где вызывается. А в вашем подходе найти концы не представляется возможным
И что нам дает типизированность кода в вопросе доступа к методам? И видимо у меня не получилось донести идеи. Первое — концы конечно же можно найти — система в любой момент может отдать граф зависимостей. Приспособить его к тому же Atom — дело техники. Далее — приведенная Вами библиотека — классический вариант DI, разве что с TS интерфейсами, которые существуют только до момента транспилинга. И сам автор указывает на то, что извлечь из контейнера объект следует в самом верху дерева, после чего приходится носиться с этим объектом как с писаной торбой и прокидывать его дальше вглубь. Или прокинуть контейнер и получить -500 к карме. В этом отношении даже древнючий intravenous и то лучше, правда с ним мы так же в итоге не понимаем что откуда берется и что куда девается.
Каким образом в нем можно разделить видимость методов, кроме как написав кучу оберток интерфейсов?
Вы пишете ровно столько же интерфейсов, сколько кода в методе getServiceConfig
вашей имплементации. Только с Typescript и интерфейсами вы получаете все преимущества статической типизации.
И что нам дает типизированность кода в вопросе доступа к методам?
private поля и методы.
система в любой момент может отдать граф зависимостей
Зачем городить велосипеды и пытаться интегрировать их в редактор, если уже есть готовые?
Давайте зайдем все же рассмотрим вариант интеграции с DI.
В любом варианте мы в итоге имеем что-то типа
class Foo {
constructor(dependency1, dependency2, dependency3) {
this.ext1 = dependency1
this.ext2 = dependency2
this.ext3 = dependency3
}
doFoo1 (dataIn) {
this.ext1.callFn(dataIn)
}
}
так? Если не брать совсем экстремальные варианты то вроде бы так. А значит при необходимости сделать тест нашего Foo нам надо на минуточку — найти что за объекты пробрасываются в конструктор (а если это довольно далеко от корня — могут быть нюансы), далее — найти методы этих зависимостей, которые используются (по всему коду), далее — сделать на них моки.
Или, используя велосипед — просто посмотреть в перечисление require и дать именно те методы, которые там перечислены. Все! Больше никаких зависимостей у класса нет и быть не может.
Статически объявленная гранулярность зависимостей на уровне методов.
Нет, не нужно? Точно?
просто посмотреть в перечисление require и дать именно те методы, которые там перечислены
Не так уж просто. Вам нужно будет поддерживать моки в будущем, при изменении require. С интерфейсами тоже не сложно: пока интерфейс (контракт) не меняется, то и мок может оставаться прежним. Немного больше работы на старте, зато проще потом.
Апдейт: попробовал реализовать разделение ответственности на 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');
}
}
Получилось декларативно и компактно, я думаю.
следующими видимо будут фемтосервисы.
Если он в том «почему не все со всеми» — так это и не нужно и неудобно будет, здесь четкое понимание зависимостей прямо в конфигурации видно.
Если «динамическое разрешение доступа» — то по факту-то оно как раз статическое, при сборке всех зависимостей и разрешается.
Ну смотрите, вот вы делаете присваивание 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
— опять константа, его тоже можно упростить.
Но давайте попробую общё — во-первых я не могу себе позволить упрощать код, выкидывая сообщение об ошибках. А так как у нас много ленивых вычислений — верить нельзя никому.
Например — нельзя упросить `proxyUpcomingExecute`, потому что в момент его бинда к `doRequireCall` вполне возможно что `grantedItem.layer` просто отсутствует.
И так много где.
И это мы еще не вспоминаем об асинхронной инициализации сервисов.
Но давайте попробую общё — во-первых я не могу себе позволить упрощать код, выкидывая сообщение об ошибках.
Те сообщения об ошибках, который я предлагаю выкинуть — никогда не будут происходить в принципе.
Конкретно doRequireCall
, конечно же, требует всей той логики которая уже написана — за счет своей "динамической" природы. Но вот второй способ обращения к зависимостям (судя по истории — более новый) вполне поддается упрощению, половины ошибок там просто не могут произойти.
А так как у нас много ленивых вычислений [...]
И это мы еще не вспоминаем об асинхронной инициализации сервисов.
Ваша задача прекрасно решается в два этапа: сначала — экспорт всех экшенов, потом связывание и импорт, никаких промежуточных состояний вида "половина сервисов еще не зарезолвились, а остальные нужно использовать" при этом не будет — а значит, и не будет связанных с этим ошибок.
Те сообщения об ошибках, который я предлагаю выкинуть — никогда не будут происходить в принципе.
Да. Но, в теории, они могут появится при внесении изменений. Поэтому моя практика подсказывает проверять то, что может упасть, даже если пока оно падать в общем-то не должно.
Ваша задача прекрасно решается в два этапа: сначала — экспорт всех экшенов, потом связывание и импорт, никаких промежуточных состояний вида «половина сервисов еще не зарезолвились, а остальные нужно использовать» при этом не будет — а значит, и не будет связанных с этим ошибок.
Сложность в том что этих этапов нет, оно начинает разрешаться при импорте домена и как-то там себе разрешается или нет, новый импорт домена — новый цикл разрешений с учетом «ждущих» зависимостей. И если вы 100% уверены что дерево зависимостей корректно и нет асинхронных инициализаций — не нужно даже дожидаться `. onReady()`. Для введения этапов придется жестко задавать момент старта всего процесса, что выглядит не так изящно, и я сейчас не уверен что это сработает всегда. При написании проекта я ориентировался на нативный стиль того, как нода поступает с разрешением зависимостей.
Но мысли интересные, я подумаю.
Да. Но, в теории, они могут появится при внесении изменений. Поэтому моя практика подсказывает проверять то, что может упасть, даже если пока оно падать в общем-то не должно.
Каких таких изменений?
Если значение сначала укладывается в словарь, а потом из него извлекается — то, конечно же, тут может возникнуть ошибка. Но стоит передать значение напрямую, без промежуточного словаря — и ошибка становится невозможной в принципе.
По поводу асинхронных инициализаций. Загрузка модулей в ноде — операция синхронная и блокирующая. Она будет мешать выполняться асинхронным операциям. Поэтому одновременная работа одних сервисов и дозагрузка остальных нормально работать не будет. Все сервисы надо грузить сразу, в самом начале.
Второе соображение: во время выполнения мы, конечно, потенциально можем загрузить сервис (с учетом просадки производительности) — но мы не можем его надежно выгрузить. А значит, изменение списка сервисов во время работы может работать только в одном направлении — то есть является довольно странной фичей.
Для введения этапов придется жестко задавать момент старта всего процесса
У вас в примерах уже есть строчка antiniteSys.ensureAllIsReady()
, можно старт процесса повесить на этот вызов. Или на первый вызов execute
если почему-то решили ensureAllIsReady
не вызывать.
Загрузка модулей в ноде — операция синхронная и блокирующая.
Да, синхронная, нет, не блокирующая. Она начинает проходить по дереву зависимостей и тому, что недорезолвилось — ставит флаг. Именно поэтому в принципе возможны циркулярные зависимости (хоть они и зло). Кроме того новая реализация, которая ES6 modules import — вообще асинхронная.
У вас в примерах уже есть строчка antiniteSys.ensureAllIsReady(), можно старт процесса повесить на этот вызов.
Нет, `.ensureAllIsReady()` это проверка, которая в случае фейла бросает эксепшен. Потому что лучше совсем не взлететь, чем взлететь непонятно на чем, в смысле что не все разрезолвилось.
Или на первый вызов execute если почему-то решили ensureAllIsReady не вызывать.Некрасиво.
Если вопрос стоит так — сделать что-то сложно выглядящее по-простому или что-то просто выглядящее, но сложное внутри — ИМХО нужно выбирать последнее.
Какая, в сущности разница, как устроен черный ящик, если он работает как надо?
Да, синхронная, нет, не блокирующая.
Проверим?
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()
, это общепринятый подход.
В целом, если хорошо по-упрощать ваш код, его можно привести вот примерно к такому виду.
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);
}
}
JavaScript — заполняем нишу между микросервисами и объектами — «нано-сервисы»