RubyとRackで始めるWebアプリケーションフレームワーク自作入門

# はじめに

Ruby Advent Calendar 2020 3日目の記事です。
今回はRackを使ってシンプルなWebアプリケーションフレームワークを作っていきたいと思います。

※注意
筆者は最近Webアプリケーションの勉強を始めたばかりの初心者なので、間違っている箇所も多々あるかと思いますが、その場合はコメント欄で指摘していただければありがたく思います。

# Rack概要
RackはWebサーバーとWebアプリケーションをつなぐためのライブラリです。
Rackを使用することで、ユーザーはサーバーの種類(WEBRick, Unicorn, Pumaなど)に縛られることなくWebアプリケーションフレームワークを使用することができます。
実際、RailsやSinatraといった代表的なWebアプリケーションフレームワークでもRackが使用されています。

## とりあえずやってみる
最低限のWebアプリケーションを作ってみます。

```ruby:application.rb
class Application
  def call(env)
    [200, { "Content-Type" => "text/html" }, ["<h1>Hello, Rack!<h1>"]]
  end
end
```

```ruby:config.ru
require_relative "application"

run Application.new
```

そして次のコマンドを入力します。

```console
rackup config.ru
```

これでhttp\://localhost:9292にアクセスすると、次のような結果が表示されます。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280356/01e4c78f-572c-d98d-51bf-71680aa928e6.png" width="320px" height="240px">

ここでApplication#callはRackアプリケーションの仕様に沿った実装を行うことでアプリケーションサーバの種類に依存しないWebアプリケーションが作成できます。

Rackアプリケーションは次のような仕様を満たす必要があります。
・callメソッドが実装されていること。
・引数envを取ること。
・[HTTPステータスコード, ヘッダーのハッシュ、ボディーの配列]からなる戻り値を返すこと。[^1]

envの中身を見てみましょう。

```text
{"GATEWAY_INTERFACE"=>"CGI/1.1",
 "PATH_INFO"=>"/",
 "QUERY_STRING"=>"",
 "REMOTE_ADDR"=>"127.0.0.1",
 "REMOTE_HOST"=>"127.0.0.1",
 "REQUEST_METHOD"=>"GET",
 "REQUEST_URI"=>"http://127.0.0.1:9292/",
 "SCRIPT_NAME"=>"",
 "SERVER_NAME"=>"127.0.0.1",
 "SERVER_PORT"=>"9292",
 "SERVER_PROTOCOL"=>"HTTP/1.1",
 "SERVER_SOFTWARE"=>"WEBrick/1.4.2 (Ruby/2.6.6/2020-03-31)",
 "HTTP_HOST"=>"127.0.0.1:9292",
 "HTTP_CONNECTION"=>"keep-alive",
 "HTTP_UPGRADE_INSECURE_REQUESTS"=>"1",
 "HTTP_USER_AGENT"=>
  "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.66 Safari/537.36",
 "HTTP_ACCEPT"=>
  "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9",
 "HTTP_PURPOSE"=>"prefetch",
 "HTTP_SEC_FETCH_SITE"=>"none",
 "HTTP_SEC_FETCH_MODE"=>"navigate",
 "HTTP_SEC_FETCH_USER"=>"?1",
 "HTTP_SEC_FETCH_DEST"=>"document",
 "HTTP_ACCEPT_ENCODING"=>"gzip, deflate, br",
 "HTTP_ACCEPT_LANGUAGE"=>"ja,en-US;q=0.9,en;q=0.8",
 "HTTP_COOKIE"=>
  "_ga=GA1.1.1161372688.1606192620; _gid=GA1.1.468860798.1606809138",
 "rack.version"=>[1, 3],
 "rack.input"=>
  #<Rack::Lint::InputWrapper:0x00007fffca6b9eb0
   @input=#<StringIO:0x00007f49b0012178>>,
 "rack.errors"=>
  #<Rack::Lint::ErrorWrapper:0x00007fffca6b9dc0 @error=#<IO:<STDERR>>>,
 "rack.multithread"=>true,
 "rack.multiprocess"=>false,
 "rack.run_once"=>false,
 "rack.url_scheme"=>"http",
 "rack.hijack?"=>true,
 "rack.hijack"=>
  #<Proc:0x00007fffca6baa18@/home/ootoro/.rbenv/versions/2.6.6/lib/ruby/gems/2.6.0/gems/rack-2.2.2/lib/rack/lint.rb:567>,
 "rack.hijack_io"=>nil,
 "HTTP_VERSION"=>"HTTP/1.1",
 "REQUEST_PATH"=>"/",
 "rack.tempfiles"=>[]}
```

