Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rack 2-> Rack 3アップグレードガイド(翻訳)

概要

MITライセンスに基づいて翻訳・公開いたします。


  • 初版: 2023/01/05
  • 更新: 2024/05/30

Rack 2-> Rack 3アップグレードガイド(翻訳)

本ドキュメントは現在作成中ですが、Rack 3の主な変更点のうち、サーバーやミドルウェアやアプリケーションをアップグレードするうえで知っておくべき概要について説明しています。

🔗 インターフェイスの変更

🔗 Rack 2とRack 3の互換性

ほとんどのアプリケーションは、特に以下のRack仕様の厳密な移行ポイントに従うことでRack 2およびRack 3と互換性を保てます。

  • レスポンスの配列はfrozenにできなくなった
  • レスポンスのstatusは100以上の整数が必須になった
  • レスポンスのheadersはfrozenでないハッシュが必須になった
  • レスポンスヘッダーのキーには大文字を含められなくなった
  • rack.inputはリライト可能にするうえで必須ではなくなった
  • 以下のRack環境変数が必須設定されなくなった
    • rack.multithread
    • rack.multiprocess
    • rack.run_once
    • rack.version
  • rack.hijack?(部分ハイジャック)およびrack.hijack(完全ハイジャック)は独立したオプションになった
  • rack.hijack_ioは完全に削除された
  • SERVER_PROTOCOLは必須キーとなり、リクエストで用いられるHTTPプロトコルとマッチするようになった
  • ミドルウェアはbodyで#eachを呼べなくなった
    • ただし#to_aryに応答する場合はbodyで#to_aryを呼べる

Rack 3には、後方互換性のない機能変更が1件あります。

  • レスポンスヘッダの値は、複数の値を扱うためArrayになる可能性がある
    • これに伴い、\nを含むエンコード済みヘッダーはサポート対象外になる

Rack::Response#add_headerを用いることで互換性を保てます。これは、背後のフォーマットを気にせずにヘッダーを追加できます。


Rack 3には、直接的な後方互換性のない機能変更が1件あります。

  • レスポンスのbodyが#each(enumerableなbody)ではなく#call(ストリーミングのbody)に応答するようになった
    • これにより旧バージョンのレスポンスハイジャックと同等のことが行える

サーバーで部分的Rackハイジャックがサポートされている場合は、部分的Rackハイジャックを利用できます(あるいはこの振る舞いをミドルウェアにラップできます)。

🔗 config.ruRack::Builder#runがブロックを受け取れるようになった

従来の Rack::Builder#runメソッドが受け取れたのは、呼び出し可能な(callable)引数のみでした。

run lambda{|env| [200, {}, ["Hello World"]]}

上は以下のようにシンプルに書き直せます。

run do |env|
  [200, {}, ["Hello World"]]
end

🔗 レスポンスbodyを双方向ストリーミングで利用可能になった

従来は、rack.hijackレスポンスヘッダを双方向ストリーミング(WebSocketsなど)の実装に利用可能でした。

def call(env)
  stream_callback = proc do |stream|
    stream.read(...)
    stream.write(...)
  ensure
    stream.close(...)
  end

  return [200, {'rack.hijack' => stream_callback}, []]
end

この機能はオプションですが、正しく使うのが難しい機能でした。変更後は、以下のようにstream_callbackをレスポンスbodyとして渡すことで同じことができるようになりました。

def call(env)
  stream_callback = proc do |stream|
    stream.read(...)
    stream.write(...)
  ensure
    stream.close(...)
  end

  return [200, {}, stream_callback]
end

🔗 Rack::Sessionが別gemに切り出された

従来のRack::Sessionrack gemに含まれていましたが、すべてのアプリケーションがこれを必要としているわけではなく、rackのセキュリティサーフェス(露出面)が増加してしまうため、rack-sessionという別gemに切り出して個別に更新することが決定されました。

rack-sessionを利用するアプリケーションは、今後このgemを個別に追加する必要があります。

gem 'rack-session'

これで、従来のセッション機能がすべて利用可能になります。

🔗 bin/rackupRack::ServerRack::HandlerRack::Lobsterが別gemに切り出された

