Как mkdev сделал рефакторинг отправки писем
New parameterized mailers make it easier to share filters, defaults across actions in Rails mailers. Coming in 5.1! https://t.co/PXS63Ou9Kf
— DHH (@dhh) 28. Januar 2017
Одно интересное нововведение в Rails 5.1 – это параметризованные мейлеры, которые предоставили возможность использовать в мейлерах фильтры и параметры в значениях по умолчанию. Эти две наиполезнейшие фичи позволили нам провернуть небольшой, но очень эффективный рефакторинг, о котором и пойдет речь.
Мейлеры до рефакторинга
Есть разные мейлеры, которые не использует параметризацию. Например, NotificationsMailer, который уведомляет учеников и менторов о важных событиях: состоянии подписки, оплате и т.п.
# app/mailers/notifications_mailer.rb
class NotificationsMailer < ApplicationMailer
def student_inactive_notice(user)
@user = user
I18n.locale = @user.locale
mail subject: default_i18n_subject,
to: @user.email
end
def new_payment_for_mentor(user, amount)
@user = user
@amount = amount
I18n.locale = @user.mentor.locale
mail subject: t('.subject'),
to: @user.mentor.email
end
# ...
end
Для шаблона каждого метода есть две локализации (русская и английская). С помощью I18n.locale в теле любого из методов задается язык письма, а также вызывается метод mail(headers = {}, &block). В последний передаются разные аргументы, для большинства из которых есть значения по-умолчанию. В примере выше из заголовков мы используем: :subject - тема письма, :to - адрес электронной почты получателя, :from - адрес электронной почты отправителя. Для указания темы письма в большинстве мейлерах используется метод default_i18n_subject(interpolations = {}) -- это тоже самое, что вызвать t('.subject').
Все классы-мейлеры наследуются от ApplicationMailer, который сыграет ключевую роль в статье. До рефакторинга он выглядел вот так:
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
default from: "\"mkdev\" <#{ENV['SUPPORT_EMAIL']}>"
end
Также имеется модель, где происходит вызов методов экземпляра нашего NotificationsMailer. Все письма отправляются в фоне с помощью deliver_later(options = {}), тем самым используя Active Job. Если вы раньше использовали для этой же цели метод delay(options = {}), то самое время от него отказать по причине его несовместимости с параметризованными мейлерами.
# app/models/user.rb
class User < ApplicationRecord
...
class << self
def notify_inactive
students.where("last_active_at < ?", 2.days.ago).find_each do |user|
NotificationsMailer.student_inactive_notice(user).deliver_later
# ...
end
end
end
...
end
Само собой все мейлеры покрыты тестами. Они довольно однообразные и приводить их код я не буду.
Теперь приступим к безумному (поверьте (: ) рефакторингу, чтобы увидеть изящество параметризованных мейлеров!
Рефакторинг
Вынесем из NotificationsMailer заголовки from:, subject: и to:, поместив их внутри ApplicationMailer в хеш default(value = nil), опции которого по умолчанию будут доступны для всех мейлеров.
Далее определим фильтр before_action(names, block) для задания локали и получателя письма. В итоге получим бодипозитивный ApplicationMailer:
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
default from: "\"mkdev\" <#{ENV['SUPPORT_EMAIL']}>",
subject: -> { default_i18n_subject },
to: -> { @recipient.email }
before_action :load_recipient
private
def load_recipient
@recipient = params[:recipient]
I18n.locale = if @recipient.is_a?(User)
@recipient.locale
elsif @recipient.user
@recipient.user.locale
else
:en
end
end
end
Все, получили локаль для всех мейлеров в одном месте. Удобно? Еще и DRY.
Но что ещё круче так это то, как теперь будет выглядеть NotificationsMailer. Да и, поверьте, другие ничем не хуже!
Думаю, лишним не будет пояснить, что переменные экземпляров, определяемые из значений хеша params нужны для шаблонов.
# app/mailers/notifications_mailer.rb
class NotificationsMailer < ApplicationMailer
def student_inactive_notice
mail
end
def new_student_for_mentor
@student, @memo, @amount = params[:student], params[:memo], params[:amount]
mail
end
# ...
end
Вам не видно, но после рефакторинга код NotificationsMailer уменьшился на 35% и стал гораздо понятнее. Разных мейлеров на mkdev много, поэтому можно представить как всё стало хорошо с ними в целом.
Вернемся к коду, пришло время исправить отправку мейлеров в моделях.
Нужно всего лишь перечислить через запятую пары "ключ: значение" в методе with(params), который предоставляет параметры мейлеру.
NotificationsMailer.with(recipient: user).student_inactive_notice # создается объект ActionMailer::MessageDelivery
В итоге получаем такую модель:
# app/models/user.rb
class User < ApplicationRecord
...
class << self
def notify_inactive
students.where("last_active_at < ?", 2.days.ago).find_each do |user|
NotificationsMailer.with(recipient: user).student_inactive_notice.deliver_later
# ...
end
end
end
...
end
Вот и закончили рефакторинг - пора подвести черту.
Ранее параметры, передаваемые в Action Mailer, были доступны только в теле методов мейлера. С появлением параметризованных мейлеров эта проблема решена. Теперь мейлеры могут вызываться с параметрами для, например, извлечения общего поведения, как было продемонстрировано выше - и это прекрасно! Мейлеры становятся тоньше, более удобочитаемыми, но в то же время остаются такими же практичными.
Настройки оповещений
Также хотелось бы поделиться, что этот рефакторинг позволил с легкостью внедрить настройки (разрешить/запретить) оповещений по почте. Сейчас достаточно лишь в одном месте вызвать policy, чего невозможно было бы достигнуть без параметризованных мейлеров :) Между прочим, эти настройки мы используем не только для одного мейлера.
Сейчас покажу с помощью кода как это было реализовано.
Добавим общий метод мейлеров mail_if_subscribed(*args).
# app/mailers/application_mailer.rb
class ApplicationMailer < ActionMailer::Base
...
private
def mail_if_subscribed(*args)
mail *args if NotificationsPolicy.new(@recipient, self.class, self.action_name).notify?
end
...
end
Параметр self.class нужен, чтобы указать на каком конкретно мейлере нужно вызвать policy, в self.action_name - на каком методе мейлера. В то время как *args используется для перезаписи дефолтных заголовков, передаваемых в mail.
Теперь, переписав NotificationsMailer, получаем:
# app/mailers/notifications_mailer.rb
class NotificationsMailer < ApplicationMailer
...
def student_subscription_expires
mail_if_subscribed
end
def mentor_subscription_expires
@student = params[:student]
mail_if_subscribed
end
...
end
На этом все, вот теперь точно все!