CGI環境変数やrackのオプションっぽい値が入っていますね。
これだけでWebアプリケーションを作成できそうな雰囲気ですがちょっとこのまま扱うのは大変そうです。そこでこれらを扱いやすくするために、Rackにはあらかじめリクエストとレスポンスを扱うためのクラスRack::RequestとRack::Responseが用意されています。

## Rack::RequestとRack::Response
Rack::RequestとRack::Responseを使うことでより手軽にリクエストとレスポンスの操作が可能です。これらを使って先ほどのコードを書き直してみます。

```ruby:application.rb
class Application
  def call(env)
    req = Rack::Request.new(env)
    res = Rack::Response.new(["<h1>Hello, Rack!<h1>"], 200, {})
    res.content_type = "text/html"
    res.finish
  end
end
```

これだけだと何のメリットがあるのか分からないですね。
というわけで試しにこれらのクラスを使用してアクセスカウンタを作ってみたいと思います。

```ruby:application.rb
ACCESS_COUNT_FILE_NAME = "access_count.txt"

class Application
  def call(env)
    req = Rack::Request.new(env)
    count = get_access_count
    if req.cookies["visited"] != "1"
      count = access_countup(count)
    end
    res = Rack::Response.new(["あなたは#{count}人目の訪問者です。"], 200, { "Content-Type" => 'text/plain;charset="UTF-8"' } )
    res.set_cookie("visited", { value: "1" })
    res.finish
  end

  def get_access_count
    return File.read(ACCESS_COUNT_FILE_NAME).to_i if File.exist?(ACCESS_COUNT_FILE_NAME)
    0
  end

  def access_countup(count)
    count += 1
    File.write(ACCESS_COUNT_FILE_NAME, count)
    count
  end
end
```

このようにRack::Request#cookiesとRack::Response#set_cookieを使用することで簡単にcookieを扱うことができます。
Rack::RequestとRack::Responseには他にもクエリストリングのパースなどといった便利な機能が備わっているため、これらを使いこなすことでより簡単にWebアプリケーションの作成が可能になります。

## 静的ファイルを返す
アクセス数が数字だけだとさみしいので画像で表示されるようにしてみましょう。
※アクセスカウンタの画像はこちらの素材をお借りしました。
http://kan-chan.stbbs.net/download/digits/main.html
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280356/78a70804-1981-3fb5-21a7-5100894c09cf.png" width="320px" height="240px">

静的ファイルを返すための機能ですが、そのままやるとContent-Typeの設定など大変な点が色々あります。しかしRack::Filesを使用すればContent-Typeなどの設定を行った結果を生成することができるため、簡単に静的ファイルを返す機能を実装することができます。

```ruby:application.rb
ACCESS_COUNT_FILE_NAME = "access_count.txt"

class Application
  def call(env)
    req = Rack::Request.new(env)
    if File.exist?("#{Dir.pwd}/public#{req.path_info}") && !Dir.exist?("#{Dir.pwd}/public#{req.path_info}")
      # path_infoに該当するファイルが存在する場合、静的ファイルを返す
      files = Rack::Files.new("public")
      files.get(req.env)
    else
      # path_infoに該当するファイルが存在しない場合、HTMLを生成して返す
      count = get_access_count
      if req.cookies["visited"] != "1"
        count = access_countup(count)
      end
      res = Rack::Response.new([generate_html(count)], 200, { "Content-Type" => 'text/html;charset="UTF-8"' } )
      res.set_cookie("visited", { value: "1" })
      res.finish
    end
  end

  def get_access_count
    return File.read(ACCESS_COUNT_FILE_NAME).to_i if File.exist?(ACCESS_COUNT_FILE_NAME)
    0
  end

  def access_countup(count)
    count += 1
    File.write(ACCESS_COUNT_FILE_NAME, count)
    count
  end

  def generate_html(count)
    img_tags = sprintf("%06d", count).split("").map { |dig|
      %`<img src="dp7seg/#{dig}.png">`
    }.join("")
    <<~HTML
      <p>
        あなたは
        #{img_tags}
        人目の訪問者です。
      <p>
    HTML
  end
end
```

## Rackミドルウェア

Rackにはミドルウェアというアプリケーションに機能を追加する機能があります。
ミドルウェアを使用することで、Rackアプリケーションが返したレスポンスのステータスコードやヘッダー、ボディーを編集することができるようになります。

