Современный backend разнообразен, но всё-таки подчиняется некоторым негласным правилам. Многие из нас, кто разрабатывает серверные приложения, сталкивается с общепринятыми подходами, такими как Clean Architecture, SOLID, Persistence Ignorance, Dependency Injection и прочими. Многие из атрибутов серверной разработки настолько заезжены, что не вызывают никаких вопросов и используются бездумно. О некоторых много говорят, но никогда не используют. Смысл остальных же либо неправильно интерпретирован, либо перевран. Статья рассказывает о том, как построить простую, совершенно типичную, архитектуру backend, которая не только может без какого-либо ущерба следовать заветам известных теоретиков программирования, но и в некоторой степени может их усовершенствовать.
Посвящается всем тем, кто не мыслит программирование без красоты и не приемлет красоту среди абсурда.
Модель предметной области
Моделирование — это то, с чего должна начинаться разработка программных приложений в идеальном мире. Но все мы не идеальные, мы много говорим об этом, но делаем всё как обычно. Зачастую причиной является несовершенство существующих инструментов. А если быть честными, то наша лень и боязнь брать на себя ответственность уйти от «best practices». В неидеальном мире разработка ПО начинается, в лучшем случае, со scaffolding'a, в худшем — с оптимизации производительности ничего. Хотелось бы всё же отбросить тяжёлые примеры «выдающихся» архитекторов и порассуждать о вещах более обыденных.
Итак, у нас есть техническое задание, и даже есть дизайн пользовательского интерфейса (или нет, если UI не предусмотрен). Следующим шагом мы должны отразить требования в модели предметной области. Для начала можно набросать диаграмму объектов модели для наглядности:
Далее, как правило, мы начинаем проецировать модель на средства её реализации — язык программирования, объектно-реляционный преобразователь (Object-Relational Mapper, ORM) или же на какой-то комплексный фреймворк типа ASP.NET MVC или Ruby on Rails, иначе говоря — начинаем писать код. В этом случае, мы идём по пути фреймворка, что я считаю не корректным в рамках разработки на основе модели, как бы удобно изначально это ни казалось. Здесь вы делаете огромное допущение, которое впоследствии сводит на нет преимущества разработки на основе предметной области. В качестве более свободного варианта, не ограниченного рамками какого-то инструмента, я бы предложил остановиться на использовании только синтаксических средств языка программирования для построения объектной модели предметной области. В работе я использую несколько языков программирования — C#, JavaScript, Ruby. Судьба распорядилась так, что экосистема Java и C# — это место моего вдохновения, JS — основной заработок, а Ruby — язык, который мне нравится. Поэтому далее буду показывать простые примеры на 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 (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» выводит все доступные планы подписки и «подсвечивает» текущий. Происходит следующее:
- ":before -> common/open_db_session" — открытие сессии JetSet
- /auth ":before -> current_user@users/load_current_user" — загрузка текущего пользователя (по токенам). Результат регистрируется в IoC-контейнере как current_user (инструкция current_user@).
- /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
- «plans@plans/load_plans» — загрузка списка всех доступных планов. Результат регистрируется в IoC-контейнере как plans (инструкция plans@).
- ":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 и прочее. Строгие определения на эти и другие использованные термины пытливый читатель без труда сможет найти в просторах сети, не говоря уже о коллегах по цеху, для кого эти аббревиатуры являются частью повседневной речи. И напоследок подчеркну, постарайтесь не акцентировать внимание на инструментах, которые мне потребовалось реализовать для решения поставленных проблем. Это всего лишь демонстрация состоятельности размышлений, не сама их суть. Если данная статья вызовет какой-нибудь интерес, то напишу отдельный материал об этих библиотеках.