はじめに
Ruby Advent Calendar 2020 3日目の記事です。
今回はRackを使ってシンプルなWebアプリケーションフレームワークを作っていきたいと思います。
※注意
筆者は最近Webアプリケーションの勉強を始めたばかりの初心者なので、間違っている箇所も多々あるかと思いますが、その場合はコメント欄で指摘していただければありがたく思います。
Rack概要
RackはWebサーバーとWebアプリケーションをつなぐためのライブラリです。
Rackを使用することで、ユーザーはサーバーの種類(WEBRick, Unicorn, Pumaなど)に縛られることなくWebアプリケーションフレームワークを使用することができます。
実際、RailsやSinatraといった代表的なWebアプリケーションフレームワークでもRackが使用されています。
とりあえずやってみる
最低限のWebアプリケーションを作ってみます。
class Application
def call(env)
[200, { "Content-Type" => "text/html" }, ["<h1>Hello, Rack!<h1>"]]
end
end
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を使うことでより手軽にリクエストとレスポンスの操作が可能です。これらを使って先ほどのコードを書き直してみます。
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
これだけだと何のメリットがあるのか分からないですね。
というわけで試しにこれらのクラスを使用してアクセスカウンタを作ってみたいと思います。
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などの設定を行った結果を生成することができるため、簡単に静的ファイルを返す機能を実装することができます。
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アプリケーションが返したレスポンスボディーにメッセージを追加するミドルウェアを作ってみましょう。
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にはあらかじめいくつかの便利なミドルウェアが用意されています。
今回は試しにログの保存機能とスクリプトのリロード機能をつけてみましょう。
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
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
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の各種機能を使用するメソッドを実装します。
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サーバを起動するための機能とミドルウェアの適用を実装します。
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アプリケーションフレームワークでもう一度アクセスカウンタを実装しなおしてみましょう。
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
掲示板を作ってみる
簡単なフレームワークができたところで、試しに掲示板を作ってみましょう。
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
<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
-
厳密にはヘッダーやボディーは適切なメソッドが実装されていれば型はなんでもいいです。詳細はhttps://github.com/rack/rack/blob/master/SPEC.rdoc ↩
-
rackupコマンドはruファイルをRack::BuilderのDSLで評価した結果をRack::Serverに渡しているだけなのでこれらを使用することでrackupコマンドと同じ動作をさせることが可能です。 ↩
-
Railsがレールならこっちは自由だ!!って感じでfreedomって名前にしました。かっこつけていますがRailsと比べたら機能が少なすぎて自由という名の虚無になってます。。。 ↩