Rackミドルウェアは次のような仕様を満たす必要があります。
・コンストラクタの第一引数でappを受け取ること。
・callメソッドが実装されていること。
・引数envを取ること。
・[HTTPステータスコード, ヘッダーのハッシュ、ボディーの配列]からなる戻り値を返すこと。
最初の仕様以外はRackアプリケーションの仕様と同じですね。

試しにRackアプリケーションが返したレスポンスボディーにメッセージを追加するミドルウェアを作ってみましょう。

```ruby:config.ru
require_relative "application"

class MessageMiddreware
  def initialize(app, message)
    @message = message
    @app = app
  end

  def call(env)
    status, headers, body = @app.(env)
    # レスポンスボディーの先頭にメッセージを追加する
    body.unshift(@message)
    [status, headers, body]
  end
end

# useでアプリケーションにミドルウェアを追加する
# 第一引数でミドルウェアを指定し、第二引数以降にミドルウェアのコンストラクタに渡す引数を指定する
use MessageMiddreware, "<p>Welcome to my homepage.<p>"
use MessageMiddreware, "<p>Sorry, this page is japanese only.<p>"
run Application.new

```

上記のコードの実行結果です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280356/6e119538-bdb4-d1f0-f690-2f9442f4aa1d.png" width="320px" height="240px">


Rackにはあらかじめいくつかの便利なミドルウェアが用意されています。
今回は試しにログの保存機能とスクリプトのリロード機能をつけてみましょう。

```ruby:config.ru
require "logger"
require_relative "application"

# CommonLoggerミドルウェアをアプリケーションに追加
use Rack::CommonLogger, Logger.new("access_log.txt")
# Reloaderミドルウェアをアプリケーションに追加
use Rack::Reloader

run Application.new
```

これだけでアプリケーションでログの保存とスクリプトのリロードが可能になります。このようにミドルウェアを使用すればアプリケーションに追加する機能を自由自在にカスタマイズすることが可能となります。

## rackupを使用せずにサーバを立ち上げる
今までrackupコマンドを使用してアプリケーションサーバを起動してきましたが、Rack::BuilderとRack::Serverを使用すれば通常のrubyコマンドでサーバを起動することができるようになります。[^2]

```ruby:application.rb
require "logger"

ACCESS_COUNT_FILE_NAME = "access_count.txt"

class Application
  def initialize(options = {})
    super()
    builder = Rack::Builder.new(self)
    builder.use(Rack::CommonLogger, Logger.new("access_log.txt"))
    builder.use(Rack::Reloader)
    rack_options = options.clone
    rack_options[:app] = builder.to_app
    rack_options[:Port] = 9292
    @rack_server = Rack::Server.new(rack_options)
  end

  def start
    @rack_server.start
  end

  def call(env)
    req = Rack::Request.new(env)
    if File.exist?("#{Dir.pwd}/public#{req.path_info}") && !Dir.exist?("#{Dir.pwd}/public#{req.path_info}")
      # path_infoに該当するファイルが存在する場合、静的ファイルを返す
      files = Rack::Files.new("public")
      files.get(req.env)
    else
      # path_infoに該当するファイルが存在しない場合、HTMLを生成して返す
      count = get_access_count
      if req.cookies["visited"] != "1"
        count = access_countup(count)
      end
      res = Rack::Response.new([generate_html(count)], 200, { "Content-Type" => 'text/html;charset="UTF-8"' } )
      res.set_cookie("visited", { value: "1" })
      res.finish
    end
  end

  def get_access_count
    return File.read(ACCESS_COUNT_FILE_NAME).to_i if File.exist?(ACCESS_COUNT_FILE_NAME)
    0
  end

  def access_countup(count)
    count += 1
    File.write(ACCESS_COUNT_FILE_NAME, count)
    count
  end

  def generate_html(count)
    img_tags = sprintf("%06d", count).split("").map { |dig|
      %`<img src="dp7seg/#{dig}.png">`
    }.join("")
    <<~HTML
      <p>
        あなたは
        #{img_tags}
        人目の訪問者です。
      <p>
    HTML
  end
end
```

```ruby:main.rb
require "rack"
require_relative "application"

app = Application.new
app.start
```

こうすることによってWebアプリケーションフレームワーク側であらかじめミドルウェアを指定しておくことが可能になったりします。

# Webアプリケーションフレームワークの自作
Rackの簡単な使い方が分かってきたところで、いよいよWebアプリケーションフレームワークの自作に移りたいと思います。

## ルーティングの実装

