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

Спойлер: миллионерами мы так и не стали.

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

Спойлер: тестами код мы тоже так и не покрыли.

Давид Хейнемейер Ханссон, создатель фреймворка Ruby on Rails, в своей статье Test-induced design damage утверждает, что те архитектурные изменения, которые необходимо внести в проект, чтобы сделать возможным написание unit тестов для контроллеров, настолько сильно бьют по остальным характеристикам кода, что лучше отказаться от этой идеи в пользу интеграционных тестов.

Реально ли придумать такую архитектуру, которая не заставляла бы чем-то жертвовать? Это можно выяснить при помощи научного метода. Сначала необходимо проанализировать имеющиеся зоны боли, а затем попытаться наложить на код такие ограничения и правила, которые бы исправили ситуацию. На каждой последующей итерации ограничения либо добавляются, либо пересматриваются. Сложность состоит в том, что неправильные решения могут стать причиной появления еще большего числа зон боли. В этом болоте можно увязнуть надолго. Лучший способ проверить адекватность любой гипотезы — поставить эксперимент. И на чем же еще стоит экспериментировать, как не на рабочем проекте?

Спойлер: в итоге мы переписали проект 6 раз. Все ради науки.

Глубокая аналитика текущей ситуации

Как известно, все возможные подходы к программированию были придуманы еще в 60е годы разработчиками на LISP. Все новые, по заверениям авторов, разработки, чаще всего, либо заново открывают давно забытое, либо комбинируют уже имеющееся. Время от времени, еще изобретаются надстройки, но они, как показывает практика, не получают особой популярности и долго не живут. Привет аспектно-ориентированному программированию.

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

Требования бизнеса

Можно ли не заставлять бизнес выбирать два пункта из трех — "Быстро, качественно, недорого"? Чтобы ответить на этот вопрос, нужно разобраться, в чем причины нарушения каждого из пунктов.

Быстро

Зачастую, на этапе разработки начальной версии продукта, фичи выкатываются очень стремительно. Затем, по мере увеличения объема кода, скорость начинает замедляться. Все чаще приходится признавать, что на предыдущих этапах были приняты неверные решения, и что имеющихся возможностей расширения системы, если они вообще закладывались, недостаточно. В такие моменты появляется выбор: воткнуть костыль, или переписать часть системы так, чтобы она гармонично подходила под новые требования? При принятии решения хорошо помогает вопрос: сколько из грядущих изменений будут опираться на этот участок логики? Если навалить поверх костыля еще кода, то все равно этот костыль, в скором времени, придется раскопать и переписать нормально вместе со всей надстройкой. Если, конечно, вы не ставите целью превращение проекта в тотальный хаос. И чем больше будет навалено сверху кода, тем бóльшая когнитивная нагрузка ляжет на разработчика, который займется переписыванием. Это может привести, в лучшем случае, к невнимательности и появлению новых багов, а в худшем — к выгоранию и дальнейшему падению скорости разработки.

В динамически развивающемся продукте не может существовать конечных решений. Раньше, по неопытности, я старался придерживаться принципа открытости-закрытости, создавая абстракции, в рамки которых, как мне казалось, должны уложиться все последующие изменения в системе. Опыт показал, что при первой же необходимости изменить или дополнить имеющееся поведение, эти абстракции приходится выкидывать, полностью или частично, заменяя их новыми. Тогда получается, что максимальной гибкости системы можно достичь только путем тотального отказа от подобного оверинжиниринга. Чрезмерное использование инверсии зависимостей, метаданных, преждевременные попытки сделать решение переиспользуемым. Все это, на самом деле, приносит больше вреда, чем пользы. Конечно, добиться абсолютной гибкости кода так же невозможно, как невозможно в одно мгновение изменить спецификацию, чтобы учесть все новые требования. Зато можно не плодить лишнего, чтобы потом не пришлось тратить время на избавление от всего этого.

Качественно

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

Однако, это все касается внутреннего устройства кодовой базы. Бизнес же интересует, чтобы все работало безошибочно и, по возможности, быстро. Большинство компаний пытаются снизить число ошибок при помощи добавления тестирования. Оно, безусловно, важно, ведь позволяет, при внесении в код изменений, быть уверенными, что старая логика не поломается. К сожалению, тестирование не может помочь отыскать ошибочные состояния, в которые, при определенной последовательности вызовов, может попасть программа, и которые не были учтены разработчиками. Найти их можно только при помощи моделирования и автоматизированной проверки полученной модели на непротиворечивость. Сейчас у всех на слуху язык спецификаций TLA+, который нацелен на моделирование параллельных систем. Можно ли как-то адаптировать его для описания распределенных систем на JavaScript, или же придется использовать что-то другое? Вопрос требует изучения.

