13
8

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 1 year has passed since last update.

RubyAdvent Calendar 2022

Day 4

Rubyの組み込みライブラリのみを使用してWebサーバーを自作する

Last updated at Posted at 2022-12-04

はじめに

本記事では、gemなどを一切使わず、Rubyの組み込みライブラリのみを使用して、Webサーバーを自作します。
この記事の内容は下記の「Webサーバーを作りながら学ぶ 基礎からのWebアプリケーション開発入門」を参考にしています。
この書籍自体は、Javaで書かれていますが、どの言語にも通じる根本の部分が分かりやすく書かれているのでとてもおすすめです。

TCPサーバーの実装

TCP/IPを用いた通信について

Webサーバーをブラウザはインターネットを経由して通信を行います。
この通信にはTCP/IPというプロトコルが使用されています。
TCP/IPプロトコルではIPアドレス、ポート番号を使用して通信相手を特定します。

例えば、Webサーバーのネットワーク上の住所(IPアドレス)である、「127.0.0.1」と
Webサーバープログラムのコンピュータ上の住所(ポート)である、「80」を組み合わせて
「127.0.0.1:80」という形で通信相手を指定します。

上記のようにIPアドレス、ポートを指定してあげれば情報を送る側は情報を受け取る側を特定できます。
しかし、通信を行うにはこれだけでは不十分です。
送られた情報を受け取る側もTCP/IP用にポートを待機させておく必要があります。

これをプログラムから行うには、コンピューターにポート80はTCP/IP通信用に使うよ!と
教えてあげる必要があります。それを実装するためにソケットと呼ばれるものを使用します

ソケットとは

ソケットとは、TCPやUDPを利用して通信するためによく使用されるAPIのことです。
通信の接続口と言われたりします。

クライアント側のソケットとサーバー側のソケットを接続し、
接続されたソケットに書き込み、読み込みを行うことで互いの情報を交換することができます。

なお、本記事でソケットという言葉を使う場合は、ソケットの中でも特にTCP通信を担うTCPソケットのことを指します。

Rubyでソケットを使ってTCPサーバー、TCPクライアントを実装する

Rubyでソケット通信を実装するにはSocketライブラリのTCPSocketクラスとTCPServerクラスを使用します。
TCPSocketはクライアント記述用のクラスで、
TCPServerはサーバー記述用のクラスです。

サーバー側の実装

まずはサーバー側の実装です。下記のサンプルプログラムをご覧ください。
クライアントからの書き込みを1バイトずつ読み込んで、server_recv.txtというファイルに書き込み、
server_send.txtというファイルを読み込んでsocketに書き込むという処理をしています。

tcp_server.rb
require "socket"

begin
  # ポート3030をオープンし、クライアントからの接続を受け付ける
  server = TCPServer.open(3030)
  puts("クライアントの接続を受け付けています")
  socket = server.accept
  puts("クライアント接続")

  # server_recv.txtにsocketから読み出した文字列を1つずつ書き出す
  File.open("server_recv.txt", "w") do |file|
    # 0が送られてくるまで、socketへの書き込みを1バイトずつ読み出し続ける
    while (ch = socket.read(1)) != "0"
      file.write(ch)
    end
  end

  File.foreach("server_send.txt") do |line|
    socket.write(line)
  end
rescue => e
  puts e
ensure
  socket.close
  server.close
end

TCPServer.open(3030)で、指定したポートのサーバー接続をオープンし、
server.acceptでクライアントからの接続を受け付けています。
TCPServer#accept()はクライアントとの接続が行われるとTCPSocketインスタンスを返します。

File.open(~)の部分では、socketから読み出した文字列をserver_recv.txtというファイルに書き出しています。
socket.read(1)で、クライアントがソケットに書き込んだ内容を1バイトずつ読み出しています。
(引数で一度に読み出すバイト数を設定できます。)

while (ch = socket.read(1)) != "0"の部分で、
クライアントから"0"という文字列が書き込まれるまで読み出しを行います。
"0"である必要はありませんが、クライアントの書き込み終了の合図はなにかしら事前に決めておく必要があります。

