Хабрахабр

[Из песочницы] Ортодоксальный Backend

Современный backend разнообразен, но всё-таки подчиняется некоторым негласным правилам. Многие из нас, кто разрабатывает серверные приложения, сталкивается с общепринятыми подходами, такими как Clean Architecture, SOLID, Persistence Ignorance, Dependency Injection и прочими. Многие из атрибутов серверной разработки настолько заезжены, что не вызывают никаких вопросов и используются бездумно. О некоторых много говорят, но никогда не используют. Смысл остальных же либо неправильно интерпретирован, либо перевран. Статья рассказывает о том, как построить простую, совершенно типичную, архитектуру backend, которая не только может без какого-либо ущерба следовать заветам известных теоретиков программирования, но и в некоторой степени может их усовершенствовать.
Посвящается всем тем, кто не мыслит программирование без красоты и не приемлет красоту среди абсурда.

Модель предметной области

Моделирование — это то, с чего должна начинаться разработка программных приложений в идеальном мире. Но все мы не идеальные, мы много говорим об этом, но делаем всё как обычно. Зачастую причиной является несовершенство существующих инструментов. А если быть честными, то наша лень и боязнь брать на себя ответственность уйти от «best practices». В неидеальном мире разработка ПО начинается, в лучшем случае, со scaffolding'a, в худшем — с оптимизации производительности ничего. Хотелось бы всё же отбросить тяжёлые примеры «выдающихся» архитекторов и порассуждать о вещах более обыденных.

Следующим шагом мы должны отразить требования в модели предметной области. Итак, у нас есть техническое задание, и даже есть дизайн пользовательского интерфейса (или нет, если UI не предусмотрен). Для начала можно набросать диаграмму объектов модели для наглядности:

NET MVC или Ruby on Rails, иначе говоря — начинаем писать код. Далее, как правило, мы начинаем проецировать модель на средства её реализации — язык программирования, объектно-реляционный преобразователь (Object-Relational Mapper, ORM) или же на какой-то комплексный фреймворк типа ASP. Здесь вы делаете огромное допущение, которое впоследствии сводит на нет преимущества разработки на основе предметной области. В этом случае, мы идём по пути фреймворка, что я считаю не корректным в рамках разработки на основе модели, как бы удобно изначально это ни казалось. В работе я использую несколько языков программирования — C#, JavaScript, Ruby. В качестве более свободного варианта, не ограниченного рамками какого-то инструмента, я бы предложил остановиться на использовании только синтаксических средств языка программирования для построения объектной модели предметной области. Поэтому далее буду показывать простые примеры на Ruby: убеждён, что это не вызовет проблем в понимании у разработчиков на других языках. Судьба распорядилась так, что экосистема Java и C# — это место моего вдохновения, JS — основной заработок, а Ruby — язык, который мне нравится. Итак, переносим модель на класс Invoice в Ruby:

class Invoice attr_reader :amount, :date, :created_at, :paid_at def initialize(attrs, payment_service) @created_at = DateTime.now @paid_at = nil @amount = attrs[:amount] @date = attrs[:date] @subscription = attrs[:subscription] @payment_service = payment_service end def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) @paid_at = DateTime.now end
end

Т.е. мы имеем класс, конструктор которого принимает Hash атрибутов, зависимости объекта и инициализирует его поля, и метод «pay», который может изменять состояние объекта. Всё очень просто. Сейчас мы не задумываемся о том, как и где мы будем отображать и хранить этот объект. Он просто есть, мы можем его создавать, менять его состояние, взаимодействовать с другими объектами. Обратите внимание, в коде отсутствуют какие-то инородные артефакты наподобие BaseEntity и прочий мусор, не имеющий отношения к модели. Это очень важно. Кстати, на этом этапе мы уже можем начинать разработку через тестирование (TDD), используя объекты-заглушки вместо зависимостей типа payment_service:

