5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gakken LEAPAdvent Calendar 2024

Day 8

Rackアプリケーションを作ってRackについて学ぶ

Last updated at Posted at 2024-12-07

はじめに

Gakken LEAPで働いていますkoboriです。本稿は、Rubyを使ったWebアプリケーション開発の基盤となるRackを題材に書いていきます。私は日頃、Ruby on Railsを使ってWebアプリケーションを開発していて、Rackについて強く意識する機会はあまりありませんでした。しかし、先日参加した Kaigi on Rails 2024で聴講した基調講演を受け、システムに対する解像度をより高めたいと思い、今回はRackを題材に選ぶことにしました。応募が間に合わず参加できなかったRackのワークショップ に参加したかった心残りもあります。

Rackについて

現時点での私の大まかな解釈としては、RackはWebアプリケーションとWebサーバーの間のインターフェースを提供しており、各ライブラリはRackのインターフェースに従って実装されているので、アプリケーション開発者はWebアプリケーションフレームワークとWebサーバーを自由に選択することができている、という理解を持っています。

次に、RackのREADMEを見てみると、下記の記載があります。

Rack provides a minimal, modular, and adaptable interface for developing web applications in Ruby. By wrapping HTTP requests and responses in the simplest way possible, it unifies and distills the bridge between web servers, web frameworks, and web application into a single method call.

理解が概ね正しいことが分かりました。また、 into a single method call という記述から、WebアプリケーションとWebサーバーのインターフェースとは、ある単一のメソッドのことを指していることが分かりました。

ほんの少し解像度が高まったところで、Rackアプリケーションを作ってみます。

Rackアプリケーションを作る

最小構成のRackアプリケーションを作ってみるということで、RackのGithubに記載されているサンプルを参考に一部改変をしつつ実装を進めていきます。

今回使用するRubyのバージョンは、下記の通りです。

$ ruby -v
ruby 3.3.3 (2024-06-12 revision f1c7b6f435) [x86_64-linux]

それではやっていきます。

はじめに、プロジェクトを作成します。

bundle init

Gemfileに下記の記述を追加し、インストールします。

gem "puma"
gem "rack"
gem "rackup"
bundle install

インストールされた各gemのバージョンは下記の通りです。

  • puma (6.4.3)
  • rack (3.1.8)
  • rackup (2.2.1)

WebサーバーはPumaを使用し、rackupを使ってアプリケーションの起動を行います。

config.ru というファイルを作成し、下記の内容を記述します。app変数に詰められたlambdaオブジェクトが、Rackアプリケーションになります。
それを run メソッドに渡すことで、アプリケーションを起動することができます。

app = lambda do |env|
  [200, { 'content-type' => 'text/html' }, ["Hello World\n"]]
end

run app

準備が整ったので、アプリケーションを起動します。

$ bundle exec rackup
Puma starting in single mode...
* Puma version: 6.4.3 (ruby 3.3.3-p89) ("The Eagle of Durango")
*  Min threads: 0
*  Max threads: 5
*  Environment: development
*          PID: 7818
* Listening on http://127.0.0.1:9292
* Listening on http://[::1]:9292
Use Ctrl-C to stop

良い感じに起動しました。
リクエストを投げてみると、config.ru に記述した値が返ってきました。

$ curl http://localhost:9292 -i
HTTP/1.1 200 OK
content-type: text/html
Content-Length: 12

Hello World

Rackの仕様書

ここで、作成したRackアプリケーションとRack仕様書を照らし合わせてみます。
仕様書の冒頭には、下記の記載があります。

A Rack application is a Ruby object (not a class) that responds to call. It takes exactly one argument, the environment and returns a non-frozen Array of exactly three values: The status, the headers, and the body.

まとめると下記のように解釈できます。

  • call を呼び出すことができるRubyオブジェクト(クラスではない)
  • 引数を1つ受け取ること
  • HTTPステータス、ヘッダー、ボディの3つの要素を持つ凍結されていない配列を返すこと

仕様書を読みつつ解釈を進めていきます。

call を呼び出すことができるRubyオブジェクトという制約を満たしていれば、今回のサンプルで利用したlambdaオブジェクト以外にも、ProcオブジェクトやクラスのインスタンスなどもRackアプリケーションとして利用することができます。

引数にはHTTPリクエストに関する情報や、それを処理するための情報、ログ出力に関する情報など、様々な情報が含まれています。

HTTPレスポンスとして返却される、戻り値の配列についても見ていきます。
1つ目の要素はHTTPステータスコードで、100以上の整数値である必要があります。

