|
Professor Seleznov
|
Вечный спор в среде MVC-фреймворков - что лучше? Толстые модели и тонкие контроллеры или наоборот? Классический подход Rails — “Fat Model, Skinny Controller”. Но что происходит, когда ваша модель User разрастается до 800 строк кода, содержит 15 валидаций, 10 коллбеков и 30 методов бизнес-логики? Тестировать это становится кошмаром, а понять что и когда вызывается — квестом для детектива. Сегодня мы рассмотрим альтернативный вариант — тонкие контроллеры и… тонкие модели! В основе нашего эксперимента будет стоять первое правило SOLID — Single Responsibility Principle (у класса должна быть только одна причина для изменения). Как правило, модель ответственна за обращения к БД, валидации, коллбеки, пользовательскую бизнес-логику сущностей и так далее. Теперь модель будет отвечать только за связь с БД. Таким же образом мы снимем лишнюю ответственность с контроллеров, раскидаем всё по классам, чтобы код стал читабельнее, красивее и проще в поддержке. Что не так с классическим подходом? Давайте посмотрим на типичную модель в Rails-проекте:
class User < ApplicationRecord validates :email, presence: true, uniqueness: true, format: { with: EMAIL_REGEX } validates :password, length: { minimum: 8 } # ... ещё 15 валидаций before_validation :normalize_email after_create :send_welcome_email after_create :create_profile # ... ещё 10 коллбеков scope :active, -> { where(active: true) } scope :premium, -> { where(plan: 'premium') } # ... ещё 18 scope'ов def upgrade_to_premium! # 50 строк кода end def calculate_discount # 30 строк кода end # ... ещё 28 методов end
Проблемы такого подхода:
- Модель на 800+ строк — никто не понимает что там происходит
- Коллбеки срабатывают везде — даже когда не нужно
- Тесты медленные — приходится поднимать всю ActiveRecord
- Переиспользовать логику сложно — всё завязано на модель
- Рефакторинг — страшный сон (изменил одно — сломалось в 10 местах)
Как избежать этих проблем? Давайте рассмотрим пример, в котором мы делаем вывод списка товаров и их создание, а далее по шагам поймём, какие есть проблемы и как их решить, используя паттерны.
# Route # config/routes.rb Rails.application.routes.draw do resources :products end # Контроллер # app/controllers/products_controller.rb class ProductsController < ApplicationController def index @products = Product.all @products = @products.where('name LIKE ?', "%#{search_params[:name]}%") if search_params[:name].present? @products = @products.where('amount > ?', search_params[:min_amount]) if search_params[:min_amount].present? @products = @products.where('amount < ?', search_params[:max_amount]) if search_params[:max_amount].present? @products = @products.order('amount DESC') end def new @product = Product.new end def create @product = Product.create(create_params) redirect_to products_path end private def search_params params.permit(:name, :min_amount, :max_amount) end def create_params params.require(:product).permit(:name, :amount) end end # Модель # app/models/product.rb class Product < ApplicationRecord validates :name, presence: true, length: { maximum: 20 } validates :amount, presence: true, numericality: true before_validation -> do # transliterate - вымышленный метод для класса String, который я добавил для примера self.code = name.transliterate end end # Schema ActiveRecord::Schema[8.0].define(version: 2025_05_25_000000) do create_table "products", force: :cascade do |t| t.string "name" t.string "code" t.integer "amount" t.datetime "created_at", null: false t.datetime "updated_at", null: false end end
<!-- views/products/index.html.erb --> <%= link_to('Добавить', new_product_path) %> <% @products.each do |product| %> <p> <%= product.name %> (<%= product.amount %>руб) </p> <% end %> <!-- views/products/new.html.erb --> <%= form_with model: @product do |f| %> <%= f.text_field :name %> <%= f.number_field :amount %> <%= f.submit %> <% end %>
-Первая проблема, с которой мы сталкиваемся — контроллер несёт дополнительную ответственность в виде модерации входящих параметров с помощью ActionController::Parameters и require/permit. Глобально это хорошая защита, которую нам даёт Rails, но с увеличением количества методов увеличивается количество проверок, и всё это лежит в самом контроллере. Вторая проблема — мы не можем точно гарантировать, какие параметры нам придут в контроллер, с каким значением и типом. Например, min_amount может прийти как строка, а может не прийти вовсе. У нас достаточно простой пример, в котором это не критично, но чтобы потом не натолкнуться на undefined method for an instance of Integer в будущем, лучше сразу подготовиться и использовать следующий паттерн: Паттерн ActionParams Цель: более тонкий контроль параметров контроллера. Реализация:
# app/core/action_params/products/index.rb module ActionParams module Products class Index def call(params) { name: params[:name].presence.try(:to_s), min_amount: params[:min_amount].presence.try(:to_i), max_amount: params[:max_amount].presence.try(:to_i) } end end end end # app/core/action_params/products/create.rb module ActionParams module Products class Create def call(params) { name: params[:name].presence.try(:to_s), amount: params[:amount].presence.try(:to_i), } end end end end
Важно! Для наших новых классов мы создали папку app/core. Внутри этой директории мы создаём папки по названию паттернов. В данном случае это action_params. Мы это делаем для удобства вызова классов, чтобы первый модуль всегда был по названию паттерна.
Использование:
class ProductsController < ApplicationController def index action_params = ::ActionParams::Products::Index.new.call(params) @products = Product.all @products = @products.where('name LIKE ?', "%#{action_params[:name]}%") if action_params[:name].present? @products = @products.where('amount > ?', action_params[:min_amount]) if action_params[:min_amount].present? @products = @products.where('amount < ?', action_params[:max_amount]) if action_params[:max_amount].present? @products = @products.order('amount DESC') end def new @product = Product.new end def create action_params = ::ActionParams::Products::Create.new.call(params[:product]) @product = Product.create(action_params) redirect_to products_path end end
Преимущества:
- Не создаём в контроллере дополнительные проверки параметров require/permit;
- Мы точно понимаем, какие параметры будут использоваться дальше в коде;
- Мы точно понимаем, какого типа будут параметры. Например, name будет либо nil, либо string и никак иначе.
- Легко тестировать — это обычный Ruby-класс без привязки к Rails.
-Следующая проблема, с которой мы сталкиваемся — большое количество фильтраций модели Product внутри контроллера, которые мы также вынесем в отдельный класс. Паттерн Query Цель: инкапсуляция запросов в базу данных Реализация
# app/core/queries/products/list.rb module Queries module Products class List def call(params) products = Product.all products = name_filter(products, params[:name]) products = amount_range(products, params[:min_amount], params[:max_amount]) products = sorting(products) products end private def name_filter(relation, name) return relation if name.blank? relation.where('name LIKE ?', "%#{name}%") end def amount_range(relation, min, max) relation = relation.where('amount > ?', min) if min.present? relation = relation.where('amount < ?', max) if max.present? relation end def sorting(relation) relation.order('amount DESC') end end end end
Использование
class ProductsController < ApplicationController def index action_params = ::ActionParams::Products::Index.new.call(params) @products = Queries::Products::List.new.call(action_params) end end
Преимущества
- При большом количестве фильтраций контроллер разрастётся как муравейник, и его будет неудобно поддерживать. Теперь мы видим только одну строчку вызова Query.
- Класс Query можно повторно использовать. Например, товары можно отфильтровать на детальной странице для раздела “Рекомендации”.
- Инкапсуляция логики Active Record. Внутри Query не обязательно использовать логику Rails. Мы можем обращаться к Redis, json-файлу на диске и всё что угодно, главное, чтобы на входе были параметры фильтрации, а на выходе — объекты модели или другие структуры данных.
-С index-ом разобрались, далее create. Что тут не так? Вроде одна строчка кода и ничего сложного? Но проблема в том, что часть логики лежит в модели. Сейчас она отвечает за валидацию и транслитерацию кода. Соответственно, при увеличении количества валидаций и дополнительных обработок данных модель будет разрастаться, и её станет тяжело поддерживать. Как вы можете уже догадаться, валидацию мы вынесем в отдельный класс. Паттерн Validation Цель: инкапсуляция валидации входящих данных перед сохранением Реализация
# app/core/validators/base.rb module Validators class Base include ActiveModel::Validations include ActiveModel::AttributeAssignment def initialize(params) assign_attributes(params || {}) end def call raise ActiveRecord::RecordInvalid.new(self) unless valid? end end end # app/core/validators/products/record.rb module Validators module Products class Record < Base attr_accessor :name, :amount validates :name, presence: true, length: { maximum: 20 } validates :amount, presence: true, numericality: true end end end
Обратитете внимание на класс base.rb. Мы сюда вынесли общую логику, чтобы её не прописывать каждый раз для каждого валидатора. Что делает этот класс:
- Наследуется от Rails-валидатора
- В initialize делает ассоциацию параметров класса и параметров, которые пришли в валидатор. Обратитете внимание, что они должны совпадать.
- В call вызываем ошибку, если параметры не валидны.
Что мы описываем в основном классе валидации:
- В attr_accessor перечисляем параметры, которые придут в валидатор и которые мы будем проверять. Именно эти параметры проассоциируются методом assign_attributes
- Дальше, как мы и привыкли в модели, описываем наши валидации. Можно просто перенести из модели как они есть.
Использование
class ProductsController < ApplicationController def create action_params = ::ActionParams::Products::Create.new.call(params[:product]) ::Validators::Products::Record.new(action_params).call @product = Product.create(action_params) end end
Обратитете внимание, что если в валидатор передать ключи, которых нет в attr_accessor, то метод assign_attributes упадёт с ошибкой. Но мы эту проблему избегаем, так как в ActionParams собираем именно те ключи, которые необходимо отдать в валидатор. Преимущества
- Вынесли валидации из модели
- Можно уйти от валидаций, предоставляемых Rails, написать собственные проверки и кастомизировать валидации.
- Можно проверять не только значения полей модели, но и любые другие параметры.
-И последняя проблема, которую мы можем заметить — создание товара в контроллере и обработка коллбека в самой модели. Тут всё решается очень просто: любую бизнес-логику можно вынести в сервис. Паттерн Service Цель: инкапсуляция бизнес-логики Реализация
# app/core/services/products/create.rb module Services module Products class Create def call Product.create!(prepare_params(params)) end private def prepare_params(params) params[:code] = params[:name].transliterate end end end end
Использование
class ProductsController < ApplicationController def index action_params = ::ActionParams::Products::Index.new.call(params) @products = Queries::Products::List.new.call(action_params) end def new @product = Product.new end def create action_params = ::ActionParams::Products::Create.new.call(params[:product]) ::Validators::Products::Record.new(action_params).call @product = Services::Products::Create.call(action_params) redirect_to products_path end end
Преимущества
- Вся бизнес-логика в одном месте. Коллбеки поддерживать очень сложно — при каждом взаимодействии с моделью в коде нужно идти в саму модель и смотреть, есть ли коллбеки. Теперь мы всю логику реализуем в одном месте. И если коллбек не нужен в другом контексте, то мы его не используем.
- Переиспользование логики. Сервис можно вызвать из разных мест приложения: из контроллера, фоновой задачи, rake-таски, консоли.
- Простота тестирования. Сервис — это обычный Ruby-класс, который легко покрыть unit-тестами.
- Гибкость. Можно легко добавить дополнительную логику, например, интеграцию с внешними API, отправку уведомлений, логирование и т.д.
- Итоговый результат Теперь давайте посмотрим, как выглядит наш код после всего рефакторинга: Модель
# app/models/product.rb class Product < ApplicationRecord # Модель отвечает только за связь с БД # Никаких валидаций, коллбеков и бизнес-логики end
Контроллер
# app/controllers/products_controller.rb class ProductsController < ApplicationController def index action_params = ::ActionParams::Products::Index.new.call(params) @products = Queries::Products::List.new.call(action_params) end def new @product = Product.new end def create action_params = ::ActionParams::Products::Create.new.call(params[:product]) ::Validators::Products::Record.new(action_params).call @product = Services::Products::Create.call(action_params) redirect_to products_path end end
Структура директорий
app/ ├── controllers/ │ └── products_controller.rb ├── models/ │ └── product.rb └── core/ ├── action_params/ │ └── products/ │ ├── index.rb │ └── create.rb ├── queries/ │ └── products/ │ └── list.rb ├── validators/ │ ├── base.rb │ └── products/ │ └── record.rb └── services/ ├── base.rb └── products/ └── create.rb
- Заключение Мы рассмотрели подход к построению Rails-приложения с использованием паттернов проектирования, который позволяет держать и контроллеры, и модели тонкими. Что мы получили:
- Разделение ответственности — каждый класс выполняет только одну задачу согласно принципу Single Responsibility из SOLID.
- Читабельность кода — при взгляде на контроллер сразу понятно, что происходит: парсинг параметров → валидация → выполнение бизнес-логики.
- Переиспользование — Query, Validator и Service можно использовать в разных местах приложения.
- Простота тестирования — каждый класс легко покрыть тестами изолированно от других компонентов.
- Масштабируемость — при росте приложения не нужно бороться с “жирными” моделями на тысячи строк кода.
- Гибкость — легко добавлять новую логику без риска сломать существующую функциональность.
Когда использовать этот подход:
- В средних и крупных приложениях, где важна долгосрочная поддержка кода
- Когда над проектом работает команда разработчиков
- Когда бизнес-логика приложения сложная и разнообразная
- Когда нужна высокая тестовая покрытость
Когда можно обойтись без паттернов:
- В маленьких pet-проектах или MVP
- В простых CRUD-приложениях без сложной бизнес-логики
- Когда скорость разработки важнее архитектуры
-Важно помнить, что паттерны — это инструменты, а не догма. Используйте их разумно и по мере необходимости. Не стоит усложнять простой код ради следования паттернам, но и не стоит бояться рефакторинга, когда код начинает разрастаться. Главное правило: если вы не понимаете, что делает код без отладчика — пора рефакторить. Надеюсь, эта статья поможет вам сделать ваши Rails-приложения чище, поддерживаемее и приятнее в разработке!-P.S. Если у вас остались вопросы или вы хотите поделиться своим опытом внедрения паттернов — пишите в комментариях. Давайте вместе делать Ruby-сообщество лучше! 🚀-Источник
|