RSpec.describe Invoice do before :each do @payment_service = double(:payment_service) allow(@payment_service).to receive(:charge) @amount = 100 @credit_card = CreditCard.new() @customer = Customer.new({credit_card: @credit_card, ...}) @subscription = Subscription.new({customer: customer, ...}) @invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service) end describe 'pay' do it "charges customer's credit card" do expect(@payment_service).to receive(:charge).with(@credit_card, @amount) @invoice.pay end it 'makes the invoice paid' do expect(@invoice.paid_at).not_to be_nil @invoice.pay end end
end

или даже поиграться с моделью в интерпретаторе (irb для Ruby), который вполне может быть, хотя и не очень дружелюбным, пользовательским интерфейсом:

irb > invoice = Invoice.new({amount: @amount, date: DateTime.now, @subscription: subscription}, payment_service)
irb > invoice.pay

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

# Плохо
class User < Entity # наличие общего базового класса table :users # название таблицы в БД # mapping полей field :name, type: 'String' # метод сохранения def save ... end
end
user = User.load(id) # модель загружает своё состояние
user.save # модель сохраняет сама себя

# Хорошо
class User # использование средств языка, библиотек и зависимостей из модели attr_accessor :name, :lastname
end
user = repo.load(id) # сотояние загружает внешний компонент
repo.save(user) # сотояние сохраняет внешний компонент

Такой подход обусловлен и фундаментальными причинами — соблюдение принципа единственной ответственности (Single Responsibility Principle, S в SOLID). Если модель кроме своей функциональной составляющей описывает параметры сохранения состояния, а также занимается его сохранением и загрузкой, то, очевидно, она имеет слишком много ответственностей. Вытекающим и не последним преимуществом Persistence Ignorance является возможность замены средства сохранения и даже типа самого хранилища в процессе разработки.

Model-View-Controller

Концепция MVC настолько популярна в среде разработки всевозможных, не только серверных, приложений на разных языках и платформах, что мы уже и не задумываемся о том, что это такое и зачем оно вообще нужно. У меня больше всего вопросов из этой аббревиатуры вызывает «Controller». С точки зрения организации структуры кода — это неплохая вещь — группировать действия над моделью. Но контроллер вообще не должен быть классом, это должен быть скорее модуль, включающий методы для обращения к модели. Мало того, должен ли он иметь место быть вообще? Как разработчик, следовавший по пути .NET -> Ruby -> Node.js, я был просто умилён контроллерами на JS (ES5), которые реализуют в рамках express.js. Имея возможность решать задачу, возлагаемую на контроллеры, в более функциональном стиле, разработчики как околдованные снова и снова пишут магическое «Controller». Чем же плох типичный контроллер?

Каждый отдельный метод может требовать разных зависимостей. Типичный контроллер — это набор малосвязанных между собой методов, объединяемых лишь одним — определённой сущностью модели; а иногда и не одной, что ещё хуже. Поэтому подобные зависимости мне нужно инициализировать где-то снаружи и передавать в конструктор контроллера. Забегая немного вперёд, замечу, что я — сторонник практики инверсии зависимостей (Dependency Inversion, D в SOLID). Например, при создании нового счёта я должен отправлять уведомления бухгалтеру, для чего мне нужен сервис уведомлений, а в остальных методах он мне не нужен:

class InvoiceController def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def index @repository.get_all end def show(id) @repository.get_by_id(id) end def create(data) @repository.create(data) @notification_service.notify_accountant end
end

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

class ListInvoices def initialize(invoice_repository) @repository = invoice_repository end def call @repository.get_all end
end class CreateInvoice def initialize(invoice_repository, notification_service) @repository = invoice_repository @notification_service = notification_service end def call @repository.create(data) @notification_service.notify_accountant end
end

Хорошо, вместо контроллера теперь есть набор «функций» для доступа к модели, которые, кстати, тоже можно структурировать, используя каталоги файловой системы, например. Теперь нужно «открыть» эти методы вовне, т.е. организовать что-то вроде Router'a. Как человек, искушённый всякого рода DSL (Domain-Specific Language), я бы предпочёл иметь более наглядное описание инструкций для веб-приложения, нежели выкрутасы на Ruby или другом языке общего назначения для задания маршрутов:

`HTTP GET /invoices -> return all invoices`
`HTTP POST /invoices -> create new invoice`

или хотя бы

