Posted at

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


謝辞

レビューしてくれた人