RubyでWebサーバを作ってみた

gihyo.jp

この書籍を参考に、RubyでWebサーバを作ってみようと思います。

目次

動作環境

実装

実装のゴールの確認

WebサーバはブラウザからのHTTPリクエストに対し、HTTPレスポンスを返すのが仕事です。 具体的には

  1. ステータスライン
  2. レスポンスヘッダー
  3. 空行
  4. 要求されたパスの内容

をクライアントに返します。

今回はまず、前回作ったTCPサーバ/クライアントをベースに、クライアントに上記の内容を返すことを目指します。

初期化

class Modoki
  # 初期化
  def initialize
    @server = TCPServer.open(8001)
    @client = nil
    @path = nil
  end
end

TCPServerRubyの標準ライブラリで、TCPプロトコルを使用したサーバーを作成することができます。 openメソッドは新しいサーバーを作成し、指定したポート(ここでは8001)で接続を待ち受けます。 @clientは、後でTCP接続のクライアントを保存し、@pathはHTTPリクエストから取得したリクエストパスを保存するために用意します。

待機

def accept
    @client = @server.accept
end

acceptメソッドは、クライアントからの接続があるまで処理をブロックします。 接続があるとクライアントとの通信用のソケットを返すので、それを@client変数に代入します。

リクエスト受信

def recv
   request = @client.gets
   @path = request.split[1] if request.start_with?('GET')
   request = @client.gets until request.chomp.empty?
end

getsメソッドは、接続済みのクライアント(@client)からHTTPリクエストの1行目を受け取ります。 リクエストが'GET'で始まる場合は、リクエストを空白文字で分割しリクエストのパスを@pathに代入し、 リクエストが空行になるまで1行のデータを受け取り、それを変数requestに代入し続けます。

レスポンス送信

def send
    # ヘッダーを送信する
    @client.puts("HTTP/1.0 200 OK\r\n")
    @client.puts("Date: #{Time.now.gmtime.strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n")
    @client.puts("Server: Modoki/0.1\r\n")
    @client.puts("Content-Type: text/html\r\n")
    @client.puts("\r\n")

    # ボディを送信する
    File.open("#{DOCUMENT_ROOT}#{@path}", 'r') do |f|
      f.each_line { |line| @client.puts(line) }
    end
end

ヘッダーとボディを送信します。 ボディは、ドキュメントルート(DOCUMENT_ROOT)と、@pathを連結したパスを作成し、そのファイルから1バイトずつ読み込んでクライアントに送信しています。

切断

def close
    @client.close
end

最後にクライアントとの接続を切断します

全体のコード

require 'socket'

class Modoki
  # ドキュメントルートの設定
  DOCUMENT_ROOT = '/mnt/c/mywebsite'

  # 初期化
  def initialize
    @server = TCPServer.open(8001)
    @client = nil
    @path = nil
  end

  def run
    accept
    recv
    send
    close
  end

  private

  def accept
    # クライアントからの接続を待つ
    @client = @server.accept
  end

  def recv
    # クライアントからのHTTPリクエストを受信する
    # 空行が来るまで受信する
    request = @client.gets
    @path = request.split[1] if request.start_with?('GET')
    request = @client.gets until request.chomp.empty?
  end

  def send
    # クライアントにHTTPレスポンスを送信する
    # ファイルが存在する場合は、ファイルの内容を送信する
    # ヘッダーを送信する
    @client.puts("HTTP/1.0 200 OK\r\n")
    @client.puts("Date: #{Time.now.gmtime.strftime('%a, %d %b %Y %H:%M:%S GMT')}\r\n")
    @client.puts("Server: Modoki/0.1\r\n")
    @client.puts("Content-Type: text/html\r\n")
    @client.puts("\r\n")

    # ボディを送信する
    File.open("#{DOCUMENT_ROOT}#{@path}", 'r') do |f|
      f.each_line { |line| @client.puts(line) }
    end
  end

  def close
    # クライアントとの接続を切断する
    @client.close
  end
