Ruby の Enumerator を dis る

こんにちは、masm11 です。

Ruby には Enumerator というクラスがあります。

Rails で、

self.response_body = Enumerator.new { ... }

とすると、response body をその場で生成するのでなく、 あとで Enumerator のブロックが呼び出され、そこで生成させてくれます。

便利そうですね。………そうですか?

今回は、そんな (Rails +) Enumerator を dis りたいと思います。

サンプルコード

今回使用する test_controller.rb です。

class TestController < ApplicationController
  def index
    headers['Cache-Control'] ||= 'no-cache'
    headers['Content-Type'] = 'application/octet-stream'
    headers['Content-Disposition'] = 'attachment; filename="test.csv"'
    headers['Last-Modified'] = Time.zone.now.httpdate
    response.status = 200
    self.response_body = generate_csv
  end

  def generate_csv
    Enumerator.new do |y|
      100.times do |i|
        sleep 0.05         # 時間のかかる処理
        y << "test#{i}\n"
      end
    end
  end
end

http://localhost:3000/test 等にアクセスすると、 test.csv がゆっくりダウンロードされます。

ファイル名は test.csv 固定です。

generate_csv は Enumerator を返します。 この Enumerator は、ブロックの中で時間のかかる処理をやりつつ、 CSV を生成しつつ、y << でクライアントに送信する想定です。

curl でアクセスしてみましょう。