`HTTP GET /invoices -> ./invoices/list_invoices`
`HTTP POST /invoices -> ./invoices/create`

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

Dependency Injection

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

Зачем же это вообще нужно? Как видно, весь мой роутер пронизан внедрением зависимостей с использованием IoC-контейнера. Пример: Понятие «внедрение зависимостей» восходит к технике инверсии зависимостей (Dependency Inversion), которая предназначена для уменьшения связности объектов путём вывода инициализации зависимостей за рамки их использования.

class Repository; end # Плохо (инициализируем зависимость в конструкторе)
class A def initialize @repo = Repository.new end
end # Хорошо (передаём зависимость в конструктор)
class A def initialize(repo) @repo = repo end
end

Такой подход значительно помогает тем, кто использует Test-Driven Development. В приведённом примере мы легко можем положить в конструктор заглушку вместо реального объекта репозитория, соответствующую его интерфейсу, без «взлома» объектной модели. Это не единственный бонус DI: при правильном применении этот подход привнесёт в ваше приложение много приятной магии, но обо всём по порядку. Dependency Injection это подход, который позволяет интегрировать технику Dependency Inversion в комплексное архитектурное решение. В качестве инструмента реализации обычно служит IoC- (Inversion of Control) контейнер. В мире Java и .NET существует масса действительно классных IoC-контейнеров, их десятки. В JS и Ruby, к сожалению, достойных вариантов нет, одни лишь пародии, своего рода карго-культ. Вот так бы выглядел бы мой класс с dry-container:

class Invoice include Import['payment_service'] def pay credit_card = @subscription.customer.credit_card amount = @subscription.plan.price @payment_service.charge(credit_card, amount) end
end

Вместо стройного использования конструктора мы обременяем класс внедрением собственных зависимостей, что уже на первоначальном этапе уводит нас от чистой и независимой модели. Уж что-что а модель вообще не должна знать об IoC! Это справедливо и для действий типа CreateInvoice. Для приведённого случая в своих тестах я уже обязан использовать IoC как что-то неотъемлемое. Это категорически неправильно. Объекты приложения в основной своей массе не должны знать о существовании IoC. После поиска и долгих размышлений я набросал свой IoC, который не был бы таким навязчивым.

Сохранение и загрузка модели

Для удовлетворения требованиям Persistence Ignorance потребуется использовать ненавязчивый объектный преобразователь. В данной статье я буду иметь ввиду работу с реляционной базой данных, основные моменты будут справедливы и для других типов хранилищ. В качестве подобного преобразователя для реляционных БД используется объектно-реляционный преобразователь — ORM (Object Relational Mapper). В мире .NET и Java существует изобилие поистине мощных инструментов ORM. Все они имеют те или иные незначительные недостатки, на которые можно закрывать глаза. В JS и Ruby хороших решений нет. Все они так или иначе жёстко привязывают модель к фреймворку и заставляют декларировать инородные элементы, не говоря уже неприменимости Persistence Ignorance. Как и в случае с IoC, я задумался о реализации ORM собственными силами, таково уж состояние дел в Ruby. Я не стал делать всё с нуля, а взял в качестве основы простой ORM Sequel, который предоставляет ненавязчивые инструменты для работой с разными реляционными СУБД. Меня прежде всего интересовала возможность выполнять запросы в виде обычного SQL, получая на выходе массив строк (хэш-объектов). Оставалось только реализовать свой Mapper и обеспечить Persistence Ignorance. Как я уже упоминал, я не хотел бы примешивать mapping полей в модель предметной области, поэтому я реализую Mapper таким образом, чтобы он использовал отдельный файл конфигурации в формате типа:

entity Invoice do field :amount field :date field :start_date field :end_date field :created_at field :updated_at reference :user, type: User reference :subscription, type: Subscription
end

Persistence Ignorance достаточно просто реализовать с использованием внешнего объекта типа Repository:

repository.save(user)

Но мы пойдём дальше и реализуем паттерн Unit of Work (Единица Работы). Для этого потребуется выделить понятие сессии. Сессия — это объект, который существует на протяжении времени, в течение которого производится набор действий над моделью, являющихся единой логической операцией. В течение существования сессии может происходить загрузка и изменение объектов модели. В момент завершения сессии происходит транзакционное сохранение состояния модели.
Пример единицы работы:

