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("<", "<").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を使っています。
そのため
- Aの画面でエラー画面を表示
- Bの画面でエラー画面を表示
- Aの画面でREPLを動かす
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