今度は、File.foreach(~)server_send.txtというファイルを読み出し、サーバーからソケットに書き込みを行います。
最後にsocket.closeで接続を切断し, server.closeでポート3030を閉じます。

クライアント側の実装

tcp_client.rb
require "socket"

socket = TCPSocket.new("localhost", 3030)

# client_send.txtの内容を一行ずつ書き込む
File.foreach("client_send.txt") do |file_line|
  socket.write(file_line)
end

# 書き込み終了の合図として0を書き込む
socket.write("0")

File.open("client_recv.txt", "w") do |file|
  while ch = socket.read(1)
    file.write(ch)
  end
end

TCPSocket.new("localhost", 3030)の部分で、localhostのポート3030に接続します。
接続に成功するとTCPSocketインスタンスを返します。

File.foreach(~)client_send.txtを一行ずつ読み出し、
socket.writeで書き込んでいます。

書き込みが終わったら、クライアント側の書き込み終了の合図として"0"を書き込みます。
最後に、File.open(~)でサーバーから書き込まれた内容を読み出し、server_send.txtというファイルを読み出し、サーバーからソケットに書き込みを行います。
サーバー側でsocketの接続を切断しているのでクライアント側では特にclose処理をしていません。

その他

クライアントからサーバー、サーバーからクライアントに書き込む準備をするため、
上記で読み出していた、"server_send.txt", "client_send.txt"を作成します。
中身は両者が区別できればなんでも良いです。

server_send.txt
server string
server string
server string
server string
server string
server string
server string
server string
server string
server string
client_send.txt
client string
client string
client string
client string
client string
client string
client string
client string
client string
client string

接続してみる

では、早速作成したプログラムを実行し見ましょう。

まずはTCPサーバーを起動します。

ruby tcp_server.rb
# => "クライアントの接続を受け付けています"

別ターミナルで起動したTCPサーバーにTCPクライアントから接続を行います

ruby tcp_client.rb

接続に成功するとTCPサーバー側のターミナルにクライアント接続という文字列が出力され、
現在のディレクトリ下にserver_recv.txt, client_recv.txtが作成されます。
中身を確認すると、下記のようになっています。

server_recv.txt
client string
client string
client string
client string
client string
client string
client string
client string
client string
client string
client_recv.txt
server string
server string
server string
server string
server string
server string
server string
server string
server string
server string

それぞれ、server_recv.txtclient_send.txtと 、
client_recv.txtserver_send.txtと中身が同じであれば通信成功です。
これでRubyのソケットライブラリを使用してTCPサーバーを実装し、TCPクライアントとの接続を行うことができました。

Webサーバーの実装

Webサーバーについて

ここまでTCPサーバーを作成して、TCPクライアントとの接続を行いました。
Webサーバーを実装するためにはこのTCPサーバーをHTTPプロトコルに適用させる必要があります。
つまり、クライアントからHTTPリクエストを受け取って、HTTPレスポンスを返すようにする必要があります。
そのためには、HTTPリクエストとHTTPレスポンスについて知る必要があります。
これらについて詳しく見ていきましょう。

HTTPリクエスト

HTTPにおいてクライアントがサーバー側に送るものです。
先程のtcp_server.rbを少し改良して実際のHTTPリクエストを確認してみます。

tcp_server.rb
require "socket"

begin
  server = TCPServer.open("localhost", 3030)
  puts "クライアントからの接続を待ちます。"
  socket = server.accept()
  puts "クライアント接続"

  # server_recv.txtにsocketから読み出した文字を一つずつ書き出す
  File.open("server_recv.txt", "w") do |file|
    # 0が送られてくるまで、socketへの書き込みを1バイトずつ読み出し続ける
    while (line = socket.gets) != "\r\n" #<= ここだけ変更
      file.write(line)
    end
  end

  File.foreach("server_send.txt") do |file_line|
    socket.write(file_line)
  end
rescue => e
  puts e
ensure
  socket.close
  server.close
end

先程はread(1)で一文字ずつ読み出していましたが、socket.getsで1行ずつ取り出します、
また、クライアントの書き込み終了を"\r\n"という文字列で判断するようにしています。
こちらを実行し、ご自身が使用しているブラウザからhttp://localhost:3030 にアクセスしてみてください。
すると、server_recv.txtに下記のような内容が書き込まれるはずです。