end

### サーバーの起動
server = Modoki.new
server.run

index.htmlを用意する

C:\mywebsite\フォルダにindex.htmlを用意しました

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>It works🎉
</body>
</html>

起動

$ ruby Modoki/modoki.rb

確認

ブラウザでhttp://localhost:8001/index.htmlを叩くと、
成功しました😄

ソースコード

https://github.com/kei-kmj/HenacatRuby/pull/3/commits/d93d49d1c3b8451d690a05d11d5d488c0ff49800

モドキじゃないサーバへ

1つのHTMLファイルを返し、ブラウザで表示することが出来ました。 次は

に対応してみたいと思います。

実装

画像やCSSなどを送信できるようにする

リクエストの拡張子に応じてMIMEタイプを変えないといけません。

 @client.puts("Content-Type:  text/html\r\n")

これを

 @client.puts("Content-Type: #{content_type}\r\n")

にしてcontent_typeメソッドを書いていきます。

EXTENSION_TO_MIME = 
    {
      'html' => 'text/html',
      'txt' => 'text/plain',
      'png' => 'image/png',
      'jpg' => 'image/jpg',
      'jpeg' => 'image/jpeg',
      'gif' => 'image/gif',
      'css' => 'text/css',
      'js' => 'text/javascript'
    }.freeze

def content_type
    ext = @path.split('.')[-1]
    EXTENSION_TO_MIME[ext] || 'text/plain'
end 

リクエストされたパスからファイルの拡張子を取得し、対応するMIMEタイプを EXTENSION_TO_MIMEハッシュから引き出し、 対応するMIMEタイプが見つからない場合は、デフォルトとして 'text/plain' を返します。

TCP接続を繰り返し受け付けるようにする

# frozen_string_literal: true

require_relative 'server_thread'
require 'socket'

class Server
  # ドキュメントルートの設定
  DOCUMENT_ROOT = '/mnt/c/mywebsite'

  # 初期化
  def initialize
    @server = TCPServer.open(8001)
  end

  def run
    loop do
      Thread.start(@server.accept) do |client|
        ServerThread.new(client).process_request
      end
    end
  end
end

## サーバーの起動
server = Server.new
server.run

クライアントからの接続を受け続けるServerクラスを用意しました。 Thread.startメソッドを使うことで、各クライアントからのリクエストがそれぞれ独立したスレッドで処理されるので、多数のクライアントからのリクエストを効率良く処理できます。 実際のリクエストの処理はServerThreadが担当します。

HTMLファイルの用意

index.htmlを書き換え

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <style>
        body {
            background-color: #fffce6;
            text-align: center;
            padding: 20px;
        }
        img {
            width: 80px;
            height: auto;
        }
        a {
            font-size: 50%;
        }
    </style>
</head>
<body>
    It works<br>
    <img src="\blob_cheer.jpg" alt="blob_cheer"><br>
    <a href="next.html">次のページ</a>
</body>
</html>

css、画像、リンクを書き足してみました。

next.htmlを用意する

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Next</title>
    <style>
        body {
            background-color: #fffce6;
            text-align: center;
            padding: 20px;
        }
        img {
            width: 30px;
            height: auto;
        }
    </style>
</head>
<body>工事中です 🙇<200d>♀️</body>
</html>

起動

$ ruby FlimsyServer/server.rb

確認

CSSも画像も表示でき、リンクも辿れました🎉

ソースコード

Implement web server by kei-kmj · Pull Request #3 · kei-kmj/HenacatRuby · GitHub


  • 画像の表示が遅いです。まるで20年前のWebサイトに訪問したかのようです。画像データも1行ずつ送っているためだと思われます。バッファリングなどを考えれば良いのかもしれませんが、今回の目的から外れるので、ここでは対応しません。
  • 次回、ディレクトリトラバーサル脆弱性、404エラーに対応していきたいと思います