2018-02-07

better_errorsコードリーディング

BetterErrorsのコードリーディングをしました。

基本的にはRackミドルウェアでエラーハンドリングをして、エラーのバックトレースを表示。binding_of_callerがある場合はWeb上からirbでデバッグできるようにJSからRackミドルウェア向けにAPIを叩いてコマンドを実行・結果出力する、という感じです。

エラー表示するまで

Railtieでは、productionではなくconsider_all_requests_localがtrueの場合のみBetterErrors::Middlewareを差し込みます。
module BetterErrors
  # @private
  class Railtie < Rails::Railtie
    initializer "better_errors.configure_rails_initialization" do
      if use_better_errors?
        insert_middleware
        BetterErrors.logger = Rails.logger
        BetterErrors.application_root = Rails.root.to_s
      end
    end

    def insert_middleware
      if defined? ActionDispatch::DebugExceptions
        app.middleware.insert_after ActionDispatch::DebugExceptions, BetterErrors::Middleware
      else
        app.middleware.use BetterErrors::Middleware
      end
    end

    def use_better_errors?
      !Rails.env.production? and app.config.consider_all_requests_local
    end

    def app
      Rails.application
    end
  end
end

allow_ip!ではBetterErrorsでエラーページ表示するクライアントのIPアドレスを設定しています。デフォルトで許可されているのはローカルネットワーク内だけで、dockerやvagrantなどの仮想環境で動かす場合は明示的にallow_ip!を呼び出す必要があります。

module BetterErrors
  class Middleware
    ALLOWED_IPS = Set.new

    def self.allow_ip!(addr)
      ALLOWED_IPS << IPAddr.new(addr)
    end

    allow_ip! "127.0.0.0/8"
    allow_ip! "::1/128" rescue nil # windows ruby doesn't have ipv6 support

    def initialize(app, handler = ErrorPage)
      @app      = app
      @handler  = handler
    end

    def call(env)
      if allow_ip? env
        better_errors_call env
      else
        @app.call env
      end
    end

better_errors_callが実際の処理になります。caseの上の2つはBetterErrorsのAPIになります。Railsアプリに対してリクエストが来た場合はprotected_app_callが呼ばれます。

    def better_errors_call(env)
      case env["PATH_INFO"]
      when %r{/__better_errors/(?<id>.+?)/(?<method>\w+)\z}
        internal_call env, $~
      when %r{/__better_errors/?\z}
        show_error_page env
      else
        protected_app_call env
      end
    end

    def protected_app_call(env)
      @app.call env
    rescue Exception => ex
      @error_page = @handler.new ex, env
      log_exception
      show_error_page(env, ex)
    end

protected_app_callは@app.callを呼びつつrescueでエラーハンドリングを行っています。show_error_pageがBetterErrorsのエラーページを表示するメソッドです。

@error_pageに入る値はErrorPageのインスタンスです。

    def show_error_page(env, exception=nil)
      type, content = if @error_page
        if text?(env)
          [ 'plain', @error_page.render('text') ]
        else
          [ 'html', @error_page.render ]
        end
      else
        [ 'html', no_errors_page ]
      end

      status_code = 500
      if defined?(ActionDispatch::ExceptionWrapper) && exception
        status_code = ActionDispatch::ExceptionWrapper.new(env, exception).status_code
      end

      [status_code, { "Content-Type" => "text/#{type}; charset=utf-8" }, [content]]
    end

ErrorPage#renderはERBのテンプレートをErrorPageオブジェクトのコンテキストでレンダリングしています。

    def render(template_name = "main")
      binding.eval(self.class.template(template_name).src)
    end

templates/main.erbは一部を抜粋するとこんな感じです↓

   <div class='top'>
        <header class="exception">
            <h2><strong><%= exception_type %></strong> <span>at <%= request_path %></span></h2>
            <p><%= exception_message %></p>
        </header>
    </div>

    <section class="backtrace">
        <nav class="sidebar">
            <nav class="tabs">
                <a href="#" id="application_frames">Application Frames</a>
                <a href="#" id="all_frames">All Frames</a>
            </nav>
            <ul class="frames">
                <% backtrace_frames.each_with_index do |frame, index| %>
                    <li class="<%= frame.context %>" data-context="<%= frame.context %>" data-index="<%= index %>">
                        <span class='stroke'></span>
                        <i class="icon <%= frame.context %>"></i>
                        <div class="info">
                            <div class="name">
                                <strong><%= frame.class_name %></strong><span class='method'><%= frame.method_name %></span>
                            </div>
                            <div class="location">
                                <span class="filename"><%= frame.pretty_path %></span>, line <span class="line"><%= frame.line %></span>
                            </div>
                        </div>
                    </li>
                <% end %>
            </ul>
        </nav>

