16
10

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 5 years have passed since last update.

ElixirAdvent Calendar 2017

Day 7

ElixirでシンプルなHTTPサーバーを作ってみる

Last updated at Posted at 2017-12-14

本記事は Elixir Advent Calendar 2017 7日目の記事です

遅くなってしまい、申し訳ありません。。。
その上、内容的にも未完成なのですが、これ以上遅くなるのも・・・と思ったので
一旦投稿させていただきます。。。


新しいプログラミング言語の学び方 HTTPサーバーを作って学ぶ Java, Scala, Clojure の発表スライドを見た影響で、
HTTPサーバーを作ってみたくなったのでElixirでHTTP サーバーを作成してみました。

Elixir初心者なので、
手順を記録していきながら実装していきたいと思います。

要件

今回は以下の機能を持ったHTTPサーバーを作成したい思います。

  • localhostの任意のポートで起動する
  • GET のみに対応 (それ以外の場合は 501 エラーを返す)
  • ブラウザで http://localhost/index.html にアクセスすると index.html の中身が表示される (GET)
  • 存在しないURLにアクセスすると 404 エラーが返ってくる

実装

プロジェクト作成

mix コマンドを使ってプロジェクトを作成します
--supをつけるとSupervisorのエントリーポイントを作成してくれるみたいです

mix new --sup elixir_simple_http_server

以下のようなディレクトリ構成のプロジェクトができました

elixir_simple_http_server/
├── README.md
├── config
│   └── config.exs
├── lib
│   ├── elixir_simple_http_server
│   │   └── application.ex
│   └── elixir_simple_http_server.ex
├── mix.exs
└── test
    ├── elixir_simple_http_server_test.exs
    └── test_helper.exs

EchoServerの実装

HTTP Server の実装の前にまずは EchoServer を作成したいと思います。
TCP接続を受け付けて何かメッセージを受信したらそのメッセージを返す というサーバーになります

EchoServerはこちらコピペ 参考に実装します

lib/elixir_simple_http_server
defmodule ElixirSimpleHttpServer do

  def start(port \\ 80) do
    {:ok, lsocket} = :gen_tcp.listen(port,  [:binary, packet: :line, active: false, reuseaddr: true])
    loop_acceptor(lsocket)
  end

  def loop_acceptor(socket) do
    {:ok, client} = :gen_tcp.accept(socket)
    serve(client)
    loop_acceptor(socket)
  end

  def serve(socket) do
    socket
    |> read_line
    |> write_line(socket)

    serve(socket)
  end

  def read_line(socket) do
    {:ok, message} = :gen_tcp.recv(socket, 0)
    message
  end

  def write_line(message, socket) do
    :gen_tcp.send(socket, message)
  end

end

