MVC — это главный архитектурный принцип, вокруг которого строится не только Ruby on Rails, но и любой другой фреймворк, работающий со сложными структурами данных и их отображением. Этот архитектурный паттерн появился довольно давно, на заре объектно-ориентированного программирования, но он не сразу был принят веб-программистами. 

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

Аббревиатура MVC (или model-view controller) отражает систему, состоящую из модели, отображения (view) и контроллера. Разбирая MVC-приложение, мы столкнёмся с множеством файлов, каждый из которых определяет либо model, либо view, либо controller. Время от времени будут встречаться промежуточные сущности, примеры которых мы увидим чуть позже.

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

На диаграмме видно, как взаимодействуют эти сущности. Модель хранит данные, контроллер управляет вводом-выводом, а view — отображает. Обязательный элемент тут — пользователь, с которого начинается и которым заканчивается сложная цепочка взаимодействий. Давайте подробнее разберёмся во взаимодействиях и рассмотрим каждый элемент в отдельности:

M — model

Модель — это сущность для работы с данными. Она хранит данные, контролирует их целостность и консистентность (то есть соответствие между разными моделями и статусу, который имеет приложение). Ещё модель производит и обрабатывает запросы на чтение или изменение данных к лежащему ниже механизму базы данных. Например, нельзя сохранить сущность пользователя, если для него не существует записи e-mail — это проверка на целостность.

Перед вами типичная модель:

# /app/models/payments.rb

# == Schema Information
#
# Table name: payments
#
#  id          :integer          not null, primary key
#  user_id     :integer          indexed
#  sum         :decimal(10, 2)
#  paid_at     :datetime
#  creator_id  :integer          indexed
#  description :string
#  created_at  :datetime
#  updated_at  :datetime
#  notify      :boolean          default(TRUE)
#

class Payment < ApplicationRecord
  include ArelHelpers::ArelTable
  include UserFinance

  belongs_to :user

  validates :sum, :paid_at, presence: true
  validates :sum, numericality: { greater_than_or_equal_to: 1 }

  after_create :notify_user, if: :notify

  has_paper_trail

  private

  def notify_user
    text = %(
      Hi, #{user.profile.name}! :slightly_smiling_face:
      :money_with_wings: Money was transferred from your balance.
      Details here: #{RoutesUrlHelper.new.work_url}
    )
    NotifierService.send_slack_message user, text
  end
end

Этот код взят из живого проекта Evrone. Здесь много непонятных пока для вас слов, но мы выделим несколько основных элементов. Мы видим здесь описание связей между этой моделью и другой моделью user. Видим проверку консистентности и целостности данных — объявление validates. Также мы видим бизнес-логику: что делает эта модель после сохранения данных, а также обращения к некоторым библиотечным функциям. Ещё тут можно увидеть служебный метод для нотификации пользователя.

В Ruby on Rails для создания моделей мы чаще всего пользуемся библиотекой ActiveRecord. Подробно мы разберём её в одной из будущих лекций, а пока нам будет достаточно знать, что она реализует шаблон проектирования «object-relation mapper». Это значит, что модель формирует единую сущность из объектно-ориентированного кода на Ruby и таблицы в базе данных. 

В примере вы видите сущность, которая реализует модель payment — на нижнем уровне это таблица с некоторыми необходимыми полями. В ходе взаимодействия базы данных и кода этого ruby-объекта и создаётся модель.

V — view

Перейдём к view или «отображениям». Их задача — взаимодействовать с пользователем. Пользователем может быть человек, который видит наше приложение через браузер или мобильное устройство.

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

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

На иллюстрации — типичное отображение: 