server_recv.txt
GET /index.html HTTP/1.1
Host: localhost:3030
Connection: keep-alive
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="107", "Chromium";v="107", "Not=A?Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
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
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8

これがHTTPリクエストの正体です。
最も重要なのは一行めのGET /index.html HTTP/1.1です。
この部分をHTTPリクエストラインといい、ここではGETメソッドを使用して指定したリソース(/index.html)の参照をリクエストしています。
Webサーバー側はこのリクエストに応え、/index.htmlにあるリソースをHTTPレスポンスボディに含めて、返却する必要があります。
2行目以降はHTTPリクエストヘッダーが並んで、最後に空行(\r\n)が入ります。

つまり、HTTPリクエストとは下記のような形式をしています。

HTTPリクエストライン
HTTPリクエストヘッダー1
HTTPリクエストヘッダー2
HTTPリクエストヘッダー3
HTTPリクエストヘッダー4
...
\r\n

HTTPレスポンス

今度はHTTPレスポンスを見てみましょう。
dockerでnginxを起動してリクエストを送り、そのレスポンスをみてみます。
下記を実行しnginxをポート8081で起動します。

docker run -p 8081:80 nginx:latest

ちなみに、webサーバーならnginxではなく、Apacheなどでも大丈夫ですし、
dockerを使いたくない方は公式インストールページからインストールしていただいても大丈夫です。
レスポンスの中身を見たいだけなので、用意するのが面倒という方は
このまま記事を追ってくださればそれでも問題ありません。

ブラウザからアクセスして、nginxが起動したことを確認しましょう。

スクリーンショット 2022-12-04 18.46.39.png

TCPサーバー実装時に使用したtcp_client.rbに少し手を加えて、client_send.txtに先程のHTTPヘッダーをコピペします。

tcp_client.rb
require "socket"

socket = TCPSocket.new("localhost", 8081) # <= ここを変更

# client_send.txtの内容を一行ずつ書き込む
File.foreach("client_send.txt") do |file_line|
  socket.write(file_line)
end

socket.write("\n") # <= ここを追加
socket.write("0")

File.open("client_recv.txt", "w") do |file|
  while ch = socket.read(1)
    file.write(ch)
  end
end
client_send.txt
GET /index.html HTTP/1.1
Host: localhost:3030
Connection: keep-alive
Cache-Control: max-age=0
sec-ch-ua: "Google Chrome";v="107", "Chromium";v="107", "Not=A?Brand";v="24"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "macOS"
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36
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
Sec-Fetch-Site: none
Sec-Fetch-Mode: navigate
Sec-Fetch-User: ?1
Sec-Fetch-Dest: document
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8

tcp_client.rbを実行すると、client_recv.txtに下記の内容が書き込まれます。

client_recv.txt
HTTP/1.1 200 OK
Server: nginx/1.23.2
Date: Sun, 04 Dec 2022 09:57:44 GMT
Content-Type: text/html
Content-Length: 615
Last-Modified: Wed, 19 Oct 2022 07:56:21 GMT
Connection: keep-alive
ETag: "634fada5-267"
Accept-Ranges: bytes

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
html { color-scheme: light dark; }
body { width: 35em; margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif; }
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and
working. Further configuration is required.</p>

<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>

<p><em>Thank you for using nginx.</em></p>
</body>
</html>

内容を見てみると一行ずつHTTPレスポンスヘッダーが並び、
一行の空白で隔ててHTTPレスポンスボディが記載されるという構成になっています。
つまり、HTTPレスポンスは下記のような形式をしています。

HTTPレスポンスヘッダー1
HTTPレスポンスヘッダー2
HTTPレスポンスヘッダー3
HTTPレスポンスヘッダー4
...
\r\n
HTTPレスポンスボディ

HTTPレスポンスを返却する

それでは実際にHTTPレスポンスを返却する部分を実装します。

main部分の実装

main.rb
require "socket"
require "./server_thread"

server = TCPServer.open(3030)