user = session.load(User, id: 1)
plan = session.load(Plan, id: 1) subscription = Subscription.new(user, plan)
session.attach(subscription) invoice = Invoice.new(subscription)
session.attach(invoice) # ...
# где-то в другом методе из цепочки выполнения
if Date.today.yday == 1 subscription.comment = 'New year offer' invoice.amount /= 2
end session.flush

В результате будет выполнено 2 инструкции в БД вместо 4х, причём обе будут выполнены в рамках одной транзакции.

Здесь возникает ощущение дежавю, как и с контроллерами: а не является ли репозиторий такой же рудиментарной сущностью? И тут внезапно вспомним про репозитории! Основное назначение репозитория — избавить слой бизнес-логики от взаимодействия с реальным хранилищем. Забегая наперёд, отвечу — да, является. Бесспорно, это очень разумное решение. Например, в контексте реляционных БД — это написание SQL-запросов прямо в коде бизнес-логики. Репозиторий с точки зрения ООП это по сути тот же контроллер — тот же набор методов, только уже не для обработки запросов, а для работы с хранилищем. Но вернёмся назад к моменту, когда мы избавились от контроллера. По всем признакам эти действия ничем не будут отличаться от того, что мы предложили вместо контроллера. Репозиторий также можно разбить на действия (Action). То есть, мы можем отказаться от Repository и Controller в пользу единого унифицированного Action!

class LoadPlan def initialize(session) @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p WHERE p.id = 1 SQL @session.fetch(Plan, sql) end
end

Наверное вы обратили внимание, что я использую SQL вместо какого-то объектного синтаксиса. Это дело вкуса. Я предпочитаю SQL, потому что это язык запросов, своего рода DSL для работы с данными. Понятно, что всегда проще написать Plan.load(id) чем соответствующий SQL, но это для тривиальных случаев. Когда дело доходит до немного более сложных вещей, SQL становится очень желанным инструментом. Иногда проклинаешь очередной ORM в попытках заставить его делать так, как чистый SQL, который «я написал бы за пару минут». Для сомневающихся я предлагаю заглянуть в документацию MongoDB, где пояснения даются в SQL-подобном виде, что выглядит очень забавно! Поэтому интерфейсом для запросов в ORM JetSet, который я написал для своих целей, является SQL с минимальными вкраплениями типа «AS ENTITY». Кстати, в большинстве случаев для вывода табличных данных я не использую объекты модели, разного рода DTO и т.д — я просто пишу SQL запрос, получаю массив хэш-объектов и отображаю его во view. Так или иначе, мало кому удаётся «скроллить» большие данные проецируя связанные таблицы на модель. На практике скорее используются плоские проекции (view), а совсем зрелые продукты приходят к стадии оптимизации, когда начинают использоваться более сложные решения типа CQRS (Command and Query Responsibility Segregation).

Соединяя всё воедино

Итак, что мы имеем:

  • мы разобрались с загрузкой и сохранением модели, мы также спроектировали примерную архитектуру web-средства доставки модели, некоего роутера;
  • мы пришли к выводу, что всю логику, которая не является частью предметной области можно вынести в действия (Actions) вместо контроллеров и репозиториев;
  • действия должны поддерживать внедрение зависимостей;
  • достойный инструмент Dependency Injection реализован;
  • необходимый ORM реализован.

Дело осталось за малым — реализовать тот самый «роутер». Поскольку мы избавились от репозиториев и контроллеров в пользу действий, то очевидно, что на один запрос нам потребуется выполнять несколько действий. Действия являются автономными и мы не можем их вкладывать друг в друга. Поэтому в рамках фреймворка Dandy я реализовал роутер, который позволяет создавать цепочки действий. Пример конфигурации (обратите внимание на /plans):

:receive .-> :before -> common/open_db_session GET -> welcome -> :respond <- show_welcome /auth -> :before -> current_user@users/load_current_user /profile -> GET -> plan@plans/load_plan \ -> :respond <- users/show_user_profile PATCH -> users/update_profile /plans -> GET -> current_plan@plans/load_current_plan \ -> plans@plans/load_plans \ -> :respond <- plans/list :catch -> common/handle_errors

