Ruby
Rails
pry
debug
REPL

REPLとデバッガで鮮やかにバグを解決していく

この記事はRuby on Rails Advent Calendar 2018の14日目の記事です。

昨日のAdventCalendarは @iwtn さんの「RailsにおけるOOUI(オブジェクト指向ユーザーインターフェース)」という記事でした。

本記事では、RailsでのWebアプリ開発で培ったREPLを使ったデバッグについて書きます。


はじめに

基本的にはRails利用者向けに書いていますが、Railsの機能を利用している具体的な箇所をご自身が利用されているフレームワークに当てはめれば、Railsを使っていない方でも役に立つかもしれません。

話の内容としては以下の2本立てです。


  1. pry-byebugってなに?

  2. REPL駆動デバッグをはじめよう

Railsに興味がない人は 2 だけでも読んでいただければと思います。


1. pry-byebugってなに?

prybyebugという2つのGemを組み合わせて使えるようにするライブラリです。

pryとは高機能なREPL(インタプリタとの対話環境)です。

一方で、byebugはブレークポイントをコード上に打ってステップ実行をしていくデバッガです。

pry-byebugはこの2つのGemを組み合わせ、byebugの機能を対話的に実行できるようにするものです。


具体例を見せてくれ

controllerにbinding.pryを置いて実行してみます。


サンプルコード(Controller)

class BullteinBoardController < ApplicationController

def show
binding.pry # pryのブレークポイントの置き方
@board = BullteinBoard.find(params[:id])
end
end

ここでconsoleを確認してみると以下のような画面が表示されます。

pry-byebugはこの画面でプログラムと対話をしながら、デバッグをしていきます。


サンプルのコンソール

From: {省略}/app/controllers/bulltein_boards_controller.rb @ line 4 BullteinBoardsController#show:

2: def show
3: binding.pry
=> 4: @board = BullteinBoard.find(params[:id])
5: end

