マイペースなRailsおじさん

Ruby、Ruby on Rails、オブジェクト指向設計を主なテーマとして扱います。だんだん大きくなっていくRuby on Rails製プロダクトのメンテナンス性を損なわない方法を考えたり考えなかったりしている人のブログです。

gemの概要を雑に把握するシリーズ② ruby-i18n/i18n

概要

Ruby用の国際化(i18n)機能を提供するgem。ymlで書かれた設定ファイルを、規定の構造で設定しておくと、I18.tメソッドで翻訳を取得できる。

github.com

仕組み

I18n.tの引数と、現在設定されているロケールを頼りにymlに書かれた対応関係に従って翻訳を返す。 I18n::BackEnd::Simpleに手を加えたい人もいるだろうから、Simpleを拡張できるようにした。オリジナルのTransformerをつくったら、こうやって読み込めば使える。

I18n::Backend::Simple.send(:include, I18n::Backend::Transformers)

gettextにも対応。.poにmsgidとmsgstrを記述する。_メソッドに元になるテキストを引数として渡す。

gettext メッセージIDとメッセージの対応ではなく、メッセージと別の言語のメッセージで対応を取る方式。ある特定の言語のメッセージをIDと捉えると言ってもいい。

気になる実装

ロケールの設定がマルチスレッド考慮している。グローバル変数として使いたくなる値はThread.current[]でスレッドローカルにする。

翻訳の機構の本体は、「backend」と呼ばれていて、configで付け替えられる。 tは配列にも対応。

transliterate(翻字)機能もある。

翻字(ほんじ、英: transliteration)とは、言語学において特定の言語を記した文字表記を別の文字による表記に移すことをいう。翻字は印刷物の発行する際などに技術的な問題や読者の便宜のために行われる。

翻字 - Wikipedia

localizeもある。 https://github.com/ruby-i18n/i18n/blob/master/lib/i18n/backend/simple.rb#L89 lookupに本体実装が有る。 ファイルの読み込みはload_fileからload_xxxを呼んだあとにstore_translations(バックエンド固有)を使う。拡張子ごとに読み込み方法を変えるというの、メタプロの使い所としてかなり美味しい。

Fiber Schedulerの使い方

この記事では、Ruby3で追加される予定のScheduled Fiberを紹介します。Ruby 3.0.0 preview1の情報であり、この機能はまだまだ議論と開発の真っ只中です。実際に正式にリリースされるのものとはインターフェースや実装が異なることをご理解ください。

Ruby本体の事情にかなり疎いので、いろいろと想像で補っている部分があります。間違ったこと言っていたら教えてくださると助かります。

Fiber Scheduler

とは

Fiber Schedulerは、Ruby3で追加される予定の、Fiberの新機能です。IOを扱うファイバーを複数作って、これらを適切にコンテキストスイッチしてあげると、Threadを使うよりも高い並行性を得られるという話があります。Fiber Schedulerは、このコンテキストスイッチを実装しやすくするための機能で、もともとはAuto Fiberと呼ばれていました。

経緯

これらのチケットで議論されました。もともとAuto Fiberは、Ruby Coreの方で適切なコンテキストスイッチを提供してあげようという提案でした。紆余曲折あって、Fiberのブロッキング操作に対してフックできるインターフェースを提供しようという事になったようです。

Ruby 3.0 preview1にマージされたのはこのissueで作られたPRです。

PR

何がうれしいか?

Fiber Schedulerが開発された目的は、既存のコードをあまり変更せずに並行性を向上させることです。

Fiberを使ってIO待ちの間、うまいことコンテキストスイッチをしてやると、Threadよりも効率よく非同期処理ができる、という話があります。

IO待ちのファイバーを適切に切り替えて行けるSchedulerを実装しておくと、IO#reade, IO#writeなどのブロッキング処理を、Fiberの提供する軽量スレッドを使って非同期で実行できるようになります。 うまく行けば、既存のロジックを変更せず、Fiber.scheduleで囲むだけで、同時接続に対してスケールできます。

この機能を作っているSamuel Williamsさんによる説明があるので、英語がわかる方はこちらをどうぞ。 Ruby Concurrency Final Report

使い方

