Привет! Меня зовут Вероника Молчанова. Я - 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/

В этой статье хотим выделить наиболее практичные инструменты, которые мы внедрили в проекты и используем на постоянной основе. 

dry-auto_inject

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], определяет из контейнера зависимость и позволяет использовать её под указанным именем.

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

Кроме того, имеется возможность простого обновления списка зависимостей.

dry-validation

Фреймворк 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 обеспечит эффективную настройку среды для валидации входных параметров без дополнительной нагрузки классов-моделей.

dry-struct

Бывают ситуации, когда необходимо указывать в методе большое количество передаваемых параметров. 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.

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


  1. ZloyHobbit
    23.01.2023 15:13
    +1

    Для полнноты картины хорошо было бы описать, как аналогичного результата можно добиться без Dry билибиотек. Например, с учетом того что Ruby позволяет передавать класс как атрибут в любой метод, dependency injections реализуется в лоб без каких либо проблем и сторонних библиотек.
    А еще упомянуть разные хитрости реализации. Например то, что dry-validation использует dry-schema, которая сохраняет ошибки в переменую класса, и в результате можно получить ошибки одной валидации в совершенно другом месте, создав соверешенно новый класс с той же схемой.


    1. VeronikaMolchanova Автор
      24.01.2023 10:56

      Спасибо за Ваш комментарий! В данной статье хотели поделиться именно нашим опытом работы с dry-rb. Надеюсь, статья была полезной для Вас!


  1. read_from_left_to_right
    23.01.2023 17:01

    Интересная статья, но код лучше отфарматировать


    1. VeronikaMolchanova Автор
      24.01.2023 10:57

      Спасибо, учту в дальнейшем!