exception_type、request_path、backtrace_framesなどは全てErrorPageのメソッドです。

インタラクティブな処理

templates/main.erbにはフレームを選択したりREPLでRubyを実行するなどのインタラクティブな処理をするためにJavaScriptで処理が書かれています。これらの処理ではBetterErrorsのAPIを呼び出しています。
    function apiCall(method, opts, cb) {
        var req = new XMLHttpRequest();
        req.open("POST", "//" + window.location.host + <%== uri_prefix.gsub("<", "&lt;").inspect %> + "/__better_errors/" + OID + "/" + method, true);
        req.setRequestHeader("Content-Type", "application/json");
        req.send(JSON.stringify(opts));
        req.onreadystatechange = function() {
            if(req.readyState == 4) {
                var res = JSON.parse(req.responseText);
                cb(res);
            }
        };
    }

パスが /__better_errors/{OID}/{method} となっているのがAPIです。以下再掲↓

    def better_errors_call(env)
      case env["PATH_INFO"]
      when %r{/__better_errors/(?<id>.+?)/(?<method>\w+)\z}
        internal_call env, $~
      when %r{/__better_errors/?\z}
        show_error_page env
      else
        protected_app_call env
      end
    end

(ちなみに/__better_errors/のパスでアクセスすると直近のエラー画面を表示することができます)

internal_callが実際の処理でErrorPage#do_variables, ErrorPage#do_evalが呼び出されます。

    def internal_call(env, opts)
      return no_errors_json_response unless @error_page
      return invalid_error_json_response if opts[:id] != @error_page.id

      env["rack.input"].rewind
      response = @error_page.send("do_#{opts[:method]}", JSON.parse(env["rack.input"].read))
      [200, { "Content-Type" => "text/plain; charset=utf-8" }, [JSON.dump(response)]]
    end

variablesの方はフレームを切り替えたときに、そのフレームのローカル変数を画面表示するための処理で、evalはREPLの処理です。いずれも各フレームでのbindingが必要になるので、binding_of_callerを利用しています。

binding_of_callerが存在している場合、BetterErrors::ExceptionExtensionを読み込みます。Module#prepend_featuresによってExceptionに#set_backtraceと#__better_errors_bindings_stackが定義されます。

module BetterErrors
  module ExceptionExtension
    prepend_features Exception

    def set_backtrace(*)
      if caller_locations.none? { |loc| loc.path == __FILE__ }
        @__better_errors_bindings_stack = ::Kernel.binding.callers.drop(1)
      end

      super
    end

    def __better_errors_bindings_stack
      @__better_errors_bindings_stack || []
    end
  end
end

これらのメソッドが定義されている場合、RaisedExceptionの@backtraceに各フレームのバインディングがStackFrameのインスタンス変数にセットされます。

    def setup_backtrace_from_bindings
      @backtrace = exception.__better_errors_bindings_stack.map { |binding|
        file = binding.eval "__FILE__"
        line = binding.eval "__LINE__"
        name = binding.frame_description
        StackFrame.new(file, line, name, binding)
      }
    end

また、@error_pageはRaisedExceptionやenvを保持しているBetterErrors::Middlewareのインスタンス変数です。ミドルウェアのインスタンス変数は同一プロセス内では同じ値を保持することになるので、APIを叩くときには直近で発生したエラーの@error_pageを使っています。

そのため

  1. Aの画面でエラー画面を表示
  2. Bの画面でエラー画面を表示
  3. Aの画面でREPLを動かす
といったことをするとAの画面でBのエラー内容を参照してしまうことになるので、BetterErrorsでは以下のようにErrorPageのidの突き合わせを行うことでエラーハンドリングをしています。
module BetterErrors
  class Middleware
# ...
    def internal_call(env, opts)
      return no_errors_json_response unless @error_page
      return invalid_error_json_response if opts[:id] != @error_page.id

module BetterErrors
  class ErrorPage
# ...
    def id
      @id ||= SecureRandom.hex(8)
    end
このエントリーをはてなブックマークに追加