Schedulerを作り、Fiber.schedulerに非同期で実行したい処理を書きます。

Schedulerのインターフェースはこちらで定義されています。 このインターフェースを実装したクラスをThread.current.schedulerに設定したうえで、Fiber.scheduleにブロックを渡すと、渡したブロックで実行されるブロッキング処理を行う際、Schedulerに実装されたメソッドをフックします。

class MyScheduler
  def io_wait(io, events, timeout)
  # 実装する
  end
  # 省略
end
Thread.current.scheduler = MyScheduler.new

Fiber. schedule do
  # この中身はMySchedulerによってコンテキストスイッチしながら非同期で実行される
end

つかってみる

以前紹介していた、nio4rを使ってselectすることで、ノンブロッキング処理ができるSchedulerを作って使っています。 Fiberとnio4rでサーバー - まいおじ

gist.github.com

これで一応動くんですが、acceptが動かなかったりするので、なにか間違えてるかもしれません。ソースコード読みながら何がおかしいか探っていく予定。

gemの概要を雑に把握するシリーズ① intridea/multi_json

github.com

概要

システムで利用可能なJSONパーサーのうち、最適なものを選んで使ってくれるJSONパーサーのラッパー。 できるだけ早いものを使うようにしていて、独自に優先順位を付けている。 候補がなにも使えない環境の場合はOkJSONを使う。

仕組み

Adapterクラスで、9種類のJSONパーサに同じインターフェイスをもたせている。偉い。 デザインパターンパターン的にはAdapterパターンかFacadeパターン。 どのAdapterを使うかは、defined?(::Adapter)みたいにして定数の存在を確認する docs.ruby-lang.org

Fiberとnio4rでサーバー

Fiberとnio4rを組み合わせてサーバーを作りました。あたまバグりそうになるのでよかったら見てってください。

Fiberとnio4rを組み合わせる

Fiberは、軽量なスレッドを作成できます。このスレッド上で、途中まで実行した処理を中断し、再開することができます。 この特徴をうまく使ってやると、nio4rのselectと組み合わせて効率的な通信処理を実現できます。

非常にややこしいので、注意してください。

クライアントとの通信

Fiber.newで、ブロックにとった処理を行うFiberを作成できます。作成した時点では処理は実行されません。Fiber#resumeを呼ぶと、Fiber.yieldまで処理を実行したあと、呼び出し元に戻ります。この時点で、Fiberは処理が「中断」された状態になります。このあと、再びFiber#resumeを呼ぶと、中断されたところから処理を再開します。

f = Fiber.new do
  puts 'Hello1'
  Fiber.yield
  puts 'Hello2'
end

f.resume # => Hello 1
f.resume # => Hello 2

ここで、nio4rのselectと組み合わせて効率的な通信を行うことを考えます。selectは複数のIOを待って、利用可能なものを選択できます。ブロッキングが起こりそうなときに処理を中断してselectし、またなくても良くなったら処理を再開するようにFiberを制御すると、Fiberはノンブロッキングに処理を実行できます。

コードにするとこういう感じです。 ioは、ノンブロッキング通信が可能な状態で渡されるソケットです。@selectorNIO::Selectorです。

Fiber.new do |io|
  message = io.read_nonblock 5000
  @selector.register io, :w
  Fiber.yield

  io.write_nonblock "response"
  io.close
end

ノンブロッキングでreadしたら、次は書き込みを行いたので、書き込みを待つようにselectorに登録します。こうなったらいったん処理を抜けます。 処理が再開されたら、それはすなわち書き込みができる状態ということなので、ソケットに書き込みます。

処理の振り分け

振り分ける側は、適切な(=ノンブロッキングで処理が可能な)fiberをresumeできるように、@fibersにioとfiberの組み合わせを持つようにします。 selectで適切なioがわかるので、ioをキーに、そのioを処理するfiberを値にもつハッシュを作ってやれば、どのfiberをresumeすべきかは簡単にわかります。

@srv = TCPServer.new 13_000
@selector.register @srv, :r

loop do
  @selector.select do |m|
    case m.io
    when @srv
      socket = m.io.accept_nonblock
      @fibers[socket] = communication_fiber
      @selector.register socket, :r
    else
      f = @fibers[m.io]
      @selector.deregister m.io
      f.resume m.io
    end
  end
