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

# はじめに

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](https://github.com/rack/rack)を見てみると、下記の記載があります。

> 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のバージョンは、下記の通りです。

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

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

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

```bash
bundle init
```

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

```Gemfile
gem "puma"
gem "rack"
gem "rackup"
```

```bash
bundle install
```

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

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

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

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

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

run app

```

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

```bash
$ 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` に記述した値が返ってきました。

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

Hello World
```

# Rackの仕様書

ここで、作成したRackアプリケーションと[Rack仕様書](https://github.com/rack/rack/blob/v3.1.8/SPEC.rdoc)を照らし合わせてみます。
仕様書の冒頭には、下記の記載があります。

> 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系を利用しているサンプルコードを流用すると、エラーが発生するので注意が必要です。

https://github.com/rack/rack/commit/472e12f5b6505cc2fdd3b5e529b616b3a2d5fbf8#diff-06572a96a58dc510037d5efa622f9bec8519bc1beab13c9f251e97e657a9d4edR19

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

https://github.com/rack/rack/commit/4600828602aede534c9358b231cfd766f0bbbc68#diff-667ef9253264992e00e4fa64721ce4d9d1bca18e52645ffe55addfd8061a2d6aR262

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

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

Rackがインターフェースとして仕様を提供していることが分かってきたものの、具体的にどのような実装を提供しているのか、という疑問が残りました。
調べていく中で、[PumaのQuick Start](https://github.com/puma/puma?tab=readme-ov-file#quick-start)に、Pumaが`config.ru`ファイルを探してくれる旨の記述を見つけました。

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

[gemspec](https://github.com/puma/puma/blob/master/puma.gemspec)を確認すると、rackもrackupも含まれていないようです。Puma単独でRackアプリケーションを起動できるということでしょうか。ということで、試してみました。

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

```bash
$ ls -1
Dockerfile
Gemfile
config.ru
```

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

```Dockerfile
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にしました。

```bash
$ 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アプリケーションを起動することができました。

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

```text
! 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`に渡しています。

```ruby
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`を追っていきます。

```ruby
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`を呼び出しています。
ログの出力内容とクラス名からここでサーバーが起動されていることが分かります。

```ruby
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アプリケーションではないか、という推測が立ち始めます。

```ruby
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`メソッドが呼ばれています。

```ruby
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クラスに渡して初期化しています。

```ruby
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を注入しているのではないかと思います。

```ruby
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` を呼び出しています。

```ruby
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クラス内の定数](https://github.com/puma/puma/blob/v6.4.3/lib/puma/configuration.rb#L157)から、デフォルト値は `'config.ru'.freeze` に設定されていることが分かります。

```ruby
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::Builder`が`rack/builder`と同等のインターフェースを提供していることが分かりました。

```ruby
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](https://github.com/rack/rackup?tab=readme-ov-file#soft-deprecation)には、**(Soft) Deprecation** という記載があり、rackupへの依存を推奨しない旨が書かれています。また、Ruby on Railsはrackupへの依存があり、Sinatraはrackupへの依存が無いようで、そのあたりの違いも気になるところです。

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

# エンジニア募集

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

https://gakken-leap.co.jp/recruit/