従来のRackにはrackup実行ファイルが含まれていましたが、Rubyの"default gem"にWEBrickが含まれなくなったので、私たちはrackwebrickに依存させるべきか別gemに切り出すかを選択しなければなり、後者を選択しました。これにより、rackupを「Rackインターフェイス」と独立させて迅速に設計および実装できるようになると期待しています。

Rack 3では以下をGemfileに記述する必要があります。

gem 'rackup'

これにより、従来のrackup機能がすべて利用可能になります。

Rack::ServerRack::HandlerRack::Lobsterもrackup gemに移動し、それぞれRackup::ServerRackup::HandlerRackup::Lobsterにリネームされました。

アプリをRack 3のRackup::Serverで起動するには以下のようにします。

require 'rackup'
Rackup::Server.start app: app, Port: 3000

🔗 config.ruのオートロードはrequire 'rack'しない限り無効になる

従来はrackupで'rack'がrequireされていたので、rack/directoryなどのRackモジュールがオートロードされていました。Rack 3では、以下のようにrequire 'rack'するか、特定のモジュールを明示的にrequireする必要があります。

+require 'rack'
run Rack::Directory.new '.'

または

+require 'rack/directory'
run Rack::Directory.new '.'

🔗 リクエストの変更点

🔗 rack.versionが必須でなくなった

従来はrack.versionでRackプロトコルバージョンを取得できましたが、実用性が乏しいため、要件から削除されました。

🔗 rack.multithreadrack.multiprocessrack.run_onceが必須でなくなった

従来のサーバーは、Rackアプリケーション実行環境が参照できるようにこれらのキー(を通した設定値)を提供しようとしていましたが、遅すぎて実用的でないため、要件から削除されました。

🔗 rack.hijack?が部分ハイジャックのみに適用されるようになった

従来は、rack.hijack?が完全ハイジャックと部分ハイジャックの両方を制御していましたが、部分ハイジャックのみに適用されるようになりました(これでストリーミングbodyで置き換え可能になります)。

🔗 rack.hijackだけで完全ハイジャックを実行可能になった

従来はリクエストのenv内にrack.hijackが存在し、かつ rack.hijack?もtrueでなければなりませんでした。変更後は、rack.hijackコールバックが存在するだけで済むようになりました。

🔗 rack.hijack_ioが削除された

従来は完全ハイジャックを行うためにrack.hijackが呼び出されると、リクエストのenvへのrack.hijack_ioの設定を試みていました。しかしこれはミドルウェアがenv.dupを呼び出していると実行不可能になることが多かったため、この要件は完全に削除されました。

🔗 rack.inputはリライト可能にするうえで必須でなくなった

従来はリライト可能にする(io.seek(0))ためにrack.inputが必須でしたが、これは一般にファイルベースの場合にしか利用できないため、リクエストbodyを効率的にストリーミングするうえで妨げとなっていました。変更後のrack.inputはリライト可能にするうえで必須ではなくなりました。

🔗 rack.inputはフォームやマルチパートのデータを消費した後にrewindしなくなった

従来は、フォームやマルチパートのデータを消費した後に.rewindが呼び出されていた。
この従来の振る舞いに合わせるには、Rack::RewindableInput::Middlewareを用いてbodyをrewind可能にしたうえで、.rewindを明示的に呼び出すこと。

🔗 レスポンスの変更点

🔗 レスポンスはミュータブルが必須になった

Rack 3ではレスポンスの配列[status, headers, body]がミュータブルであることが要求されます。レスポンスをfrozenにする既存のコードは変更が必要です。

NOT_FOUND = [404, {}, ["Not Found"]].freeze

def call(env)
  ...
  return NOT_FOUND
end

上は以下のように書き換えること。

def not_found
  [404, {}, ["Not Found"]]
end

def call(env)
  ...
  return not_found
end

前のバージョンには微妙なバグがある点に注意してください。ヘッダーのハッシュは変更可能ですが、これらの変更が以後のリクエストに漏洩する可能性があります。

🔗 レスポンスのヘッダーはミュータブルなハッシュが必須になった