luna:chunked % curl --raw http://localhost:3000/test --verbose
*   Trying 127.0.0.1:3000...
* Connected to localhost (127.0.0.1) port 3000 (#0)
> GET /test HTTP/1.1
> Host: localhost:3000
> User-Agent: curl/7.85.0
> Accept: */*
> 
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Date: Sat, 01 Oct 2022 15:25:47 GMT
< Connection: close
< X-Frame-Options: SAMEORIGIN
< X-XSS-Protection: 0
< X-Content-Type-Options: nosniff
< X-Download-Options: noopen
< X-Permitted-Cross-Domain-Policies: none
< Referrer-Policy: strict-origin-when-cross-origin
< Cache-Control: no-cache
< Content-Type: application/octet-stream
< Content-Disposition: attachment; filename="test.csv"
< Last-Modified: Sat, 01 Oct 2022 15:25:47 GMT
< X-Request-Id: 67ffe14c-a2e1-4ede-9ac8-ca8c1747d5f6
< X-Runtime: 0.072237
< Server-Timing: start_processing.action_controller;dur=0.20, process_action.action_controller;dur=0.67
< Transfer-Encoding: chunked
< 
6
test0

6
test1

6
test2

6
test3

6
test4

6
test5
...(中略)
7
test98

7
test99

0

* Closing connection 0
luna:chunked % 

Transfer-Encoding: chunked が付いて、 chunk-size と chunk-data が交互に来て、 最後は 0 バイトの chunk ですね。 この受信データが、全体がまとめて表示されるのでなく、 受信できたものから表示されています。

いい感じのコードになっていると思います。

今回使用したのは、

  • Rails 7.0.4
  • Unicorn 6.1.0

です。unicorn/config.rb は以下の通りです。

# :tcp_nopush This prevents partial TCP frames from being sent out
# :tcp_nodelay Disables Nagle’s algorithm on TCP sockets if true.
listen 3000, tcp_nopush: false, tcp_nodelay: true

なお、最近の Rack では、

    headers['Last-Modified'] = Time.zone.now.httpdate

の行が必要だそうです。これがないと、chunked にはなるものの、 body 全体がまとめて送られてしまいました。 このページに感謝です。stackoverflow のコードにも書いてありますがスルーしてしまってました。

時間計測

では、処理にかかっている時間を計測してみましょう。

  def index
    b = Time.zone.now
    headers['Cache-Control'] ||= 'no-cache'
    headers['Content-Type'] = 'application/octet-stream'
    headers['Content-Disposition'] = 'attachment; filename="test.csv"'
    headers['Last-Modified'] = Time.zone.now.httpdate
    response.status = 200
    self.response_body = generate_csv
    e = Time.zone.now
    Rails.logger.info "duration=#{e - b}"
  end

メソッドの最初と最後で時刻を取得して、かかった時間をログに出力しています。 ログには以下のように出力されていました。

duration=0.000133403

あれれ。0.1ミリ秒程度ですか?? 5秒はかかっているはずですが。

それもそのはず、Enumerator のブロックは後で実行されるのです。 generate_csv メソッドでは、ブロックを引数にして Enumerator.new しているだけで、ブロックは実行していないのです。

そりゃ速いですね。

でも、だからと言って、サーバで処理がかかってないわけではありません。 見えてないだけで、確実に5秒はかかっているのです。

サーバが処理であっぷあっぷしてても、 これでは改修対象にすべき箇所一覧から外れてしまうかもしれません。

例外捕捉

raise を追加しました。 CSV を途中まで生成したところで、エラーが発生してしまった想定です。

  def generate_csv
    Enumerator.new do |y|
      100.times do |i|
        sleep 0.05         # 時間のかかる処理
        y << "test#{i}\n"
        raise 'test exception !!' if i == 90
      end
    end
  end

curl でアクセスしてみます。

7
test89

7
test90

HTTP/1.1 500 Internal Server Error

* Illegal or missing hexadecimal sequence in chunked-encoding
* Closing connection 0
curl: (56) Illegal or missing hexadecimal sequence in chunked-encoding
luna:chunked % 

chunk-size があるはずの場所に HTTP/1.1 500 Internal Server Error が 出力されてしまいました。curl はこれを処理できていません。まぁそりゃそうでしょう。

ブラウザからアクセスしてみると、ファイルをダウンロードしますが、 「失敗しました」表示となりました。文字がちっちゃくて、 成功の場合とよく似ています。

ユーザはこれにちゃんと気づいてくれるのでしょうか? ユーザが気づかないといけない、というのは最終手段ではないでしょうか? CSV のダウンロードごときで頼って良いものではないと思います。 気づかないといけないのなら、もっと目立つように表示するべきです。

ダウンロードする内容を代わりに画面に表示するように変更してみます。

  def index
    headers['Cache-Control'] ||= 'no-cache'
    # headers['Content-Type'] = 'application/octet-stream'
    # headers['Content-Disposition'] = 'attachment; filename="test.csv"'
    headers['Content-Type'] = 'text/plain'
    headers['Last-Modified'] = Time.zone.now.httpdate
    response.status = 200
    self.response_body = generate_csv
  end

画面に表示はされましたが、もちろん test90 までで、 例外処理ができていません。 通常、development 環境では、例外が発生すると、backtrace などが画面に表示されますよね? あれが表示されません。

また、ステータスが 200 と表示されていますね。 これが JavaScript からアクセスする API だとすると、 この中途半端なデータのまま、 エラーに気づかれることなく処理が進んでしまうのでしょうか?

処理の流れ

先程の例外が発生した時、ログには以下のように出力されていました。

127.0.0.1 - - [02/Oct/2022:00:47:21 +0900] "GET /test HTTP/1.1" 200 - 4.5946
E, [2022-10-02T00:47:21.370387 #45803] ERROR -- : app error: test exception !! (RuntimeError)
E, [2022-10-02T00:47:21.370523 #45803] ERROR -- : /home/masm/src/chunked/app/controllers/test_controller.rb:17:in `block (2 levels) in generate_csv'
E, [2022-10-02T00:47:21.370546 #45803] ERROR -- : /home/masm/src/chunked/app/controllers/test_controller.rb:14:in `times'
E, [2022-10-02T00:47:21.370565 #45803] ERROR -- : /home/masm/src/chunked/app/controllers/test_controller.rb:14:in `block in generate_csv'
E, [2022-10-02T00:47:21.370585 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/actionpack-7.0.4/lib/action_dispatch/http/response.rb:145:in `each'
E, [2022-10-02T00:47:21.370605 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/actionpack-7.0.4/lib/action_dispatch/http/response.rb:145:in `each'
E, [2022-10-02T00:47:21.370624 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/actionpack-7.0.4/lib/action_dispatch/http/response.rb:145:in `each_chunk'
E, [2022-10-02T00:47:21.370644 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/actionpack-7.0.4/lib/action_dispatch/http/response.rb:127:in `each'
E, [2022-10-02T00:47:21.370664 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/actionpack-7.0.4/lib/action_dispatch/http/response.rb:75:in `each'
E, [2022-10-02T00:47:21.370682 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/actionpack-7.0.4/lib/action_dispatch/http/response.rb:480:in `each'
E, [2022-10-02T00:47:21.370702 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/body_proxy.rb:41:in `method_missing'
E, [2022-10-02T00:47:21.370748 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/body_proxy.rb:41:in `method_missing'
E, [2022-10-02T00:47:21.370778 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/body_proxy.rb:41:in `method_missing'
E, [2022-10-02T00:47:21.370938 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/body_proxy.rb:41:in `method_missing'
E, [2022-10-02T00:47:21.371022 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/body_proxy.rb:41:in `method_missing'
E, [2022-10-02T00:47:21.371048 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/body_proxy.rb:41:in `method_missing'
E, [2022-10-02T00:47:21.371069 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/lint.rb:754:in `each'
E, [2022-10-02T00:47:21.371088 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/body_proxy.rb:41:in `method_missing'
E, [2022-10-02T00:47:21.371107 #45803] ERROR -- : /home/masm/src/chunked/vendor/bundle/ruby/3.0.0/gems/rack-2.2.4/lib/rack/chunked.rb:38:in `each'
...

test_controller.rb:17 の上が test_controller.rb:14 でその上が test_controller.rb:14。 その上は………… actionpack? そんなところから呼ばれるわけがないですね。

処理の流れがわかりにくいです。 一番上に GET /test とあるので、これを手がかりに処理の流れを思い出し (知らなければコードを見て勉強し)、 …なんてことをする必要があります。

まとめ

これだけのデメリットがある機能を使う理由って何ですか?

「流行だから」?

「ダウンロード開始までの時間が短くできる」?

やめましょうよ…

頭痛の種を増やしてまでやることとは私には思えません。

所感

以上、最近何度か私から見て最悪だと思ったので dis りました。

私はエンジニア歴40年近い老害ですが、 「最近の若者」は、これでも問題なくやっていけるんですかね? よく解りません…