На пути к более чистой программной архитектуре

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

Вот пример. Мы будем использовать веб-фреймворк Express:

Предположим, мы создаем игру-викторину, в которой пользователи могут отправить свой ответ на вопрос. Ответ будет отправлен через HTTP-запрос POST. Все данные хранятся в базе данных MongoDB.

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

Вот один из способов реализовать это в Express.

Здесь мы разделили внутренний код на несколько сервисов. Каждая служба несет ответственность в своем собственном домене. В приведенном выше примере у нас есть: AnswerService, EmailService, PointsService и QuestionService.

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

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

  • откуда пришли данные (req.user, req.params, req.body).
  • где найти необходимые данные (QuestionService, AnswerService,…)
  • как начислять баллы пользователю (PointsService)
  • как отправить результат обратно пользователю (res.render, EmailService)
  • используемые шаблоны HTML (правильный ответ, неправильный ответ)
  • как определить правильный ответ (.trim (). toLowerCase ())

Бизнес-вариант использования жестко привязан к модулям инфраструктуры и сервиса, кратко описанным на этой диаграмме:

Давайте подумаем об этом. Нам нужно протестировать модуль и все, к чему он подключен.

В итоге мы заглушили множество вещей, например:

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

  • экспресс-роутер
  • как он обрабатывает запрос
  • как он отправляет ответ
  • код ответа и текст ответа

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

Наш набор тестов превратился бы в повторяющийся шум.

Как будто хуже некуда ...

На самом деле, приведенный выше пример очень прост. Некоторый код, который я видел и написал, они связаны с моделью ORM - что-то вроде этой диаграммы:

Существуют также другие тесные связи с внешними службами, такими как Mailgun и Redis (которые я оставлю в этом примере).

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

Нам нужно издеваться над правильным методом. Если бы я изменил свой код с req.user.save () на UserModel.findOneAndUpdate (), тогда мои тесты не пройдут.

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

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

Бизнес развивается…

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

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

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

Время масштабировать…

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

Мы решили, что перейдем к микросервисам.

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

Следовательно, необходимо переписать большое количество производственного кода и тестов.

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

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

Технологические изменения…

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

Что, если мы также должны предложить REST API, чтобы наша игра-викторина могла интегрироваться с другими SaaS?

Для приложения в реальном времени, как насчет использования чего-то вроде Firebase или RethinkDB, которое поддерживает запросы в реальном времени из коробки, вместо MongoDB?

Как быстро мы можем двигаться?

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

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

Более чистая архитектура

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

Если вы новичок в чистой архитектуре, я действительно рекомендую посмотреть этот доклад:

В JavaScript мне показалось, что это на удивление легко реализовать (поэтому я и хотел написать эту статью). Подход, который я использую здесь, вдохновлен гексагональной архитектурой.

Сначала я покажу вам код мечты, который описывает вариант использования в JavaScript. Не беспокойтесь, откуда пришли данные и куда пойдет ответ. Для меня это выглядит так:

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

Здесь есть небольшая необходимая деталь: большинство этих функций доступа к данным, вероятно, должны выполнять некоторый ввод-вывод, который по своей сути асинхронен в мире Node.js.

Вот почему мне нужно использовать async и await в приведенном выше примере. Некоторые люди могут предпочесть генераторы, которые обеспечивают большую гибкость.

Порт…

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

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

Здесь мы написали средство взаимодействия с вариантами использования. Это функция, которая представляет собой чисто бизнес-вариант использования . Ему ничего не известно, кроме бизнес-правил, за которые он несет ответственность.

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

Для тестирования достаточно отправить правильное значение через порт в интерактор и подтвердить, что получен правильный результат:

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

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

Путем инвертирования зависимостей и разделения интерфейсов становится намного проще изменить детали реализации (например, предоставить конечную точку REST или перейти в режим реального времени с помощью WebSockets), не касаясь кода нашего варианта использования.

Подключим его к нашему приложению Express.

Механизм доставки

Теперь у нас есть надежный и хорошо протестированный инструмент взаимодействия. Вернемся к нашему приложению Express, которое, по сути, является одним из возможных механизмов доставки. Теперь это написано так:

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

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

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

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

Иногда у меня даже нет автоматизированного теста оболочки.

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

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

Несколько портов…

Вот снова интерфейс порта для варианта использования submitAnswer.

export async function submitAnswer ({
  answer,
  question,
  currentUser,
  isAnswerToQuestionCorrect,
  awardPoints,
  recordCorrectAnswerToQuestion,
  hasUserAnsweredAllQuestions,
  sendCongratulationsEmailToUser,
  respondWithCorrectAnswer,
  respondWithIncorrectAnswer
})

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

Например, если мне нужно предоставить конечные точки REST API и WebSocket для этого варианта использования. Многие из этих полей останутся прежними (например, isAnswerToQuestionCorrect), а другие будут другими (например, responseWithCorrectAnswer).

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

export async function submitAnswer ({
  request: {
    answer,
    question,
    currentUser
  },
  data: {
    isAnswerToQuestionCorrect,
    awardPoints,
    recordCorrectAnswerToQuestion,
    hasUserAnsweredAllQuestions
  },
  notification: {
    sendCongratulationsEmailToUser
  },
  response: {
    respondWithCorrectAnswer,
    respondWithIncorrectAnswer
  }
})

Теперь вы можете собрать и протестировать адаптер каждого порта отдельно:

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

Теперь он такой минималистичный и чистый.

Чтобы представить этот вариант использования через WebSockets, теперь нам нужно только заменить порты request и response, в то время как порты данных и уведомления останутся прежними.

Не забывайте о познавательной нагрузке!

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

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

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

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

Вот и все. Реализация чистой архитектуры на JavaScript.

Спасибо за прочтение!