TCP接続のハンドリングにはErlangモジュールの:gen_tcpを使っています
各関数の役割は多分こんな感じだと思います。(たぶん。。。。

関数名 備考
listen/2 指定されたポートでTCP接続を待ち受ける
accept/1 メッセージを待ち続ける
recv/2 メッセージを受信する。第2引数に0を指定すると受け取ったメッセージ全て読み込む
send/2 メッセージを送信する

それではiex から実行してみます

$ iex -S mix
iex(1)> ElixirSimpleHttpServer.start(50000)

実行できたら別窓でtelnetを使って接続してみます

$ telnet localhost 50000                                                                                                             木 12/ 7 18:19:09 2017
Trying ::1...
telnet: connect to address ::1: Connection refused
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
test
test
hoge
hoge
hogehoge
hogehoge

動いてますね!

ただ今のままだと 2つ 問題があります

1.接続を閉じるとエラーが発生しサーバーが落ちてしまう

クライアントから接続を閉じると以下のエラーが発生し、
サーバーが止まってしまいます。

iex(1)> ElixirSimpleHttpServer.start(50000)
** (MatchError) no match of right hand side value: {:error, :closed}
    (elixir_simple_http_server) lib/elixir_simple_http_server.ex:23: ElixirSimpleHttpServer.read_line/1
    (elixir_simple_http_server) lib/elixir_simple_http_server.ex:16: ElixirSimpleHttpServer.serve/1
    (elixir_simple_http_server) lib/elixir_simple_http_server.ex:10: ElixirSimpleHttpServer.loop_acceptor/1
iex(1)>

2. 複数クライアントからの接続が出来ない

サーバーを立ち上げ、telnetで接続したあとに、別窓からtelnetで接続すると
2個目に繋いだtelnetでは反応がありません。

1つめのtelnet
$telnet localhost 50000 
Trying ::1...
telnet: connect to address ::1: Connection refused
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
test
test
hogehoge
hogehoge
test
test
2つめのtelnet
$telnet localhost 50000 
Trying ::1...
telnet: connect to address ::1: Connection refused
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
test
hogehoge

この2つの問題は今のとこ置いておきHttpの実装を進めていきたいと思います。

Http Server の実装

HTTPとは決めたられた仕様(RFC)に従って
TCPでやりとりするプロトコルになります。(ざっくりとした理解。。。)
実際に確かめてみるために、ここまでで作成した Echo Server に対して
一度 Http接続を行ってみます。

$ curl http://localhost:50000
GET / HTTP/1.1
Host: localhost:50000
User-Agent: curl/7.54.0
Accept: */*

こちらから送信したと思われるヘッダがそのまま表示され、
コマンドが終了せずに実行中のままになりました。
ブラウザで叩くと、ずっと接続中になります。。

これは(当たり前ですが)サーバーから正しくレスポンスが返してないからですね。

Http Server を実装するのに最低限必要なこととして
RFCに従った受信メッセージの解析と応答メッセージの送信が必要になります。

Hello World!!

まずはメソッドやパス、ヘッダにかかわらず以下のレスポンスを返すようにしてみたいと思います

レスポンス
HTTP/1.1 200 OK
Content-Length: 12

HELLO WORLD!

lib/elixir_simple_http_server.ex を以下のように修正します

lib/elixir_simple_http_server.ex
  def serve(socket) do
    case read_line(socket) do
      :continue -> serve(socket)
      :end -> response(socket)
    end
  end

  def read_line(socket) do
    {:ok, message} = :gen_tcp.recv(socket, 0)

    case String.split(message, " ") do
      [_method, _target, _version] ->
        :continue
      [_field_name, _field_value] ->
        :continue
      _ ->
        :end
    end
  end

  def response(socket) do

    message = 
"""
HTTP/1.1 200 OK
Content-Length: 12

HELLO WORLD!
"""

    :gen_tcp.send(socket, message)
    :gen_tcp.close(socket)
  end

read_line の部分で送られてきたメッセージを解析し、
ヘッダ部分が終了したらresponseを読んでいます

試しにターミナルでcurlコマンドを叩いてみると HELLO WORLD! の文字列が表示されました

$curl http://localhost:50000
HELLO WORLD!

ブラウザで確認してみても、きちんと表示されています :sparkles:

スクリーンショット 2017-12-08 17.44.49.png

GETメソッド以外の場合に501エラーを返す

固定のレスポンスを返すことはできたので次はリクエスト内容を見て、
レスポンス内容を変更するようにしてみたいと思います。

まずはリクエストメソッドを確認して、 GET 以外だったら 501 Not Implemented を返すようにします。

elixir_simple_http_server.ex を次のようにしました。

lib/elixir_simple_http_server.ex
  def serve(socket, res_header) do
    case read_line(socket, res_header) do
      {:continue, res} -> serve(socket, res)
      {:end, res} ->
         process_response(res)
         |> response(socket)
    end
  end

  @doc """
  送信されたメッセージを読み取る

    スペースで区切られたフィールドの数によって処理を分岐する
      3つ -> start_line の読み取り
      2つ -> ヘッダーフィールドの読み取り
      その他 -> 送信メッセージの終了(Bodyの読み取りは対応しない)
  """
  def read_line(socket, res_header) do
    {:ok, message} = :gen_tcp.recv(socket, 0)

    case String.split(message, " ") do
      [method, target, _version] ->
        read_start_line(method, target)
      [_field_name, _field_value] ->
        {:continue, res_header}
      _ ->
        {:end, res_header}
    end
  end

  @doc """
  送信メッセージの1行目(start_line)を読み込む
  """
  def read_start_line("GET", target) do
    {:continue, ["HTTP/1.1 200 OK"]}
  end

  def read_start_line(method, _) do
    {:end, ["HTTP/1.1 501 Not Implemented"]}
  end

  @doc """
  レスポンスヘッダのリストをCRLFで結合する
  """
  def process_response(res_header) do
    header = res_header
    |> Enum.join("\r\n")

    header <> "\r\n"
  end

  @doc """
  レスポンスメッセージをクライアントに送信する
  """
  def response(message, socket) do
    :gen_tcp.send(socket, message)
    :gen_tcp.close(socket)
   end

read_lineの中でスペースで文字列を区切り、
3つだったらread_start_line関数に渡しています。

read_start_line は引数のパターンマッチングで分岐になるようにしています。
ここでは将来対応メソッドを増やすときに関数を分けた方がいいかなと思い、
このようにしました。

この辺は処理を分岐させるときに関数内でcase 文を使うときと
引数のパターンマッチングを使うときのどっちがいいかっていうのは
意識しながらプログラミングしていったほうがよさげな気がしますね。
(なにかデザインパターン的なものはあったりするんでしょうか。。。)

上記コードでサーバーを動かして動作確認してみます

$ curl -i http://localhost:50001
HTTP/1.1 200 OK
$ curl -i -X POST http://localhost:50001
HTTP/1.1 501 Not Implemented

よさげですね:sparkles:

存在しないパスを指定された場合、404を返す

501エラーの実装はできたので、今度は404エラーの実装をしたいと思います。

index.htmlへのリクエストを送ると以下のようなリクエストヘッダになります

$ curl -v -i http://localhost:50001/index.html
> GET /index.html HTTP/1.1

/index.htmlが目的のパスなのですが、絶対パスになっていますね。
サーバー側のルートディレクトリはサーバーを動かしているパス(=mix.exsがあるパス)
になるので相対パスの方が都合がよさそうです。

ElixirのPathモジュールを使って絶対パスを相対パスに変換し、
Fileモジュールを使って存在確認を行いたいと思います。

lib/elixir_simple_http_server.ex
  @doc """
  GET メソッドの処理

    target パスのファイル存在チェックを行う
      存在している -> 200
      存在していない -> 404
  """
  def read_start_line("GET", target) do
    # ファイルの存在チェック
    # 絶対パスを強制的に相対パスに変更
    is_exists = target
    |> Path.relative
    |> File.exists?

    case is_exists do
      true ->  {:continue, ["HTTP/1.1 200 OK"]}
      false -> {:end, ["HTTP/1.1 404 Not Found"]}
    end
  end

動作確認

$ curl -i http://localhost:50001/index.html
HTTP/1.1 200 OK
$ curl -i http://localhost:50001/index.htm
HTTP/1.1 404 Not Found

よさげですね :sparkles:

GETリクエストでファイルの中身を表示する

File.read!/1 を用いてファイルの中身を読み込み
レスポンスメッセージに結合します

lib/elixir_simple_http_server
  @doc """
  レスポンスメッセージをリストで組み立ててCRLFで結合する
  """
  def process_response(res_header, target) do
    file = target
    |> Path.relative
    |> File.read!

    res_header ++ ["", file]
    |> Enum.join("\r\n")
  end

以下のようなHTMLを用意して、ブラウザからアクセスしてみます

index.html
<!DOCTYPE html>
<html>
<head>
<title>てすと</title>
</head>
<body>
<b>HELLO WORLD</b>
</body>
</html>

スクリーンショット 2017-12-14 19.10.21.png

titleが文字化けしてますが表示できました:sparkles:
レスポンスヘッダに文字コードの指定など何もしていないのが原因ですね。。。

拙い実装ですがとりあえずここまでで、最初にあげた以下の要件を形だけでも満たすことができました

  • localhostの任意のポートで起動する
  • GET のみに対応 (それ以外の場合は 501 エラーを返す)
  • ブラウザで http://localhost/index.html にアクセスすると index.html の中身が表示される (GET)
  • 存在しないURLにアクセスすると 404 エラーが返ってくる

問題点

  • EchoServer実装時にあったエラー時にサーバーが落ちる問題 & 同時コネクション不可
  • res_header, target などの状態を保持するためだけに引数として渡している箇所がある
  • コードの問題点
    • read_start_line 時点でレスポンスコードが決定しまっている
    • read_start_lineでファイルの存在チェックを行い、process_responseでファイル読み込みを行っている(直したい)
16
10
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
16
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?