15
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-12-02

はじめに

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

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

Rack概要

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

とりあえずやってみる

最低限のWebアプリケーションを作ってみます。

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

run Application.new

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

rackup config.ru

これでhttp://localhost:9292にアクセスすると、次のような結果が表示されます。

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

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

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

{"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を使うことでより手軽にリクエストとレスポンスの操作が可能です。これらを使って先ほどのコードを書き直してみます。

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

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

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

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

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

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

上記のコードの実行結果です。

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

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

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
main.rb
require "rack"
require_relative "application"

app = Application.new
app.start

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

Webアプリケーションフレームワークの自作

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

ルーティングの実装

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

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メソッドでルーティングの設定を行うことができるようになります。

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

コントローラの実装

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

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サーバを起動するための機能とミドルウェアの適用を実装します。

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アプリケーションフレームワークでもう一度アクセスカウンタを実装しなおしてみましょう。

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

掲示板を作ってみる

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

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
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>

実行結果です。

おわりに

なんだかほとんど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と比べたら機能が少なすぎて自由という名の虚無になってます。。。

15
13
2

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
15
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?