-# /app/views/payments/_form.html.haml
- inline ||= false
= bootstrap_form_for [:admin, payment.user, payment], remote: true,
                    data: { type: 'json',
                            prompt: ({ message: 'Enter the reason of payment:',
                                        default: '',
                                        param: 'payment[description]' } if inline) },
                    html: ({ class: 'd-flex' } if inline) do |f|
  = f.number_field :sum, { label: 'Sum for payment',
                          step: '0.01' }.merge(inline ? { label_class: 'erp-label-sm',
                                                          class: 'form-control-sm',
                                                          wrapper: { class: 'mb-0' },
                                                          style: 'width: 130px' } : {})
  = f.check_box :notify, { label: 'Notify employee' } unless inline
  = f.text_area :description unless inline
  = instance_form_submit f, caption: 'Pay', class: ('btn-sm ml-2' if inline), disabled: inline

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

C — controller

Теперь настало время заняться контроллерами. Дело в том, что модель не образует сама своё отображение. Она не общается с пользователем напрямую. Должен быть инструмент, который поможет им взаимодействовать. Например, создаст эту модель из данных, хранящихся в СУБД, сформирует отображение, отправит его пользователю. Или такой сценарий: пользователь работает с формой в отображении, нажимает «enter» или «ok», и отправляет в наше приложение какой-то HTTP-запрос. Посредником между пользователем, моделью и отображением является контроллер. Контроллер можно представить себе как умный маршрутизатор, который управляет взаимодействиями между моделью и пользователем или отображением. 

Кратко по контроллеру:

  1. Он принимает, интерпретирует и валидирует всё, что вводит пользователь.

  2. Он создаёт на основе имеющихся моделей отображения и отправляет их пользователям.

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

На иллюстрации вы видите код одного из контроллеров: 

# /app/controllers/payments_controller.rb
class PaymentsController < BaseController
  respond_to :html, :json
  before_action :load_user
  before_action :load_and_authorize_payment, except: %i[new create]

  def new
    @payment = @user.payments.build(params[:payment] ? payment_params : {})
    authorize! :create, @payment
    render layout: false
  end

  def create
    @payment = @user.payments.build(
      { creator_id: current_user.id, paid_at: DateTime.current }.merge(payment_params)
    )
    authorize! :create, @payment
    @payment.save
    respond_with :admin, @payment, location: (request.referer unless referer_params[:controller] == 'admin/main')
  end

  # ...

  private

  # ...
end

В нём содержится несколько выражений, которые программируют поведение приложения при получении какого-то http-запроса или при формировании http-ответа.

Давайте повторим полученные сведения: MVC — это базовый архитектурный паттерн, на котором построен Ruby on Rails. Он определяет компонентную структуру приложения: оно состоит из моделей, отображений и контроллеров. Каждый из этих компонентов играет свою роль в стеке приложения. Модель отвечает за данные, контроллер маршрутизирует сообщения между моделью и отображением, отображение показывает состояние модели пользователю и позволяет с ней взаимодействовать. 

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

Теперь давайте представим себе маршрут, по которому движутся данные в MVC-приложении:

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

  2. Контроллер получает этот запрос и интерпретирует. Данные, которые ввёл пользователь, проходят через защитные конструкции — валидаторы. Запрос превращается в вызов методов в логике приложения.

  3. Контроллер вызывает исполнение бизнес-логики. Бизнес-логика — это сложное понятие. Для простоты мы будем представлять её себе как обобщённое функциональное содержимое приложения.

  4. Бизнес-логика внутри нашего приложения выполняет нужную трансформацию состояния моделей. Производимые действия на этом уровне разнообразны, но в итоге всё приводит к изменению состояния модели и сохранению этого состояния. 

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

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

Структура бизнес-логики и паттерны проектирования

Давайте подробнее остановимся на структуре бизнес-логики. Мы уже знаем, что приложения сложны, в них много страниц, форм, кнопок. Даже если перед нами только одна кнопка, например, «вызвать такси» — под капотом, как мы уже знаем, движется сложный поток управления и трансформации.

Бизнес-логика — не какой-то отдельный элемент. Это совокупность элементов, которые выполняют трансформацию данных в соответствии с «заказом» пользователя. Она должна эффективно работать в коде приложения, поэтому важно её правильно скомпоновать и разместить — приложение должно работать быстро и корректно. И вот тут может возникнуть ряд проблем, которые могут сделать код и структуру приложения избыточно сложными.