2つ目の要素のHTTPヘッダーにも制約が定められており、そのうちのいくつかを下記に抜粋します。

  • 凍結されていないHashであること
  • ヘッダーのキーは文字列であること
  • ヘッダーのキーは大文字であること
  • ヘッダーの値は文字列 または、文字列の配列であること

このうち、ヘッダーのキーが大文字でなければならない制約については、Rack3.0.0から追加されたもののようで、Rack2系を利用しているサンプルコードを流用すると、エラーが発生するので注意が必要です。

また、ヘッダーの値に配列が利用できるようになったのも、Rack3.0.0からの変更のようです。日々便利になっていってるのですね。

3つ目の要素のHTTPボディでは、文字列の配列だけでなく、文字列を生成するEnumerableなオブジェクト、Procオブジェクト、Fileのようなオブジェクトもサポートされています。これらのオブジェクトについて、each メソッドを呼び出すことができるか、call メソッドを呼び出すことができるか、その両方かで、それぞれに制約が定められています。

RackアプリケーションをPumaだけで動かす

Rackがインターフェースとして仕様を提供していることが分かってきたものの、具体的にどのような実装を提供しているのか、という疑問が残りました。
調べていく中で、PumaのQuick Startに、Pumaがconfig.ruファイルを探してくれる旨の記述を見つけました。

Without arguments, puma will look for a rackup (.ru) file in working directory called config.ru.

gemspecを確認すると、rackもrackupも含まれていないようです。Puma単独でRackアプリケーションを起動できるということでしょうか。ということで、試してみました。

ファイル構成は次の通りです。

$ ls -1
Dockerfile
Gemfile
config.ru

ローカル環境でグローバルにインストールされたgemの影響を排除するため、Docker環境を利用することにしました。

FROM ruby:3.3

WORKDIR /usr/src/app
COPY . .
RUN bundle install

EXPOSE 9292

CMD ["bundle", "exec", "puma"]

GemfileにはPumaのみをインストールします。config.ru は先ほどと同様です。
Docker imageをビルドし、コンテナを起動します。Docker image名は、puma + app でpumappにしました。

$ docker build -t pumapp .
$ docker run -p 9292:9292 pumapp
Puma starting in single mode...
* Puma version: 6.4.3 (ruby 3.3.6-p108) ("The Eagle of Durango")
*  Min threads: 0
*  Max threads: 5
*  Environment: development
*          PID: 1
* Listening on http://0.0.0.0:9292
Use Ctrl-C to stop

起動したコンテナにリクエストを投げると、先ほどと同様にconfig.ruに記述したレスポンスが返ってきました。
Rackとrackupを使わず、PumaのみでRackアプリケーションを起動することができました。

$ curl localhost:9292 -i
HTTP/1.1 200 OK
content-type: text/html
Content-Length: 12

Hello World

なぜPumaだけでRackアプリケーションを起動できたのかを調べていきます。
まず、config.ru に記述された run メソッドが気になります。これはRackが提供するメソッドだと思い込んでいましたが、Pumaが提供するメソッドなのでしょうか。

run appの記述を削除し、アプリケーションを再起動した後、再度リクエストを投げてみると、エラーが出力されました。
手掛かりになりそうな箇所を下記に抜粋しています。

! Unable to load application: RuntimeError: missing run or map statement
bundler: failed to load command: puma (/usr/local/bundle/bin/puma)
/usr/local/bundle/gems/puma-6.4.3/lib/puma/rack/builder.rb:277:in `to_app': missing run or map statement (RuntimeError)
        from config.ru:5:in `<main>'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/rack/builder.rb:172:in `eval'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/rack/builder.rb:172:in `new_from_string'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/rack/builder.rb:163:in `parse_file'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/configuration.rb:368:in `load_rackup'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/configuration.rb:290:in `app'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/runner.rb:162:in `load_and_bind'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/single.rb:44:in `run'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/launcher.rb:194:in `run'
        from /usr/local/bundle/gems/puma-6.4.3/lib/puma/cli.rb:75:in `run'

出力されたbacktraceを起点にコードを追っていきます。

Pumaコマンドが呼ばれた後の実行の起点となる CLI#run から始めていきます。
CLIクラスでは初期化時にLaunchクラスの初期化を行い、@launchへ格納しています。またその際、設定情報が格納されているConfigurationクラスの初期化を行い@launchに渡しています。

def run
  @launcher.run
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/cli.rb#L75

Launch#runを見ていきます。ここでは、@runnerに格納されたオブジェクトの rumメソッドを呼び出しています。@runnerには、Launcherクラスの初期化時に、設定値に応じてClusterクラスまたは、Singleクラスのインスタンスが格納されています。
Pumaの起動時に出力されているログから、今回のアプリケーションはSingleモードで起動されていることが分かっているので、Single#runを追っていきます。