end

これが非常にややこしいんだなぁ…

Fiberなサーバー

ソースコード全量がこちら。前回に引き続き、最低限のHTTPレスポンスを返すサーバーを作ってます

gistb6da8b93f525ccfdb150f160717fa786

ブラウザで表示したようす。受け取ったメッセージをそのまま返すので、WebブラウザのHTTPリクエストが書いてあります。

f:id:ytnk531:20201022010753p:plain

nio4rでselect

TCPコネクションのようなIOを複数扱うとき、書き込みや読み込みが可能になっているものを一つ選びたい、ということがあります。RubyにはIO.selectというメソッドがあり、これを使えばそのようなことができます。

IO.selectは、Rubyが動く環境ならどこでも動くというメリットがあるのですが、selectの機構はカーネルによってはepollなどのより効率のいい方法が使えます。nio4rは、これらの効率のいいシステムコールないしCライブラリでを使ったselectを実現するライブラリです。

これ1年位前にちょっと触ったんですが、なんだかよくわかりませんでした。復習兼ねて使い方を説明します。

nio4rの使い方の大まかな流れ

1. NIO::Selectorオブジェクトを作る

2. 監視したいIOオブジェクトを登録する

3. NIO::Selectorで処理をブロックして待ち受ける

という感じです。

 

で、これの何が嬉しいの?という点なんですが、複数のIOを待ち受けて、処理が可能になったときだけ処理を行う、というようなことができます。普通にやったら無限ループを書いて、ステータスを確認して…というコードになるところを、無駄な処理を行わず、かつ無駄な待ち時間を発生させずに処理できます。

 

下記のサンプルコードでは、TCPで接続を待ち受けつつ、標準出力が書き込み可能になるのを待ちます。接続されない間は標準出力にドットを出力し続け、接続されたら、TCPソケットに送られてきた文字を出力します。

gist9cf98cd662a2d55c37d88c89278d5cc2

 

 動画です。左側でドットを吐き出し、データが送信されてきたタイミングで"!"が出力されるのがわかるかと思います。

そしてFiberへ

asyncというライブラリでは、Fiberとnio4rを組み合わせて、低コストな非同期処理を実現しています。

github.com

大まかな流れは下記のとおりです。

1. タスクをFiberで実行する

2. IO待ちが起こりそうになったら、IOをNIO::Selectorに登録する

3. IOとFiberの組み合わせを配列に保存する

4. Fiberの処理を中断する

5. selectする。このとき、他に処理可能なFiberがあればそっちを処理する

6. 中断していたIOがSelectされたら、3.の配列からFiberを取り出して再開する

 

こちらのほうがThreadを使った並列処理よりもパフォーマンスが良いそうです。Fiberは、Threadよりも低コストにスレッドが生成できます。Threadは、GVLの制約を受けてIOブロッキングが起きている間しか並列に処理が実行できません。ということは、より生成コストの低いスレッドをSelectを使って半自動で制御してあげたほうがパフォーマンスいいよね、という話のようです。

Socket.tcp_server_loopでかんたんHTTP

ちゃんとしたHTTPサーバーを作るのって難しいんですが、それっぽいものを作るなら実はそんなに難しく無いです。

HTTPサーバーの処理の流れ

とってもざっくり説明すると、HTTPサーバーの処理はこんな流れです。

  1. TCPコネクションを確立する
  2. HTTPリクエストを受信する
  3. HTTPレスポンスを送信する
  4. TCPコネションを切断する