Недорого

Наши знакомые недавно пожаловались, что двухкратное увеличение штата разработчиков совсем не увеличило скорость разработки их продукта. Секрет кроется в функции роста сложности процессов. Во сколько раз больше людей и, соответственно, денег потребуется, чтобы поддерживать вдвое более сложную систему на плаву? В 2 раза больше? В 4? Или, может, в 20 раз? Все зависит от объема технического долга и архитектуры. Допустим, команда регулярно уделяла внимание качеству кода, и технический долг стремится к нулю. Тогда остается сравнивать лишь архитектурные подходы. Хороший подход сохраняет максимальную простоту и предсказуемость системы, что позволяет дольше удерживать в голове полную картину происходящего и, соответственно, управлять процессами силами меньшего числа разработчиков. А это значит, что пропадает необходимость нанимать кучу макак на поддержку. Все высвободившиеся деньги можно и нужно будет потратить на оплату услуг высококвалифицированных хранителей тайн совершенной архитектуры — нас с вами. Недорого разработать продукт не получится ????.

Уровни разработки

Наконец, переходим к технической части.

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

Когда происходит составление спецификации системы, ее поведение в деталях выражается при помощи натурального языка. Представим, что мы решили написать консольное приложение для управления списком задач. Тогда часть спецификации может выглядеть примерно так: "Программа выполняется в бесконечном цикле. На каждой итерации цикла необходимо очистить консоль, вывести текущий список задач и запрос на ввод команды, а затем считать введенную пользователем строку. Если команда не может быть распознана, необходимо вывести сообщение об ошибке и запросить подтверждение пользователя. Команда '.' завершает работу программы. За добавление новой задачи отвечает команда '+'. Оставшаяся часть введенной строки — текст задачи. Когда пользователь пытается добавить новую задачу, необходимо очистить текст задачи от пробельных символов слева и справа. Если полученная строка является пустой, оповестить пользователя об ошибке. Иначе добавить новую задачу в список.". Это есть уровень прецедентов.

На втором уровне, уровне реализации, в случае, например, объектно-ориентированного программирования с использованием разрекламированного подхода CQRS, часть, занимающаяся обработкой пользовательского ввода будет выглядеть следующим образом: "Вызвана команда AskUserForInput, обработкой которой занимается AskUserForInputHandler. Он ожидает ввода пользователя и выбрасывает событие NewUserInput, на которое подписан слушатель, вызывающий команду HandleUserInput, обрабатываемую классом HandleUserInputHandler. Он проверяет, равна ли строка символу '.', и, если да, выбрасывает событие ProgramEnd. Иначе проверяет, начинается ли строка со знака '+', и если нет, выбрасывает событие UnknownCommandInput, на которое подписан слушатель, вызывающий команду NotifyUserAboutUnknownInput, обработкой которой занимается NotifyUserAboutUnknownInputHandler, выводящий сообщение об ошибке на экран. Если же команда начинается со знака '+', то выбрасывается событие AddCommandInvoked, на которую подписан слушатель, вызывающий команду HandleAddCommand, обрабатываемую классом HandleAddCommandHandler, который отрезает крайний левый '+', а затем очищает пробельные символы слева и справа...". Кто, не дочитав, посмотрел в конец?

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

Также хочется отметить еще одну распространенную проблему — разорванность логики. Причиной этого является желание чрезмерно разделять код. Шины команд и событий, рушащие связь между вызывающим и вызываемым кодом, инверсия зависимостей там, где это не надо, попытки писать функции длиной не более 10 строк. Не спорю, короткие функции, выполняющие одно действие, это хорошо. Но что если одно действие выполняет большая функция? Стоит ли ее разбивать на маленькие функции, задача которых размыта? Должно ли это зависеть от того, возможно ли эту маленькую функцию переиспользовать? А что насчет принципа единственности ответственности? Где гарантия, что вынесенный блок кода не потребуется изменить только для одного из потребителей? На самом деле, высосанные из пальца функции выявить очень легко — им сложно придумать простое название. Обычно получается что-то в духе addRoomWatchdogIfOnlyBotClientsLeftInRoom или canSendMessageToSocketOrCanStoreItInQueue.

