Привет! Меня зовут Вероника Молчанова. Я - Ruby-разработчик в компании Joy Dev. В этой статье мы рассмотрим набор Ruby-библиотек dry-rb и расскажем, как они помогли упростить архитектуру проектов. В частности хотим показать, как на наших проектах используются такие гемы из dry-экосистемы, как:
dry-auto_inject
dry-validation
dry-struct
Don’t repeat yourself
Один из принципов Ruby on Rails заключается в использовании механизмов, минимизирующих дублирование кода в приложениях - принцип Don’t repeat yourself, DRY. Принцип DRY позволяет построить модульную архитектуру приложения и чётко разделить ответственность за бизнес-логику между программными классами. Однако следовать этому принципу в рамках больших проектов не так просто, как это может показаться на первый взгляд.
На собственном опыте мы убедились, что dry-rb хорошо справляется с задачей декомпозиции сложных операций на более простые и значительно облегчает понимание программного кода.
В dry-rb входят более 20 гемов, которые помогают реализовать различные задачи. Более подробно с каждым из них вы можете ознакомиться в официальной документации: https://dry-rb.org/
В этой статье хотим выделить наиболее практичные инструменты, которые мы внедрили в проекты и используем на постоянной основе.
DI (Dependency Injection, внедрение зависимостей) — это эффективная идея, благодаря которой возможно обрабатывать зависимости вне зависимого класса. Внедрение зависимостей — это то, что позволяет комбинировать небольшие компоненты для создания более сложного поведения.
С использованием гема dry-auto_inject процесс упрощается. Вместо создания конструкторов или методов доступа необходимо всего лишь добавить дополнительные зависимости.
Для того, чтобы использовать dry-auto_inject, после установки гема в директории config/initializers/auto_inject.rb создаём класс-контейнер и настраиваем модуль DI:
require_relative 'containers/forms'
require_relative 'containers/queries'
require_relative 'containers/serializers'
require_relative 'containers/services'
require_relative 'containers/helpers'
require_relative 'containers/presenters'
class DryContainer
extend Dry::Container::Mixin
import FormsContainer
import QueriesContainer
import SerializersContainer
import ServicesContainer
import HelpersContainer
import PresentersContainer
end
DI = Dry::AutoInject(DryContainer)
Этот класс включает в себя контейнеры из различных модулей приложения. Для удобства мы их распределили в отдельные файлы.
Регистрируем Query-объекты под определенным пространством имён:
QueriesContainer = Dry::Container::Namespace.new('queries') do
namespace(:admin) do
namespace(:users) do
register(:index) { Admin::Users::Index.new }
register(:show) { Admin::Users::Show.new }
end
namespace(:feeds) do
register(:index) { Admin::Feeds::Index.new }
register(:show) { Admin::Feeds::Show.new }
end
namespace(:articles) do
register(:index) { Admin::Articles::Index.new }
register(:show) { Admin::Articles::Show.new }
end
end
end
После того, как все контейнеры описаны, подключаем настроенный модуль DI для класса, использующего зависимости. Например:
module Api
module Admin
class UsersController < AdminController
include DI[
users_list_serializer: 'serializers.admin.users.list',
serializer: 'serializers.admin.users.single',
index_query: 'queries.admin.users.index',
show_query: 'queries.admin.users.show',
index_form: 'forms.admin.users.index',
update_form: 'forms.admin.users.update',
destroy_form: 'forms.admin.users.destroy',
destroy_service: 'services.admin.users.destroy',
update_service: 'services.admin.users.update',
show_presenter: 'presenters.users.show',
]
Модуль, подключаемый с помощью include DI[*dependencies], определяет из контейнера зависимость и позволяет использовать её под указанным именем.
Предварительно создав контейнер, мы можем зарегистрировать объекты один раз и в дальнейшем многократно использовать их в различных частях приложения.
Кроме того, имеется возможность простого обновления списка зависимостей.
Фреймворк Ruby on Rails предоставляет стандартные методы для валидации входных данных в моделях. Однако если требуются дополнительные проверки или список параметров варьируется, то класс модели будет нечитабелен из-за огромного количества кода для валидации.
Альтернативой может служить гем dry-экосистемы dry-validation.
С помощью этого гема мы можем вынести логику валидации в отдельные формы или контракты, которые состоят из параметров, описывающих условия проверки каждого из них, и кастомных правил.
module Users
class CreateForm < ApplicationForm
params do
required(:gender).value(:string, included_in?: User.genders)
required(:name).value(:string)
required(:birthday).value(:date)
required(:country).value(:string)
required(:city).value(:string)
optional(:city_description).value(:string)
optional(:image_ids).value(:array).each(:integer, gteq?: 1)
required(:main_image_id).value(:integer, gteq?: 1)
required(:avatar_image_id).value(:integer, gteq?: 1)
optional(:referal_code).maybe(:string, min_size?: 6, max_size?: 6)
end
rule(:birthday).validate(:valid_birthday)
end
end
Правила можно описывать в самой форме, но на практике оказалось удобнее выносить их в отдельные макросы. Так мы можем определить правило один раз и использовать его в нескольких формах без дублирования кода.
module Macros
module BirthdayMacros
extend ActiveSupport::Concern
included do
register_macro(:valid_birthday) do |_macro:|
key.failure(I18n.t('api.errors.messages.wrong_birthday')) if value.present? && value > Date.today
key.failure(I18n.t('api.errors.messages.min_birthday')) if value.present? && ((Date.today.to_time - value.to_time) / 1.year).floor < 18
end
end
end
end
В родительском классе ApplicationForm описываем конфигурацию для локализации ошибок и включаем все используемые макросы:
class ApplicationForm < Dry::Validation::Contract
config.messages.backend = :i18n
include Macros::CodeMacros
include Macros::DateMacros
include Macros::BirthdayMacros
include Macros::PhoneNumberMacros
include Macros::SubcategoryMacros
include Macros::DateRangeMacros
include Macros::PaginateMessagesMacros
include Macros::ImageFileMacros
include Macros::CategoryTitleMacros
include Macros::ImageRelationMacros
include Macros::MessageLocalIdMacros
include Macros::HobbyNameMacros
include Macros::UniqueMacros
include Macros::LastNameMacros
end
Чтобы валидация параметров была удобней, мы прописали concern для контроллеров с помощью форм.
module DryValidatable
extend ActiveSupport::Concern
def validate_with(form, params, key = nil)
params = params.to_unsafe_h unless params.is_a?(Hash)
validation_result = form.new.(params[key] || params)
raise Api::UnprocessableEntityException.new(validation_result.errors.to_h) unless validation_result.success?
validation_result.to_h
end
end
Метод validate_with проверяет, вернула ли форма ошибки. В случае успешной валидации он возвращает хэш с проверенными параметрами. Вызов этого метода в контроллере выглядит следующим образом:
valid_params = validate_with(create_form, params)
В данном случае create_form - это зависимость формы, подключение которой мы рассмотрели ранее с помощью dry-auto_inject.
Использование гема dry-validation обеспечит эффективную настройку среды для валидации входных параметров без дополнительной нагрузки классов-моделей.
Бывают ситуации, когда необходимо указывать в методе большое количество передаваемых параметров. Ruby позволяет использовать параметр **kwargs, который передаёт аргументы переменной длины с ключевыми словами.
Однако если вы работаете на крупном проекте, при использовании **kwargs сложно отследить, какие параметры необходимы тому или иному методу.
В этом случае на помощь приходит гем dry-struct.
module Profiles
class UpdateStruct < Dry::Struct
attribute :name, Types::String.optional.default(nil)
attribute :last_name, Types::String.optional.default(nil)
attribute :phone_number, Types::Integer.optional.default(nil)
attribute :password, Types::String.optional.default(nil)
attribute :email, Types::String.optional.default(nil)
attribute :points, Types::Integer.optional.default(nil)
attribute :vip_period, Types::Integer.optional.default(nil)
attribute :age, Types::Integer.optional.default(nil)
attribute :city, Types::String.optional.default(nil)
attribute :city_description, Types::String.optional.default(nil)
attribute :country, Types::String.optional.default(nil)
attribute :birthday, Types::Params::Date.optional.default(nil)
attribute :status_phrase, Types::String.optional.default(nil)
attribute :scope_of_activity, Types::String.optional.default(nil)
attribute :profession, Types::String.optional.default(nil)
attribute :is_smoking, Types::String.optional.default(nil)
attribute :is_drinking, Types::Bool.optional.default(nil)
attribute :chronotype, Types::String.optional.default(nil)
attribute :personality_type, Types::String.optional.default(nil)
attribute :gender, Types::String.optional.default(nil)
attribute :hobbies_ids, Types::Array.optional.default([].freeze)
attribute :image_ids, Types::Array.optional.default([].freeze)
attribute :main_image_id, Types::Integer.optional.default(nil)
attribute :avatar_image_id, Types::Integer.optional.default(nil)
attribute :role, Types::String.optional.default('user')
end
end
В структуре описываются все необходимые атрибуты, их типы и значения по умолчанию. Стоит отметить, что dry-struct не имеет возможности валидировать параметры, как это делает dry-validation, поэтому этот гем стоит использовать только для структурирования данных.
Инициализация структуры выглядит следующим образом:
def update
valid_params = validate_with(update_form, params)
params_object = ::Profiles::UpdateStruct.new(valid_params)
update_service.(current_user, params_object)
end
Создаём структуру с параметрами, первоначально проверенными формой. И затем передаём эту структуру в необходимый метод.
Таким образом, используя объект структуры в качестве параметра методов, мы избегаем длинных списков параметров и добиваемся большей читабельности кода.
В этой статье мы описали далеко не все преимущества dry-экосистемы. Нашей целью было показать вам, что с помощью гемов можно реализовать архитектуру, удобную в работе над крупными проектами.
Валидация, структурирование, внедрение зависимостей - это возможности, которые вы легко и просто настраиваются благодаря инструментам, предоставляемым dry-rb.
ZloyHobbit
Для полнноты картины хорошо было бы описать, как аналогичного результата можно добиться без Dry билибиотек. Например, с учетом того что Ruby позволяет передавать класс как атрибут в любой метод, dependency injections реализуется в лоб без каких либо проблем и сторонних библиотек.
А еще упомянуть разные хитрости реализации. Например то, что dry-validation использует dry-schema, которая сохраняет ошибки в переменую класса, и в результате можно получить ошибки одной валидации в совершенно другом месте, создав соверешенно новый класс с той же схемой.
VeronikaMolchanova Автор
Спасибо за Ваш комментарий! В данной статье хотели поделиться именно нашим опытом работы с dry-rb. Надеюсь, статья была полезной для Вас!