begin 
  socket = server.accept

  # "\r\n"だけの行になるまで、1行ずつsocketへの書き込みを読み込む
  while (line = socket.gets) != "\r\n"
    path = line.split(" ")[1] if line&.start_with?("GET")
  end

  # Dateヘッダー用に現在時刻をUTCで取得し、形式を整える
  utc_date_string = DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")

  # HTTPレスポンスヘッダーの書き込み
  socket.write("HTTP/1.1 OK\n")
  socket.write("Date: #{utc_date_string}\n")
  socket.write("Server: RubySimpleServer\n")
  socket.write("Connection: close\n")
  socket.write("Content-type: text/html\n")

  # 空白行
  socket.write("\r\n")

  # HTTPレスポンスボディの書き込み
  File.foreach("src/#{path}") do |line|
    socket.write(line)
  end
rescue => e
  puts e
ensure
  socket.close
  server.close
end

server、socketをオープンし、読み取りと書き込みを行う大枠の流れに変化はありませんが、
個別の処理を見ていきましょう。

  # "\r\n"だけの行になるまで、1行ずつsocketへの書き込みを読み込む
  while (line = socket.gets) != "\r\n"
    path = line.split(" ")[1] if line&.start_with?("GET")
  end

先程確認した通り、HTTPリクエストの最終行には空行("\r\n")が入るので、
空行が入るまで一行ずつ読み出します。
また、リクエストラインで指定されたpathにあるリソースを返却したいので、G
ETから始まっている列がある場合はpath部分を取り出し変数に格納するようにしています。

  # Dateヘッダー用に現在時刻をUTCで取得し、形式を整える
  utc_date_string = DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")

  # HTTPレスポンスヘッダーの書き込み
  socket.write("HTTP/1.1 OK\n")
  socket.write("Date: #{utc_date_string}\n")
  socket.write("Server: RubySimpleServer\n")
  socket.write("Connection: close\n")
  socket.write("Content-type: text/html\n")

確認した、HTTPレスポンスの形式通り、最初にHTTPレスポンスヘッダーの書き込みをしています。
ここでは最低限のヘッダーしか含めていません。

  # 空白行
  socket.write("\r\n")

  # HTTPレスポンスボディの書き込み
  File.foreach("src/#{path}") do |line|
    socket.write(line)
  end

HTTPヘッダーの書き込みが終わったら、空白行を1行挟み、HTTPレスポンスボディを書き込みます。
書き込む内容は、pathで指定されたファイルの内容です。

参照する静的ファイルの実装

次に、srcディレクトリを作成し、index.htmlを配置します。

src/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Main Page</title>
  </head>
  <body>
    <h1>メインページです。</h1>
    <p>メインページのパラグラフです。</p>
  </body>
</html>

main.rbを実行し、ブラウザからhttp://localhost3030/index.html にアクセスすると下記のようにindex.htmlが表示されます。

スクリーンショット 2022-12-04 18.22.33.png

試しに別のhtmlも追加してみます。

test.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Test Page</title>
</head>
<body>
  <h1>テストページです</h1> 
  <p>テストページのパラグラフです。</p>
</body>
</html>

再度main.rbを実行し、ブラウザから今度はhttp://localhost:3030/test.html にアクセスします。
すると、test.htmlが表示されます。

スクリーンショット 2022-12-04 18.29.02.png

これで無事HTTPリクエストに応じた静的ファイルをHTTPレスポンスとして返却することができました!
Webサーバーの実装完了です!..といきたいところですがこのままだとまだ不十分です。

複数リクエストに対応する

現状のままだとリクエストが1つ送られてきた途端にサーバープログラムは終了します。
これでは実用的ではありません。
そこで、RubyのThreadを使用して、複数リクエストに対応できるようにします。
ここではThread詳細の説明はしませんが、ざっくり言えば、Rubyで並行処理を実現するためのクラスです。

実装は下記のようになります。

main.rb
require "socket"
require "./server_thread"

server = TCPServer.open(3030)

begin 
  loop do
    puts "クライアントからの接続を待ちます"
    Thread.start(server.accept) do |socket|
      puts "クライアント接続"
      server_thread = ServerThread.new(socket)
      server_thread.run
    end
  end
rescue => e
  puts e
ensure
  server.close
end