Разработчики давно спорят, где размещать бизнес-логику. Что предлагает нам Ruby on Rails?

«По учебнику» бизнес-логика размещается в моделях. Там можно и нужно размещать код, который следит за корректностью и консистентностью данных, за тем, как формируются запросы к этим данным. Но если мы разместим всё возможное взаимодействие с данными в моделях, то объём кода в них вырастет. Классы станут огромными, кодом будет трудно управлять. Учтём, что над программой работает часто не один человек, а целая команда. Программисты сменяют друг друга на одних и тех же участках кода. Код должен быть понятен и читаем. 

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

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

Чтобы упорядочить распределение логики между моделями, отображениями и контроллерами, есть ряд приёмов, которые называются «паттерны проектирования». Эти инструменты — следствие компромиссов. Мы с одной стороны размещаем логику наиболее правильно, а с другой — приложение работает достаточно быстро. Вот эти инструменты.

Service-object

Первый паттерн проектирования, с которым мы познакомимся — это service object или service. Он ещё может называться interactor, transaction, operation или saga.

Пример такого сервиса:

# Service
class CsvGenerator
  attr_reader :filename

  def call(input_data)
    headers, *body = input_data

    CSV.generate do |csv|
      csv << headers
      body.each { |row| csv << row }
    end
  end
end

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

До появления этого инструмента многие «организующие» операции помещали в контроллерах. Сейчас сложное взаимодействие стали выносить в специальные классы. Классы эти, как правило, имеют интерфейс, состоящий из одного публичного метода call. Если вспомните лекцию по функциональному программированию, то узнаете в сервисах паттерн «функциональный объект». Это значит, что объект, порождаемый этим классом, служит функцией, которая получает аргументы для своих параметров, объявленных в методе call, и производит с ними нужные действия. 

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

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

Вызываются сервис-объекты контроллерами изнутри action'ов контроллера и возвращают порождённые данные в action контроллера для отображения пользователю. 

Посмотрите на более сложный пример интерактора:

# Interactor
class ShowDaysOff < BaseInteractor
  Dry::Validation.load_extensions(:monads)

  param :filter_params, proc(&:to_h)

  FilterSchema = Dry::Validation.Params do
    required(:user_id).filled(:int?)
    required(:since).filled(:date?)
    required(:through).filled(:date?)
  end

  def call
    filters = yield accept_filter_params
    slots = yield apply_filters(initial_scope, filters)
    grouped_slots = slots.group_by { |slot| Project::VACATION_TYPES_BY_PROJECT_ID[slot.project_id] }
    build_days_off(grouped_slots, filters)
  end

  private

  # ...

end

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

Value object

Следующий паттерн проектирования — это value object или «объект для значений». В таком объекте можно группировать сложные данные в виде компактной структуры. Value object’ы переносят между частями бизнес-логики пакеты связанных между собой данных. Данные в Value object’ах можно передавать из контроллера в модель, между моделями, между моделями и view и так далее. 

Вот пример value object:

class DetailedPayment < Dry::Struct
  attribute :payment
  attribute :payment_salaries
  attribute :unrecognized_salaries
  attribute :payment_expenses
  attribute :unrecognized_expenses
  attribute? :unknown_sum

  def details
    # ... constructing details
    details
  end

  delegate :paid_at, to: :payment

  private

  def salary_details(salary)
    # ...
  end

  def expense_details(expense)
    # ...
  end
end

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

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

Presenter

Ещё один интересный паттерн — presenter. Его можно назвать близким родственником view. Он берёт какой-то объект, например, модель и «представляет» её для взаимодействия с отображением. 

Посмотрите на пример такого объекта:

class EventPlanPresenter
  attr_reader :object

  def initialize(object)
    @object = object
  end

  def time_until_end_of_day
    time_diff = Time.current.end_of_day - Time.current
    Time.at(time_diff.to_i.abs).utc.strftime '%H hours %M minutes'
  end
end

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

Decorator