「1. TCPコネクションを確立する」でまとめてしまっていますが、この中にもいくつか手順があります。TCPソケットをbindしてlistenしてacceptします。混乱したでしょう。そうだと思います。

  1. TCPソケットを作成する
    コンピュータがデータを送受信するとき、何らかの通信方式に従っている必要があります。英語しかしゃべれない人が訪ねてくる施設の受付に日本語しかしゃべれない人を置いたらまずいのと同じです。何らかの通信方式でデータを送受信するための出入り口がソケットです。
    ソケットにはいくつか種類がありますが、HTTPはTCPという通信方式で接続してくるため、TCPソケットを使います。
    ※) RFCではHTTPは特定のトランスポートプロトコルに依存しない、としている。が、基本的にはTCPと考えて良い。RFC 7230 — HTTP/1.1: Message Syntax and Routing (日本語訳)
  2. bindする TCPソケットが、コンピュータの持つポートのうちどこに繋げるかを支持します。普通、HTTPサーバーはTCPの80番ポートに接続します。
  3. listenする ソケットを接続待ちの状態にします。これで外部から接続できるようになります。
  4. acceptする 接続されるまでプログラムの実行を停止(ブロック)します。接続されたら、接続してきたクライアントと通信可能な状態にして、プログラムを再開します。

大まかにこんな流れでTCPコネクションを確立します。大変です。C言語で実装しようものなら、謎の構造体や識別子をいっぱい覚えないといけないので非常に大変です。

Socket.tcp_server_loop

TCPコネクションの確率が大変なのはわかっていただけたと思います。ですが安心してください、Rubyには、「1. TCPコネクションを確立する」を代わりにやってくれるメソッドがあります。スゴーイ。

docs.ruby-lang.org

これを使うと、TCPコネクションを確立して、Socket.tcp_server_loopに渡したブロックを処理してくれます。

コネクションを確立したら、socketからメッセージを読み取り、加工して相手に送信するプログラムはこのようにかけます。9999は接続を待ち受けるTCPのポート番号です。

require 'socket'

Socket.tcp_server_loop(9999) do |sock|
  msg = sock.recv 1000
  sock.sendmsg "#{msg.strip}を受信しました"
ensure
  sock.close
end

これは、ncを使うと動作確認できます。

$ echo hello |  nc localhost 9999
helloを受信しました

注意点としては、このプログラムでは、複数のリクエストを同時に扱うことができない点があります。マルチスレッディングやforkを利用すると、この問題に対処できますがここでは扱いません

HTTPサーバーもどき

TCPサーバーが簡単に作れることがわかったので、HTTPサーバーもどきを作ります。

メッセージフォーマット

HTTP1.1のレスポンスは、ざっくりこのようなフォーマットに従っていることを期待します

ステータスライン
レスポンスヘッダー
CRLF(改行)
メッセージ本体

レスポンスヘッダーは省略可能です。 ステータスラインはHTTPのバージョンとHTTPステータスの組み合わせです。 メッセージ本体には、HTMLなど、なんでもいいのでデータが入ります。

つまり、最低限こんな風になっていればHTTPリクエストとしては正しいです。

HTTP1.1 200 OK

こんにちは

これを踏まえて、HTMLを返すようなプログラムはこんなかんじにかけます

Socket.tcp_server_loop(9999) do |sock, _client_addrinfo|
  sock.write <<~RESP
    HTTP/1.1 200 OK

    <html>
    <body>
    <h1>こんにちは 世界</h1>
    <p>HTTPを使って通信するサーバーを作ったよ。</p>
    </body>
    </html>
  RESP
ensure
  sock.close
end

え?ほんとに動くの?という感じですね。 ブラウザでhttp://localhost:9999/を開きます。

f:id:ytnk531:20201020084516p:plain

うごいてます。HTTP自体はそんなに難しくない気がしてきましたね。

おわりに

以上、HTTPのサーバーもどきは意外と簡単に作れるという話でした。

注意ですが、今回作ったプログラムは、クライアントの要求を無視してただただ単純なリクエストを返すだけのものなので、まだまだサーバーと呼べる代物ではありません。 少なくとも、リクエストメッセージのパース、要求されたファイルの返却、GET、POST、HEADなどのHTTPメソッドへの対応、複数リクエストの同時処理…などなどやらなければいけないことはたくさんあります。 ただ、鬼門となりがちなTCPのコネクション確立が、ほとんどなにも知らなくてもできるっていうのは結構すごいことなんじゃないでしょうか。Rubyでネットワークプログラミングってけっこう楽しいかもしれません。

extendを使ったStrategyパターン風のアルゴリズム切り替え

に、extendの興味深い利用方法が出てきていたのでご紹介。この本の、8.13 タイプコードからモジュールのextendへ(Replace Type Code with Module Extention) の内容をStrategyパターン と比較します。