main部分で変更があったのは下記です。

  loop do
    puts "クライアントからの接続を待ちます"
    Thread.start(server.accept) do |socket|
      puts "クライアント接続"
      server_thread = ServerThread.new(socket)
      server_thread.run
    end
  end

ここやっていることは、server.acceptでクライアントからの接続を待ち受けて、
接続が行われたら、Thread処理を開始します。
mainプログラム自身は再度ループして、server.acceptを実行し、クライアントからの接続を待ち受けます。
Thread処理内では、ServerThreadインスタンスを生成しServerThread#runを実行しています。

ServerThreadはThread処理の中身を別クラスとして切り出したもので、中身は下記のようになっています。

server_thread.rb
require "socket"

class ServerThread
  def initialize(socket)
    @socket = socket
  end

  def run
    begin
      while (line = @socket.gets) != "\r\n"
        path = line.split(" ")[1] if line&.start_with?("GET")
      end
    
      utc_date_string = DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")
    
      @socket.write("HTTP/1.1 OK\n")
      @socket.write("Date: #{utc_date_string}\n")
      @socket.write("Server: RubySimpleServer\n")
      @socket.write("Connection: close\n")
      @socket.write("Content-type: text/html\n")
      @socket.write("\r\n")
    
      File.foreach("src/#{path}") do |line|
        @socket.write(line)
      end
    rescue => e
      puts e
    ensure
      @socket.close
    end
  end
end

初期化時にsocketをインスタンス変数として受け取っている以外は前回実装したHTTPレスポンスを返却する処理と変化ありません。
これで、何度リクエストしてもサーバープログラムは終了せず、リクエストに応じたレスポンスが返却されるようになりました!
ちなみに、サーバープログラムを終了したければCtrl + Cで終了できます。

404 Not Foundページを返却する

現状のWebサーバーでは、リソースが存在すれば無事表示されますが、存在しない場合はエラーが発生します。
そこで、指定されたpathにリソースが存在しない場合は404 Not Foundページを返却するようにします。

server_thread.rb
require "socket"
require "./http_response"

class ServerThread
  BASE_DIR = "src"

  def initialize(socket)
    @socket = socket
  end

  def run
    begin
      while (line = @socket.gets) != "\r\n"
        path = line.split(" ")[1] if line&.start_with?("GET")
      end

      path_to_file = File.join(BASE_DIR, path)

      if File.exist?(path_to_file)
        HttpResponse.send_ok(@socket, path_to_file)
      else
        HttpResponse.send_not_found(@socket)
      end
    rescue => e
      puts e
    ensure
      @socket.close
    end
  end
end

File.exist?で指定されたpathにファイルが存在するかチェックしています。
ファイルが存在すれば、そのファイルを存在しなければ404ページを返却するようにしています。
200レスポンスと404レスポンスの書き出しをserver_thread.rbに書き出すと、
runメソッドが肥大化するので、HttpResponseという別のmoduleを作成し、
ServerThreadからはHttpResponse.send_okまたはHttpResponse.send_not_foundを呼び出すようにします。

http_response.rb自体は下記のようになっています。

http_response.rb
require "./util"

module HttpResponse
  PATH_TO_NOT_FOUND = "src/404.html"
  SERVER_NAME = "RubySimpleServer"

  class << self
    def send_ok(socket, path)
      socket.write("HTTP/1.1 200 OK\n")
      socket.write("Date: #{Util.get_utc_date_string}\n")
      socket.write("Server: #{SERVER_NAME}\n")
      socket.write("Connection: close\n")
      socket.write("Content-type: text/html\n")
      socket.write("\r\n")
      write_body(socket, path)
    end

    def send_not_found(socket)
      socket.write("HTTP/1.1 404 Not Found\n")
      socket.write("Date: #{Util.get_utc_date_string}\n")
      socket.write("Server: #{SERVER_NAME}\n")
      socket.write("Connection: close\n")
      socket.write("Content-type: text/html\n")
      socket.write("\r\n")
      write_body(socket, PATH_TO_NOT_FOUND)
    end

    private

    def write_body(socket, path)
      File.foreach(path) do |line|
        socket.write(line)
      end
    end
  end
end