Если подытожить все сказанное выше, то промежуточный вариант идеального кода, реализующего спецификацию, должен выглядеть как-то так:

async function runProgram () {
  while (true) {
    clearConsole()

    writeTodoItems()

    const input = await readLine('Write the next command: ')

    if (input === '.') { return }
    
    if (input.startsWith('+')) {
      const text = input.substring(1).trim()

      if (text.length === 0) {
        await notify('Can not add an empty item')

        continue
      }

      addTodoItem(text)

      continue
    }

    await notify('Wrong command')
  }
}

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

Типизация

Как некоторые уже заметили, в представленном выше коде не хватает типов. Я сторонник статической типизации, однако, мне не нравится "Горизонтальная" природа TypeScript. Так как информация о типах размещается на той же строке, что и исполняемый код, то, чтобы уложиться в ширину экрана, во многих местах приходится одну строку разбивать на несколько. Особенно часто и сильно от этого страдают объявления функций — некоторые строки получаются длиннее других до 20-30 раз, что не добавляет читаемости.

Я уже высказывался в одной из дискуссий на Github, где сразу набежали хейтеры и меня заминусовали, что дальнейшее развитие TypeScript, как языка программирования, бессмысленно, ведь их компилятор научился проверять в .js файлах типы, описанные через JsDoc. Теперь ничего не надо компилировать, да еще и декларации типов расположены на отдельной строке. Все, о чем можно было мечтать. Если вы уже пишете проект на TypeScript, то тратить время на переписывание не стоит, а вот новые проекты определенно следует попробовать типизировать через JsDoc.

Давайте добавим декларацию типов к нашей функции.

/** @type {() => Promise<void>} */
async function runProgram () {
...
}

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

/**
 * @typedef {{
 *   text: string
 *   id: number
 * }} Item
 */

Возможность тестирования

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

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

С точки зрения JavaScript, алгебры на множестве всех типов это просто объекты, поля которых являются функциями. Также стоит отметить, что набор операций алгебры образует встраиваемый предметно-ориентированный язык, конкретная реализация которого называется интерпретатором. Договоримся, что, когда будем говорить об уровне типов, будем использовать слово "Алгебра", а когда о runtime объектах, удовлетворяющих типу алгебры — "Интерпретатор".

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

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

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

Следующий вопрос — как именно производить инъекцию зависимостей. В объектно-ориентированном подходе все зависимости передаются в сервисы через конструктор и сохраняются в полях. Это не удобно, так как вынуждает использовать устаревшую модель программирования, основанную на прототипах. Особенно доставляет боль тот факт, что методы классов теряют контекст, если попробовать их передать в функцию высшего порядка. Чтобы это обойти, приходится писать конструкции вида

const result = array.map(this.myFacade.doSomething.bind(this.myFacade))

Это прекрасно лечится заменой классов на замыкания. Однако, есть еще одна проблема: зависимости по прежнему передаются списком, и доступ к каждой зависимости происходит через ее собственный параметр функции. Если для выполнения действий требуются 5-10 зависимостей, то код, отвечающий за инстанцирование, будет по объему примерно равен самой логике программы. В Tagless Final решили эту проблему, предложив просто объединить все фасады и репозитории в один объект при помощи spread оператора. Тогда этот объект всех объектов будет удовлетворять любому требуемому объединению алгебр эффектов, о котором известно в программе.

Описание алгебр эффектов

Ладно, давайте посмотрим на алгебру репозитория, хранящего задачи

/** @typedef {{ hasTodoItem(id: number): boolean }} HasTodoItem */

/** @typedef {{ removeTodoItem(id: number): void }} RemoveTodoItem */

/** @typedef {{ getTodoItems(): ReadonlyArray<Item> }} GetTodoItems */

/** @typedef {{ addTodoItem(id: number, item: Item): void }} AddTodoItem */

/**
 * @typedef {(
 *  & HasTodoItem
 *  & AddTodoItem
 *  & GetTodoItems
 *  & RemoveTodoItem
 * )} TodoItemsRepository
 */

Здесь мы можем наблюдать список из 4 алгебр операций. Каждая такая алгебра содержит в себе всего одно поле-функцию. Затем, при помощи операции объединения типов, из этих 4 алгебр получается одна алгебра репозитория, содержащая в себе 4 операции. 4(1) -> 1(4).

Аналогичным образом объявим алгебры для репозитория-счетчика идентификаторов:

/** @typedef {{ getNextTodoId(): number }} GetNextTodoId */

/** @typedef {{ incrementNextTodoId(): void }} IncrementNextTodoId */

/**
 * @typedef {(
 *  & GetNextTodoId
 *  & IncrementNextTodoId
 * )} TodoIdsRepository
 */

И фасада логирования:

/** @typedef {{ clearConsole(): void }} ClearConsole */

/** @typedef {{ write(message: string): void }} Write */

/** @typedef {{ readLine(question: string): Promise }} ReadLine */

/**
 * @typedef {(
 *  & Write
 *  & ReadLine
 *  & ClearConsole
 * )} ConsoleFacade
 */

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

/**
 * @typedef {(
 *  & TodoIdsRepository
 *  & TodoItemsRepository
 * )} TodoItemsAlgebra
 */

Фасад консоли может быть использован сам по себе, так что в состав сервиса консоли входит только он один.

/**
 * @typedef {(
 *  & ConsoleFacade
 * )} ConsoleAlgebra
 */

И последний этап — объединить все алгебры сервисов в единую алгебру приложения.

/**
 * @typedef {(
 *  & ConsoleAlgebra
 *  & TodoItemsAlgebra
 * )} Program
 */

Реализация интерпретаторов

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

Интерпретатор репозитория задач

/** @type {Map<number, Item>} */
const items = new Map()

/** @type {TodoItemsRepository} */
const todoItemsRepository = {
  getTodoItems: () => Array.from(items.values()),
  addTodoItem: (id, item) => item.set(id, item),
  removeTodoItem: id => items.delete(id),
  hasTodoItem: id => items.has(id),
}

Интерпретатор, хранящий идентификатор для следующей задачи

/** @type {number} */
let nextId = startTodoId

/** @type {TodoIdsRepository['getNextTodoId']} */
function getNextTodoId () { return nextId }

/** @type {TodoIdsRepository['incrementNextTodoId']} */
function incrementNextTodoId () { nextId = nextId + 1 }

/** @type {TodoIdsRepository} */
const todoIdsRepository = {
  incrementNextTodoId,
  getNextTodoId,
}

Интерпретатор для фасада консоли

const c = readline.createInterface({
  input: process.stdin,
  output: process.stdout
})

/** @type {ConsoleFacade['write']} */
function write (message) {
  c.write(message)
}

/** @type {ConsoleFacade['readLine']} */
function readLine (question) {
  return new Promise(resolve => {
    c.question(question, resolve)
  })
}

/** @type {ConsoleFacade['clearConsole']} */
function clearConsole () {
  console.clear()
}

/** @type {ConsoleFacade} */
const consoleFacade = { readLine, write, clearConsole }

Теперь, по аналогии с алгебрами, создадим интерпретаторы для сервисов

/** @type {TodoItemsAlgebra} */
const todoItemsAlgebra = {
  ...todoItemsRepository,
  ...todoIdsRepository,
}

/** @type {ConsoleAlgebra} */ 
const consoleAlgebra = { 
  ...consoleFacade 
} 

И интерпретатор приложения

/** @type {Program} */
const program = {
  ...todoItemsAlgebra,
  ...consoleAlgebra,
}

Философия UNIX

Часто приходится слышать мнение, что программы стоит проектировать в соответствии с философией UNIX. Когда начинаешь узнавать, в чем, по мнению говорящего, она заключается — в ответ слышишь: "Нужно писать маленькие программы, которые делают одно дело, но делают его лучше остальных". Подобная трактовка этой философии уже успела завести человечество в ад микросервисов, из которого, пока, мало кто хочет выбираться.

Что же упущено здесь из виду? Давайте посмотрим на две программы, которые являются эталонными представителями философии UNIX — grep и sed. Какими еще особенностями они обладают, помимо того, что выполняют единственную задачу? Во-первых, они ничего не знают друг про друга. Чтобы использовать несколько независимых программ вместе, их достаточно просто объединить при помощи слоя высшего порядка, которым выступает Shell, в случае UNIX. Получается красивая двуслойная архитектура — множество независимых приложений, на нижнем уровне, объединяются в одно на верхнем. Сравните это с графом асинхронно взаимодействующих друг с другом микросервисов. Или хотя-бы с иерархией объектно-ориентированных сервисов, взаимодействующих точно так же, но синхронно.

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

Вычисления

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