ルーティングを実装します。BaseApplicationを継承したクラスのroutesメソッドを例えば次のように実装することでルーティングを実現できます。

```ruby
class BaseApplication
  def initialize
    @routing_table = {}
    # ルーティングテーブルを設定
    routes
  end

  def call(env)
    req = Rack::Request.new(env)
    resolve(req)
  end

  def routes
    raise NotImplementedError.new
  end

  def get(table_data)
    route("GET", table_data)
  end

  def post(table_data)
    route("POST", table_data)
  end

  # get/post以外のHTTPメソッドは省略

  private

  # ルーティングの登録を行う
  def route(http_method, table_data)
    table_data.each do |path, controller_action|
      controller_name, action_name = controller_action.split("#")
      controller_class = Kernel.const_get(controller_name)
      @routing_table[[http_method, path]] = [controller_class.new, action_name.to_sym]
    end
  end

  def resolve(request)
    result = match_routing_table(request)
    if result
      controller, action = *result
      begin
        res = controller.call(action, request)
        res.finish
      rescue => e
        STDERR.puts e.full_message
        status_500
      end
    else
      files = Rack::Files.new("public")
      files.get(request.env)
    end
  end

  # ルーティングテーブルを検索し、リクエストにマッチするコントローラとアクションの組を取得する
  def match_routing_table(request)
    @routing_table.each do |key, val|
      http_method, path = *key
      next unless http_method == request.request_method
      return val if path == request.path_info
    end
    nil
  end

  def status_500
    res = Rack::Response.new(["500 Internal Server Error"], 500)
    res.finish
  end
end
```

これでBaseApplicationを継承するクラスのroutesメソッドでルーティングの設定を行うことができるようになります。

```ruby
# ルーティングの設定例
class MyApp < Application
  def routes
    get "/" => "MyController#index"
    post "/create" => "MyController#create"
  end
end

```

## コントローラの実装

続いてコントローラの実装です。
ここでRack::RequestとRack::Responseの各種機能を使用するメソッドを実装します。

```ruby:controller.rb
class Controller
  def initialize
    @request = nil
    @response = nil
  end

  def call(action, req)
    @request = req
    @response = Rack::Response.new(nil, 200)
    catch(:halt) do
      body = send(action)
      return_plain
    end
    @response
  end

  def request
    @request
  end

  def response
    @response
  end

  def params
    @request.params
  end

  def return_html(body)
    @response.content_type = 'text/html;charset="UTF-8"'
    halt(body: body)
  end

  def erb(name)
    html = ERB.new(File.read("views/#{name}.erb", encoding: "UTF-8")).result(binding)
    return_html(html)
  end

  def get_cookie(key)
    @request.cookies[key]
  end

  def set_cookie(key, cookie)
    hash = cookie.is_a?(String) ? { value: cookie} : cookie
    @response.set_cookie(key, hash)
  end

  def url(path, scheme = nil)
    scheme ||= @request.scheme
    match_data = @request.env["REQUEST_URI"].match(/.+\:\/\/(.+?)\//)
    domain = match_data[1]
    "#{scheme}://#{domain + path}"
  end

  def redirect(uri)
    @response.location = uri
    halt status: 303
  end

  def halt(body: nil, status: 200)
    @response.status = status if status
    @response.body = [body] if body
    throw :halt
  end
end
```

## アプリケーションの実装

Rackサーバを起動するための機能とミドルウェアの適用を実装します。

```ruby:application.rb
class Application < BaseApplication
  def initialize(options = {})
    super()
    @options = options.clone
    @middlewares = default_middlewares
    @rack_server = nil
  end

  def built?
    !!@rack_server
  end

  def build
    builder = Rack::Builder.new(self)
    @middlewares.each do |middleware_array|
      builder.use(*middleware_array)
    end
    rack_options = @options
    rack_options[:app] = builder.to_app
    rack_options[:Port] = 9292 unless rack_options[:Port]
    @rack_server = Rack::Server.new(rack_options)
  end

  def start
    build unless built?
    @rack_server.start
  end

  def add_middleware(middleware_class, *middleware_args)
    @middlewares << [middleware_class, *middleware_args]
  end

  private

  def default_middlewares
    [
      [Rack::CommonLogger, Logger.new("access_log.txt")],
      [Rack::Reloader],
    ]
  end
end
```

## アクセスカウンタの実装
ここまで作ったWebアプリケーションフレームワークでもう一度アクセスカウンタを実装しなおしてみましょう。