やりたいこと

レンタルビデオの料金計算を行うプログラムを考えます。料金の計算は、下記のルールで行います。

  • レンタル料は、ビデオの基本レンタル料に利用者種別ごとの割引を適用して計算する。
  • ビデオごとに値段が設定してあり、これを基本レンタル料と呼ぶ。
  • 利用者種別には、子供と大人の二種類がある。
    • 大人: 基本レンタル料から100円を引いた金額がレンタル料となる
    • 子供: 基本レンタル料の半額がレンタル料となる

素直にcase文

case文を使って、計算方法を切り替えます。

Video = Struct.new(:price)

class Rental
  ADULT_DISCOUNT_AMOUNT = 100
  CHILD_DISCOUNT_RATE = 0.5

  attr_writer :person_kind

  def initialize(video, person_kind)
    @video = video
    @person_kind = person_kind
  end

  def price
    case @person_kind
    when :adult
      @video.price - ADULT_DISCOUNT_AMOUNT
    when :child
      @video.price * CHILD_DISCOUNT_RATE
    end
  end
end

利用者種別が2種類のみと少なく、また計算方法が単純な間は、priceメソッドは小さく収まります。 priceは呼び出しがあるたびに計算し直します。これは、person_kindが変更されることを許容するためです。

このクラスを使って、基本レンタル料が300円のビデオのレンタル料を計算する例を示します。

video = Video.new(300)
child_rental = Rental.new(video, :child)
adult_rental = Rental.new(video, :adult)
p child_rental.price # => 150.0
p adult_rental.price # => 200
adult_rental.person_kind = :child
p adult_rental.price # => 150.0

Strategyパターンを使う

さきほどの例では、料金計算方法の切り替えにcase文を使いました。十分にシンプルなコードになったので、コードの複雑さに関して問題はありません。

ここで、料金体系が追加される可能性について考えます。利用者種別が、20代、シニア、ポイントカード所持者など追加するかもしれません。case文でこれらの計算方法を切り替える場合、case文が巨大化していくことは明らかです。また、料金の計算方法ももっと複雑化するかもしれません。また、case文が追加されるたびに、Rental.priceのメソッド切り替えが壊れていないかに気をつける必要もあります。

そこで、利用者種別が追加されたとしてもStrategyパターンをつかって、Rentalクラスのに変更を加えなくて済む方法を考えます。

Video = Struct.new(:price)

class Pricer
  def initialize(video)
    @video = video
  end
end

class ChildPricer < Pricer
  DISCOUNT_RATE = 0.5

  def price
    @video.price * DISCOUNT_RATE
  end
end

class AdultPricer < Pricer
  DISCOUNT_AMOUNT = 100

  def price
    @video.price - DISCOUNT_AMOUNT
  end
end

class Rental
  def initialize(video, person_kind)
    @video = video
    self.person_kind = person_kind
  end

  def person_kind=(person_kind)
    # person_kindが変更されたらpricerも付け替える
    @person_kind = person_kind
    @pricer = pricer
  end

  def pricer
    pricer_klass = Object.const_get "#{@person_kind.to_s.capitalize}Pricer"
    pricer_klass.new(@video)
  end

  def price
    @pricer.price
  end
end

Pricer、AdultPricer、ChildPricerが導入されました。Pricerは抽象クラスで、AdultPricer、ChildPricerはその派生クラスです。person_kindとPricerクラスの派生クラスは、Rental.pricerにあるような方法でクラスを取得できるような名前を意図的に命名しています。

この方法により、新たなクラスを導入するという構造上の複雑さが増加した代わりに、変更に対して閉じていて拡張に対して閉じている、という開放/閉鎖原則に合致するの特性を得ることができました。

ここで得られたメリットは、主に下記の2つです。

  • Rentalの中にあった定数が各Pricerの中に移動した。
    • 本当に使うところに情報が移動したことで、計算方法に関する知識がPricer内で「閉じる」ことになった。
    • 各Pricerには、他の計算方法の知識が入ることはないので、定数名に利用方法を接頭辞として入れる必要がなくなり、名前を短くできた。
  • Rentalは計算方法についての具体的な知識が必要なくなった。
    • priceメソッドを持つインスタンスが@pricerに設定されていれば料金が計算できる。
    • その代わり、Pricerの生成方法、つまり自分にとって適切なPricerが何なのかを知る必要が出てきた。(FactoryメソッドをPricerに生やせば、この知識も外に出せる)

