Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby: SinatraのWeb Serverが起動後にURLにアクセスして処理が実行されるまでのコードを読んでみた。

Sinatraコードリーディングの第二回目です🎩 前回は、ruby app.rb実行後にServerが起動するまでのコードを読んでみましたが、今回はServerが起動したあとに特定のURLにアクセス際にapp.rbで定義した処理が実行されるまで、どのような動きになっているのかを追ってみました🕵️‍♀️

👇ちなみに前回の内容はこちらです。

madogiwa0124.hatenablog.com

今回のサンプルアプリケーション

ではまず、今回のサンプル実装を見ていきます。今回はなるべくシンプルにルート/にアクセスしたらHello Worldが標準出力されるようなアプリケーションで実際にルートパスにアクセスして標準出力されるまでを見ていきます。

require 'sinatra'

get '/' do
  puts "Hello World"
end

routingの定義

ではまずrouting(URLと処理の紐付け)を行っている。#getの実装を見てみることにします。このメソッドで実行してるのは下記の2つ。

  • 条件の取得・設定
  • routeの定義
# Defining a `GET` handler also automatically defines
# a `HEAD` handler.
def get(path, opts = {}, &block)
  # 条件の取得
  conditions = @conditions.dup
  # routeの定義
  route('GET', path, opts, &block)

  @conditions = conditions
  route('HEAD', path, opts, &block)
end