```ruby:main.rb
require "rack"
require "logger"
require_relative "base_application"
require_relative "application"
require_relative "controller"

ACCESS_COUNT_FILE_NAME = "access_count.txt"

class AccessCounterApp < Application
  def routes
    get "/" => "AccessCounterController#index"
  end
end

class AccessCounterController < Controller
  def index
    count = get_access_count
    if get_cookie("visited") != "1"
      count = access_countup(count)
    end
    set_cookie("visited", { value: "1" })
    return_html generate_html(count)
  end

  def get_access_count
    return File.read(ACCESS_COUNT_FILE_NAME).to_i if File.exist?(ACCESS_COUNT_FILE_NAME)
    0
  end

  def access_countup(count)
    count += 1
    File.write(ACCESS_COUNT_FILE_NAME, count)
    count
  end

  def generate_html(count)
    img_tags = sprintf("%06d", count).split("").map { |dig|
      %`<img src="dp7seg/#{dig}.png">`
    }.join("")
    <<~HTML
      <p>
        あなたは
        #{img_tags}
        人目の訪問者です。
      <p>
    HTML
  end
end

app = AccessCounterApp.new
app.start
```

## 掲示板を作ってみる
簡単なフレームワークができたところで、試しに掲示板を作ってみましょう。

```ruby:main.rb
require "rack"
require "logger"
require_relative "base_application"
require_relative "application"
require_relative "controller"

BULLETIN_BOARD_FILE_NAME = "bulletin_board.marshal"

class BulletinBoardApp < Application
  def routes
    get "/" => "BulletinBoardController#index"
    post "/writing" => "BulletinBoardController#writing" # 掲示板への書き込みを行う
  end
end

class BulletinBoardController < Controller
  def index
    # ビューを読み込み表示する
    erb "index"
  end

  def writing
    save_bulletion_board(params["name"], params["writing"])
    # トップページへリダイレクトする
    redirect url("/")
  end

  # 書き込み内容をファイルから読み込む
  def load_bulletin_board
    if (File.exist?(BULLETIN_BOARD_FILE_NAME))
      return Marshal.load(File.read(BULLETIN_BOARD_FILE_NAME))
    end
    []
  end

  # 書き込み内容をファイルに保存する
  def save_bulletion_board(name, writing)
    bulletin_board = load_bulletin_board
    bulletin_board << [name, writing]
    marshal = Marshal.dump(bulletin_board)
    File.write(BULLETIN_BOARD_FILE_NAME, marshal)
  end

  # 掲示板の書き込み部分のHTMLを生成する
  def generate_writing_html
    bulletin_board = load_bulletin_board
    bulletin_board.map.with_index { |(name, writing), i|
      %`<p>#{sprintf("%03d", i + 1)}:　#{name}</p><p>#{writing}</p>`
    }.join("")
  end
end

app = BulletinBoardApp.new
app.start
```

```ruby:views/index.erb
<h1>掲示板</h1>
<%= generate_writing_html %>

<form action="<%= url '/writing' %>" name="create_form" method="post">
    <p>名前</p>
    <input cols="16" name="name"></input>

    <p>書き込み</p>
    <textarea rows="10" cols="80" name="writing"></textarea><br>

    <a href="javascript:create_form.submit()">作成</a>
</form>
```

実行結果です。
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/280356/4eeff6e8-0eac-6151-cfa6-42b3ecc491e2.png" width="480px" height="320px">

# おわりに
なんだかほとんどRackの解説ばかりになってしまいましたが、こんな感じでWebアプリケーションフレームワークは意外と簡単に作ることができます。フレームワークを作ることは既存のフレームワークの中身を理解する足掛かりとして最適だと思うのでみなさんもやってみてください！

最後に、上記のコードをベースとして私が作成しているWebアプリケーションフレームワーク「ruby-freedom」[^3]を公開したいと思います。最低限の機能しかなく実用にはほど遠いですが、その分コードは簡単だと思うので興味がある方はよかったら覗いてみてください！
https://github.com/unagiootoro/ruby-freedom

[^1]: 厳密にはヘッダーやボディーは適切なメソッドが実装されていれば型はなんでもいいです。詳細はhttps://github.com/rack/rack/blob/master/SPEC.rdoc

[^2]: rackupコマンドはruファイルをRack::BuilderのDSLで評価した結果をRack::Serverに渡しているだけなのでこれらを使用することでrackupコマンドと同じ動作をさせることが可能です。

[^3]: Railsがレールならこっちは自由だ！！って感じでfreedomって名前にしました。かっこつけていますがRailsと比べたら機能が少なすぎて自由という名の虚無になってます。。。