Вычисления делятся на два вида: сервисные и прецедентные. Сервисные вычисления строятся на основе алгебры сервиса, что позволяет легко переиспользовать их в других проектах. Так как сервисы полностью независимы друг от друга, то для этого достаточно просто скопировать папку с сервисом в нужный проект и подключить алгебру сервиса к алгебре приложения, а интерпретатор сервиса к интерпретатору приложения. Вычисления прецедентов являются, с точки зрения описанного в предыдущем разделе, вторым, верхним, слоем архитектуры, который объединяет все сервисы воедино. Они зависят от алгебры приложения.

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

/** @type {(P: TodoItemsAlgebra) => (text: string) => void} */
const addTodoItem = P => text => {
  const id = P.getNextTodoId()

  /** @type {Item} */
  const item = { id, text }

  P.addTodoItem(id, item)

  P.incrementNextTodoId()
}

Вычисление принимает, в качестве единственного параметра, интерпретатор, удовлетворяющий алгебре сервиса, а затем возвращает функцию, принимающую текст задачи и выполняющую действия по ее добавлению. Сначала вызывается операция getNextTodoId, являющаяся частью интерпретатора, и возвращающая идентификатор для новой задачи. Затем строится объект задачи и добавляется к списку при помощи операции addTodoItem. Чтобы в следующий раз задача создавалась уже с новым идентификатором, необходимо его инкрементировать, вызвав incrementNextTodoId.

Важно отметить, что на данном этапе можно отбросить знание о том, что операции принадлежат к разным репозиториям. Поэтому я намеренно опустил, что addTodoItem, например, принадлежит к TodoItemsRepository.

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

addTodoItem(todoItemsAlgebra)('task1')
// Is equal to
addTodoItem(program)('task1')

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

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

const texts = ['task1', 'task2']

texts.forEach(text => addTodoItem(program)(text))

Как можно заметить, применение анонимной функции здесь излишне, так как результатом addTodoItem(program) уже является функция, принимающая текст и добавляющая задачу. Поэтому вызов можно упростить:

texts.forEach(addTodoItem(program))

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

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

/** @type {(P: ConsoleAlgebra) => (text: string) => Promise<void>} */
const notify = P => async text => {
  P.write('\n' + text + '\n')

  await P.readLine('Press enter to continue')
}

Она выводит сообщение на экран, обрамляя его символами новой строки, а затем ожидает подтверждение от пользователя.

Чтобы вывести задачи на экран, необходимо обратиться сразу к двум сервисам: сервису задач и сервису консоли. Это значит, что такое вычисление будет прецедентным, зависящим от алгебры приложения.

/** @type {(P: Program) => (item: Item) => void} */
const writeTodoItem = P => item => P.write(`${item.id}) ${item.text}\n`)

/** @type {(P: Program) => () => void} */
const writeTodoItems = P => () => {
  const items = P.getTodoItems()

  if (items.length === 0) {
    P.write('TODO list is empty\n')
  } else {
    P.write('TODO items list:\n')

    items.forEach(writeTodoItem(P))
  }
}

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

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

В данном конкретном случае, вычисление writeTodoItems принимает интерпретатор через параметр P, а затем передает его во writeTodoItem:

items.forEach(writeTodoItem(P))

Осталось переписать самую главную функцию, чтобы она соответствовала новым правилам.

/** @type {(P: Program) => () => Promise<void>} */
async function runProgram = P => () => {
  while (true) {
    P.clearConsole()

    writeTodoItems(P)()

    const input = await P.readLine('Write the next command: ')

    if (input === '.') { return }
    
    if (input.startsWith('+')) {
      const text = input.substring(1).trim()

      if (text.length === 0) {
        await notify(P)('Can not add an empty item')

        continue
      }

      addTodoItem(P)(text)

      continue
    }

    await notify(P)('Wrong command')
  }
}

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

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

runProgram(program)()

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

Итоги

Подведем итоги, выделив три правила:

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

  2. Отдельные алгебры, которые не могут использоваться в отрыве друг от друга, должны быть объединены в сервисы. Алгебры сервисов должны быть объединены в алгебру приложения.

  3. Все вычисления, основанные на полученном предметно-ориентированном языке, должны делиться на два вида: сервисные вычисления, которые зависят от алгебры сервиса, и вычисления прецедентов, зависящие от алгебры приложения.

Для желающих глубже разобраться я создал репозиторий с кодом рассмотренной программы и даже чуть больше: awerlogus/todo-app-example.

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

В следующей статье поговорим про написание тестов для вычислений.

