На пути к поддерживаемому эликсиру: ядро ​​и интерфейс

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

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

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

Ядро против интерфейса

Типичный бэкэнд VBT работает на Phoenix, поэтому на самом высоком уровне мы используем контексты Phoenix для организации кода. В наших проектах мы рассматриваем контексты как ядро ​​системы, а сеть как интерфейс. Ядро отвечает за все задачи, которые должны быть выполнены независимо от того, как к системе обращаются внешние клиенты, в то время как уровень интерфейса содержит всю логику, специфичную для способа доступа клиентов к системе, такую ​​как REST, GraphQL или WebSocket. Другими словами, ядро ​​реализует желаемое поведение системы, а уровень интерфейса предоставляет систему внешним клиентам.

Такой дизайн помогает нам достичь поставленных целей. Когда разработчик хочет что-то понять о поведении системы, он может сразу перейти к ядру, игнорируя все особенности маршрутизации, декодирования / кодирования, кодов ошибок HTTP и т. Д. С другой стороны, изменив что-то в интерфейсе или даже поддержка совершенно нового протокола (например, добавление GraphQL к существующему REST API) может выполняться без знания основных внутренних устройств.

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

Основная функция

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

Спецификации типов - это наш основной инструмент документирования, и они также помогают нам проверять дизайн. В сочетании с хорошим именованием хорошо продуманная спецификация может быть очень информативной. В этом примере в спецификации четко указаны необходимые входные данные для операции с регистром и возможные результаты. Для проектов VBT мы стремимся к точной типизации экспортируемых функций, что означает, что не только должны быть включены спецификации типов, но также и типы должны быть конкретными. Широкие типы, такие как map и any, рассматриваются как запахи кода. Ecto.Changeset.t обычно возвращается только как часть кортежа с ошибкой.

Это наше главное отличие от официальных документов Phoenix, которые предлагают несколько иной подход, например:

Проблема с «официальным» подходом заключается в том, что проблемы интерфейса просачиваются в ядро, размывая границу между двумя уровнями. Это противоречит главной цели дизайна: четкому разделению слоев.

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

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

Давайте посмотрим на реализацию основной функции:

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

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

Реализация интерфейсной функции

Теперь давайте сосредоточимся на уровне интерфейса, выбрав в качестве примера стандартный контроллер Phoenix. Действие контроллера принимает два параметра: Plug conn и параметры действия. Последняя представляет собой карту произвольной формы со следующими свойствами:

  • Ключи - это струны
  • Некоторые ключи могут отсутствовать
  • Могут присутствовать неизвестные ключи
  • Значения могут быть нетипизированными (например, если используется формат строки запроса foo=bar&baz=2)

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

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

В контроллерах мы обычно нормализуем входные данные с помощью бессхемных ревизий Ecto:

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

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

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

С точки зрения LOC, это совсем не короче, хотя для больших входных схем будет небольшая экономия. Однако реальное преимущество этого подхода состоит в том, что входная схема консолидирована и представлена ​​более четко.

Письмо активации

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

Вопрос в том, кто должен вызывать эту функцию? Один из вариантов - заставить интерфейсный код вызывать его после register:

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

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

Тело электронного письма будет содержать ссылку активации, которая состоит из URL-адреса сайта, некоторых фиксированных путей (например, / activate) и токена активации для конкретного пользователя. Первые две части информации относятся к интерфейсу, а последняя - к основной проблеме. Объединение этих трех частей в путь - это забота интерфейса, но нам нужно сделать это во время основной операции. Итак, как мы можем примирить это? Наш подход состоит в том, чтобы ввести основной контракт на предоставление URL-адресов:

Основная функция принимает в качестве параметра модуль реализации:

Уровень интерфейса реализует контракт:

Наконец, клиент может вызвать основную функцию:

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

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

Функция Repo.transact - это наша маленькая оболочка вокруг Repo.transaction. Эта функция фиксирует транзакцию, если лямбда возвращает {:ok, result}, и откатывает ее, если лямбда возвращает {:error, reason}. В обоих случаях функция возвращает результат лямбды. Мы выбрали этот подход, а не Ecto.Multi, потому что экспериментально установили, что multi добавляет много шума без реальной пользы для наших нужд.

Резюме

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

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

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

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