Go アドベントカレンダーその 6 の 5 日目のエントリーです。
はじめに
HTTP サーバを自作してみよう!という試みです。もちろん実践的には net/http パッケージや Echo や Gin といったフレームワークを用いることが多いと思います。本稿では学習目的として net/http パッケージやフレームワークを使わずに、簡易的な HTTP サーバを実装することを試みます。車輪の再発明大好きです。
インクリメンタルに実装していきます。クライアントには curl を用いることにします。
HTTP サーバは何をするのか
HTTP サーバはシンプルにいうと以下のことを実施します。
- クライアントからの接続を待ち受ける
- クライアントから送信された HTTP リクエストをパースする
- HTTP リクエストに基づいて HTTP レスポンスを生成/返却する
クライアントからの接続を待ち受ける
HTTP は TCP/IP 上で動作するプロトコルです。まずはソケット通信を実装します。
package main
import (
"fmt"
"net"
"os"
"github.com/pkg/errors"
)
func main() {
if err := run(); err != nil {
fmt.Printf("%+v", err)
}
}
func run() error {
fmt.Println("start tcp listen...")
// Listen ポートの生成をする
listen, err := net.Listen("tcp", "localhost:12345")
if err != nil {
return errors.WithStack(err)
}
defer listen.Close()
// コネクションを受け付ける
conn, err := listen.Accept()
if err != nil {
return errors.WithStack(err)
}
defer conn.Close()
fmt.Println(">>> start")
buf := make([]byte, 1024)
// Read メソッドの返り値が 0 byte ならすべて Read したとしておく
for {
n, err := conn.Read(buf)
if n == 0 {
break
}
if err != nil {
return errors.WithStack(err)
}
fmt.Println(string(buf[:n]))
}
fmt.Println("<<< end")
return nil
}
curl -v http://localhost:12345
start tcp listen...
>>> start
GET / HTTP/1.1
Host: localhost:12345
User-Agent: curl/7.55.1
Accept: */*
ひとまずクライアントからの HTTP リクエストを読み込むことができたことが分かります。これはクライアントから Ctrl + C
などで中断させないと処理が完了しませんが、ひとまず良いものとします。
クライアントから送信された HTTP リクエストをパースする
HTTP のリクエストとレスポンスの構造
HTTP のリクエストとレスポンスは大きく次の 2 つから構成されます。
- ヘッダー
- リクエストボディ
ヘッダーとボディを区切るのは空行になります。クライアントからのリクエストを読み込んだときに、空行を読み込むまではヘッダー、それ以降がボディと判断することができます。
ヘッダーの取得
まずはヘッダーまでを取得することにします。
package main
import (
"bufio"
"fmt"
"net"
"os"
"github.com/pkg/errors"
)
func main() {
if err := run(); err != nil {
fmt.Printf("%+v", err)
}
}
func run() error {
fmt.Println("start tcp listen...")
// Listen ポートの生成をする
listen, err := net.Listen("tcp", "localhost:12345")
if err != nil {
return errors.WithStack(err)
}
defer listen.Close()
// コネクションを受け付ける
conn, err := listen.Accept()
if err != nil {
return errors.WithStack(err)
}
defer conn.Close()
fmt.Println(">>> start")
scanner := bufio.NewScanner(conn)
// 一行ずつ処理する
for scanner.Scan() {
// つまりリクエストヘッダーを表示する
// Text() からの返り値が空文字であれば空行と判断する
if scanner.Text() == "" {
break
}
fmt.Println(scanner.Text())
}
// non-EOF error がある場合
if scanner.Err() != nil {
return scanner.Err()
}
fmt.Println("<<< end")
return nil
}
curl -v http://localhost:12345/ -X POST -d "Sample Message."
start tcp listen...
>>> start
POST / HTTP/1.1
Host: localhost:12345
User-Agent: curl/7.55.1
Accept: */*
Content-Length: 15
Content-Type: application/x-www-form-urlencoded
<<< end
想定どおり、リクエストのヘッダーを表示することができました。
ボディの取得
続いてメッセージのボディを取得します。リクエストボディの終端を判断は、リクエストヘッダーの Content-Length
を使います。このヘッダーはリクエストボディのバイト数を表しています。Content-Length
のバイト数だけ文字を取得すればよいです。
package main
import (
"bufio"
"fmt"
"io"
"net"
"strconv"
"strings"
"github.com/pkg/errors"
)
func main() {
if err := run(); err != nil {
fmt.Printf("%+v", err)
}
}
func run() error {
fmt.Println("start tcp listen...")
// Listen ポートの生成をする
listen, err := net.Listen("tcp", "localhost:12345")
if err != nil {
return errors.WithStack(err)
}
defer listen.Close()
// コネクションを受け付ける
conn, err := listen.Accept()
if err != nil {
return errors.WithStack(err)
}
defer conn.Close()
fmt.Println(">>> start")
scanner := bufio.NewScanner(conn)
var contentLength int
// 一行ずつ処理する
// リクエストヘッダー
for scanner.Scan() {
// Text() からの返り値が空文字であれば空行と判断する
line := scanner.Text()
if line == "" {
break
}
if strings.HasPrefix(line, "Content-Length") {
contentLength, err = strconv.Atoi(strings.TrimSpace(strings.Split(line, ":")[1]))
if err != nil {
return errors.WithStack(err)
}
}
fmt.Println(line)
}
// non-EOF error がある場合
if scanner.Err() != nil {
return scanner.Err()
}
// リクエストボディ
buf := make([]byte, contentLength)
_, err = io.ReadFull(conn, buf)
if err != nil {
return errors.WithStack(err)
}
fmt.Println("BODY:", string(buf))
// non-EOF error がある場合
if scanner.Err() != nil {
return scanner.Err()
}
fmt.Println("<<< end")
return nil
}
curl -v http://localhost:12345/ -X POST -d "Sample Message."
start tcp listen...
>>> start
POST / HTTP/1.1
Host: localhost:12345
User-Agent: curl/7.55.1
Accept: */*
Content-Length: 14
Content-Type: application/x-www-form-urlencoded
Ctrl + C
で終了します。
ここで問題なのが、実は上記の実装では、リクエストボディを読み込むことができません。具体的に言うと、_, err = io.ReadFull(conn, buf)
でリクエストボディを読み込みたいのですが、Scanner がバッファリングですでにすべてのリクエストコンテンツを読んでしまっているため読み込むことができません。
今回は Reader に net/textproto
を用いることにします。net/textproto
は HTTP, NNTP, SMTPといったテキストベースのリクエスト/レスポンスプロトコルへの包括的なサポートを実装していて、自作 HTTP サーバの実装に役に立ちます。ということでいくつか修正すると以下のようになります。
package main
import (
"bufio"
"fmt"
"io"
"net"
"net/textproto"
"strconv"
"strings"
"github.com/pkg/errors"
)
func main() {
if err := run(); err != nil {
fmt.Printf("%+v", err)
}
}
func run() error {
fmt.Println("start tcp listen...")
// Listen ポートの生成をする
listen, err := net.Listen("tcp", "localhost:12345")
if err != nil {
return errors.WithStack(err)
}
defer listen.Close()
// コネクションを受け付ける
conn, err := listen.Accept()
if err != nil {
return errors.WithStack(err)
}
defer conn.Close()
fmt.Println(">>> start")
reader := bufio.NewReader(conn)
scanner := textproto.NewReader(reader)
var contentLength int
// 一行ずつ処理する
// リクエストヘッダー
for {
line, err := scanner.ReadLine()
if line == "" {
break
}
if err != nil {
return errors.WithStack(err)
}
if strings.HasPrefix(line, "Content-Length") {
contentLength, err = strconv.Atoi(strings.TrimSpace(strings.Split(line, ":")[1]))
if err != nil {
return errors.WithStack(err)
}
}
fmt.Println(line)
}
// リクエストボディ
buf := make([]byte, contentLength)
_, err = io.ReadFull(reader, buf)
if err != nil {
return errors.WithStack(err)
}
// in buf we will have the POST content
fmt.Println("BODY:", string(buf))
fmt.Println("<<< end")
return nil
}
curl -v http://localhost:12345/ -X POST -d "Sample Message."
start tcp listen...
>>> start
POST / HTTP/1.1
Host: localhost:12345
User-Agent: curl/7.55.1
Accept: */*
Content-Length: 15
Content-Type: application/x-www-form-urlencoded
BODY: Sample Message.
<<< end
想定どおりリクエストボディも処理することができました。
リファクタリング1
リクエストヘッダーの解析をキレイにしておきます。リクエストヘッダーの 1 行目はリクエストラインであって、 RFC7230 のとおり次の形式で定められるものでした。
A request-line begins with a method token, followed by a single space (SP), the request-target, another single space (SP), the protocol version, and ends with CRLF.
request-line = method SP request-target SP HTTP-version CRLF
以下のようにして whiteSpace で split しておきます。
headerLine := strings.Fields(line)
リクエストヘッダーの 2 行目以降から空行まではヘッダーフィールドでした。コロン(":")のあとの whitespace は任意ですが、今回はあるものとします。そうするとヘッダーフィールドの解析は以下のようになります。
headerFields := strings.SplitN(line, ": ", 2)
ということで軽微なリファクタリングを加えました。
package main
import (
"bufio"
"fmt"
"io"
"net"
"net/textproto"
"strconv"
"strings"
"github.com/pkg/errors"
)
func main() {
if err := run(); err != nil {
fmt.Printf("%+v", err)
}
}
func run() error {
fmt.Println("start tcp listen...")
// Listen ポートの生成をする
listen, err := net.Listen("tcp", "localhost:12345")
if err != nil {
return errors.WithStack(err)
}
defer listen.Close()
// コネクションを受け付ける
conn, err := listen.Accept()
if err != nil {
return errors.WithStack(err)
}
defer conn.Close()
fmt.Println(">>> start")
reader := bufio.NewReader(conn)
scanner := textproto.NewReader(reader)
// 一行ずつ処理する
// リクエストヘッダー
var method, path string
header := make(map[string]string)
isFirst := true
for {
line, err := scanner.ReadLine()
if line == "" {
break
}
if err != nil {
return errors.WithStack(err)
}
// Request Line
if isFirst {
isFirst = false
headerLine := strings.Fields(line)
header["Method"] = headerLine[0]
header["Path"] = headerLine[1]
fmt.Println(method, path)
continue
}
// Header Fields
headerFields := strings.SplitN(line, ": ", 2)
fmt.Printf("%s: %s\n", headerFields[0], headerFields[1])
header[headerFields[0]] = headerFields[1]
}
// リクエストボディ
method, ok := header["Method"]
if !ok {
return errors.New("no method found")
}
if method == "POST" || method == "PUT" {
len, err := strconv.Atoi(header["Content-Length"])
if err != nil {
return errors.WithStack(err)
}
buf := make([]byte, len)
_, err = io.ReadFull(reader, buf)
if err != nil {
return errors.WithStack(err)
}
fmt.Println("BODY:", string(buf))
}
// completed
fmt.Println("<<< end")
return nil
}
HTTP リクエストに基づいて HTTP レスポンスを生成/返却する
ステータスラインのみを返す
HTTP サーバからレスポンスを返却できるようにします。RFC 7230 のとおり、1 行目はステータスラインを返すことになっていて、以下の形式で定められています。
The first line of a response message is the status-line, consisting of the protocol version, a space (SP), the status code, another space, a possibly empty textual phrase describing the status code, and ending with CRLF.
status-line = HTTP-version SP status-code SP reason-phrase CRLF
以下の実装を追加します。
// レスポンス
io.WriteString(conn, "HTTP/1.1 200 OK\r\n")
$ curl -i http://localhost:12345/
HTTP/1.1 200 OK
クライアントからのリクエストに対して、ステータスコードを返すことができるようになりました。
ヘッダーとボディも返す
続いて、レスポンスのヘッダーとボディを生成します。非常に簡単なレスポンスを返却します。
io.WriteString(conn, "Content-Type: text/html\r\n")
io.WriteString(conn, "\r\n")
io.WriteString(conn, "<h1>Hello World!!</h1>")
$ curl -i http://localhost:12345/
HTTP/1.1 200 OK
Content-Type: text/html
<h1>Hello World!!</h1>
HTML をレスポンスとして受け取りました。ブラウザでも表示させてみます。
ブラウザからアクセスすることができました。
追加機能の実装
リクエストを受け取って、レスポンスを返すことができるようになりました。続いていくつかの機能を実装していきます。
- チャンク
- マルチバイト対応
- GET メソッドが来たらパスで指定されたファイルを返すようにする
- ファイルが存在しない場合は 404 を返すようにする
- 複数リクエストに対応する
チャンク
チャンクの仕様は Chunked Transfer Coding です。
チャンク形式転送エンコーディングとは、送信したいデータを任意のサイズのチャンクに分割し、各々のチャンクにサイズ情報を付与するエンコード方式です。Content-Length
ではあらかじめ送信するバイト数を明記していましたが、チャンクの場合は、チャンクそれぞれのバイト数を 16 進数で明記して、チャンクサイズに 0 のときに終了になります。チャンクエンコーディングを扱う場合はヘッダーに "Transfer-Encoding: chunked" を指定します。
Go で実装する前に、どのような挙動なのか確認してみます。適当に 100 KB のファイルを作成、サーバにアップロードする挙動を Netcat で表示させてみます。
$ dd if=/dev/zero of=100KB.txt bs=1K count=100
$ nc -l 8888
$ curl -T 100KB.txt -H "Transfer-Encoding: chunked" http://localhost:8888
$ nc -l 8888
PUT /100KB.txt HTTP/1.1
Host: localhost:8888
User-Agent: curl/7.55.1
Accept: */*
Transfer-Encoding: chunked
Expect: 100-continue
3ff4
3ff4
3ff4
3ff4
3ff4
3ff4
1048
0
16 進数の 3ff4 を 10 進数で表示すると 16372 Byte で 1048 が 4168 Byte ですから、16372 * 6 + 4168 = 102400 Byte = 100 KB になります。たしかにチャンクに分割して送信できていることが分かります。
// TODO: ちゃんと 16 進数のバイト数分の Read して処理する
transferEncoding, ok := header["Transfer-Encoding"]
if !ok {
return errors.New("no match operation")
}
if transferEncoding == "chunked" {
for {
line, err := scanner.ReadLine()
if line == "0" {
break
}
if err != nil {
return errors.WithStack(err)
}
fmt.Println(line)
}
}
マルチバイトに対応させる
Go では文字列は単なるバイトの slice でした。なので Linux 環境から以下のように curl したバイト数 Read して表示させればマルチバイトを扱えます。Windows で curl する場合はデフォルトで SJIS なので chcp 65001
などで UTF-8 表示モードに変更し、マルチバイト文字を Unicode エンコーディングしておく必要があり、ちょっとだけ面倒です。
$ curl -X POST -v http://localhost:12345 -d "サンプルメッセージ"
start tcp listen...
>>> start
Host: localhost:12345
User-Agent: curl/7.58.0
Accept: */*
Content-Length: 27
Content-Type: application/x-www-form-urlencoded
BODY: サンプルメッセージ
<<< end
GET メソッドが来たらパスで指定されたファイルを返すようにする
リクエストヘッダーのパスからローカルのファイルを参照して HTML を返却するようにします。Go での実装例は以下です。ファイルパスの扱いには "path/filepath"
パッケージを使うとクロスプラットフォームに対応できてスマートです。
var resp []byte
if method == "GET" {
path, ok := header["Path"]
if !ok {
return errors.New("no path found")
}
cwd, err := os.Getwd()
if err != nil {
return errors.WithStack(err)
}
p := filepath.Join(cwd, filepath.Clean(path))
if err != nil {
return errors.WithStack(err)
}
resp, err = ioutil.ReadFile(p)
}
以下のような HTML を用意しておきます。
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Simple HTTP Server</title>
</head>
<body>
<h1>Hello Simple HTTP Server</h1>
</body>
</html>
$ curl http://localhost:12345/sample.html
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>Simple HTTP Server</title>
</head>
<body>
<h1>Hello Simple HTTP Server</h1>
</body>
</html>
start tcp listen...
>>> start
GET /sample.html
Host: localhost:12345
User-Agent: curl/7.55.1
Accept: */*
<<< end
ファイルが存在しない場合は 404 を返すようにする
"path/filepath"
を用いてファイルパスは取得できるようになったので、ファイルの有無を確認する実装が必要です。
func run() error {
// ...
if !fileExists(p) {
io.WriteString(conn, "HTTP/1.1 404 Not Found\r\n")
io.WriteString(conn, "Content-Type: text/html\r\n")
io.WriteString(conn, "\r\n")
io.WriteString(conn, string("<h1>Error 404</h1>"))
} else {
resp, err := ioutil.ReadFile(p)
if err != nil {
return errors.WithStack(err)
}
io.WriteString(conn, "HTTP/1.1 200 OK\r\n")
io.WriteString(conn, "Content-Type: text/html\r\n")
io.WriteString(conn, "\r\n")
io.WriteString(conn, string(resp))
}
// ...
}
func fileExists(filename string) bool {
info, err := os.Stat(filename)
if os.IsNotExist(err) {
return false
}
return !info.IsDir()
}
ブラウザから確認してみます。たしかに存在しないファイル名やディレクトリの場合には 404 がクライアントに返却できていることが分かります。
リファクタリング2
たいぶごちゃごちゃしてきたので、ファイルに分割してリファクタリングします。
こんな感じのディレクトリ構造にしました。
/
│ index.html
│ main.go
│ request.go
│ response.go
│ server.go
│ utils.go
それぞれのファイルは https://github.com/d-tsuji/simple-http-server にコミットしておきました。
複数のリクエストに同時に対応する
最後に、複数のリクエストを同時に扱えるようにします。もともとの実装では、サーバが処理している間は他のクライアントはコネクションを確立することができませんでした。これは困るので、複数のクライアントから同時にリクエストが来た場合にレスポンスを返せるように修正します。
これは listen.Accept()
したあとのサーバの処理を goroutine を用いて非同期で行うことで実現できます。エラーが返ってきた場合は Internal Server Error としておきましょう。
func Run() error {
// ...
go func(conn net.Conn) {
defer conn.Close()
// エラーが発生した場合は Status Code 500 としてクライアントに返却する
if err := service(conn); err != nil {
fmt.Printf("%+v", err)
InternalServerError(conn)
}
}(conn)
// ...
}
func service(conn net.Conn) error {
fmt.Println(">>> start")
reader := bufio.NewReader(conn)
scanner := textproto.NewReader(reader)
// 一行ずつ処理する
// リクエストヘッダー
req, err := NewHttpRequest(scanner)
if err != nil {
return errors.WithStack(err)
}
// リクエストボディ
switch req.headers["Method"] {
case "GET":
path, ok := req.headers["Path"]
if !ok {
return errors.New("no path found")
}
cwd, err := os.Getwd()
if err != nil {
return errors.WithStack(err)
}
p := filepath.Join(cwd, filepath.Clean(path))
// file not found
if !fileExists(p) {
NotFoundError(conn)
} else {
data, err := ioutil.ReadFile(p)
if err != nil {
return errors.WithStack(err)
}
GetOk(conn, data)
}
case "POST", "PUT":
if err := req.GetRequestBody(reader, scanner); err != nil {
return errors.WithStack(err)
}
PostOK(conn)
return nil
default:
return errors.New("no match method")
}
// completed
fmt.Println("<<< end")
return nil
}
まとめ
シンプルな HTTP サーバを実装しました。一度は自作 HTTP サーバを作ってみたいと思っていたので、Go で実現できてよかったです。必然的に RFC も読むことになり、HTTP プロトコルの勉強にもなっておすすめです。