[1] pry(#<BullteinBoardsController>)> step # 直後のメソッドコールにステップインします

From: {省略}/vendor/bundle/ruby/2.4.0/gems/actionpack-5.1.5/lib/action_controller/metal/strong_parameters.rb @ line 1067 ActionController::StrongParameters#params:

1066: def params
=> 1067: @_params ||= Parameters.new(request.parameters)
1068: end

[1] pry(#<BullteinBoardsController>):1> request.parameters # 呼び出せる変数や関数を呼び出すとその結果が表示されます
=> {"controller"=>"bulltein_boards", "action"=>"show", "id"=>"16"}


詳しい使い方はk0kubunさんの『今更聞けないpryの使い方と便利プラグイン集』におまかせします。


2. REPL駆動デバッグをはじめよう


REPL駆動デバッグとは?

私の造語で、REPLを使ってデバッグをしていくことを指します。1

エラーメッセージと発生箇所だけで原因が特定できる単純なエラーであれば、対処は非常に楽です。

しかし、バグやエラーの原因が発生箇所とは遠い彼方のモジュールにあったり、利用しているデータが壊れていることが原因だったり、はたまた複合的な要因だったりすることもあります。

そういう状況でのバグ対処では、まずは原因を切り分けて発生源を特定していきますよね。

REPL駆動デバッグは、その作業をREPLを駆使してサッと解決してしまおうというものです。


REPLとデバッガでバグを絞り込むテクニック


① REPLで正常な挙動をチェックする

バグを修正するためには、まずはゴールを確認する必要があります。

そのため、「どこをどうすればいいのか」の情報を得る必要があります。

最初にバグが発生している部分に焦点を当てて、本来あるべき正常な挙動現在発生している異常な挙動を確認します。

そして、その部分をコード上で確認し、関係しているロジックのどの値がどうなっていればあるべき挙動になるのかを調査します。

それさえできれば、その値が作られたロジックを遡って、挙動の確認を繰り返していくことで芋づる式にバグの原因にまでたどり着けるからです。2

こうして、それぞれの部分であるべき動作を確認する作業では、コード上で思いつく限りの可能性を実験的に試していくことになると思います。

そんなシチュエーションでは、小さな修正とコードの再実行を繰り返すことになります。

そのため、コードを実行をしなおす時間すら惜しく思えてくるのではないかと思います。

そういうときにこそ、REPLが役に立ちます。


具体例

以下は、REPLを使って正常な挙動を確認する具体例です。

# 周辺のお店を紹介するサービスを運営しているという想定。

# LPのような特集ページで動的に生成しているリンク一覧が、仕様どおりの順番で並んでいないというバグがあった!という問題設定。

# 場面設定: Viewで実行している`StoreListLinks#generate`が出力しているリンクの順番がおかしい事がわかった

class StoreListLinks
def initialize(locations:, arranger:)
@locations = locations
@arranger = arranger
end

def generate
# ...
# @locations から Linkクラス的なモノのコレクションを作り、
# それを @arranger でいい感じに整形して返すロジック
# @arranger は StoreListLink::RecommendedArranger と StoreListLink::MarketableArranger がある想定。[2]

# この関数の内部で binding.pry を使って調べていく
end
...
end

まずはじめに、#generateの挙動を調べるため、メソッドの内部でbinding.pryをおきます。

どこにおけばわからないときは、とりあえずメソッドの一番上に置くといいんじゃないかと思います。

binding.pryで動作を止めた箇所で、正しい挙動を確認してみます。

このメソッドの結果に影響を及ぼしていそうなところをチェックしましょう。

【メソッド内をチェックするときに見ると良い箇所】


  • 引数

  • インスタンス変数

  • 内部で利用しているメソッド・関数

ここで@arrangerの値が想定外のモノだったとします。

その場合、それがどんな値になれば正常な挙動をするのかをチェックしてみましょう。


binding.pryのREPL

...(pryのデバッグコードが並ぶ)

[1] pry(<StoreListLinks>)> @arranger
=> #<StoreListLink::MarketableArranger:0x007ffedfeb5330>
[2] pry(<StoreListLinks>)> # これは想定外のクラス!
[2] pry(<StoreListLinks>)> # 仕様どおりであれば、StoreListLink::RecommendedArrangerが来るべきだった
[4] pry(<StoreListLinks>)> # まずは、とりあえず正しい挙動を試してみよう
[5] pry(<StoreListLinks>)> @arranger = StoreListLink::RecommendedArranger.new(...)
=> <StoreListLink::RecommendedArranger:0x007ffedfe27238>
[6] pry(<StoreListLinks>)> # つまり、REPLでインスタンス変数に本来来るべきクラスを代入してしまう
[7] pry(<StoreListLinks>)> self.generate # そして、メソッドの実行結果を試してみて正しい挙動になることを確認してみる

このようにREPLを使うと、@arranger = StoreListLink::RecommendedArranger.new(...)のようにインスタンス変数や引数を直接書き換えて、挙動をチェックできます。

このようにして、REPLを使って試行錯誤をすることで、原因の切り分けを手早く終わらせていきましょう。


② 探索範囲を徐々に狭めて二分探索していく

個別のクラスや関数の中でバグの原因を調査をするとき、まずは関数やクラスの結合点を調べると良いです。

バグの原因が外にあるのか内から生まれたのかを確認して、探索範囲を絞っていきます。

そうして、範囲を徐々に絞り込んでいくことで、関係のない箇所を調査して時間を浪費してしまうことを避けられます。

【関数やクラスの結合点の主な例】


  • クラスのイニシャライザ

  • メソッド・関数の引数

  • メソッド内で利用されているインスタンス変数


具体例

以下に軽い具体例をあげます。

クラスのイニシャライザ(#intialize)で代入されてる値が不正でないかをチェックしてみる、という例です。

# コードは先ほどと同じ例です

# 以下のコードで2つのケースを考える
# 1. `#initialize`の時点で`sort_key`が間違っていることがわかった場合
# 2. 特に引数の値の間違っているものがない場合

class StoreListLinks
def initialize(locations:, arranger:)
@locations = locations
@arranger = arranger
binding.pry
# ここで止まるので @locations と @arranger の値を確認してみる
end
...
end

#initializeの時点でarrangerが間違っていることがわかった場合

引数の時点で不正な値になっている場合は、現在のクラスの外にバグの原因があると推測できます。

さらに言えば、このクラスを利用している部分を調べる必要があるでしょう。

引数に渡している値を調べて、バグの原因をさらに辿っていくと良いです。

特に引数の値の間違っているものがない場合

不正な値が入っているように見えなければ、そのクラス内でバグが作られている可能性が高いと判断できます。

クラス内のロジックにバグが潜んでいて、それが原因で想定外の結果が生み出されていると言えそうです。

そのため、バグの発生箇所で呼び出しているメソッドのロジックに関係している部分(プライベートメソッド、インスタンス変数など)を確認してみましょう。

このように結合点を確認して、バグを調べていくことでより早くバグの原因にたどり着けるようになります。


以上です。

ツッコミや「もっといい方法がありますよ」という提案などをお待ちしております。 :muscle:

明日は @fursich さんです!