Go言語でTCPのEchoサーバーを実直に実装する


６月からDMM.comラボの六本木オフィスでミドルウェアを作るエンジニアインターンをしている@kawasin73です。
DMM.comラボではluaで実装されたKVS(キーバリューストア)を利用しています。
これは、TCPの上で独自プロトコルで通信しており、URIのPathがKeyとなり最長共通接頭辞検索をするKVSで、社内でluaの皮を被ったC言語で実装されたものが運用されています。
この度、このKVSをGo言語で再実装することになり、設計は既存のミドルウェアを踏襲した形で DMM.com ラボの方が行い、実装は僕がすることになりました。
Go言語の実装手法（goroutine や channel等）については僕が学びながらそれについて都度相談するというスタイルで行なっています。
その開発記を連載しています。

今回は、実装の第１ステップとして、TCPのエコーサーバーを実装していきます。

第1回 [GoでTCPサーバー上で独自プロトコルKey-Value Storeを実装する(知識編)](http://qiita.com/kawasin73/items/f28b4335715a0f474ac9)
第2回 [GolangでハイパフォーマンスなTCPサーバーを実装する(下準備編)](http://qiita.com/kawasin73/items/5d93f77fe653417449c9)
第3回 [Go言語でTCPのEchoサーバーを実直に実装する](http://qiita.com/kawasin73/items/3371d35166af733c2ce4)
第4回 [Go言語でシグナルハンドリングをするTCPのEchoサーバーを実装する](http://qiita.com/kawasin73/items/2824e14e1e619cb68fdf)
第5回 [Go言語でGraceful Shutdown可能なTCPのechoサーバーを実装する(その１)](http://qiita.com/kawasin73/items/3b7c4b7c7d48ff642704)
第6回 [Go言語でGraceful Shutdown可能なTCPのechoサーバーを実装する(その２)](http://qiita.com/kawasin73/items/e67af04188668b8e9f20)
第７回 [GolangでTCPサーバーに再起動とGraceful Shutdownを実装する](http://qiita.com/kawasin73/items/5afe34396d238ab1cbbc)

## はじめに

開発環境は以下の通りです。

- OS: macOS 10.12.5
- Go version: 1.8.3
- IDE: Gogland

今回開発した内容は、こちらのgithubリポジトリで公開しています。

https://github.com/dmmlabo/tcpserver_go

## echo(エコー)サーバー とは

echoサーバーとは、受け取ったリクエスト内容をそのままレスポンスするサーバーのことです。
普通のサーバーは受け取ったリクエストに対してレスポンス内容を生成して送りますが、echoサーバーでは内部の処理を行いません。
そのため、利用する言語のTCPサーバーとしての理論的な最高のパフォーマンスを出すことができます。

サーバーを構築するための基盤となる第一歩と言えるでしょう。

https://github.com/methane/echoserver

このリポジトリには、echoサーバーが様々な言語で実装されています。
また、ベンチツール(`bench.sh`)も、内包されているため、言語ごとの最高値を試すことができると思います。

## 実装していく

[https://github.com/methane/echoserver](https://github.com/methane/echoserver/blob/master/server_go.go) では、`io.Copy` を使って、echoサーバーを実装しています。

しかし、これでは後々手を入れにくいので、`Read`と`Write`をそれぞれ使ったechoサーバーの実装をしました。

## とりあえず書いてみた。

これらは、[リポジトリ](https://github.com/dmmlabo/tcpserver_go)の`tcp1`ディレクトリにあります。

```go:main.go
package main

import (
	"log"
	"net"
)

func handleConnection(conn *net.TCPConn) {
	defer conn.Close()

	buf := make([]byte, 4*1024)

	for {
		n, err := conn.Read(buf)
		if err != nil {
			if ne, ok := err.(net.Error); ok {
				switch {
				case ne.Temporary():
					continue
				}
			}
			log.Println("Read", err)
			return
		}

		n, err = conn.Write(buf[:n])
		if err != nil {
			log.Println("Write", err)
			return
		}
	}
}

func handleListener(l *net.TCPListener) error {
	defer l.Close()
	for {
		conn, err := l.AcceptTCP()
		if err != nil {
			if ne, ok := err.(net.Error); ok {
				if ne.Temporary() {
					log.Println("AcceptTCP", err)
					continue
				}
			}
			return err
		}

		go handleConnection(conn)
	}
}

func main() {
	tcpAddr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:12345")
	if err != nil {
		log.Println("ResolveTCPAddr", err)
		return
	}

	l, err := net.ListenTCP("tcp", tcpAddr)
	if err != nil {
		log.Println("ListenTCP", err)
		return
	}

	err = handleListener(l)
	if err != nil {
		log.Println("handleListener", err)
	}
}

```

## 解説

### `net.ListenTCP`

Goの`net`パッケージの`net.Listen`関数を利用することで`Listener`を生成することができます。
しかし、今回は`net.ListenTCP`を使っています。
`net.Listen`関数の返り値は、`net.Listen`というインターフェースであり、`net.ListenTCP`で得られる`*net.TCPListener`より使えるAPIが少ないからです。
具体的には、`*net.TCPListener`を`AcceptTCP`して得られる、`*net.TCPConn`は、`CloseWrite`,`CloseRead`メソッドがあり、ソケットの`Close`をより細かく制御できます。
これらのAPIを後々利用するため、最初から`net.ListenTCP`を使っています。

### `net.Error`

`AcceptTCP()`, `Read(buf)`それぞれの内部実装(`libexec/src/net/tcpsock.go`)を見ると、発生しうるエラーは、`syscall.EINVAL`, `*net.OpError` のどちらかです。
`*net.OpError`は、`func (e *OpError) Timeout() bool` と `func (e *OpError) Temporary() bool` のメソッドを持っており、回復可能なエラーを簡単に見分けることができます。

また、`net.Error`は、

```go:libexec/src/net/net.go
// An Error represents a network error.
type Error interface {
	error
	Timeout() bool   // Is the error a timeout?
	Temporary() bool // Is the error temporary?
}
```

というインターフェイスであり、`*net.OpError`は、`net.Error`を満たしています。
そのため、`net.Error`にキャストしてエラー種類の判別を行いました。

一方、`syscall.Errno` にも

```go:libexec/src/syscall/syscall_unix.go
func (e Errno) Temporary() bool {
	return e == EINTR || e == EMFILE || e == ECONNRESET || e == ECONNABORTED || e.Timeout()
}

func (e Errno) Timeout() bool {
	return e == EAGAIN || e == EWOULDBLOCK || e == ETIMEDOUT
}
```

というメソッドがあり、`Temporary()`は`Timeout()`を内包していることがわかります。

`AcceptTCP()` は内部的には`Accept`システムコールを、 `Read(buf)`は内部的には、`Read`システムコールを呼んでいます。
それぞれのシステムコールから発生しうるエラーは決まっています。それらが、Goの中でどのようにハンドリングするのかを調査し決定しました。


#### `Accept` のシステムコールエラー

https://linuxjm.osdn.jp/html/LDP_man-pages/man2/accept.2.html

こちらの man page を参考にしました。

ハンドリングの「落とす」とは、回復不能なエラーのためプロセスを終了させるということです。
ハンドリングの「timeout」 と 「temporary」はそれぞれ、`Timeout()`, `Temporary()`に当てはまるため、`for`文内で`continue`するということです。

|エラー名|説明|ハンドリング|
|:--|:--|:--|
|EAGAIN|ソケットが非停止になっていて、 かつ受付け対象の接続が存在しない。 POSIX.1-2001 は、この場合にどちらのエラーを返すことも認めており、 これら 2 つの定数が同じ値を持つことも求めていない。 したがって、移植性が必要なアプリケーションでは、両方の可能性を 確認すべきである。| timeout. golangでは`AcceptTCP()`の中で待つため、ハンドリング必要なし| 
|EWOULDBLOCK| EAGAINと同じ| timeout|
|EBADF|ディスクリプターが不正。|落とす|
|ECONNABORTED|接続が中止された。|temporary golangでは`AcceptTCP()`の中で待つため、ハンドリング必要なし|
|EFAULT|addr 引き数がユーザーアドレス空間の書き込み可能領域にない。|落とす|
|EINTR|有効な接続が到着する前に捕捉されたシグナルによって システムコールが中断された。 signal(7) 参照。| temporary |
|EINVAL|ソケットが接続待ち状態ではない。もしくは、 addrlen が不正である (例えば、負の場合など)。|落とす|
|EINVAL|(accept4()) flags に不正な値が指定されている。| 落とす|
|EMFILE|1プロセスがオープンできるファイルディスクリプター数の上限に達した。|temporary|
|ENFILE|オープンされたファイルの総数がシステム全体の上限に達していた。|temporary|
|ENOBUFS, ENOMEM|メモリーが足りない。 多くの場合は、システムメモリーが足りないわけではなく、 ソケットバッファーの大きさによるメモリー割り当ての制限である。| ? |
|ENOTSOCK|ディスクリプターはソケットではなくファイルを参照している。|落とす|
|EOPNOTSUPP|参照しているソケットの型が SOCK_STREAM でない。| 落とす|
|EPROTO|プロトコルエラー。| 落とす|
|EPERM|上記に加えて、Linux の accept() は以下のエラーで失敗する:ファイアウォールのルールにより接続が禁止された。|落とす|

`AcceptTCP()` から発生しうるエラーで回復可能なエラーは、全て`Temporary() == true` になることがわかりますので、追加のハンドリングは必要ありません。
`ENOBUFS`, `ENOMEM` については、`Accept`だけが失敗し、既に`Accept`しているコネクションの`Read`と`Write`には影響を与えないエラーです。
通常であれば、エラーログを吐き、一定の時間`Sleep`してから`Accept`を再試行するなどの処理をして落とさないことが好ましいですが、今回はプロセスを落とすことにします。

#### `Read` のシステムコールエラー

https://linuxjm.osdn.jp/html/LDP_man-pages/man2/read.2.html

こちらの man page を参考にしました。

|エラー名|説明|ハンドリング|
|:--|:--|:--|
|EAGAIN|ファイルディスクリプター fd がソケット以外のファイルを参照していて、 非停止 (nonblocking) モード (O_NONBLOCK) に設定されており、読み込みを行うと停止する状況にある。|timeout. golangでは`Read()`の中で待つため、ハンドリング必要なし|
|EAGAIN または EWOULDBLOCK|ファイルディスクリプター fd がソケットを参照していて、非停止 (nonblocking) モード (O_NONBLOCK) に設定されており、読み込みを行うと停止する状況にある。 POSIX.1-2001 は、この場合にどちらのエラーを返すことも認めており、 これら 2 つの定数が同じ値を持つことも求めていない。 したがって、移植性が必要なアプリケーションでは、両方の可能性を 確認すべきである。|timeout|
|EBADF|fd が有効なファイルディスクリプターでないか、読み込みのために オープン (open) されていない。|終了|
|EFAULT|buf がアクセス可能なアドレス空間の外にある。|終了|
|EINTR|何のデータも読み込まないうちにシグナルに割り込まれた。 signal(7) 参照。| temporary|
|EINVAL|fd は読み込みに適していないオブジェクトを参照している。 もしくは、ファイルが O_DIRECT フラグを指定してオープンされているが、 buf に指定されたアドレス、 count に指定された値、 現在のファイルオフセットのいずれかの アラインメントが不適切である。| 終了|
|EINVAL|fd が timerfd_create(2) の呼び出しで作成されたが、 read() に間違ったサイズのバッファーが渡された。 さらなる情報は timerfd_create(2) を参照のこと。|終了|
|EIO|I/O エラー。これは例えばプロセスがバックグランドプロセスグループで、それを制御している端末から読み込もうとし、 SIGTTIN が無視 (ignore) または禁止 (blocking) されている場合や、 そのプロセスグループが孤立 (orphan) している場合に起こる。 またディスクやテープを読んでいる時に低レベル I/O エラー が発生した場合にも起こる。|起こり得ない|
|EISDIR|fd がディレクトリを参照している。|終了|

`Read`も、`Accept`と同様に、発生しうるエラーで回復可能なエラーは、全て`Temporary() == true` になることがわかりますので、追加のハンドリングは必要ありません。

### バッファーサイズ

`Read`のバッファーサイズは、

```
buf := make([]byte, 4*1024)
```

と、4KB に設定しました。
このバッファーは、コネクションごとに作成されるため、あまり大きな値を設定するとメモリを無駄に消費してしまいます。
逆に、小さな値を設定すると、何度も`Read`を行うことになり、オーバーヘッドが生じてしまいます。
それぞれのサービスの特性(プロトコルや利用用途など)を考慮して、バッファーのサイズを決定するのが良いです。

### どのように動くか

TCPサーバーがどのような順序で動くかは以下の図の通りです。

```bash:
Client Connection Flow

+--------+      +----------------+  +------------------+
| client |      | handleListener |  | handleConnection |
+----+---+      +-------+--------+  +---------+--------+
     |                  |                     |
     |    Connect       |                     |
     +------------------>                     |
     |                  |                     |
     |               Accept()                 |
     |                  |      goroutine      |
     |                  +--------------------->
     |                  |                     |
     |                  |  +--------------------------------------+
     | send("hello")    |  |                  |                   |
     +---------------------------------------->                   |
     |                  |  |                  |                   |
     |                  |  |                Read()     Echo Loop  |
     |                  |  |                  |                   |
     |                  |  |                Write()               |
     |                  |  |   Reply Message  |                   |
     <----------------------------------------+                   |
     |                  |  |                  |                   |
     |                  |  +--------------------------------------+
     |                  |                     |
```

詳しくは、[第1回のTCPサーバーの立ち上げ方の章](http://qiita.com/kawasin73/items/f28b4335715a0f474ac9#tcp%E3%82%B5%E3%83%BC%E3%83%90%E3%83%BC%E3%81%AE%E7%AB%8B%E3%81%A1%E4%B8%8A%E3%81%92%E6%96%B9)を参照してください。

## 動かしてみる

以下のコマンドでechoサーバーが立ち上がります。

```bash
go run main.go
```

ターミナルを複数画面開いて、

```bash
telnet localhost 12345
```

とすると、telnetで接続できます。何か文字列を打ち込んで送信し(Enter)同じ文字列がレスポンスされることを確認しましょう。

接続を切断するためには、`Ctrl + ]` を押した後に、`q`を入力してEnterを押すと切断できます。
切断すると、サーバー側には、

```
2017/07/18 17:57:07 Read EOF
```

というログが表示されると思います。

## 最後に

ここまでで、echoサーバーの最小限の実装を行いました。
次回は、Graceful Shutdown を実現するためにシグナルのハンドリング処理の実装を行なっていきます。