Как 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
На этом все, вот теперь точно все!