«GET /auth/plans» выводит все доступные планы подписки и «подсвечивает» текущий. Происходит следующее:

  1. ":before -> common/open_db_session" — открытие сессии JetSet
  2. /auth ":before -> current_user@users/load_current_user" — загрузка текущего пользователя (по токенам). Результат регистрируется в IoC-контейнере как current_user (инструкция current_user@).
  3. /auth/plans «current_plan@plans/load_current_plan» — загрузка текущего плана. Для этого из контейнера берётся значение @current_user. Результат регистрируется в IoC-контейнере как current_plan (инструкция current_plan@):

    class LoadCurrentPlan def initialize(current_user, session) @current_user = current_user @session = session end def call sql = <<~SQL SELECT p.* AS ENTITY plan FROM plans p INNER JOIN subscriptions s ON s.user_id = :user_id AND s.current = 't' WHERE p.id = :user_id LIMIT 1 SQL @session.execute(sql, user_id: @current_user.id) do |row| map(Plan, row, 'plan') end end
    end

  4. «plans@plans/load_plans» — загрузка списка всех доступных планов. Результат регистрируется в IoC-контейнере как plans (инструкция plans@).
  5. ":respond < — plans/list" — зарегистрированный ViewBuilder, например JBuilder, отрисовывает View 'plans/list' типа:

    json.plans @plans do |plan| json.id plan.id json.name plan.name json.price plan.price json.active plan.id == @current_plan.id
    end

В качестве @plans и @current_plan извлекаются значения из контейнера, зарегистрированные на предыдущих шагах. В конструкторе Action вообще можно «заказывать» всё что вам нужно, точнее всё то, что зарегистрировано в контейнере. У внимательного читателя скорее всего возникнет вопрос, а происходит ли изоляция такого рода переменных в «многопользовательском» режиме? Да, происходит. Дело в том, что IoC-контейнер Hypo имеет возможность задавать время жизни объектов и, более того, привязывать его ко времени существования других объектов. В рамках Dandy, переменные типа @plans, @current_plan, @current_user привязаны к объекту запроса и будут уничтожены в тот момент, когда запрос завершится. Кстати, сессия JetSet так же привязана к запросу — сброс её состояния также будет выполнен в момент завершения запроса Dandy. Т.е. каждый запрос имеет свой изолированный контекст. Всем жизненным циклом Dandy управляет Hypo, как бы весело этот каламбур не звучал в дословном переводе названий.

Выводы

В рамках приведённой архитектуры я использую объектную модель для описания предметной области; я использую соответствующие практики вроде Dependency Injection; я могу использовать даже наследование. Но, при этом, все эти Action — это по сути обычные функции, которые могут объединяться в цепочки на декларативном уровне. Мы получили тот желаемый backend в функциональном стиле, но со всеми преимуществами объектного подхода, когда вы не испытываете проблем с абстракциями и тестированием вашего кода. На примере DSL роутера Dandy мы вольны создавать необходимые языки для описания маршрутов и не только.

Заключение

В рамках этой статьи я провёл своего рода экскурсию по основополагающим аспектам создания backend'a, каким его вижу я. Повторюсь, статья является поверхностной, в ней не затронуты многие важные темы, такие как, например, оптимизация производительности. Я постарался акцентировать внимание только на тех вещах, которые могут быть действительно полезны сообществу в качестве пищи для размышлений, а не переливать в очередной раз из пустого в порожнее, что такое SOLID, TDD, как выглядит схема MVC и прочее. Строгие определения на эти и другие использованные термины пытливый читатель без труда сможет найти в просторах сети, не говоря уже о коллегах по цеху, для кого эти аббревиатуры являются частью повседневной речи. И напоследок подчеркну, постарайтесь не акцентировать внимание на инструментах, которые мне потребовалось реализовать для решения поставленных проблем. Это всего лишь демонстрация состоятельности размышлений, не сама их суть. Если данная статья вызовет какой-нибудь интерес, то напишу отдельный материал об этих библиотеках.

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть