【Rails】Proc/tap/ブロック引数を活用してバッチ処理をリファクタリングする

Ruby

今回利用するサンプルコード

lib/tasks/example.rake

namespace :batch_example do
  task task_example: :environment do |task|
    # ログを標準出力する
    logger = ActiveSupport::Logger.new(STDOUT)

    # ログのフォーマッタをセットする
    logger.formatter = Logger::Formatter.new

    # タイムスタンプのフォーマットを設定する
    logger.formatter.datetime_format = '%Y-%m-%d %H:%M:%S'

    # ログにタグを出力させる
    Rails.logger = ActiveSupport::TaggedLogging.new(logger)

    # ログに出力するタグをtask.nameとする
    Rails.logger.tagged("#{task.name}") do
      Rails.logger.info("start")
      Author.all.each do |author|
        Rails.logger.info("author_id:#{author.id} is being processed")
        # 処理
      end
      Rails.logger.info("end")
    end
  end
end

実行結果

$ rails batch_example:task_example

I, [2021-07-09 12:07:16#315]  INFO -- : [batch_example:task_example] start
D, [2021-07-09 12:07:16#315] DEBUG -- : [batch_example:task_example]    (0.4ms)  SET NAMES utf8mb4,  @@SESSION.sql_mode = CONCAT(CONCAT(@@sql_mode, ',STRICT_ALL_TABLES'), ',NO_AUTO_VALUE_ON_ZERO'),  @@SESSION.sql_auto_is_null = 0, @@SESSION.wait_timeout = 2147483
D, [2021-07-09 12:07:16#315] DEBUG -- : [batch_example:task_example]   ↳ lib/tasks/example.rake:118:in `block (3 levels) in <main>'
D, [2021-07-09 12:07:16#315] DEBUG -- : [batch_example:task_example]   Author Load (0.5ms)  SELECT `authors`.* FROM `authors`
D, [2021-07-09 12:07:16#315] DEBUG -- : [batch_example:task_example]   ↳ lib/tasks/example.rake:118:in `block (3 levels) in <main>'
I, [2021-07-09 12:07:16#315]  INFO -- : [batch_example:task_example] author_id:1 is being processed
I, [2021-07-09 12:07:16#315]  INFO -- : [batch_example:task_example] author_id:2 is being processed
I, [2021-07-09 12:07:16#315]  INFO -- : [batch_example:task_example] author_id:3 is being processed
I, [2021-07-09 12:07:16#315]  INFO -- : [batch_example:task_example] author_id:4 is being processed
I, [2021-07-09 12:07:16#315]  INFO -- : [batch_example:task_example] author_id:5 is being processed
I, [2021-07-09 12:07:16#315]  INFO -- : [batch_example:task_example] author_id:6 is being processed
I, [2021-07-09 12:07:16#315]  INFO -- : [batch_example:task_example] end

サンプルとなるrakeタスクではログの設定を行ったあと、ブロック内でバッチ処理を行っています。

以下では上記のコードをリファクタリングする手順について紹介します。

ブロック引数を利用してバッチのコードとログの設定準備を別メソッドに分ける

バッチのコードとログ出力に関する設定が手続き的に書かれているのでまずはそれぞれ別のメソッドに分けるところから始めます。

ブロックをブロック引数として渡し、メソッド内でブロック引数を利用する場合

ブロック引数を利用することで、以下のように書き換えられます。

lib/tasks/example.rake

def batch_logger(task_name, &block)
  logger = ActiveSupport::Logger.new(STDOUT)
  logger.formatter = Logger::Formatter.new
  logger.formatter.datetime_format = '%Y-%m-%d %H:%M:%S'
  Rails.logger = ActiveSupport::TaggedLogging.new(logger)

  Rails.logger.tagged("#{task_name}") do
    Rails.logger.info("start")

    # ブロックを呼び出してバッチ処理を実行
    block.call

    Rails.logger.info("end")
  end
end

namespace :batch_example do
  task task_example: :environment do |task|
    batch_logger(task.name) do

      # ここのブロックは batch_loggerのblock.callで実行される
      Author.all.each do |author|
        Rails.logger.info("author_id:#{author.id} is being processed")
        # 処理
      end

    end
  end
end

block.callの代わりにyieldを利用すると以下のようになります。yieldを利用した場合、ブロック引数(&block)は省略できます。

lib/tasks/example.rake

def batch_logger(task_name)
  logger = ActiveSupport::Logger.new(STDOUT)
  logger.formatter = Logger::Formatter.new
  logger.formatter.datetime_format = '%Y-%m-%d %H:%M:%S'
  Rails.logger = ActiveSupport::TaggedLogging.new(logger)

  Rails.logger.tagged("#{task_name}") do
    Rails.logger.info("start")
    yield
    Rails.logger.info("end")
  end
end

namespace :batch_example do
  task task_example: :environment do |task|
    batch_logger(task.name) do
      Author.all.each do |author|
        Rails.logger.info("author_id:#{author.id} is being processed")
        # 処理
      end
    end
  end
end

Procオブジェクトを利用する場合

ブロック引数を利用するメソッドはProcオブジェクトでも代替可能です。
Procオブジェクトを利用してバッチのコードとログの設定準備を別メソッドに分ける場合は以下のようになります。

lib/tasks/example.rake

# 『lamda do...end』でProcオブジェクトを作成する
batch_logger = lambda do |task_name, &block|
  logger = ActiveSupport::Logger.new(STDOUT)
  logger.formatter = Logger::Formatter.new
  logger.formatter.datetime_format = '%Y-%m-%d %H:%M:%S'
  Rails.logger = ActiveSupport::TaggedLogging.new(logger)

  Rails.logger.tagged("#{task_name}") do
    Rails.logger.info("start")
    block.call
    Rails.logger.info("end")
  end
end

namespace :batch_example do
  task task_example: :environment do |task|

    # callメソッドでProcオブジェクトを実行する
    batch_logger.call(task.name) do

      Author.all.each do |author|
        Rails.logger.info("author_id:#{author.id} is being processed")
        # 処理
      end
    end
  end
end

今回はlambdaを利用してProcオブジェクトを作成していますが、ほかの方法でProcオブジェクトを作成しても問題ありません。
Procオブジェクトの作成・実行方法の詳細解説は【Ruby】Procオブジェクトの作成・実行方法まとめで紹介しています。

ログの設定を管理する独自Loggerクラスを作成する

rakeタスクに実装されているログの設定を別クラスで管理することで、バッチ処理のコードを簡潔にします。

既存のコードを独自Loggerクラスに移動する

ログ設定を管理するクラスを新しく作成・利用する場合、以下のようになります。

lib/my_logger.rb

class MyLogger
  def self.logger
    @logger = ActiveSupport::Logger.new(STDOUT)
    @logger.formatter = Logger::Formatter.new
    @logger.formatter.datetime_format = '%Y-%m-%d %H:%M:%S'
    @logger
  end
end

lib/tasks/example.rake

require 'my_logger'

batch_logger = lambda do |task_name, &block|
  # MyLogger.loggerでログの設定をlogger変数にセットする
  logger = MyLogger.logger

  Rails.logger = ActiveSupport::TaggedLogging.new(logger)
  Rails.logger.tagged("#{task_name}") do
    Rails.logger.info("start")
    block.call
    Rails.logger.info("end")
  end
end

クラスメソッドをtapで書き換える

インスタンスの生成とインスタンスメソッドの実行をまとめて行う場合、tapを利用することで簡潔に書き換えられます。

今回作成したクラスメソッドをtapで書き換えると以下のようになります。

lib/my_logger.rb

class MyLogger
  def self.logger
    @logger ||= ActiveSupport::Logger.new(STDOUT).tap do |logger|
      logger.formatter = Logger::Formatter.new
      logger.formatter.datetime_format = '%Y-%m-%d %H:%M:%S'
    end
  end
end

なお、tap以外にもProcオブジェクトやbegin式でも書き換え可能です。
tap、Procオブジェクト、begin式の違いについてはProc/begin式/tapによるインスタンス生成方法の比較で紹介しています。

ログの設定準備を別メソッドに分ける

複数のバッチで共通利用できるようにするため、ログの設定を別メソッドで定義します。

新たにメソッドを定義する場合

ログ設定用のメソッドを定義する場合、以下のようになります。

lib/tasks/example.rake

def set_logger
  logger = MyLogger.logger
  Rails.logger = ActiveSupport::TaggedLogging.new(logger)
end

def batch_logger(task_name, &block)
  # ログの設定をするset_loggerメソッドを作成
  set_logger

  Rails.logger.tagged("#{task_name}") do
    Rails.logger.info("start")
    block.call
    Rails.logger.info("end")
  end
end

Procオブジェクトを利用する場合

メソッドの定義と実行はProcオブジェクトの生成と呼び出しでも表現可能です。
Procオブジェクトを利用した場合、以下のようになります。

lib/tasks/example.rake

set_logger = lambda do
  logger = MyLogger.logger
  Rails.logger = ActiveSupport::TaggedLogging.new(logger)
end

batch_logger = lambda do |task_name, &block|
  # callメソッドでProcオブジェクトを実行
  set_logger.call

  Rails.logger.tagged("#{task_name}") do
    Rails.logger.info("start")
    block.call
    Rails.logger.info("end")
  end
end

最終的なコード

最終的なコードは以下の通りです。

lib/my_logger.rb

class MyLogger
  def self.logger
    @logger ||= ActiveSupport::Logger.new(STDOUT).tap do |logger|
      logger.formatter = Logger::Formatter.new
      logger.formatter.datetime_format = '%Y-%m-%d %H:%M:%S'
    end
  end
end

lib/tasks/example.rake

require 'my_logger'

set_logger = lambda do
  logger = MyLogger.logger
  Rails.logger = ActiveSupport::TaggedLogging.new(logger)
end

batch_logger = lambda do |task_name, &block|
  set_logger.call
  Rails.logger.tagged("#{task_name}") do
    Rails.logger.info("start")
    block.call
    Rails.logger.info("end")
  end
end

namespace :batch_example do
  task task_example: :environment do |task|
    ### callメソッドでProcオブジェクトを実行する
    batch_logger.call(task.name) do
      Author.all.each do |author|
        Rails.logger.info("author_id:#{author.id} is being processed")
        # 処理
      end
    end
  end
end

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!

参考