send_okの処理はいままでのレスポンス生成処理とほとんど変わりはありません。
変更はserver名を定数で切り出している点、
日付の取得処理をUtilというこれまた別moduleに切り出している点、
ファイルを読み出してsocketに書き出す処理を別methodに切り出している点の3点です

send_not_foundでは、"HTTP/1.1 200 OK"ではなく"HTTP/1.1 404 Not Found"を返却します。
また、pathは常に一定なので引数で受け取らず、PATH_TO_NOT_FOUNDという定数で、
常にsrc/404.htmlを参照するようにしています。

Utilモジュールは時刻用文字列を生成するmethodを一つ持つのみです。

require "date"

module Util
  class << self
    def get_utc_date_string
      DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")
    end
  end
end

あとは、404.htmlを作成し、src/直下に配置します。

src/404.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>Not Found</title>
</head>
<body>
  <h1>404 Not Found</h1>
  <p>ページが見つかりませんでした。</p>
</body>
</html>

これで、準備は全て整いました!
一度、サーバーを終了してからもう一度起動して、http://localhost:3030/hoge.html にアクセスしてみましょう。

スクリーンショット 2022-12-04 20.51.28.png

src/hoge.htmlは存在しないので、404ページが表示されました!
これで、存在しないリソースへのpathが指定されても安心です。

html以外にも返却できるようにする

これまでは、実装したWebサーバーを使ってhtmlファイルのみを返却していましたが、
Webサーバーでは画像(png, jpg)やjsonなど別ファイルも返却することもあります。
この項目ではその対応をしようと思います。

拾ってきたフリー画像をsrc/img/test.jpgに配置し、ブラウザからhttp://localhost:3030/img/test.jpg にアクセスしてみます。
すると下記のように、意味不明な文字列が表示されます。
スクリーンショット 2022-12-04 20.58.44.png

これは、今までHTTPレスポンスヘッダーのContent-Typeにtext/htmlを指定していたためです。
ファイルの内容を正確に返却するためには、返却するファイルの拡張子に応じて、
適切なContent-Typeを指定してあげる必要があります。

Content-Typeの一覧とその説明は下記の記事がわかりやすく簡潔にまとまっています。

リンク先で並べられているContent-Type全てではありませんが、それでもこれを全て実装するのは大変なので今回は、
下記のみを対象にします。

  • text.html
  • text/plain
  • text/css
  • text/csv
  • application/json
  • image/png
  • image/jpeg
  • image/gif

また、これらのどれにも当てはまらない場合はapplication/octet-streamを返すようにします。

util.rbを下記のように修正します。

util.rb
 require "date"

module Util
  CONTENT_TYPES = {
    html: "text/html",
    htm: "text.html",
    txt: "text/plain",
    css: "text/css",
    csv: "text/csv",
    json: "application/json",
    png: "image/png",
    jpg: "image/jpeg",
    jpeg: "image/jpeg",
    gif: "image/gif"
  }


  class << self
    def get_utc_date_string
      DateTime.now.new_offset.strftime("%a, %d, %b %Y %H:%M:%S %Z GMT")
    end

    def get_ext(path)
      File.extname(path).gsub(".", "")
    end

    def get_content_type(ext)
      CONTENT_TYPES[ext] || "application/octet-stream"
    end
  end
end

CONTNT_TYPEという定数で、拡張子とContent-Typeの対応表をhashで定義します。
#get_content_type()で、引数で受け取った拡張子を使用して、CONTENT_TYPESから対応するContent-Typeを取得し、見つからなければapplication/octet-streamを返却します。
#get_ext()は渡されたpathから拡張子の.以降の部分を取得します。

あとはこれらをHttpResponse.send_ok()内で呼び出すだけです。

def send_ok(socket, path)
  ext = Util.get_ext(path)&.to_sym
  socket.write("HTTP/1.1 200 OK\n")
  socket.write("Date: #{Util.get_utc_date_string}\n")
  socket.write("Server: #{SERVER_NAME}\n")
  socket.write("Connection: close\n")
  socket.write("Content-type: #{Util.get_content_type(ext)}\n")
  socket.write("\r\n")
  write_body(socket, path)
end

これで、渡されたpathにあるファイルの拡張子によってContent-Typeが変わるようになりました。
サーバーを再起動し、先程のhttp://localhost:3030/img/test.jpg にアクセスしてみましょう。

