GoでシンプルなHTTPサーバを自作する

Go アドベントカレンダーその 6 の 5 日目のエントリーです。

## はじめに

HTTP サーバを自作してみよう！という試みです。もちろん実践的には net/http パッケージや Echo や Gin といったフレームワークを用いることが多いと思います。本稿では学習目的として net/http パッケージやフレームワークを使わずに、簡易的な HTTP サーバを実装することを試みます。車輪の再発明大好きです。

インクリメンタルに実装していきます。クライアントには curl を用いることにします。

## HTTP サーバは何をするのか

HTTP サーバはシンプルにいうと以下のことを実施します。

- クライアントからの接続を待ち受ける
- クライアントから送信された HTTP リクエストをパースする
- HTTP リクエストに基づいて HTTP レスポンスを生成/返却する

### クライアントからの接続を待ち受ける

HTTP は TCP/IP 上で動作するプロトコルです。まずはソケット通信を実装します。

```go:main.go
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
}
```

```console:クライアントからcurlを送信
curl -v http://localhost:12345
```

```console:サーバー側出力
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 つから構成されます。

- ヘッダー
- リクエストボディ

ヘッダーとボディを区切るのは空行になります。クライアントからのリクエストを読み込んだときに、空行を読み込むまではヘッダー、それ以降がボディと判断することができます。

### ヘッダーの取得

まずはヘッダーまでを取得することにします。

```go
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を送信
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` のバイト数だけ文字を取得すればよいです。

```go:main.go
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
}
```

```console:クライアントからcurlを送信
curl -v http://localhost:12345/ -X POST -d "Sample Message."
```

```console:サーバー側出力
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 サーバの実装に役に立ちます。ということでいくつか修正すると以下のようになります。

```go:main.go
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
}
```

```console:クライアントからcurlを送信
curl -v http://localhost:12345/ -X POST -d "Sample Message."
```

```console:サーバー側出力
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 しておきます。

```go
headerLine := strings.Fields(line)
```

リクエストヘッダーの 2 行目以降から空行まではヘッダーフィールドでした。コロン(":")のあとの whitespace は任意ですが、今回はあるものとします。そうするとヘッダーフィールドの解析は以下のようになります。

```go
headerFields := strings.SplitN(line, ": ", 2)
```

ということで軽微なリファクタリングを加えました。

```go:main.go
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

以下の実装を追加します。

```go
	// レスポンス
	io.WriteString(conn, "HTTP/1.1 200 OK\r\n")
```

```console:クライアントからcurlを送信
$ curl -i http://localhost:12345/
HTTP/1.1 200 OK
```

クライアントからのリクエストに対して、ステータスコードを返すことができるようになりました。

### ヘッダーとボディも返す

続いて、レスポンスのヘッダーとボディを生成します。非常に簡単なレスポンスを返却します。

```go
	io.WriteString(conn, "Content-Type: text/html\r\n")
	io.WriteString(conn, "\r\n")
	io.WriteString(conn, "<h1>Hello World!!</h1>")
```

```console:クライアントからcurlを送信
$ curl -i http://localhost:12345/
HTTP/1.1 200 OK
Content-Type: text/html

<h1>Hello World!!</h1>
```

HTML をレスポンスとして受け取りました。ブラウザでも表示させてみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89391/2045a49c-e113-b137-e2b1-c0e362cbc56e.png)

ブラウザからアクセスすることができました。

## 追加機能の実装

リクエストを受け取って、レスポンスを返すことができるようになりました。続いていくつかの機能を実装していきます。

- チャンク
- マルチバイト対応
- GET メソッドが来たらパスで指定されたファイルを返すようにする
- ファイルが存在しない場合は 404 を返すようにする
- 複数リクエストに対応する

### チャンク

チャンクの仕様は [Chunked Transfer Coding](https://tools.ietf.org/html/rfc7230#section-4.1) です。

チャンク形式転送エンコーディングとは、送信したいデータを任意のサイズのチャンクに分割し、各々のチャンクにサイズ情報を付与するエンコード方式です。`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
```

```console:Netcatの出力
$ 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 して処理する

```go
	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 エンコーディングしておく必要があり、ちょっとだけ面倒です。

```console
$ curl -X POST -v http://localhost:12345 -d "サンプルメッセージ"
```

```console:サーバー側出力
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"` パッケージを使うとクロスプラットフォームに対応できてスマートです。

```go
	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 を用意しておきます。

```html: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>
```

```クライアントからcurlを送信
$ 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"` を用いてファイルパスは取得できるようになったので、ファイルの有無を確認する実装が必要です。

```go
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 がクライアントに返却できていることが分かります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89391/24d5c53b-59f1-fed3-f0ad-bf458f37f067.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89391/c4e9b1ea-c40f-b335-8a5d-671c6f98a348.png)

### リファクタリング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 としておきましょう。

```go
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 プロトコルの勉強にもなっておすすめです。

## 参考

- [簡単なHTTPサーバーを作る](https://qiita.com/opengl-8080/items/ca152658a0e52c786029)
- [Webサーバの動作を理解するための事前知識とC++によるフルスクラッチで実装して理解を深めよう](https://qiita.com/hayashier/items/73168c08451896914da5)
- [新しいプログラミング言語の学び方 HTTPサーバーを作って学ぶ Java, Scala, Clojure](https://speakerdeck.com/todokr/xin-siihurokuraminkuyan-yu-falsexue-hifang-httpsahawozuo-tutexue-hu-java-scala-clojure)
- [RubyでシンプルなHTTPサーバを作る](https://qiita.com/akiray03/items/3607c60ec8b221b3c2ba)