仕事で使っているRuby用のAPI生成フレームワーク grape のコードを読んで見たのでメモ。
コード量が多かったので、前半(初期化処理)と 後半(リクエストの処理とレスポンス生成) に分けた。今回は、前半(初期化処理)の内容を記載。
まとめ
- Rackアプリケーションとしてマウントされている。
- 1回目のリクエストの処理中に、マウントしたクラス => マウントしたクラスのシングルトンなインスタンス => Grape::Router => APIごとのGrape::Router::Route => APIごとのGrape::Endpoint => マウントしたクラスに書いたAPIの処理内容(ブロック)という参照関係が出来上がる。
- 自分にとってかなりコードの難易度の高く、読みづらいと感じた。
- 様々な箇所でHashの破壊的な操作が行われていて、コードを変更するのに怖さを感じた。メンテナンスが大変そう。
- コードをわかりやすく説明するのは難しい…。何本かブログを書いたら、1回整理したほうがよさそう。
詳細
非常に簡単なAPIを1つ定義し、どのように動作させているのかを把握する。
対象のバージョンは、執筆時点の最新である「v0.19.1」とする。
想定する使い方
今回は業務で使用している方法と同じく、Railsに組み込んで使用することを想定する。
{ status: "OK" }
というJSONを返却するAPIを定義し、これがどのように動作するかを確認する。
まず以下のコマンドでRailsの初期化やモデルの生成等を行う。
|
$ rails new grape-test $ cd grape-test $ echo 'gem "grape"' >> Gemfile $ bundle install $ mkdir -p app/apis |
次にGrape::APIを継承した app/apis/root.rb
を生成。
|
class Root < Grape::API format :json get 'healthcheck' do { status: 'OK' } end end |
最後に config/routes.rb
でRootをRackアプリケーションとしてmountする。
|
Rails.application.routes.draw do mount Root => '/' end |
routes.rbでmountを使うと何が起きるのか完全に把握はできていないのだが、以下の記事を見てある程度理解した。
http://qiita.com/higuma/items/838f4f58bc4a0645950a
/
以下のリクエストは、最終的に Root.call(env)
に到達する。
- 上記メソッドのenvはHashオブジェクトであり、リクエストに関する様々な情報が含まれている。
- callメソッドは最終的に 「ステータスコード」「レスポンスヘッダー」「レスポンスボディ」の3つの要素を含む配列をreturnしなければいけない。
|
$ bin/rails s # 別のターミナルを立ち上げてcurlで試しに動作確認してみる。 $ curl -X GET http://localhost:3000/healthcheck # => {"status":"OK"} |
初期化時の処理
まずは最初にロードされる lib/grape.rb を見てみる。
grapeが必要とするライブラリのrequireや、自身が定義するクラスやモジュールのautoloadを登録するのみで、重要な処理は行われていない模様。
リクエスト到達時の挙動
次はリクエスト到達時に最終的に呼ばれる Root.call(env)
から見てみる。
ただ Root
には .call
メソッドは定義されておらず、親クラスである Grape::API.call
が呼び出される。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
module Grape class API include Grape::DSL::API class << self attr_reader :instance LOCK = Mutex.new def call(env) LOCK.synchronize { compile } unless instance call!(env) end def compile @instance ||= new end def call!(env) instance.call(env) end end end end |
大きく分けて以下の2つの処理が行われている。
@instance
がnilだった場合、Root.new
を呼んでインスタンスを1つ生成する。
- 1で生成したインスタンスに対して
#call(env)
を呼ぶ。
上記から、Grape::APIを継承したクラスはシングルトンなクラスと考えてよさそう。
今回は、1のRootのインスタンス生成処理をみる。2は後半で見ていく。
Rootインスタンスの生成
Root.new
を呼んだ場合、最終的に Grape::API#initialize
に到達するはず。
Grape::API#initialize
は以下のコードになっている。
|
module Grape class API def initialize @router = Router.new add_head_not_allowed_methods_and_options_methods # そこまで重要でないと思うので、スキップ self.class.endpoints.each do |endpoint| endpoint.mount_in(@router) end @router.compile! @router.freeze end end end |
重要なコードがいくつかある。
Grape::Route
クラスのインスタンスを生成し、 @router
に代入。
self.class.endpoints.each
で繰り返し処理をしている。
- 上記の1つ1つの要素に対して
Grape::Route
のインスタンスを引数に #mount_in
を呼び出している。
@router.compile!
を実行。
それぞれを見ていく。
Grape::Routerインスタンスの生成
Grape::Route#initialize
のコードは以下。いくつかのインスタンス変数の初期化をしているだけ。
|
module Grape class Router attr_reader :map, :compiled def initialize @neutral_map = [] @map = Hash.new { |hash, key| hash[key] = [] } @optimized_map = Hash.new { |hash, key| hash[key] = // } end end end |
self.class.endpointsの内容
self.class.endpoints
は Root.endpoints
と読み替える。このメソッドは lib/grape/api.rb
には定義されていない。
どこに定義されているかというと Grape::DSL::API
のinclude経由で Grape::DSL::Routing
に定義されていた。
(こういう時、Rubyのmoduleの使い方の難しさを感じる。RubyMineのおかげでかなり楽できているが、それでもコードを追うのが難しい時もあったり)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
|
require 'active_support/concern' module Grape module DSL module Routing extend ActiveSupport::Concern include Grape::DSL::Configuration module ClassMethods attr_reader :endpoints, :routes # snip... def reset_routes! endpoints.each(&:reset_routes!) @routes = nil end def reset_endpoints! @endpoints = [] end end end end end |
ActiveSupport::Concern
をextendしているため、 ClassMethods
に定義した内容が、include先のクラス(Grape::API)にextendされる。
attr_reader
で endpoints
が定義されていることがわかる。
@endpoints
はRootクラスがrequireされた時点ではnilだが、 Grape::API.inherited(subclass)
が定義されていて、ここで初期化用のresetメソッドが呼ばれている。
|
module Grape class API class << self def inherited(subclass) subclass.reset! subclass.logger = logger.clone end def reset! reset_endpoints! reset_routes! reset_validations! end end end end |
これにより @endpoints
には空配列が入っている状態になっている。
endpointsへのインスタンスの追加
endpointsへのインスタンスの追加は、 Root
クラスに書いた get 'healthcheck' do ... end
で実行されている。以下が、 #get
のコードである。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
|
module Grape module DSL module Routing module ClassMethods # snip... %w(get post put head delete options patch).each do |meth| define_method meth do |*args, &block| options = args.extract_options! paths = args.first || ['/'] route(meth.upcase, paths, options, &block) end end # 今回の想定用に一部コードを省略しています。 def route(methods, paths, route_options = {}, &block) # snip... endpoint_options = { method: methods, path: paths, for: self, route_options: { params: {} }.deep_merge(route_options || {}) } new_endpoint = Grape::Endpoint.new(inheritable_setting, endpoint_options, &block) endpoints << new_endpoint # snip...(descやparamsなど複数のメソッド呼び出しで1つのEndPointを作った場合のリセットを実施している模様) end end end end end |
#get
内部は、最終的に #route
を呼んでいる。
今回の場合、methodsが 'GET'
、pathsは 'healthcheck'
、route_optionsはデフォルト値の {}
、 ブロックに do { status: 'OK' } end
が渡されている。
#route
では、まず引数からHashを生成している。
次に Grape::Endpoint.new
を呼び出している。ここで、#inheritable_setting
は Grape::DSL::Settings
で追加されたメソッドで Grape::Util::InheritableSetting
のインスタンスを返す。今回の場合、何も値は入っていないので、詳細は見ない。
最後に結果を @endpoints
に追加している。ここでようやく @endpoints
の要素が一つ追加された。
Grape::Endpointの生成
Grape::Endpoint.new
のインスタンス生成処理を見てみる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
|
module Grape class Endpoint attr_accessor :block, :source, :options attr_reader :env, :request, :headers, :params class << self def new(*args, &block) self == Endpoint ? Class.new(Endpoint).new(*args, &block) : super end end def initialize(new_settings, options = {}, &block) # snip... @options = options @options[:path] = Array(options[:path]) @options[:method] = Array(options[:method]) # snip... @source = block @block = self.class.generate_api_method(method_name, &block) end # 詳細は割愛。今回は `"GET healthcheck"` が返される。 def method_name # snip... end class << self def generate_api_method(method_name, &block) # snip... # UnboundMethodの抽出のため、メソッドを定義して、すぐに削除 define_method(method_name, &block) method = instance_method(method_name) remove_method(method_name) proc do |endpoint_instance| ActiveSupport::Notifications.instrument('endpoint_render.grape', endpoint: endpoint_instance) do method.bind(endpoint_instance).call end end end end end end |
結構つらい…。熟練のRubyistの方々は、こういったコードをすらすらと読めるのだろうか?
今回の想定と関連が低い部分はばっさりカットして、主要なコードだけ見ていく。
Grape::Endpoint.new
をオーバーライドしている。呼び出された対象クラスによって処理が変わるが、今回のケースでは Grape::Endpoint
を継承する無名クラスを作成し、このクラスの new
を呼び出す。なぜこのようなことをしているかは不明。単純にGrape::Endpointのインスタンスを生成して、必要があればこのインスタンスの特異クラスに処理をすればよいのでは、と思ったが、何かこうしないといけない理由があるのだろうか?
.new
の呼び出しの内部で Grape::Endpoint#initialize
が呼ばれる。中では色々やっているが、今回の用途で関係ありそうなのは、以下の3つ。
@options
にmethodsやpathsをArrayに変換して入れ直す
@source
に引数で渡されたブロックのProcオブジェクト自体への参照を保存
self.class.generate_api_method(method_name, &block)
の結果を @block
に保存
.generate_api_method
は渡されたブロックを持つ UnboundMethod
のオブジェクトを作成し、これをあとでbindして実行するためのProcオブジェクトを返す。なぜ UnboundMethod
に変換するのかについては、メソッドのコメントをみる限り「ブロック内でreturnを使ってもLocalJumpErrorが起きないようにするため」と推測される(確かにブロック内でreturnを使ってもエラーにならない)。おそらく、後半で @block.call(self)
と実行することで、 UnboundMethod
をEndpointのインスタンスにbindして呼び出すと思われる。
Grape::EndPoint#mount_inの処理
やっと Root.endpoints
に何が入っているのかを解明できたので、Grape::EndPoint#mount_in
の内容を見ていく。
引数で渡されている router
は Grape::Router
のインスタンスである。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
|
module Grape class Endpoint # snip.... def mount_in(router) if endpoints # 今回はnil # snip... else reset_routes! routes.each do |route| methods = [route.request_method] # snip...(ここでHEADメソッドが追加されるが、重要でないと判断) methods.each do |method| # snip... router.append(route.apply(self)) end end end end def reset_routes! # snip... @namespace = nil @routes = nil end end end |
大まかに以下の3つの処理が実行されている。
#reset_routes!
でEndpoint内のルート定義に関連するインスタンス変数にnilをセット
#routes
でこのEndpointにセットされたルート定義一覧を取得し、ループ処理を開始
- ループの各要素を
router#append
に渡して実行( route.apply(self)
はHEAD用のルート定義なのでスキップでOK )
1は、特に問題なし。
2は、追加のコードを見てみる。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
|
module Grape class Endpoint # snip.... def routes # 初回のみ `#to_routes` が呼ばれる。2回目以降は、@routesを返すだけ。 @routes ||= endpoints ? endpoints.collect(&:routes).flatten : to_routes end def to_routes route_options = prepare_default_route_attributes map_routes do |method, path| path = prepare_path(path) params = merge_route_options(route_options.merge(suffix: path.suffix)) # { suffix: "(.json)" } と { params: {} } を追加 # method = 'GET' # path.path = '/healthcheck' # params = {:namespace=>"/", :version=>nil, :requirements=>{}, :prefix=>nil, :anchor=>true, :settings=>{}, :forward_match=>nil, :suffix=>"(.json)", :params=>{}} route = Router::Route.new(method, path.path, params) route.apply(self) # Router::Routeインスタンスの @app 変数にself(Endpointのインスタンス)をセット end.flatten # 2重配列をフラット化 end # 今回の場合、以下のHashを返す。 # => {:namespace=>"/", :version=>nil, :requirements=>{}, :prefix=>nil, :anchor=>true, :settings=>{}, :forward_match=>nil} def prepare_default_route_attributes # snip... end # options[:method]には `['GET']`、options[:path]には `['healthcheck']` が入っている。 # 結果として、ブロックの処理は1回のみ('GET'と'healthcheck'が渡される)、その結果を二重配列で返す。 def map_routes options[:method].map { |method| options[:path].map { |path| yield method, path } } end # Grape::Path.new(path, namespace, path_settings)の結果を返す。 def prepare_path(path) # snip...。namespaceは'/'、path_settingsには {} が入っている。 Path.prepare(path, namespace, path_settings) end end end |
結果として Grape::Router::Route
の要素を1つだけ含んだ配列を返している。
最後に Grape::Router#append
を呼んでいる。ここでは、「メソッド文字列」 => 「配列」という形式のインスタンス変数 @map
に対応するrouteを追加している。
今回、キーは 'GET'
である。
|
module Grape class Router attr_reader :map, :compiled def initialize # snip... @map = Hash.new { |hash, key| hash[key] = [] } # snip... end def append(route) map[route.request_method.to_s.upcase] << route end end end |
routerのcompile
最後に Grape::Router#compile!
を呼んでいる。対応するコードは以下。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
module Grape class Router def compile! return if compiled @union = Regexp.union(@neutral_map.map(&:regexp)) self.class.supported_methods.each do |method| # ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] + ['*'] routes = map[method] @optimized_map[method] = routes.map.with_index do |route, index| route.index = index route.regexp = /(?<_#{index}>#{route.pattern.to_regexp})/ end @optimized_map[method] = Regexp.union(@optimized_map[method]) end @compiled = true end end end |
内容としては、 HTTPのメソッドごとに、各routeのパスの正規表現を一旦 @optimized_map
に格納し、これを結合した正規表現を最終的に格納している。
これは、指定されたパスのAPIが存在するかどうかのチェックを高速に実行するために使用されると思われる。(追記、ここで名前付きのキャプチャを使用することで、チェックだけでなくどのEndpointに対応するかまでを高速に確認できるようにしている)
初期化終了後の状態
初期化が終了すると以下のような状態になっている。
- EndPointクラスには、1つのEndPointクラスのインスタンスがセットされている。(シングルトンクラス)
- EndPointのインスタンスの
@router
変数には、コンパイル済みのGrape::Routerのオブジェクトがセットされている。
@router
インスタンスには、「GET /healthcheck」(と「HEAD /healthcheck」)に対応する Grape::Router::Route
オブジェクトが格納されている。
Grape::Router::Route
の各インスタンスの @app
変数には Grape::Endpoint
のインスタンスがセットされている。
Grape::Endpoint
の各インスタンスの @block
変数には、EndPointクラス定義時に書いたブロックを実行するためのProcオブジェクトが格納されている。
後半 では、リクエストをどのように処理するかを見ていく。