利用側のコードは、先程と全く同じです。

video = Video.new(300)
child_rental = Rental.new(video, :child)
adult_rental = Rental.new(video, :adult)
p child_rental.price # => 150.0
p adult_rental.price # => 200
adult_rental.person_kind = :child
p adult_rental.price # => 150.0

料金体系の追加や計算方法の複雑さに怯える必要はなくなりました。料金体系が追加された場合は、Pricerの派生クラスを作るだけで済み、Rentalクラスには手を加える必要がありません。また、計算方法が複雑化した場合も、変更するのはPricerの派生クラスの中だけになるため、意図しない部分の動作を変更してしまう心配もありません。

ここで気をつけるべきは、現時点でこの複雑さを導入する必要はあるのか?という点です。 結論から言えば、ほとんどの場合メリットはありません。もとのRental#priceメソッドを見ればわかるように、現時点での要件に対しては、case文を愚直に使うだけで十分わかりやすいコードです。これは例なのでお許しを。

Object#extendを使う

リファクタリング: Rubyエディションに示されている、「8.13 タイプコードからモジュールのextendへ」をStrategyパターンらしく利用する方法です。

Video = Struct.new(:price)

module ChildPricer
  DISCOUNT_RATE = 0.5

  def price
    @video.price * DISCOUNT_RATE
  end
end

module AdultPricer
  DISCOUNT_AMOUNT = 100

  def price
    @video.price - DISCOUNT_AMOUNT
  end
end

class Rental
  def initialize(video, person_kind)
    @video = video
    self.person_kind = person_kind
  end

  def person_kind=(person_kind)
    @person_kind = person_kind
    # pricerモジュールに生えているメソッドをインスタンスに生やす
    extend pricer
  end

  def pricer
    Object.const_get "#{@person_kind.to_s.capitalize}Pricer"
  end
end

Object#extendは、インスタンスでから呼び出すと、モジュールのメソッドをそのインスタンスだけのインスタンスメソッドとして生やします。

この方法では、Pricerのためにインスタンスを生成することなくRentalごとに違った料金計算方法を定義できます。extendを使ってメソッドの実装を動的に切り替えるという、非常にRubyっぽい方法に感じます。ここが一番伝えたいところです。Rentalにはpriceメソッドが映えることになりますが、インスタンスごとに違う実装になっているんです!そんなことができる言語はなかなかないので、委譲を利用するStrategyパターンが生まれたわけですね。めっちゃRubyっぽい!

(すごい感動したんですけど、文章で伝えるって難しいですね)

Strategyパターンとの違い、共通点、注意点を下記に示します。

Strategyパターンとの共通点

  • Pricerを追加するだけで料金種別の追加に対応できる
  • 各Pricerの料金計算を別々の塊に分けられるため、他の計算方法の影響を受けない

Strategyパターンとの違い

  • 料金計算をPricerに委譲するのではなく、Rental自身が行う
  • PricerはRentalのもつ構造について知っている必要がある(@videoが存在することなど)
  • Pricerのクラスの生成についての複雑さが取り除かれる

注意点

一度生成したインスタンスのPricerを変更する場合、上書きされなかったメソッドはRentalのメソッドとして残り続ける点に注意してください。

例として、ChildPricerをこのように定義します。

module ChildPricer
  DISCOUNT_RATE = 0.5

  def only_child_method
    'Only child'
  end

  def price
    @video.price * DISCOUNT_RATE
  end
end

料金の変更は反映されるものの、only_child_methodの呼び出しは可能なままです。これは、extendをつかってメソッドを追加しているだけなので、前に追加されたものを削除しているわけではないからです。

video = Video.new(300)
child_rental = Rental.new(video, :child)
puts child_rental.price # => 150
puts child_rental.only_child_method # => Only child
child_rental.person_kind = :adult
puts child_rental.price # => 200
puts child_rental.only_child_method # Only child