Благодарю за внимание.

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


  1. san-smith
    20.10.2021 13:42
    +7

    Не очень понятна описанная проблема «горизонтальности» типизации.

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

    В итоге вы всё равно разбиваете одну строку на несколько, только добавляете шума в виде комментариев документирования (всякие звёздочки и т.д.).

    Лично мне непонятно, чем это
    /** @type {Program} */
    export const program = {
      ...todoItemsAlgebra,
      ...consoleAlgebra,
    }

    лучше этого:
    export const program: Program = {
      ...todoItemsAlgebra,
      ...consoleAlgebra,
    }


    Становится ещё заметнее, если интерфейс разрастается:
    
    /**
     * @typedef {{
     *   text: string
     *   id: number
     *   hasSomething: boolean
     *   value: SomeClass
     *   hasTodoItem(id: number): boolean
     * }} Item
     */
    
    // vs.
    
    interface Item {
        text: string
        id: number
        hasSomething: boolean
        value: SomeClass
        hasTodoItem(id: number): boolean
    }

    При этом вы теряете подсветку синтаксиса.


    1. awerlogus Автор
      20.10.2021 14:41
      +2

      В случае объявления констант или интерфейсов, проблема действительно не видна. Давайте, вместо этого, попробуем сравнить реализации небольшой служебной функции на TypeScript и JavaScript.

      type Option<T> = T | undefined
      
      export const map = <P, R>(func: (arg: P) => R) => (data: Option<P>): Option<R> => data !== undefined ? func(data) : undefined
      /** @template T @typedef {T | undefined} Option */
      
      /** @type {<P, R>(func: (data: P) => R) => (data: Option<P>) => Option<R>} */
      export const map = func => data => data !== undefined ? func(data) : undefined

      Конечно, код на TypeScript можно тоже разбить на несколько строк.

      export const map = <P, R>(func: (arg: P) => R) => 
        (data: Option<P>): Option<R> => 
        data !== undefined ? func(data) : undefined

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

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


      1. san-smith
        20.10.2021 15:58
        +8

        Конечно, код на TypeScript можно тоже разбить на несколько строк.

        И получившийся результат даже короче варианта с комментариями.

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

        Опять же, никто не мешает отделить описание типов, например так:
        type Option<T> = T | undefined
        type Foo<P, R> = (arg: P) => R
        type Bar<P, R> = (data: Option<P>) => Option<R>
        
        export function map<P, R>(func: Foo<P, R>): Bar<P, R> {
            return (data) => data !== undefined ? func(data) : undefined
        }

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

        P.S. Я не эксперт в TS, допускаю, что можно записать ещё изящнее и сохранить при этом типизацию.


      1. faiwer
        22.10.2021 15:49
        +4

        /** @type {<P, R>(func: (data: P) => R) => (data: Option<P>) => Option<R>} */
        export const map = func => data => data !== undefined ? func(data) : undefined
        
        // vs
        
        type Map = <P, R>(func: (data: P) => R) => (data: Option<P>) => Option<R>;
        export const map: Map = func => data => data !== undefined ? func(data) : undefined

        Очевидно же, что 2-е лучше. И дело даже не в том, что у вас подсветка синтаксиса (vsCode и в комментарии всё подсветит). А в том что теперь TS компилятор упадёт если вы что-то сломаете в этом файле.


        JS-Doc типы нужны для двух вещей:


        • а) у вас тонна JS кода, которую надо постепенно переводить на TS. И часто вы просто не можете это сделать быстро, а уже нужно чтобы наружу от JS файлов торчали нормальные типы. Тут комментарии и спасают. TS верит вам на слово и нормальный перевод с JS на TS вы осуществите тогда, когда будет на то возможность.

        • б) вы пишете что-то примитивное на 10 минут — proof of concept, и не хотите эти 10 минут ковыряться со всякими tsConfig.json и npm. И например хотите просто скопировав код сразу запустить его в браузере.

        Просто так же писать JS + JsDoc вместо TS кода — это какой-то особенный вариант садомазохизма.


        Касательно FP стиля для типов — оно тоже удобно, но я думаю никто из TS разработчиков не перешёл бы на такой формат, если бы он был ультимативным. Удачным вариантом была бы возможность использовать и то и другое по ситуации.


  1. monane
    20.10.2021 14:10

    ...
    if (input.startsWith('+')) {
          const text = input.substring(1).trim()
          ...
    })
    


    А для чего в этой конструкции trim() что мы 'режим'? это просто мелкая придирка, извините


    1. awerlogus Автор
      20.10.2021 14:59

      Ничто не мешает пользователю, после того, как он введет '+', просто зажать пробел и, затем, нажать клавишу Enter.

      Тогда в переменнойinput будет храниться строка вида '+ '. После input.substring(1) плюс будет отрезан, и останутся только пробельные символы.trim их удалит, и, в итоге, строка будет интерпретирована, при дальнейшей обработке, как пустая.

      Также, если пользователь введет что-то в духе '+ task1 ', то trim обрежет текст задачи до 'task1'.


      1. monane
        20.10.2021 15:22

        Понял, благодарю. Проверка от «дурака».


  1. nick1612
    20.10.2021 14:48
    +3

    Реально ли придумать такую архитектуру, которая не заставляла бы чем-то жертвовать?

    По моему субъективному мнению - нет.

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


    1. vladeiko
      20.10.2021 18:56
      +4

      Статья называется не "идеальная, модульная тестируемая и расширяемая архитектура", а "размышления об ...".

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


      1. nick1612
        20.10.2021 20:37
        +1

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

        По поводу содержания - в начале автор делает достаточно конструктивный разбор проблем в нынешней разработке, с которым я вполне согласен. Но начиная с раздела "Типизация", происходит что-то странное - а именно попытка превратить Javascript в Haskell при помощи JSDoc, с уверенность в том, что это решит описанные выше проблемы. Судя по коду репозитория, получился оверинжиниринг с сомнительными преимуществами, при том, что задача достаточно простая и как мне кажется с ее ростом поддержка данного подхода будет все сложнее.

        Это мое субъективное мнение и я вполне могу ошибаться, так что не стоит воспринимать это близко к сердцу :)


        1. awerlogus Автор
          21.10.2021 03:41
          +3

          Декларация репозиториев, на данном этапе, действительно является раздутой. И типы, и runtime код можно было бы легко строить по шаблону, один раз написав порождающие функции, однако, это уже далеко не первая инициатива, которая откладывается в долгий ящик из-за недоделок в TypeScript. Конкретно здесь не хватает возможности динамически именовать параметры в типах функций: TypeScript#44939. Единственное, чем сейчас можно упростить себе жизнь — автоматизировать создание операций для интерпретаторов. Вот так, например, выглядит шаблонный add для интерпретатора репозитория, имеющего форму Map<K, Set<V>>

          /** @type {<K, V>(map: Map<K, Set<V>>) => (key: K, value: V) => void} */
          export const add = map => (key, value) => {
            const set = map.get(key)
          
            if (set === undefined) {
              map.set(key, new Set([value]))
            } else {
              set.add(value)
            }
          }

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

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

          Также, 8 архитектура не стремится превратить JavaScript в Haskell, а, наоборот, порицает погоню за академичностью ради академичности. В последней версии был оставлен только минимум абстракций для максимально быстрой разработки и развертывания, без ущерба для качества. Те участки кода, которые вы называете оверинжинирингом, являются дальними родственниками интерфейсов, и классов, эти интерфейсы реализующих. В ООП они никого не смущают, хотя и применяются, зачастую, бесконтрольно. В архитектуре, которая предлагается в этой статье, использование инверсии управления сведено к тому пороговому объему, который минимально необходим для тестирования кода.

          Сложность поддержки может показаться большой, но опыт показал, что, в действительности, это не так. Алгебры и интерпретаторы практически никогда не модифицируются — в этом просто нет необходимости. В подавляющем большинстве случаев, если требуется как-то серьезно расширить логику, просто добавляются новые операции или, целиком, сервисы. Это никак не влияет на уже существующий код. Так как алгебры не меняются, то нет необходимости менять и сервисные вычисления. Иногда, как и в случае с операциями, к имеющимся вычислениям лишь добавляются новые. Бизнес логика приложения представлена вычислениями прецедентов. А они, как можно было увидеть в статье, ничем не отличаются от простейшего, по своей структуре, процедурного кода, за исключением взаимодействия с интерпретатором, которое добавляет 2-3 дополнительных символа на каждый вызов. Зато появляется возможность, если требуются какие-то изменения, просто удалить устаревший участок кода и, на замену ему, быстро описать новую логику, используя высокоуровневый интерфейс, предоставляемый интерпретатором и сервисными вычислениями. Ну и теми вычислениями прецедентов, которые остались. Не требуется ни перепроектирование иерархии классов, ни всякие другие, отнимающие время, вещи. Работа получается точечной и, оттого, максимально продуктивной. К этому, бонусом, еще прилагаются тестируемость, переиспользуемость и читабельность.

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


          1. nick1612
            22.10.2021 08:34

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

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

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

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

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

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

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


  1. likerRr
    21.10.2021 14:15
    +1

    /** @type {TodoItemsAlgebra} */
    const todoItemsAlgebra = {
      ...todoItemsRepository,
      ...todoIdsRepository,
    }

    Как быть с возможным перетиранием имён методов репозиториев? Имею в виду, как описывать методы в новом репозитории, не заглядывая в уже имеющиеся, чтобы не нарваться на конфликт имён при добавлении этого репозитория в алгебру?


    1. awerlogus Автор
      21.10.2021 15:17
      +2

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

      Например, если у вас есть два репозитория — один хранит объекты задач по id, а второй объекты пользователей, тоже по id, то можно геттеры описать либо как getById, в обоих случаях, либо же можно назвать их более подробно: getTaskById и getUserById. Тогда будет маловероятным, что когда-либо случится пересечение. Какому еще репозиторию вздумается возвращать пользователя по id? Ну, допустим, такое может быть, если у вас есть база данных и локальный кэш. Тогда можно добавить в конце 'FromDB' и 'FromCache'. Получится getUserFromDB и getUserFromCache. Опять ничего не будет пересекаться.

      Еще частой практикой является использование названия репозитория при назывании операций. Например, если репозиторий имеет названиеTodoItemsRepository, то его геттер будет называться getTodoItem. А в случае TodoIdsRepository, в данной статье, геттер назвается getNextTodoId. Несовпадение. Хорошим решением будет переименовать этот репозиторий в NextTodoIdRepository, чтобы название более точно отражало его предназначение.

      Честно, мы, за год разработки, с конфликтами имен ни разу не сталкивались. Я уже думал о том, как их можно избежать на чисто техническом уровне, но пришел к выводу, что, без сильного усложнения, ничего сделать не получится. Поэтому, лучше оставить все как есть, чем плодить оверинжиниринг.


      1. likerRr
        21.10.2021 19:41
        +1

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

        А что на счёт код сплитинга? Может, я не до конца прочувствовал философию алгебр, но, допустим, у нас есть React и условная страница (компонент) работает только с методами todoItemsRepository. Как не тянуть на неё и todoIdsRepository и ворох всего остального, что заспредилось в todoItemsAlgebra?Создавать особые алгебры по-странично\по-компонентно?


        1. awerlogus Автор
          21.10.2021 21:45
          +1

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

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

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

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

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


          1. likerRr
            21.10.2021 23:06
            +1

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

            Про принцип единой ответственности понятно, я не имел в виду прямо в компонентах обращаться к алгебрам. Конечно, логика должна проникать внутрь извне (через пропсы). Скорее, вопрос о том, как отдавать на клиент при загрузке страницы необходимый минимум и наполнять его дальше по мере работы SPA (навигация по другим страницам) и ленивой загрузки.

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


  1. Aleksandr-JS-Developer
    21.10.2021 18:20
    +2

    Название "Восьмая архитектура", по крайней мере, интригующее. И сразу ассоциации с

    Спасибо за статью.


    1. muturgan
      22.10.2021 02:22

      Согласен, название яркое. Почему бы его и не оставить.

      Опять же, у данного названия будет предыстория. Как у apple раньше atari в телефонном справочнике. А это хорошо для бренда)


      1. Aleksandr-JS-Developer
        26.10.2021 00:13

        Особенно ярко то, что это не для "попасть на первую строку в тел. справочнике" (что, кстати, очередной миф про Яблоко), а это - восьмая попытка поиска идеальной архитектуры.


  1. Tigermax139
    21.10.2021 23:48
    +1

    Огромный респект за использование jsdoc вместо typescript. Уже несколько лет топлю за такой подход. Имхо, так разработка в разы быстрее


  1. muturgan
    22.10.2021 01:11

    Поставил лайк уже после прочтения вступления. Искренне желаю стать миллионерами!


  1. muturgan
    22.10.2021 01:12

    Да, а что за спектакль? Я что то пропустил?


  1. muturgan
    22.10.2021 01:18

    Про чрезмерное использование метаданных и ссылку - смешно)


  1. muturgan
    22.10.2021 02:00

    Фух. Многабукоф. Но очень толково. Спасибо!