Ruby

Ruby の yield を使いこなす

まえがき

yield ってなにがどうなっているのか
(yeildが使われているコードの説明で) 自分で書くとこの設計できないのよね

こんな感想をいただきました

yield の理解を深めてもらうために、こんな使い方をしているよというコードベースでご紹介していきます

対象読者

  • yield がよくわからない人
  • yield がわかっているけど、自分の書くコードで使ったことがない人

yield ユースケース

パフォーマンス監視

処理時間を記録して報告するような機能

module XXXReportable
  def with_reporting(name: )
    started = Time.zone.now
    yield
    ended = Time.zone.now
    report(name: name, started: started, ended: ended)
  end

  private

  def report(name:, started:, ended:)
    # どこか(主に外部サービス)に経過時間を記録する
  end
end

# 使い方

class MyJob
  include XXXReportable

  def run
    with_reporting(name: class.name) do
      # メインとなる処理
    end
  end
end

メインとなる処理の前後に現在時刻を取得する処理が実行されるようになる

メインとなる処理完了後に report が呼ばれる

原因がわからーんというときに限定的にログレベルを変える

def with_debug
  old = Rails.logger.level
  Rails.logger.level = :debug
  yield
ensure
  Rails.logger.level = old
end

# 使い方

# デバッグを有効にしたいメソッドを対象に
with_debug do
  # something
end

リトライ機構

  • 再実行可能な処理を with_retry do ~ end で囲む
  • 再実行可能なエラーがでたときに リトライ用の例外を飛ばす
class RetryError < StandardError; end

module Retriable
  def with_retry(max_attempts: 5)
    attempts = 0
    begin
      attempts += 1
      yield
    rescue RetryError => e
      raise e.cause if max_attempts < attempts
      retry
    end
  end
end

# 使い方

class MyJob
  include Retriable

  def run
    with_retry do
      do_something!
    end
  end

  private

  def do_something!
    raise 'someerror'
  rescue
    # 特定の条件の時に RetryError を raiseさせる
    # 例) Http接続ができなかったときなど
    raise RetryError
  end
end

MyJob.new.run

オブジェクトの設定項目が沢山あって辛い時

有名なやつ

  • メインのクラスに attr_accessor が設定されるのを防ぐことができる
  • 設定内容の検証のために valid? とか用意しても辛くない
class Configuration
  attr_accessor :max_attempts
  attr_accessor :interval

  def initialize
    @max_attempts = 5
    @interval = 10
  end
end

class ApiClient
  def initialize
    yield configuration if block_given?
  end

  def invoke
    puts configuration.max_attempts
    puts configuration.interval
  end

  def configuration
    @configuration ||= Configuration.new
  end
end

client = ApiClient.new do |config|
  config.max_attempts = 3
  config.interval = 30
end

client.invoke
# => 3, 30

HTTPリクエストのハンドリングをさせる

# module にして includeして使うケースもある
class HttpHandler
  def handle!
    response = yield
    raise MyErrors::Error::40X if response.status >= 400 && response.status < 500

    # ...

    JSON.parse(response.body)
  end
end

# 使い方

client = ... # 適当な HttpClient
handler = HttpHandler.new
response = handler.handle! do
  client.get '/path/to/resource'
end

APIの操作とレスポンスの処理のロジック分離

pagination があるようなAPIを想定

  1. APIにリクエストする
  2. レスポンス中の重要部分(課金データとか)の処理をする
  3. 次のページがあるならば繰り返し
class XXXApiClient
  def invoke(path:, query: {})
    query = { page: 1 }.merge(query)
    loop do
      response = http_client.get(path + query.to_params)
      response['Items'].each do |item|
        yield item
      end
      break unless next_page?(response)
      query[:page] += 1
    end
  end
end

# 使い方

client = XXXApiClient
client.invoke(path: '/') do |item|
  import!(item)
end

同一プロダクトのAPIではページネーション処理、エラー処理が共通化できることが多いため便利なことが多い

リモートから取得したデータはローカルに残さない

class RemoteCsvReader
  def read(remote_path:, csv_options: {})
    file = download!(remote_path)
      CSV.foreach(file, csv_options) do |row|
        yield row
      end
  ensure
    file.delete if file && file.exist?
  end

  private

  # @return [Pathname]
  def download!(remote_path)
    # リモート(S3など) からダウンロード
    # TIPS: 実行ユーザーなどの情報をログに残したりしておく
  end
end

# 使い方

reader = RemoteCsvReader.new
reader.read(remote_path: 'path/to/s3', csv_options: { col_sep: "\t" }) do |row|
  # 各行ごとの処理
end

謝辞

レビューしてくれた人