本記事は 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はこちらをコピペ 参考に実装します
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では反応がありません。
$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
$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
を以下のように修正します
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!
ブラウザで確認してみても、きちんと表示されています
GETメソッド以外の場合に501エラーを返す
固定のレスポンスを返すことはできたので次はリクエスト内容を見て、
レスポンス内容を変更するようにしてみたいと思います。
まずはリクエストメソッドを確認して、 GET
以外だったら 501 Not Implemented
を返すようにします。
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
よさげですね
存在しないパスを指定された場合、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モジュールを使って存在確認を行いたいと思います。
@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
よさげですね
GETリクエストでファイルの中身を表示する
File.read!/1
を用いてファイルの中身を読み込み
レスポンスメッセージに結合します
@doc """
レスポンスメッセージをリストで組み立ててCRLFで結合する
"""
def process_response(res_header, target) do
file = target
|> Path.relative
|> File.read!
res_header ++ ["", file]
|> Enum.join("\r\n")
end
以下のようなHTMLを用意して、ブラウザからアクセスしてみます
<!DOCTYPE html>
<html>
<head>
<title>てすと</title>
</head>
<body>
<b>HELLO WORLD</b>
</body>
</html>
titleが文字化けしてますが表示できました
レスポンスヘッダに文字コードの指定など何もしていないのが原因ですね。。。
拙い実装ですがとりあえずここまでで、最初にあげた以下の要件を形だけでも満たすことができました
- localhostの任意のポートで起動する
- GET のみに対応 (それ以外の場合は
501
エラーを返す) - ブラウザで
http://localhost/index.html
にアクセスすると index.html の中身が表示される (GET) - 存在しないURLにアクセスすると
404
エラーが返ってくる
問題点
- EchoServer実装時にあったエラー時にサーバーが落ちる問題 & 同時コネクション不可
-
res_header
,target
などの状態を保持するためだけに引数として渡している箇所がある - コードの問題点
- read_start_line 時点でレスポンスコードが決定しまっている
- read_start_lineでファイルの存在チェックを行い、process_responseでファイル読み込みを行っている(直したい)