スクリーンショット 2022-12-04 21.14.17.png

画像が表示されました!

ディレクトリトラバーサル対策をする

画像も返却できていい感じにWebサーバーが出来上がってきました。
ただ、このWebサーバーには致命的な脆弱性があります。
それはpathをなんのフィルターもなしに直接指定している点です。
今まではsrc/直下のファイルにのみアクセスしていたからよかったのですが、悪意のある攻撃者がpathを../main.rbのように指定した時、main.rbの内容が盗み見られたり、あるいは実行したくないファイルを実行させられてしまったりします。
この脆弱性をディレクトリトラバーサルの脆弱性と言います。

そうならないためには、指定されたパスが正しいディレクトリの直下にあるかどうかを確認し、不正なpathであれば404を返すようにします。
パスが正しいか検証するmethodをUtilに追加します。

util.rb
 require "date"

module Util
  ~
  BASE_DIR = "src"
  ABSOLUTE_BASE_DIR = File.expand_path(BASE_DIR, __dir__)

  class << self
    def is_valid_path?(path)
      absolute_file_path = File.expand_path(File.join(ABSOLUTE_BASE_DIR), path)
      return absolute_file_path.start_with?(ABSOLUTE_BASE_DIR)
    end

    ~
  end
end

BASE_DIRは検証に使用するのでServerThreadから移動させました。
is_valid_path?()が検証用のmethodです。
検証にはFile.expand_pathを使用して絶対パスに展開しています。
ABSOLUTE_BASE_DIRはsrcをホームディレクトリからの絶対パスで展開したパスです。
ABSOLUTE_BASE_DIRとpathをJOINして、再度絶対パスに展開したものが、
ABSOLUTE_BASE_DIRで始まっていれば、srcより下の階層にある有効なpathなので、trueを返却します。
反対にABSOLUTE_BASE_DIRで始まっていなければ、srcより上の階層を指定する無効なpathなのでfalseを返します。
あとはこれをServerThread側で呼び出すだけです。

server_thread.rb
~
path_to_file = File.join(Util::BASE_DIR, path)

# 指定されたpathが、無効ならばnot_foundを返す
return HttpResponse.send_not_found(@socket) if !Util.is_valid_path?(path_to_file)

if File.exist?(path_to_file)
~

ServerThread#runの中でUtil.is_valid_path?を呼び出しfalseが返ってきたら404Not Foundページを返却します。
これでpathに不正な値が指定されても大丈夫になりました。

ディレクトリが指定された時にリダイレクトする

現状のWebサーバーではリクエスト時にかならずfile名まで明記する必要があります。
例えば、http://localhost:3030/ にアクセスするとNotFoundとなってしまいます。
これを、ディレクトリを指定したらindex.htmlにリダイレクトされるようにしたいです。

リダイレクトを実現するためには、HttpResponseに新たに301 Moved Permanentlyを担う
send_moved_permanentlyを追加実装します。

def send_moved_permanently(socket, location)
  ext = Util.get_ext(path)&.to_sym
  socket.write("HTTP/1.1 301 Moved Permanently\n")
  socket.write("Date: #{Util.get_utc_date_string}\n")
  socket.write("Server: #{SERVER_NAME}\n")
  socket.write("Connection: close\n")
  socket.write("Content-type: #{Util.get_content_type(ext)}\n")
  socket.write("Location: #{location}\n")
  socket.write("\r\n")
end

send_okとほとんど一緒ですが、ステータスコードが301であることの他に、
Locationヘッダーを使用して、リダイレクト先を指定しています。
次はserver_thread.rbの修正です。

server_thread.rb
 if File.directory?(path_to_file)
    # ディレクトリを指定している場合は、index.htmlを返す
    path_to_file = File.join(path_to_file, "index.html")
    location = "http://localhost:3030#{path_to_file}"
    HttpResponse.send_moved_permanently(@socket, location)
  elsif File.exist?(path_to_file)
    HttpResponse.send_ok(@socket, path_to_file)
  else
    HttpResponse.send_not_found(@socket)
  end