Rack 3のレスポンスヘッダーはミュータブルなハッシュが必須です(従来は#eachに応答しkey/valueペアをyieldする任意のオブジェクトが利用できました)。

従来は以下のように書けました。

def call(env)
  return [200, [['content-type', 'text/plain']], ["Hello World"]]
end

今後は以下のようにハッシュのインスタンスを使わなければなりません。

def call(env)
  return [200, {'content-type' => 'text/plain'}, ["Hello World"]]
end

これにより、必要に応じてミドルウェアがヘッダーを予測に基づいて更新できるようになります。

🔗 レスポンスヘッダーは小文字のみになった

Rack 3ではすべてのレスポンスヘッダーを小文字にする必要があります。これはレスポンスヘッダーのフェッチや更新をシンプルにするためです。従来はRack::HeadersHashのような形にする必要がありました。

def call(env)
  response = @app.call(env)
  # HeaderHash must allocate internal objects and compute lower case keys:
  headers = Rack::Utils::HeaderHash[response[1]]

  cache_response(headers['ETag'], response)

  ...
end

しかし変更後はHTTPヘッダーでは以下のように通常の形式を使う必要があります。

def call(env)
  response = @app.call(env)
  # A plain hash with lower case keys:
  headers = response[1]

  cache_response(headers['etag'], response)

  ...
end

既存のコードに使われている個別のヘッダーキーを手動で変更せずにRack 3で動くようにしたい場合は、Rack::HeadersをRack 3で利用できます。

  headers = defined?(Rack::Headers) ? Rack::Headers.new : {}

Rack::HeadersはHashのサブクラスで、キーを自動で小文字にします。

  headers = Rack::Headers.new
  headers['Foo'] = 'bar'
  headers['FOO'] # => 'bar'
  headers.keys   # => ['foo']

🔗 複数のレスポンスヘッダーはArrayでエンコードされる

レスポンスヘッダーの値にArrayを用いて複数の値を扱えます(\nエンコードヘッダーのサポートは終了しました)。Rack::Responseを使う場合は何もする必要はありません。しかしレスポンスヘッダーに手動で値を追加する場合は、値を以下のようにArrayにする必要があります。

def set_cookie_header!(headers, key, value)
  if header = headers[SET_COOKIE]
    if header.is_a?(Array)
      header << set_cookie_header(key, value)
    else
      headers[SET_COOKIE] = [header, set_cookie_header(key, value)]
    end
  else
    headers[SET_COOKIE] = set_cookie_header(key, value)
  end
end

🔗 レスポンスbodyが#eachに応答するとは限らない

Rack 3では、レスポンスbodyの要件が厳しくなっています。従来のレスポンスbodyが応答する必要があったのは#eachのみで、#closeへの応答はオプションでした。さらに、#eachを呼び出してレスポンスをバッファリングしても安全かどうかを知る方法がありませんでした。

🔗 レスポンスbodyで#to_aryが公開されているとバッファリングされる

bodyが#to_aryに応答する場合、#eachを呼び出して生成されるものと同一の内容のArrayを返さなければなりません。bodyが#to_ary#close の両方に応答する場合、その#to_aryの実装は#closeも呼び出さなければなりません。

従来は、レスポンスbodyがすぐ利用可能か(バッファリング可能か)、またはストリーミングしているチャンクかどうかを判定できませんでした。変更後は、この点が#to_aryによって明らかにされるようになりました。

def call(env)
  status, headers, body = @app.call(env)

  # Check if we can buffer the body into an Array, so we can compute a digest:
  if body.respond_to?(:to_ary)
    body = body.to_ary
    digest = digest_body(body)
    headers[ETAG_STRING] = %(W/"#{digest}") if digest
  end

  return [status, headers, body]
end

🔗 ミドルウェアはレスポンスbodyを直接変更してはならない

レスポンスbodyが#eachに応答しない可能性がある点にご注意ください。今後、bodyがenumerableかストリーミングかを判定するときは、#eachに応答するかどうかで判定しなければなりません。

bodyで#eachを直接呼び出してはいけません。代わりに、元のbodyで#eachを呼び出す新しいbodyを返すこと。

🔗 ステータスコードはIntegerでなければならない

レスポンスのステータスコードは、100以上のIntegerでなければなりません。

従来は#to_iに応答する任意のオブジェクトが許容されていたので、["200", {}, ""]のようなレスポンスを[200, {}, ""]に置き換える必要があります。これはステータスオブジェクト上で#to_iを自分で呼び出すことで可能でした。

関連記事

Rails: Webpacker v5からShakapacker v6へのアップグレードガイド(翻訳)

Rails: Sprockets->Propshaftアップグレードガイド(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。