56
37

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

Go6Advent Calendar 2019

Day 5

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

Last updated at Posted at 2019-12-04

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

はじめに

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

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

HTTP サーバは何をするのか

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

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

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

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

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
}
クライアントからcurlを送信
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 のバイト数だけ文字を取得すればよいです。

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
}
クライアントから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: 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 サーバの実装に役に立ちます。ということでいくつか修正すると以下のようになります。

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
}
クライアントから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
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)

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

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

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

	// レスポンス
	io.WriteString(conn, "HTTP/1.1 200 OK\r\n")
クライアントからcurlを送信
$ 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を送信
$ curl -i http://localhost:12345/
HTTP/1.1 200 OK
Content-Type: text/html

<h1>Hello World!!</h1>

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

image.png

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

追加機能の実装

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

  • チャンク
  • マルチバイト対応
  • 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
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 して処理する

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

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

image.png
image.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 としておきましょう。

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

参考

56
37
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
56
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?