У presenter есть брат — паттерн decorator. Иногда их даже путают. Decorator «украшает» другие объекты (чаще всего модели) тем, что добавляет к ним какую-то функциональность. Но отличие от presenter всё таки есть: decorator содержит элементы логики преобразования данных, а не чисто презентационные.

Посмотрим:

class EventPlanDecorator
  attr_reader :object

  def initialize(object)
    @object = object
  end

  def last_call_days_left
    (object.last_call - Date.current).to_i
  end

  def last_call_soon?
    last_call_days_left.in?(0..14)
  end

  def users_not_going
    EventPlanUser.where(event_plan: object).refused.count
  end
end

И presenter, и decorator «украшают» одну и ту же сущность — event_plan. Но в presenter была «художественная» логика, которая формировала сообщение с часами и минутами, а decorator содержит вычисления и преобразования данных.

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

Form object

Для работы с бизнес-логикой форм существует паттерн Form Object. Эти объекты формируются из данных, которые приходят в запросе от пользователя. Например, пользователь заполнил какую-то форму в приложении и послал данные серверу. Контроллер принял запрос, интерпретировал его, превратил в некоторую структуру данных с элементами этого запроса: поля, параметры и так далее. 

Сами по себе запросы могут быть очень сложными. Элементы в них могут взаимодействовать друг с другом, поэтому надо сложно проверять корректность сложных запросов. Эту логику можно разместить в контроллер, а можно вынести как раз в form object'ы. Вот довольно большой пример одного из них:

class UserVacationForm < AbstractUserVacationForm
  include ActiveModel::Validations::Callbacks

  attribute :type, Integer
  attribute :hours, Float

  # ...

  validates :type, presence: true
  validate :sanitize_hours, if: -> { hours.present? }
  validate :check_vacation
  validate :check_hours, if: -> { hours.present? && begin_date.present? && end_date.present? }

  before_validation :improve_attributes

  def improve_attributes
    self.end_date = begin_date if begin_date.present? && end_date.blank?
  end

  def min_date
    if vacation?
      Date.current + Settings.vacation.vacation_buffer_period.months
    else
      Date.current.beginning_of_month - Settings.vacation.min_begin_date_for_vacation.months
    end
  end

  def project
    @project ||= Project.find(type)
  end

  private
  # ...
end

Они похожи на модель: принимают и трансформируют данные, чтобы потом передать на хранение. С другой стороны — это всё-таки не модели. Механизма сохранения данных тут нет. Цель такого объекта — предварительно обработать данные, перед тем, как они попадут в модель, интерактор, сервис и так далее. Используя form object'ы, мы разгружаем отображения и модели, так как убрали из них препроцессинг данных.

Query object

Переходим к последнему на сегодня паттерну, query object — объект-запрос. Он формирует сложные запросы к базе данных.

Библиотека active record, с помощью которой мы создаём модели в rails-приложениях, очень умная. Она хорошо конструирует сложные sql-запросы, но иногда накопленного кода не хватает, элементы бизнес-логики требуют ещё более сложных запросов, чем доступны в active record «из коробки». Тогда мы используем query object'ы. Они получают данные HTTP-запроса и на выходе формируют сложные sql-запросы, или из сложных предварительных запросов делают простые окончательные. Данные, которые они получают, отправляются в модель, интерактор или другой компонент бизнес-логики.

И снова пример:

Короткое summary

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

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

То, что мы изучили сегодня, является архитектурным ядром Ruby on Rails. Фреймворк Ruby on Rails построен на основе архитектурного паттерна MVC. Важными архитектурными компонентами rails-приложения являются: модели, которые хранят данные и управляют их консистентностью, view или отображения, которые представляют эти данные пользователю и контроллеры, которые являются посредниками между моделями и view с одной стороны, и пользователями и моделями с другой.

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

Спасибо за внимание, не стесняйтесь задавать вопросы — мы обязательно ответим.

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


  1. mc2
    29.10.2022 07:00

    Вопрос пока лишь один: где же первая часть?


    1. tin_vsl
      29.10.2022 15:57