def run
  # ...

  @runner.run

  # ...
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/launcher.rb#L194

Single#runでは、最終的にstart_serverから取得される、Serverクラスのインスタンスに対して、runを呼び出しています。
ログの出力内容とクラス名からここでサーバーが起動されていることが分かります。

def run
  # ...

  load_and_bind

  # ...

  @server = server = start_server
  server_thread = server.run
  log "Use Ctrl-C to stop"

  # ...
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/single.rb#L44

start_serverは継承元のRunnerクラスで定義されており、Serverクラスを初期化する際の第一引数に、appを渡していることが分かります。
おそらくこれが、callで呼び出すことができるRackアプリケーションではないか、という推測が立ち始めます。

def start_server
  server = Puma::Server.new(app, @events, @options)

  # ...
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/runner.rb#L177

app を辿っていくと、load_and_bind で代入されていることが分かりました。
load_and_bindは、Single#runの中でstart_serverの前に呼ばれていたメソッドです。
@config には、launcherから渡された、Configurationクラスのインスタンスが格納されており、appメソッドが呼ばれています。

def load_and_bind
  # ...

    @app = @config.app

  # ...
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/runner.rb#L155C9-L155C22

Configuration#app では、load_rackupから取得した値を、ConfigMiddlewareクラスに渡して初期化しています。

def app
  found = options[:app] || load_rackup
  # ...
  ConfigMiddleware.new(self, found)
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L289

ConfigMiddlewareクラスを見ると、見覚えのあるインターフェースがあります。
ソースコメントから推測して、Rackアプリケーションをラップし、configを注入しているのではないかと思います。

class ConfigMiddleware
  def initialize(config, app)
    @config = config
    @app = app
  end

  def call(env)
    env[Const::PUMA_CONFIG] = @config
    @app.call(env)
  end
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L264

Configuration#app 内の、load_rackup の戻り値がRackアプリケーションの本体であることが分かってきました。
Configuration#load_rackupでは、rack_builderの戻り値のオブジェクトに対して、rackupを引数にparse_file を呼び出しています。

def load_rackup
  # ...

  rack_app, rack_options = rack_builder.parse_file(rackup)

  # ...

  rack_app
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L365

順番に見ていきます。
まずはConfiguration#rackupについてです。ここでは、@optionに格納されたオブジェクトから:rackupをキーに値を取得しています。
Configurationクラス内の定数から、デフォルト値は 'config.ru'.freeze に設定されていることが分かります。

def rackup
  @options[:rackup]
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L282

次に Configuration#rack_builderです。rackから提供されている rack/builderを探し、見つからない場合はPumaで保持している Puma::Rack::Builder を返しています。
Puma::Rack::Builderrack/builderと同等のインターフェースを提供していることが分かりました。

def rack_builder
  # ...

  begin
    require 'rack'
    require 'rack/builder'
  rescue LoadError
    # ok, use builtin version
    return Puma::Rack::Builder
  else
    return ::Rack::Builder
  end
end

# https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L344

ここまで見てきた内容から、rackがインストールされていない場合でも、Rackアプリケーションを起動することができる理由が分かったところで、今回は終了にします。
また、Rackが提供するインターフェースのひとつである、rack/builderが参照されている箇所も知ることができました。

おわりに

最小構成のRackアプリケーションについて、Rackの仕様書、Pumaの実装を参照しながら理解を進んできたところで、本稿は締めに入ります。
今回は断念しましたが、いくつかの残課題も見えてきました。

1つ目に仕様書の掘り下げです。今回やりきれなかった部分について、理解を進めていきたいです。それほど文量は多くないので、時間をかければなんとか理解できるのではないかと思います。
それと併せて、rack/builder以外にも、WebアプリケーションとWebフレームワークがRackの実装(仕様ではなく)に依存する箇所を見つけていきたいです。rack/builderにおいても、RackとPumaでどのような差分があるのか気になります。

2つ目に、rackupの役割と実装についてです。本稿の前半でrackupはRackアプリケーションの起動を行うと書きましたが、後半でrackupが無くてもRackアプリケーションを起動できることが分かりました。rackupのREADMEには、(Soft) Deprecation という記載があり、rackupへの依存を推奨しない旨が書かれています。また、Ruby on Railsはrackupへの依存があり、Sinatraはrackupへの依存が無いようで、そのあたりの違いも気になるところです。

これらについては残課題として取り組んでいきたい所存で、テックブログ執筆の機会を利用して調べていこうと思います。

エンジニア募集

Gakken LEAP では教育をアップデートしていきたいエンジニアを絶賛大募集しています。
ぜひお気軽にカジュアル面談へお越しください。

5
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?