今回は条件を指定していないので、routeの定義を行っている#route見ていきます。 ここで行われているのは下記です。

  • pathに空文字が指定されていた場合に、singleton_classにempty_path_infoを定義
  • #compile!を実行してurlと処理を解決するためのsignature(URLと処理を持つもの)を取得して@routesに登録
  • 拡張機能(http://sinatrarb.com/extensions-wild.html)の実行
  • signatureを返却
def route(verb, path, options = {}, &block)
  # pathに空文字が指定されていた場合に、singleton_classに`empty_path_info`を定義
  enable :empty_path_info if path == "" and empty_path_info.nil?
  # sigature(URLパターンと処理の組み合わせ)の取得
  signature = compile!(verb, path, block, options)
  # routesへの追加
  (@routes[verb] ||= []) << signature
  # 拡張機能の実行
  invoke_hook(:route_added, verb, path, block)
  signature
end

@routessignatureが追加されることによりSinatraにapp.rbで定義したroutingが追加されるようですね👀

では実際のsignatureを取得している#compile!中身を見ていきます。 行っているのは下記です。ざっくりいうとオプションを解決して、URLと処理を持つオブジェクトを返しているようです。

  • host名やURLパターン、その他のオプションの反映
  • URLパターン解決用のオブジェクトを生成
  • verbとpathでメソッド名を生成(例: GET /)
  • メソッド名とapp.rbで定義したblockからメソッドを作成
  • メソッドを実行するProcオブジェクトをwrapperとして定義
  • URLパターン解決用のオブジェクト、条件、wrapperを返却
def compile!(verb, path, block, **options)
  # オプション周りの処理
  # Because of self.options.host
  host_name(options.delete(:host)) if options.key?(:host)
  # Pass Mustermann opts to compile()
  route_mustermann_opts = options.key?(:mustermann_opts) ? options.delete(:mustermann_opts) : {}.freeze

  options.each_pair { |option, args| send(option, *args) }

  # URLパターンマッチ用のオブジェクトの作成
  # https://github.com/sinatra/mustermann/tree/master/mustermann
  pattern                 = compile(path, route_mustermann_opts)
  # blockとHTTPメソッド、URLからメソッドを生成してwrapperに登録
  method_name             = "#{verb} #{path}"
  unbound_method          = generate_method(method_name, &block)
  conditions, @conditions = @conditions, []
  wrapper                 = block.arity != 0 ?
    proc { |a, p| unbound_method.bind(a).call(*p) } :
    proc { |a, p| unbound_method.bind(a).call }

  # URLパターンと条件とwrapper(処理)を配列にして返却
  [ pattern, conditions, wrapper ]
end

def compile(path, route_mustermann_opts = {})
  Mustermann.new(path, mustermann_opts.merge(route_mustermann_opts))
end

ここまでで、app.rbgetで定義した内容がGET /メソッドで実行されるようになり、URLパターンを解決するオブジェクトも作成されました🙌 Sinatraは、URLパターンの解析にMustermannという独自のGemを使っているようですね👀

github.com

リクエストの取得、処理の開始

ではここから具体的にリクエストが発生してどう定義したメソッドが実行されるのか見ていきます🕵️‍♀️ Rackが起動しているときに実行される処理が#callなので、そこから見ていきましょう。

To use Rack, provide an "app": an object that responds to the call method, taking the environment hash as a parameter, and returning an Array with three elements: https://rack.github.io/

色々やっているのですが、今回のURLの解決と処理の実行に関するところで#callでやっていることは下記の通りです。

  • Rackからの引数(env)を取得
  • リクエストのオブジェクト(Request < Rack::Request)を生成
  • dispatch!でURLパターンに紐づく処理を実行
  • responseを返却
# Rack call interface.
def call(env)
  dup.call!(env)
end

def call!(env) # :nodoc:
  # Rackから渡されたリクエストの内容を取得
  @env      = env
  @params   = IndifferentHash.new
  # Responseオブジェクトの生成
  @request  = Request.new(env)
  @response = Response.new
  template_cache.clear if settings.reload_templates

  @response['Content-Type'] = nil
  # 処理の実行
  invoke { dispatch! }
  invoke { error_block!(response.status) } unless @env['sinatra.error']

  unless @response['Content-Type']
    if Array === body and body[0].respond_to? :content_type
      content_type body[0].content_type
    else
      content_type :html
    end
  end

  @response.finish
end

@requestにURL等のリクエストの情報が格納され、#dispatch!で処理が実行されるようです👀 ちなみにenvに渡される値は下記のようなhashです。

{"GATEWAY_INTERFACE"=>"CGI/1.1",
 "PATH_INFO"=>"/",
 "QUERY_STRING"=>"",
 "REMOTE_ADDR"=>"::1",
 "REMOTE_HOST"=>"::1",
 "REQUEST_METHOD"=>"GET",
 "REQUEST_URI"=>"http://localhost:4567/",
 "SCRIPT_NAME"=>"",
 "SERVER_NAME"=>"localhost",
 "SERVER_PORT"=>"4567",
 "SERVER_PROTOCOL"=>"HTTP/1.1",
 "SERVER_SOFTWARE"=>"WEBrick/1.4.2 (Ruby/2.6.3/2019-04-16)",
 "HTTP_HOST"=>"localhost:4567",
 "HTTP_CONNECTION"=>"keep-alive",
 "HTTP_CACHE_CONTROL"=>"max-age=0",
 "HTTP_UPGRADE_INSECURE_REQUESTS"=>"1",
 "HTTP_USER_AGENT"=>
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.142 Safari/537.36",
 "HTTP_ACCEPT"=>
  "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3",
 "HTTP_ACCEPT_ENCODING"=>"gzip, deflate, br",
 "HTTP_ACCEPT_LANGUAGE"=>"ja,en-US;q=0.9,en;q=0.8",
 "HTTP_COOKIE"=>
  "_dogfeeds_session=jU5sXy4p7LtgpehIempTj%2BTlernru9duDYax8nYGoPo%2BRTOkOK2hB0SY19cYYlCz32yoHKBx%2FFok8bGRc6oUZ%2BS51S2DqpnvvpU6ZB65looxi1wqJVm15tMJLw9BZa4Hon67ewTt7RxsgXDlNxM%3D--ZK%2FIVj5c7IQkes2%2F--9Tv%2Bcdzo8OAPtAGO%2BaofLw%3D%3D",
 "rack.version"=>[1, 3],
 "rack.input"=>#<StringIO:0x00007f86651ba308>,
 "rack.errors"=>#<IO:<STDERR>>,
 "rack.multithread"=>true,
 "rack.multiprocess"=>false,
 "rack.run_once"=>false,
 "rack.url_scheme"=>"http",
 "rack.hijack?"=>true,
 "rack.hijack"=>
  #<Proc:0x00007f86651b9cc8@sinatora_sample/vendor/bundle/ruby/2.6.0/gems/rack-2.0.7/lib/rack/handler/webrick.rb:74 (lambda)>,
 "rack.hijack_io"=>nil,
 "HTTP_VERSION"=>"HTTP/1.1",
 "REQUEST_PATH"=>"/",
 "sinatra.commonlogger"=>true,
 "rack.logger"=>
  #<Logger:0x00007f86651b99d0
   @default_formatter=
    #<Logger::Formatter:0x00007f86651b9930 @datetime_format=nil>,
   @formatter=nil,
   @level=1,
   @logdev=
    #<Logger::LogDevice:0x00007f86651b9868
     @dev=#<IO:<STDERR>>,
     @filename=nil,
     @mon_count=0,
     @mon_mutex=#<Thread::Mutex:0x00007f86651b9750>,
     @mon_mutex_owner_object_id=70107599326260,
     @mon_owner=nil,
     @shift_age=nil,
     @shift_period_suffix=nil,
     @shift_size=nil>,
   @progname=nil>}

URL解決と処理の実行

では実際に処理が行われる#dispatch!を見ていきます。実行されるのは下記のような内容です。(Errorハンドリング等は一旦無視します😇)

  • requestのparamsをparamsに追加
  • #route!を呼び出し紐づく処理の実行
def dispatch!
  # requestのparamsをparamsに追加
  @params.merge!(@request.params).each { |key, val| @params[key] = force_encoding(val.dup) }

  invoke do
    # 静的ファイルへのアクセスに関する処理
    static! if settings.static? && (request.get? || request.head?)
    # こういうbefore_action的なやつの実行
    # before do
    # @note = 'Hi!'
    # request.path_info = '/foo/bar/baz'
    # end
    filter! :before
    # URLの解決と処理の実行
    route!
  end
rescue ::Exception => boom
  invoke { handle_exception!(boom) }
ensure
  begin
    filter! :after unless env['sinatra.static_file']
  rescue ::Exception => boom
    invoke { handle_exception!(boom) } unless @env['sinatra.error']
  end
end

#route!で実際に処理が実行されているので中身を見ていきます。下記が実行されているようです👀

  • request.request_method(GET etc...)を持つrouteを取得
    • routeの中身はcompile!で作ったURL解決用のオブジェクトと条件と処理の配列
    • [[#<Mustermann::Sinatra:"/">,[], #<Proc:0x00007ff033a1e7a0...]]
  • envの上書きと処理の実行を行うblock、URLパターン、条件を引数に#process_routeを呼び出す。
def route!(base = settings, pass_block = nil)
  # GETとかリクエストメソッド単位でrouteの一覧を取得
  if routes = base.routes[@request.request_method]
    routes.each do |pattern, conditions, block|
      取得したrouteの数だけ実行
      returned_pass_block = process_route(pattern, conditions) do |*args|
        env['sinatra.route'] = "#{@request.request_method} #{pattern}"
        route_eval { block[*args] }
      end
      # don't wipe out pass_block in superclass
      pass_block = returned_pass_block if returned_pass_block
    end
  end
end

def route_eval
  throw :halt, yield
end

いよいよ最後です!#process_route内のblock ? block[self, values] : yield(self, values)の部分でURLパターンの解決と処理の実行がされます!🎉

    def process_route(pattern, conditions, block = nil, values = [])
      # リクエストのURLの取得
      route = @request.path_info
      # routeが空文字だったら`/`に置換する。
      route = '/' if route.empty? and not settings.empty_path_info?
      # 最後に/がついてたら除去(-2は最後以外と取得する書き方)
      route = route[0..-2] if !settings.strict_paths? && route != '/' && route.end_with?('/')
      # パターンに一致しなかったらreturn
      return unless params = pattern.params(route)
      params.delete("ignore") # TODO: better params handling, maybe turn it into "smart" object or detect changes
      # encodingの設定
      force_encoding(params)
      # パラメーターの取得
      original, @params = @params, @params.merge(params) if params.any?
      # なんかパラメーターオブジェクトの属性をチェックして処理を切り替えてる?よくわからなかった・・・。
      regexp_exists = pattern.is_a?(Mustermann::Regular) || (pattern.respond_to?(:patterns) && pattern.patterns.any? {|subpattern| subpattern.is_a?(Mustermann::Regular)} )
      if regexp_exists
        captures           = pattern.match(route).captures.map { |c| URI_INSTANCE.unescape(c) if c }
        values            += captures
        @params[:captures] = force_encoding(captures) unless captures.nil? || captures.empty?
      else
        values += params.values.flatten
      end

      catch(:pass) do
        # 条件をクリアしなったら処理を抜ける
        conditions.each { |c| throw :pass if c.bind(self).call == false }
        # yieldで#routeから渡されたblockを実行
        block ? block[self, values] : yield(self, values)
      end
    rescue
      @env['sinatra.error.params'] = @params
      raise
    ensure
      @params = original if original
    end

ここで実際にapp.rbで定義した処理が実行されました!🎊

おさらい

大まかな流れをおさらいすると、

  • まずapp.rbにgetメソッドでURLと実行する処理をblockで定義する
  • #getメソッドで#routeメソッド呼び出し、#complile!メソッドが実行される。
  • complile!メソッド内で、URL解決用のオブジェクト、条件、Procオブジェクトを@routeに登録される。
  • Rackの仕組みでServerへリクエストが発生した際に#callが実行され、@request等の取得と#dispatch!メソッドが実行される
  • #dispatch!メソッドで、パラメーターが処理され、#route!メソッドが呼ばれる。
  • #route!は、@requestのメソッドからrouteの一覧を取得し、#route_eval!メソッドにblockを渡して実行するblock、URLパターン、条件を引数に#process_routeを呼び出す。
  • #process_routeが実際にURLの解決と条件のチェック、そして処理の実行を行う🎉

こんな感じのようですね!🕵️‍♀️

おわりに

実際に処理が実行される流れをみてみましたが、Server起動よりも結構複雑ですね💦(特にURL解決周り)
ですがRackを継承したSinatra独自のリクエストクラスを使っていたものの、リクエストの取得等がRackの部分で処理が行われていて以外とシンプルでした(Rackすごい・・・!)👀 引き続きRackは理解を深めていきたいですね💪

しかしSinatraでも結構複雑だからrailsはどうなってるんだろう🙄

次回はView(erb)の解決とResponseのbodyの生成部分を読んでみようと思います(これで一通り動きの概要は理解出来そうです)🙋

それでは👋

参考

sinatrarb.com

rack.github.io

qiita.com

Ruby: Sinatraの`ruby app.rb`からWeb Serverが起動するまでのコードを読んでみた。

今まで暗黙的に使ってたRailsのコードをちゃんと読もうと思ったのですが、最初からRailsのコードを読んでいくと深みにはまりそうだったので、まずはシンプルなsinatraの実装を読んでみようということで、Sinatra(ver 2.0.5)のWeb Serverが起動するまでのコードを読んでみました🎩

sinatrarb.com

※ちなみにSinatraの由来は、アメリカの歌手「フランク・シナトラ」が由来みたいですね🇺🇸

Sinatraアプリの実行

今回は下記のようなファイルを作成し、

# app.rb
require 'sinatra'

get '/' do
  'Hello world!'
end

実行した際に下記のような起動メッセージが表示されるまでを見ていきます👀

$ ruby myapp.rb 
[2019-07-27 17:36:15] INFO  WEBrick 1.4.2
[2019-07-27 17:36:15] INFO  ruby 2.6.1 (2019-01-30) [x86_64-darwin17]
== Sinatra (v2.0.5) has taken the stage on 4567 for development with backup from WEBrick 

Sinatraのメインライブラリの読み込み

まずはapp.rbの最初に記載されたrequire 'sinatra'が実行され、sinatraが読み込まれます。

https://github.com/sinatra/sinatra/blob/master/lib/sinatra.rb

sinatraの中では、'sinatra/main'が読み込まれて、enable :inline_templatesが実行されます。

require 'sinatra/main'
enable :inline_templates

enableメソッドが呼ばれるとset(:inline_templates, true)が実行され、singleton_classにメソッドが定義されます。 https://github.com/sinatra/sinatra/blob/master/lib/sinatra/base.rb#L1256

def enable(*opts)
  opts.each { |key| set(key, true) }
end

set(:inline_templates, true)はなんか、ゴニョゴニョして最後はdefine_singletonで、singleton_classinline_templatesへのsetterとgetterが定義される。 https://github.com/sinatra/sinatra/blob/master/lib/sinatra/base.rb#L1220

def set
  # ︙ なんかオプション値のErrorチェックしてraiseとかする処理
  setter = proc { |val| set option, val, true }
  getter = proc { value }
  # ︙ なんかProcとかHashを渡された時を考慮してgetter、setterを調整する処理
  define_singleton("#{option}=", setter)
  define_singleton(option, getter)
  define_singleton("#{option}?", "!!#{option}") unless method_defined? "#{option}?"
  self
end

# singleton_classにメソッドを定義する
def define_singleton(name, content = Proc.new)
  singleton_class.class_eval do
    undef_method(name) if method_defined? name
    String === content ? class_eval("def #{name}() #{content}; end") : define_method(name, &content)
  end
end

sinatra/mainを読み込むと、Sinatra::Applicationが定義されます。 定義時にsingleton_classへの実行ファイル及び実行判定のメソッドの定義と、実行時のオプションのパース、そしてWebServerの起動処理が実行されます。 https://github.com/sinatra/sinatra/blob/master/lib/sinatra/main.rb

require 'sinatra/base'

module Sinatra
  class Application < Base
    # app_fileで実行ファイルのパス(app.rb)にアクセスできるようにsingleton_classにメソッドを定義
    set :app_file, caller_files.first || $0
    # runでapp_fileの値と引数で渡ってきたファイル名を絶対パスで比較するようにsingleton_classにメソッドを定義
    # sinatraでは、実行中のファイルと引数で渡されたファイルが等しい場合に実行(ruby app.rb)とみなしている?
    set :run, Proc.new { File.expand_path($0) == File.expand_path(app_file) }


    # オブション周りの設定
    if run? && ARGV.any?
      require 'optparse'
      OptionParser.new { |op|
        op.on('-p port',   'set the port (default is 4567)')                { |val| set :port, Integer(val) }
        op.on('-o addr',   "set the host (default is #{bind})")             { |val| set :bind, val }
        op.on('-e env',    'set the environment (default is development)')  { |val| set :environment, val.to_sym }
        op.on('-s server', 'specify rack server/handler (default is thin)') { |val| set :server, val }
        op.on('-q',        'turn on quiet mode (default is off)')           {       set :quiet, true }
        op.on('-x',        'turn on the mutex lock (default is off)')       {       set :lock, true }
      }.parse!(ARGV.dup)
    end
  end

  # 例外が発生してない、かつ実行の場合に、インタプリタ終了時(app.rbの実行完了後)にrun!を実行
  # https://docs.ruby-lang.org/ja/latest/method/Kernel/m/at_exit.html
  at_exit { Application.run! if $!.nil? && Application.run? }
end

ここまでがsinatraのメインモジュールの読み込み処理です📦

Web Serverの起動処理

実際のServer起動処理を見ていきます👀
まずrun!では、RackHandlerのオブジェクトを取得し、設定を反映後Web Serverを起動します。

https://github.com/sinatra/sinatra/blob/master/lib/sinatra/base.rb#L1448

def run!(options = {}, &block)
  return if running?
  set options
  # RackHandlerのオブジェクトを取得(WebServerの本体?)してRename
  handler         = detect_rack_handler
  handler_name    = handler.name.gsub(/.*::/, '')
  # 設定の反映
  server_settings = settings.respond_to?(:server_settings) ? settings.server_settings : {}
  server_settings.merge!(:Port => port, :Host => bind)

  # Web Serverの起動
  begin
    start_server(handler, server_settings, handler_name, &block)
  rescue Errno::EADDRINUSE
    $stderr.puts "== Someone is already performing on port #{port}!"
    raise
  ensure
    quit!
  end
end

実際のWeb Serverの起動処理は、handler.runでWeb Serverを起動し、標準出力に、== Sinatra (v2.0.5) has taken the stage on 4567 for development with backup from WEBrickが表示しています🎉

https://github.com/sinatra/sinatra/blob/master/lib/sinatra/base.rb#L1518

# Starts the server by running the Rack Handler.
def start_server(handler, server_settings, handler_name)
  # Ensure we initialize middleware before startup, to match standard Rack
  # behavior, by ensuring an instance exists:
  prototype
  # Run the instance we created:
  handler.run(self, server_settings) do |server|
    # 終了 or cgiじゃなければ(?)開始のメッセージを表示
    # suppress_messages?の意味がよく分からなかった。。。
    unless suppress_messages?
      $stderr.puts "== Sinatra (v#{Sinatra::VERSION}) has taken the stage on #{port} for #{environment} with backup from #{handler_name}"
    end

    setup_traps
    set :running_server, server
    set :handler_name,   handler_name
    server.threaded = settings.threaded if server.respond_to? :threaded=

    yield server if block_given?
  end
end

とりあえず、このような流れでWeb Serverが起動しているようですね!

おわりに

今回はSinatraの実行からWeb Serverを起動するまでを見てみました。 実際のWebServerの起動についてはもっと手順が必要だと思っていたのですが、意外とシンプルでしたね💦 ※Rack::Handler.getでWebServerのオブジェクトを取得してRack::Handler.runする感じ。

Rackにかなりラップされているっぽい、かつRailsを扱っていてもRackはよく聞くので、こちらもちゃんと理解したいですね😥

github.com

参考

yktwww.hatenablog.com

Ruby: OpenStructが便利な気がしたので使い方とかメモ

最近ruby標準のOpenStructライブラリが便利だなと思ったので、使い方とかメモ✍

OpenStructとは?

OpenStructrubyの標準ライブラリです。マニュアルには下記のように記載されてます。

要素を動的に追加・削除できる手軽な構造体を提供するクラスです。 OpenStruct のインスタンスに対して未定義なメソッド x= を呼ぶと、 OpenStruct クラスの BasicObject#method_missing で捕捉され、そのインスタンスインスタンスメソッド x, x= が定義されます。 この挙動によって要素を動的に変更できる構造体として働きます。 class OpenStruct (Ruby 2.6.0)

要するに要素を動的に追加できる手頃な構造体を表現するクラスです📦
使用例は下記のような感じです👀

require 'ostruct'
# 構造体を作って動的にpropertyを追加
son = OpenStruct.new
son.name = "Thomas"
son.age = 3

# hashでもOK
son = OpenStruct.new({ :name => "Thomas", :age => 3 })
p son.name #=> "Thomas"

メソッドチェインでpropertyにアクセスできるのが便利ポイントかなと思っています🙌

具体的な使用例

Testするときとかに、わざわざ仮のClassとか定義するのはちょっと大変なのでメソッドチェインでアクセスできるMockを作るときとかに便利かなと👀

let(:mock_post) { OpenStruct.new(title: 'title', body: 'body') }
expect(Hoge.call(mock_post)).to eq fuga

あとは下記のようなCSVをパースして、処理のオブジェクトを作り返却後、何かしらの処理を行うようなプログラムがあったとします。items.reject(&:done)をやりたいので、Hashではなくメソッドチェインでアクセスできる何らかのオブジェクトを返却したいです。そういう場合に、下記みたいになattr_readerだけ定義されたClassとか作ってしまうこともあるかと思うのですが、、、

def download(path)
  items = CsvParser.call(path)
  items.reject(&:done).each do
    # なんかしら処理して保存するような処理
  end
end

class CsvParser
  require 'csv'
  
  def self.call(path)
    table = CSV.table(path)
    table.map { |row| Item.new(row) }
  end

  class Item
    def initialize(title:, body:, done:)
      @title = title
      @body = body
      @done = done
    end

    attr_reader :title, :body, :done
  end
end
p download('./date.csv')

OpenStructを使うとかなりスッキリかけます✨またClassのように受け取るCSVの定義が変わってもattr_readerのを変更しなくてもいいところも良いのかなと!(ケースバイケースですが)

def download(path)
  items = CsvParser.call(path)
  items.reject(&:done).each do
    # なんかしら処理して保存するような処理
  end
end

class CsvParser
  require 'csv'

  def self.call(path)
    table = CSV.table(path)
    table.map { |row| OpenStruct.new(row) }
  end
end
p download('./date.csv')

おまけ:パフォーマンスの話

パフォーマンスについても調査したので書いときます。そもそもなんで調査しようと思ったかというとOpenStructを使った方がClass定義するより、パフォーマンスもいいよねって話を書こうと思ったんですが(Classより構造体の方が軽いと思って)、調べたこと無かったので実際にruby標準のBenchmarkライブラリを使ってベンチマークをとってみましたが、Classの方がちょっと(1.15倍ぐらい)効率良かったです😭

👇ベンチマークをとったコードはこちら。100,000回、attr_readerを定義したClassをnewするのとOpenStruct.newを比較しました。(あとオマケに構造体とHashも測ってみた👀)

require 'benchmark'
require 'ostruct'

class Sample
  def initialize(hoge:, fuga:)
    @hoge = hoge
    @fuga = fuga
  end

  attr_reader :hoge, :fuga
end

struct = Struct.new('SampleStruct', :hoge, :fuga)

n = 100_000
Benchmark.bm(10) do |b| b.report(:class) { n.times { Sample.new(hoge: 'hoge', fuga: 'fuga') } } }
Benchmark.bm(10) { |b| b.report(:ostruct) { n.times { OpenStruct.new(hoge: 'hoge', fuga: 'fuga') } } }
Benchmark.bm(10) { |b| b.report(:struct) { n.times { struct.new(hoge: 'hoge', fuga: 'fuga') } } }
Benchmark.bm(10) { |b| b.report(:hash) { n.times { Hash.new(hoge: 'hoge', fuga: 'fuga') } } }

結果がこちら、
🥇Hash: 単純なHashの生成
🥈struct: 構造体の生成
🥉class: atrr_readerだけのclass
😭ostruct: OpenStructの生成

                 user     system      total        real
class        0.054316   0.000390   0.054706 (  0.054914)
                 user     system      total        real
ostruct      0.062140   0.000261   0.062401 (  0.062630)
                 user     system      total        real
struct       0.034693   0.000268   0.034961 (  0.035296)
                 user     system      total        real
hash         0.029182   0.000254   0.029436 (  0.029677)

Classの方が効率がいいのが意外でしたが、よくよく考えるとOpenStructは動的にpropertyを定義できるので、attr_readerだけののクラスの場合だったら、Classの方がパフォーマンスがいいのも頷ける感じですね🤔

おわりに

今回は、OpenStructについて書いてみました。パフォーマンスの件は、若干あれでしたがメソッドチェインでアクセスしたいだけなのにActiveRecordのオブジェクトを使っているところ等があれば、OpenStructを使った方が効率が良さそうです👀

rubyの標準ライブラリは見てみると便利なものがたくさんあるので、読んで見るとよさそうですね🙌 https://docs.ruby-lang.org/ja/latest/library/index.html

v3.0が出たのでfeedjiraがどう動いているか、なんとなくコードを読んでみた🦖

いろいろなファイル形式のパース処理を実装するときに便利なfeedjiraですが、最近メジャーバージョンがあがってv3.0.0になりました🎉

github.com

feedjiraを使ってxmlをパースする処理のサンプルは下記の通りです。

xml = HTTParty.get(url).body
feed = Feedjira.parse(xml)
feed.entries.first.title

Feedjira.parsexmlをパースしてオブジェクトを取得することが出来ます。

では実際に実装を見ていきます。

Feedjira.parse

Feedjira.parseは、引数にparse対象のxmlとParserのオブジェクト、ブロックを引数にとるメソッドです。 引数にparserが無かった場合、parser_for_xmlを呼び出しparse可能なparserを探して返します。これにより複数のparserから変換可能なparserを探索できるみたいですね(便利)※引数でもparser_for_xmlでもparserが見つからなかったらraiseします。

その後はxmlとブロックを引数にparser.parseを呼び出してParseします。

module Feedjira
  def parse(xml, parser: nil, &block)
    parser ||= parser_for_xml(xml)

    if parser.nil?
      raise NoParserAvailable, "No valid parser for XML."
    end

    parser.parse(xml, &block)
  end
  module_function :parse

  def parser_for_xml(xml)
    start_of_doc = xml.slice(0, 2000)
    Feedjira.parsers.detect { |klass| klass.able_to_parse?(start_of_doc) }
  end
  module_function :parser_for_xml

https://github.com/feedjira/feedjira/blob/master/lib/feedjira.rb#L57

ちなみにFeedjira.parsersにはデフォルトで下記が設定されていて、parsersに値を追加することで独自のparserもparser_for_xmlで取得する対象に含めることが出来ます。追加方法は、README(https://github.com/feedjira/feedjira#parsers)に記載されています。

module Feedjira
  module Configuration
    def default_parsers
      [
        Feedjira::Parser::RSSFeedBurner,
        Feedjira::Parser::GoogleDocsAtom,
        Feedjira::Parser::AtomYoutube,
        Feedjira::Parser::AtomFeedBurner,
        Feedjira::Parser::AtomGoogleAlerts,
        Feedjira::Parser::Atom,
        Feedjira::Parser::ITunesRSS,
        Feedjira::Parser::RSS,
        Feedjira::Parser::JSONFeed,
      ]
    end

https://github.com/feedjira/feedjira/blob/master/lib/feedjira/configuration.rb#L58

Parser#parse

実際にparserに使われているClassは、下記のような実装になっています。elementでRSSの各要素の定義を記載しています。elements :item, class: RSSEntryは、RSSEntryのオブジェクトの配列を持つことを表していて、RSSEntryも同様な形でfeedjira内で定義されています(https://github.com/feedjira/feedjira/blob/master/lib/feedjira/parser/rss_entry.rb)

parseメソッドmodule FeedUtilitiesに定義されているので、そちらも見てみます。

ちなみにelementとかの記法は、sax-machine(https://github.com/pauldix/sax-machine)というgemの機能によるものです。

module Feedjira
  module Parser
    # Parser for dealing with RSS feeds.
    # Source: https://cyber.harvard.edu/rss/rss.html
    class RSS
      include SAXMachine
      include FeedUtilities
      element :description
      element :image, class: RSSImage
      element :language
      element :lastBuildDate, as: :last_built
      element :link, as: :url
      element :rss, as: :version, value: :version
      element :title
      element :ttl
      elements :"atom:link", as: :hubs, value: :href, with: { rel: "hub" }
      elements :item, as: :entries, class: RSSEntry

      attr_accessor :feed_url

      def self.able_to_parse?(xml)
        (/\<rss|\<rdf/ =~ xml) && !(/feedburner/ =~ xml)
      end
    end
  end
end

https://github.com/feedjira/feedjira/blob/master/lib/feedjira/parser/rss.rb

しかし、FeedUtilitiesにも実際のparse処理はsuperで呼び出されていて、feedjira上にはありません。。。

module Feedjira
  module FeedUtilities
    module ClassMethods
      def parse(xml, &block)
        xml = strip_whitespace(xml)
        xml = preprocess(xml) if preprocess_xml
        super xml, &block
      end

https://github.com/feedjira/feedjira/blob/master/lib/feedjira/feed_utilities.rb#L13

実際の処理はsax-machine上にありそうです。

SAXMachine#parse

SAXMachine#parseの定義を見てみると、xmlon_errorとon_warningを引数に、handler_klassをSAXMachine.handlerから動的に生成して、 オブジェクトを生成しxmlを引数にhandler_klass#sax_parseを呼び出しています。 ※SAXMachine.handlerは、nokogiri、Oga、Ox等がReaderが指定されています。

module SAXMachine
  def parse(xml_input, on_error = nil, on_warning = nil)
    handler_klass = SAXMachine.const_get("SAX#{SAXMachine.handler.capitalize}Handler")

    handler = handler_klass.new(self, on_error, on_warning)
    handler.sax_parse(xml_input)

    self
  end

https://github.com/pauldix/sax-machine/blob/master/lib/sax-machine/sax_document.rb#L7

今回は、nokogiriを使ったhandlerのsax_parseの実装を見てみます。NokogiriのParserのオブジェクトを生成して、xmlを引数にparseを実行しているだけのようです。実際のparse処理はnokogiri側に定義されているようですね。

module SAXMachine
  class SAXNokogiriHandler < Nokogiri::XML::SAX::Document
    def sax_parse(xml_input)
      parser = Nokogiri::XML::SAX::Parser.new(self)
      parser.parse(xml_input) do |ctx|
        ctx.replace_entities = true
      end
    end

https://github.com/pauldix/sax-machine/blob/master/lib/sax-machine/handlers/sax_nokogiri_handler.rb

nokogiri側の実装は、下記に記載されていますが今回はfeedjiraの実装を知りたかっただけで、nokogiriのコードを読むと深みにハマりそうなので、やめておきます。。。 https://github.com/sparklemotion/nokogiri/blob/master/lib/nokogiri/xml/sax/parser.rb#L79

おわりに

今回は、feedjiraのコードを読んでみました。feedjiraはRSS等の主要なファイル形式をsax-machineというgemを使ってSAX形式で定義して、Nokogiri等のParseでparseするgemということがわかりました。 feedjiraの範囲では、sax-machineを使って主要形式のSAX形式の設定をSupportしているだけなので、中身は意外とシンプルでした。お手軽に主要な形式でparse処理を実装できるのは良い感じですね。

しかし独自のファイル定義メインで使うならfeedjiraで用意されているファイル形式が使えないので、変換のためのファイル定義を表すClassを新規に作らないといけないので、ruby標準のRSSとか他のを使っても良さそうな気もしました👀

docs.ruby-lang.org

ruby2.7の新機能メソッド参照演算子を使ってみよう

5/30にRuby 2.7.0-preview1がリリースされましたね🎉

www.ruby-lang.org

2.7の主要な新機能としては下記が記載されています👀

  • Pattern Matching
  • メソッド参照演算子 .:
  • 始値省略範囲式 ..100
  • 番号指定パラメータ @1
  • Enumerable#tally

今回は、中でもメソッド参照演算子が個人的に結構気になっているので、それについて書いていこうかなと思います✍

メソッド参照演算子とは

メソッド参照演算子とは.:という演算子でメソッドオブジェクトを取得できる演算子です。

サンプルコードは下記のような感じです。

irb(main):001:0> "0".:to_i
=> #<Method: String#to_i>

今まではClass#methodで取得することが出来ましたが、より簡単にかけるようになったという感じですね👀

メソッドオブジェクトとは

メソッドオブジェクトはメソッドから生成されたProcのようなものです。同じように#callで実行できます。

irb(main):001:0> "0".:to_i
=> #<Method: String#to_i>
irb(main):002:0> "0".:to_i.call
=> 0

Procとの違いとか詳細は、Rubyリファレンスマニュアルを参照してくださいm( )m

docs.ruby-lang.org

どう使えるのか?

こういうコレクションの各要素に対してオブジェクト以外のメソッドを適用したいときに便利そうですね👀

# 2.6
[0, 1, 2].map { |i| i + 1 }
# 2.7
[0, 1, 2].map(&1.:+)

番号指定パラメータでもいい感じにかけるかもしれないですが、メソッド参照演算子の方が(&:hoge)といった見慣れた形式でかけるので見やすそうですね。(下記のケースだとそうでもないですが。。。)

[0, 1, 2].map(&1.:+)
=> [1, 2, 3]
[0, 1, 2].map{ @1 + 1 }
=> [1, 2, 3]

実践的な例だとcurrent_userと合致するuserを取得するとか、メソッド参照演算子を使うと&を使ってスッキリかけますね👍

# 2.6
users.find { |user| corrent_user == user }
# 2.7
users.find(&corrent_user.id.:eql?)

hashの配列をもとにモデルのオブジェクトの配列を取得するとかも、いい感じにかけそうですね✨

# 2.6
attrs.map { |attr| Blog.new(attr) }
# 2.7
attrs.map(&Blog.:new)

おわりに

今回はruby 2.7の新機能のメソッド参照演算子を紹介してみました。2.7ではパターンマッチングや番号指定パラメータが話題になることが多いですが、irbJITの改善とか他にもいろいろ注目すべき点がありそうですね🙌今から2.7のリリースが楽しみです✨

参考

qiita.com

rails勉強BotのRailsのバージョンを6.0.0.rc1にアップグレードしました 🎉

みなさん、こんばんは。まどぎわです(・∀・)

運用しているrails勉強Botrailsのバージョンを5.2.3から6.0.0.rc1にアップグレードしました🎉

twitter.com

これでrails 6の予習がはかどりますね!!

今回は、rails 5.2.3からrails 6.0.0.rc1へのアップグレード手順をメモしておきます✍

手順

アップグレード対応を行ったPRはこちらです。 github.com

流れは下記のような形で実行しました👀 * rails guideのアップグレードガイドを読む * rails 設定ファイルの更新を把握 * bundle update でrailsのアップグレード * rails app:updateで設定ファイルを更新 * テストが通ることを確認

rails guideのアップグレードガイドを読む

rails guideにはすでに5.2から6へのアップグレードガイドが記載されているので、まずはそれをみました🙋

railsguides.jp

変更点として下記が記載されていましたが、今回はTwitterBotのAPIとして運用しているだけなので特に何も対応はしませんでした👀

  • 2.1 Force SSL
  • 2.2 署名済みまたは暗号化済みcookieのpurpose情報がcookie内部に埋め込まれるようになった
  • 2.3 Action Cable JavaScript APIの変更

業務運用しているアプリケーションの場合はcookieの互換性が失われる、 Action Cableまわりに大きな変更が入っているので注意ですね⚠

rails 設定ファイルの更新を把握

rails diffというサイトで5.2.36.0.0.rc1rails newで作成されるファイルのdiffを確認できるサイトがあるので、それを確認しました👀 http://railsdiff.org/5.2.3/6.0.0.rc1

sass-railsuglifierといったフロント関連のgemが消えたようですね👀 webpackerも入ったことですし、こういったものはgemで管理するのではなくpackage.jsonで管理するようになりそうですね。

-gem 'sass-rails', '~> 5.0'
-# Use Uglifier as compressor for JavaScript assets
-gem 'uglifier', '>= 1.3.0'
-# See https://github.com/rails/execjs#readme for more supported runtimes
-# gem 'mini_racer', platforms: :ruby

coffescriptが消えたのも印象深いですね☕

-# Use CoffeeScript for .coffee assets and views
-gem 'coffee-rails', '~> 4.2'

app/assets/javascripts/application.jsが初期で作成されなくなったようで。。。

 //= link_tree ../images
-//= link_directory ../javascripts .js
 //= link_directory ../stylesheets .css

これはもうJSはSproketsではなくWebpackerで管理するような流れっぽいですね。

rails db:prepareを使ってsetupを行うようになってみたいですね👀

   puts "\n== Preparing database =="
-  system! 'bin/rails db:setup'
+  system! 'bin/rails db:prepare'

rails db:prepare  Add db:prepare rake task. by robertomiranda · Pull Request #35768 · rails/rails · GitHub

マルチDB周りの設定が各environmentのファイルに追加されました👀(楽しみ)

+  # config.active_record.database_selector = { delay: 2.seconds }
+  # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver
+  # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session

puma.rbでスレッドの数のMINとMAXを指定できるようになったみたいですね。

+max_threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }
+min_threads_count = ENV.fetch("RAILS_MIN_THREADS") { max_threads_count }
+threads min_threads_count, max_threads_count

test_helperに並列テストの設定が入ってました。

class ActiveSupport::TestCase
+  # Run tests in parallel with specified workers
+  parallelize(workers: :number_of_processors)

気になったのは、こんなところですかね👀

bundle update railsで関連gem含めてアップグレード

bundle updateはbundle update --conservative railsで行い最低限のgemだけ更新するようにしました。 最終的に実行したコマンドは下記です 💻

bundle update --conservative rails activesupport actionpack actionview activemodel railties

rails app:updateで設定ファイルを更新

そしてrails app:updateで設定ファイルを更新しました。 基本的に特に設定周りをいじってなかったので、config/application.rb以外は上書きして、config/application.rbについては下記変更のみを入れました🙋

-    config.load_defaults 5.2
+    config.load_defaults 6.0

テストが通ることを確認

最後に既存のテストを実行して全て通ることを確認しました🍏

DEPRECATION WARNING: Single arity template handlers are deprecated. Template handlers must
now accept two parameters, the view object and the source for the view object.
Change:
  >> JbuilderHandler.call(template)
To:
  >> JbuilderHandler.call(template, source)
 (called from <top (required)> at /Users/morita.jun/Documents/repo/ruby/rails_study_bot/config/environment.rb:5)
...................................................

Finished in 0.34418 seconds (files took 2.79 seconds to load)
51 examples, 0 failures

実行時に出ていたWARNINGについては、railsのmasterでは対応したPRがマージされているようなので一旦置いときましたm( )m github.com

おわりに

今回は運用していたサービスをrails 5.2.3から6.0.0.rc1にアップグレードしたので手順をメモしました! そろそろrails 6もstableが出るかもしれないですし、予行練習にちょうどよかったです🙌 (まぁ実際の業務で触るようなアプリケーションとは規模が全然違うので、こんなにすんなり行くはずないんですが・・・)

割と設定ファイルの差分を見るとアップグレードの全体感を把握できていいですね✨

参考資料

inside.pixiv.blog

Rubyで特定の条件を満たすまで待機(Sleep)する

はじめに

非同期でAPIにPOSTを投げて、データベースにレコードが作成されてからテストを始めたいみたいなときに、単純にsleep 1みたいなことをやってしまうとテスト実行環境のパフォーマンスによって落ちてしまう可能性があり、あまり好ましくないですよね💧

そういうときに役に立ちそうな特定の条件がtrueを返すまでsleepするメソッドを作ったのでメモしておきます✍

実際のコード

下記が実際のコードです、wait_conditionにブロックを渡すのそのblockがtrueを返すまで処理を待機します。一応引数にintevalを渡すと渡したブロックを実行する間隔を指定できて、limitを渡すと待機する最大の秒数を定義します。
※limitに指定した秒数を超えても最低1回はブロックが実行されてしまうので、注意してください⚠

def wait_condition(interval: 0.5, limit: 10, &condition)
  start_at = Time.now
  raise "must give block!" unless block_given?
  while !condition.call do
    sleep interval
    break puts("time out") if (Time.now - start_at) > limit
  end
end

def two_sec_afert_true
  sleep 2
  true
end

wait_condition { two_sec_afert_true }
puts "fire!"

ちなみに上記のコードを実行すると約2秒後に"fire!"がが出力されます🔥

例: DBにレコードが作成されるまで待つ

内部のAPIの呼び出しを行い、実際にデータが作成されるまで待つようなケースでは下記のようなコードで待つことができます⏳
※Mock作ったほうがいいとかそういうケースもありますが、、、

RSpec.describe Book, type: :model do
  def wait_condition(interval: 0.5, limit: 10, &condition)
    start_at = Time.now
    raise "must give block!" unless block_given?
    while !condition.call do
      sleep interval
      break puts("time out") if (Time.now - start_at) > limit
    end
  end

  before do
    # Bookを作成するAPIにPOSTを投げる処理を実行
    wait_condition { !Book.count.zero? } # Bookが1件でも作成されるまで待機
  end

  it 'Bookが作成されること' do
    expect(Book.take.title).to eq 'book title'
  end
end

おわりに

sleep 1とかを使ってしまうとたまに落ちるテストになってしまう可能性があるので、なるべく特定の条件で待つようにするか、Mock等を用意するようにしたいですね👀

参考

docs.ruby-lang.org