File.directory?を使用して、指定されたpathがディレクトリであるかどうかを判断しています。
結果、pathがディレクトリであれば末尾にindex.htmlをJOINしたものから新たなurlを生成し、
send_moved_permanentlyにlocationとして渡しています。

これでサーバーを再起動して、http://localhost:3030にアクセスしてみましょう。
http://localhost:3030/index.html にリダイレクトされれば成功です!

注意
HTTPステータスコードの301はMoved Permanently、つまりリソースが永久にリダイレクト先へ移動されたことを意味します。
ブラウザはこれを理解し、一度301が返ってきたパスに再度アクセスするときは、ブラウザのキャッシュを参照するようになっています。
そのため、どれだけ301のエンドポイントに修正を加えても、2回目以降のアクセスはキャッシュに残っている前のレスポンスが参照されることになり、
それが残っている限りは、変更が反映されません。

解決策としては、ブラウザのキャッシュを削除してから再度リクエストを行うことです。
chromeなら、右上の「︙」 => 「その他のツール」 => 「閲覧履歴の消去」からキャッシュの削除が行えますので、適宜ご利用ください。
(だたし、他サイトのキャッシュも削除されるのでご注意ください。)

URLデコードを実装する

長々と書いていた本記事もラストの項目です。
最後はURLデコードを実装します。これは、例えば日本語のディレクトリ名を使用している場合などに必要です。

実際の例を見てみましょう。
src/日本語/index.htmlという場所にindex.htmlを配置してみます。

src/日本語/index.html
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>日本語 ページ</title>
  </head>
  <body>
    <h1>日本語ページです。</h1>
    <p>日本語ページのパラグラフです。</p>
  </body>
</html>

この状態で、http://localhost:3030/日本語/index.html にアクセスするとどうなるでしょうか。
答えは404 Not Foundが表示されます。なぜでしょうか。

スクリーンショット 2022-12-04 22.22.33.png

理由は単純で、ブラウザはWebサーバーにHTTPリクエストを送る時、URLをエンコードしてから送っているためです。
putsで先程のpathを出力すると下記のようになります。

src/%E6%97%A5%E6%9C%AC%E8%AA%9E/index.html

このようにエンコードされた文字列が送られてくるので、該当するファイルが見つからず、404ページが表示されていたのです。
したがって元の文字列を取り出したい場合は、これをデコードしてあげる必要があります。
そのためには、CGIライブラリのunescapeメソッドを使用します。

  def run
    begin
      ~

      decoded_path = CGI.unescape(path)
      decoded_path_to_file = File.join(Util::BASE_DIR, decoded_path)

      # 指定されたpathが、無効ならばnot_foundを返す
      return HttpResponse.send_not_found(@socket) if !Util.is_valid_path?(decoded_path)

      if File.directory?(decoded_path_to_file)
        # ディレクトリを指定している場合は、index.htmlを返す
        decoded_path_to_file = File.join(decoded_path, "index.html")
        location = "http://localhost:3030#{decoded_path_to_file}"
        HttpResponse.send_moved_permanently(@socket, location)
      elsif File.exist?(decoded_path_to_file)
        HttpResponse.send_ok(@socket, decoded_path_to_file)
      else
        HttpResponse.send_not_found(@socket)
      end
    rescue => e
      puts e
    ensure
      @socket.close
    end
  end

Util::BASE_DIRとJOINする前に、CGI.unescape()でデコードして元の文字列を取り出しています。
こうすることで、日本語のディレクトリ名も正しく受け取ることができます。
再度、http://localhost:3030/日本語/index.html にアクセスしてみましょう。

スクリーンショット 2022-12-04 22.33.48.png

無事表示されました!

おわりに

本記事で実装したものは下記のリポジトリに挙げています。

実用には程遠いかもしれませんが、最低限動作するWebサーバーが組み込みライブラリのみで実装できたと思います。
普段外部の便利なライブラリを使用していると気が回らなくなりがちな部分ですが、自分で作ってみると中身がわかって楽しかったりします。
本記事ではWebサーバーを作成しましたが、今度はRubyの組み込みライブラリのみでWebアプリケーションサーバーを作ってみたいと思っています。
この長い記事を最後までご覧いただきありがとうございました!

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?