📖 Ruby on Rails Toàn tập - Background Jobs và Action Mailer
60 phút

Background Jobs và Action Mailer

Active Job

Tạo Job

rails g job SendWelcomeEmail
# app/jobs/send_welcome_email_job.rb
class SendWelcomeEmailJob < ApplicationJob
  queue_as :default

  retry_on StandardError, wait: :exponentially_longer, attempts: 3

  def perform(user_id)
    user = User.find(user_id)
    UserMailer.welcome(user).deliver_now
  end
end

Enqueue Job

SendWelcomeEmailJob.perform_later(user.id)
SendWelcomeEmailJob.set(wait: 1.hour).perform_later(user.id)
SendWelcomeEmailJob.set(wait_until: Date.tomorrow.noon).perform_later(user.id)
SendWelcomeEmailJob.set(queue: :high_priority).perform_later(user.id)

Job với arguments

class ProcessOrderJob < ApplicationJob
  queue_as :orders

  discard_on ActiveRecord::RecordNotFound

  def perform(order)
    # ActiveRecord objects được serialize tự động
    order.process!
  end
end

ProcessOrderJob.perform_later(order)

Action Mailer

Generate Mailer

rails g mailer UserMailer welcome password_reset

Mailer Class

# app/mailers/user_mailer.rb
class UserMailer < ApplicationMailer
  default from: 'noreply@myapp.com'

  def welcome(user)
    @user = user
    @login_url = login_url

    mail(
      to: @user.email,
      subject: "Welcome to MyApp!"
    )
  end

  def password_reset(user)
    @user = user
    @reset_url = edit_password_reset_url(user.reset_token)

    mail(to: @user.email, subject: "Reset your password")
  end
end

Views

<!-- app/views/user_mailer/welcome.html.erb -->
<h1>Welcome, <%= @user.name %>!</h1>

<p>Thanks for signing up. We're excited to have you.</p>

<p><%= link_to "Log in here", @login_url %></p>

<p>Best regards,<br>The MyApp Team</p>

<!-- app/views/user_mailer/welcome.text.erb -->
Welcome, <%= @user.name %>!

Thanks for signing up. Log in here: <%= @login_url %>

Best regards,
The MyApp Team

Application Mailer Layout

<!-- app/views/layouts/mailer.html.erb -->
<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
    <style>
      body { font-family: Arial, sans-serif; }
      .header { background: #333; color: white; padding: 20px; }
      .content { padding: 20px; }
    </style>
  </head>
  <body>
    <div class="header">
      <h1>MyApp</h1>
    </div>
    <div class="content">
      <%= yield %>
    </div>
  </body>
</html>

Configuration

SMTP cho development (Mailcatcher/Mailhog)

# config/environments/development.rb
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address: 'localhost',
  port: 1025
}
config.action_mailer.default_url_options = { host: 'localhost', port: 3000 }

Production (Postmark, SendGrid, SES)

# config/environments/production.rb
config.action_mailer.delivery_method = :postmark
config.action_mailer.postmark_settings = {
  api_token: Rails.application.credentials.postmark_api_token
}
config.action_mailer.default_url_options = { host: 'myapp.com' }

Job Backends

Sidekiq (Redis)

# Gemfile
gem 'sidekiq'
gem 'sidekiq-cron'

# config/application.rb
config.active_job.queue_adapter = :sidekiq
# config/sidekiq.yml
:concurrency: 5
:queues:
  - [critical, 3]
  - [default, 2]
  - [low, 1]

Solid Queue (Rails 8 default)

config.active_job.queue_adapter = :solid_queue
bin/rails solid_queue:start

Recurring Jobs

sidekiq-cron

# config/initializers/sidekiq.rb
schedule = {
  'daily_digest' => {
    'cron' => '0 8 * * *',
    'class' => 'DailyDigestJob',
    'queue' => 'default'
  },
  'cleanup_job' => {
    'cron' => '0 2 * * 0',
    'class' => 'CleanupJob',
    'queue' => 'low'
  }
}

Sidekiq::Cron::Job.load_from_hash(schedule)

Solid Queue recurring

# config/recurring.yml
production:
  daily_digest:
    class: DailyDigestJob
    schedule: every day at 8am

  cleanup:
    class: CleanupJob
    schedule: every sunday at 2am

Job Patterns

Batch processing

class ProcessBatchJob < ApplicationJob
  def perform(user_ids)
    User.where(id: user_ids).find_each do |user|
      ProcessUserJob.perform_later(user.id)
    end
  end
end

# Enqueue
user_ids = User.pluck(:id)
ProcessBatchJob.perform_later(user_ids)

Job chaining

class ImportDataJob < ApplicationJob
  def perform(file_path)
    result = import_file(file_path)
    NotifyImportCompleteJob.perform_later(result.id)
  end
end

Idempotent jobs

class ChargeCustomerJob < ApplicationJob
  def perform(order_id)
    order = Order.find(order_id)
    return if order.paid?  # idempotency

    PaymentGateway.charge(order)
    order.mark_as_paid!
  end
end

Monitoring

Sidekiq Web UI

# config/routes.rb
require 'sidekiq/web'
mount Sidekiq::Web => '/sidekiq'

# Với basic auth
authenticate :user, ->(u) { u.admin? } do
  mount Sidekiq::Web => '/sidekiq'
end

Mailer Preview

# test/mailers/previews/user_mailer_preview.rb
class UserMailerPreview < ActionMailer::Preview
  def welcome
    UserMailer.welcome(User.first)
  end

  def password_reset
    UserMailer.password_reset(User.first)
  end
end

Truy cập /rails/mailers trong development.

Test Jobs và Mailers

# spec/jobs/send_welcome_email_job_spec.rb
require 'rails_helper'

RSpec.describe SendWelcomeEmailJob, type: :job do
  let(:user) { create(:user) }

  it 'queues the job' do
    expect {
      described_class.perform_later(user.id)
    }.to have_enqueued_job(described_class).with(user.id)
  end

  it 'sends welcome email' do
    expect {
      described_class.perform_now(user.id)
    }.to change { ActionMailer::Base.deliveries.count }.by(1)
  end

  it 'retries on error' do
    allow(UserMailer).to receive(:welcome).and_raise(StandardError)
    expect {
      described_class.perform_now(user.id)
    }.to have_enqueued_job(described_class)
  end
end

# spec/mailers/user_mailer_spec.rb
require 'rails_helper'

RSpec.describe UserMailer, type: :mailer do
  let(:user) { create(:user) }

  describe '#welcome' do
    let(:mail) { described_class.welcome(user) }

    it 'renders the subject' do
      expect(mail.subject).to eq('Welcome to MyApp!')
    end

    it 'sends to user email' do
      expect(mail.to).to eq([user.email])
    end

    it 'sends from noreply' do
      expect(mail.from).to eq(['noreply@myapp.com'])
    end

    it 'includes user name in body' do
      expect(mail.body.encoded).to include(user.name)
    end
  end
end

Bài tập thực hành

Hãy tạo welcome email job và mailer!

📝 Bài tập (1)

  1. Implement email workflow với jobs