Go言語を使ったTCPクライアントの作り方

この記事は [Go Advent Calendar 2020](https://qiita.com/advent-calendar/2020/go) の10日目の記事です。

こんにちは、辻です。最近はクラウドからMCプロトコルというプロトコルを用いてスマート工場のPLCを操作するTCPクライアントを実装しています。TCPクライアントの実装は [future-architect/go-mcprotocol](https://github.com/future-architect/go-mcprotocol) として公開しています。

本記事では高速で信頼できるTCPクライアントをGoで実装するための知見を共有します。TCPとソケットプログラミングはざっと概要だけ触れておきます。

## TCPとは？

TCPとは[RFC793](https://tools.ietf.org/html/rfc793)で仕様が定められているプロトコルです。特徴を簡単におさらいしておくと、

- 非構造化ストリーム
- 全二重通信
- コネクション管理
- 高信頼性
  - シーケンス番号
  - 再送制御
  - 順序制御
  - 輻輳制御
  - チェックサム

といった特徴があります。TCPのヘッダーフォーマットは以下のようになっていました。TCPのヘッダーは32ビット(4バイト)の列が5つで20バイトです。`data` がTCPのペイロードに該当します。TCPクライアントは `data` にアプリケーションのプロトコルに従ったペイロードを格納して、データをやりとりします。TCPは非構造化ストリームのプロトコルです。TCPのペイロードは単なるビット列です。ビット列に含まれるデータの解釈は上位層の仕事です。

![tcpip23.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89391/37e962a7-0c3c-1e47-1e8b-9caf8e91eb71.gif)

「[TCPヘッダのフォーマット](https://www.infraexpert.com/studygif/tcpip23.gif)」からの引用

- TCPの状態遷移 <!-- omit toc -->

またTCPはいくつかの状態を持つプロトコルです。それぞれの状態の遷移図は以下のようになります。
![wi-tcpfigure05.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89391/18e9657c-356d-314b-d408-a65fa32bb668.png)

「[TCP（Transmission Control Protocol）](https://www.atmarkit.co.jp/ait/articles/1612/05/news030.html)」からの引用

TCPサーバを起動して `netstat` などのコマンドを使って、TCP通信の状態を確認することができます。たとえば、これから紹介するEchoサーバをローカルホストの5000番ポートで起動した状態で `netstat` を使うと以下のように `LISTEN` していることがわかります。(Windowsの例)

```
> netstat -ap tcp | grep 5000
  TCP         0.0.0.0:5000           0.0.0.0:0      LISTENING
```

TCPクライアントから `SYN` フラグを含む接続リクエストを受け取るとソケットの状態が `SYN_RECEIVED` に遷移し、さらに `ACK` を受信すると `ESTABLISHED` になる、という仕組みです。

TCPそのものの仕様詳細については [TCP 詳説](https://www.nic.ad.jp/ja/materials/iw/1999/notes/C3.PDF) などの資料を参考にしてください。

## TCPソケットプログラミング

TCPのクライアントとサーバで通信するときの基本的なソケットシステムコールをおさらいしておきます。TCPクライアントとTCPサーバが通信するときは以下のようなソケット関数を用いて通信します。

![TCPソケットシーケンス.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/89391/c9cdff29-a3cb-c04e-4ee0-f5123d2816f7.png)

例えばソケットを生成するときは以下の `socket` システムコールを呼び出します。

```
int socket(int domain, int type, int protocol);  
```

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

- `domain` 引数は通信を行なうドメインを指定するもので、以下の定数を用いることできます。IPv4で通信する場合は `AF_INET` を用いる、という具合です。

- `type` は以下の定数が定義されています。TCP通信をしたい場合、通常は `SOCK_STREAM` を指定することになります。UDPの場合は `SOCK_DGRAM` です。

| 名前        | 説明                                                                                                                                               |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| SOCK_STREAM | 順序性と信頼性があり、双方向の、接続された バイトストリーム (byte stream) を提供する。 帯域外 (out-of-band) データ転送メカニズムもサポートされる。 |
| SOCK_DGRAM  | データグラム (コネクションレス、信頼性無し、固定最大長メッセージ) をサポートする。                                                                 |
| ...         | ...                                                                                                                                                |

- `protocol` はソケットによって使用される固有のプロトコルを指定します。通常は `0` です。

`socket` はドメインやソケットの型を指定して呼び出しますが、ローカルアドレスやリモートアドレスは指定していません。3Wayハンドシェイクを行ってTCP接続するのは `connect` 関数の役割です。

`socket` 以外にも上記の図に登場したシステムコールを用いてTCP通信をしますが、GoでTCP通信するときは、直接開発者がシステムコールを発行する必要はありません。Goはこのようなソケットに関するシステムコールを抽象化して、開発者が扱いやすいようなAPIを提供しています。その代わりにGoのAPIが内部的にシステムコールを発行します。

### `net` パッケージでTCP通信するときの基本

GoでTCP通信するときは `net` パッケージを使います。`net` パッケージには `Dial` や `DialTCP` といった関数が備わっており、これらの関数を使うとTCPサーバとクライアント間で1つのコネクションを確立します。

- `Dial`

```go
func Dial(network, address string) (Conn, error)
```

- `DialTCP`

```go
func DialTCP(network string, laddr, raddr *TCPAddr) (*TCPConn, error)
```

`Conn` はコネクションを示すインターフェースです。以下のメソッドが定義されています。

```go
type Conn interface {
	Read(b []byte) (n int, err error)
	Write(b []byte) (n int, err error)
	Close() error
	LocalAddr() Addr
	RemoteAddr() Addr
	SetDeadline(t time.Time) error
	SetReadDeadline(t time.Time) error
	SetWriteDeadline(t time.Time) error
}
```

`Conn` を満たす構造体の例としては `IPConn`, `TCPConn`, `UDPConn`, `UnixConn` などがあります。実体はファイルディスクリプタである `*netFD` を保持する `conn` 構造体を埋め込んでいる構造体です。

```go
type TCPConn struct {
	conn
}
```

`conn` 構造体は以下のようになっています。

```go
type conn struct {
	fd *netFD
}
```

`Dial("tcp", c.addr)` としてコネクションを確立しようとすると内部的には `DialTCP` が呼び出されます。第一引数の文字列によって処理を切り替える、デザインパターンでいうところの、いわゆるStrategyパターンです。`dial.go` の実装の一部ですが、以下のようになっています。

```go
	switch ra := ra.(type) {
	case *TCPAddr:
		la, _ := la.(*TCPAddr)
		c, err = sd.dialTCP(ctx, la, ra)
	case *UDPAddr:
		la, _ := la.(*UDPAddr)
		c, err = sd.dialUDP(ctx, la, ra)
	case *IPAddr:
		la, _ := la.(*IPAddr)
		c, err = sd.dialIP(ctx, la, ra)
	case *UnixAddr:
		la, _ := la.(*UnixAddr)
		c, err = sd.dialUnix(ctx, la, ra)
	default:
		return nil, &OpError{Op: "dial", Net: sd.network, Source: la, Addr: ra, Err: &AddrError{Err: "unexpected address type", Addr: sd.address}}
	}
```

`DialTCP` はシステムコールでいうところの `socket` によるソケットの作成や `bind` によるプロトコルアドレスのソケットへの割り当て `connect` による3Wayハンドシェイクを実施してコネクションを確立する、といったことを行ってくれます。

`Conn` インターフェースには `Read` や `Write` のメソッドが定義されています。コネクションへの読み書きはこの `Read` や `Write` メソッドを用いてバイト列を読み書きします。

## GoでTCPクライアントを作る

実際にGoでTCPクライアントを作ってみましょう。リクエスト先のTCPサーバは [methane/echoserver](https://github.com/methane/echoserver) にある以下のTCPサーバのGo実装を拝借しましょう。`io.Copy` を使ったEchoサーバです。

```go
package main

import (
	"io"
	"log"
	"net"
)

func echo_handler(conn net.Conn) {
	defer conn.Close()
	io.Copy(conn, conn)
}

func main() {
	psock, e := net.Listen("tcp", ":5000")
	if e != nil {
		log.Fatal(e)
		return
	}
	for {
		conn, e := psock.Accept()
		if e != nil {
			log.Fatal(e)
			return
		}
		go echo_handler(conn)
	}
}
```

本記事ではGoによる実務的なTCPサーバの実装方法について詳しくは述べませんが、信頼できるTCPサーバの作り方は「[堅牢なTCPサーバを作るために - katsubushiの知見から/kamakura.go#5](https://speakerdeck.com/fujiwara3/kamakura-dot-go-number-5)」の資料が参考になります。

### TCPクライアントの実装

Echoサーバに対して `hello` を書き込んで、結果を受け取るTCPクライアントを実装します。まずはともあれ動作させてみましょう。上記のEchoサーバを起動させた状態で、以下の `main.go` を実行します。

- client.go

```go
package app

import "net"

type Client struct {
	addr string
}

func NewClient(addr string) *Client {
	return &Client{addr: addr}
}

func (c *Client) Hello(b []byte) (string, error) {
	conn, err := net.Dial("tcp", c.addr)
	if err != nil {
		return "", err
	}
	defer conn.Close()

	_, err = conn.Write(b)
	if err != nil {
		return "", err
	}

	buf := make([]byte, 1024)
	n, err := conn.Read(buf)
	if err != nil {
		return "", err
	}

	return string(buf[:n]), nil
}
```

- cmd/main.go

```go
package main

import (
	"fmt"

	"github.com/d-tsuji/go-sandbox/tcpsample/app"
)

func main() {
	c := app.NewClient(":5000")

	resp, err := c.Hello([]byte("hello"))
	if err != nil {
		panic(err)
	}

	fmt.Println(resp)
}
```

実行してみると、以下のように `hello` という文字列がサーバから返ってきて表示されることがわかります。

```bash
$ go run .
hello
```

Goではとても簡単にTCPクライアントを作ることができます。また生成したコネクションへの読み書きも `Read`, `Write` を用いて `io.Reader`, `io.Write` への読み書きと同じ感覚で扱うことができます。(`Conn` インターフェースが `io.Reader`, `io.Write` インターフェースを満たしているため)

なお `Read` は引数に `[]byte` 型であるバイト配列のバッファを受け取りますが、バッファの長さがサーバからのレスポンス未満の場合で、まだサーバからレスポンスが残っている場合は複数回 `Read` を実行する必要があります。先程の `cleint.go` の `buf` を以下のように変えてみると挙動がわかるでしょう。

```diff
-	buf := make([]byte, 1024)
+	buf := make([]byte, 1)
	n, err := conn.Read(buf)
	if err != nil {
		panic(err)
	}
```

実行してみると、バッファの長さ分のレスポンスしか取得できないことがわかります。

```bash
$ go run .
h
```

### より実用的なTCPクライアント

#### 1.タイムアウト

先程のTCPクライアントはタイムアウトなしでEchoサーバと通信しています。 `Read` メソッドでデータを読み取ろうとしたときにレスポンスが返ってこなかった場合は無限にブロッキングします。実際にはタイムアウトを設定したいケースが多いでしょう。`net` パッケージには `Dial`, `Write`, `Read` のタイムアウトを設定するAPIが用意されています。

- `Dial` 時のタイムアウト

`net.DialTimeout` で引数にタイムアウトを設定する方法や `net.Dialer` 構造体の `Timeout` フィールドにタイムアウト値を設定して `Dialer.Dial` でDialする方法があります。なお `net.DialTimeout` は以下のような実装になっているため、2つの方法は実体は同じです。

```go
func DialTimeout(network, address string, timeout time.Duration) (Conn, error) {
	d := Dialer{Timeout: timeout}
	return d.Dial(network, address)
}
```

- `Write/Read` のタイムアウト

`SetWriteDeadline` メソッドを使って書き込みのタイムアウトを設定できます。同様に `SetReadDeadline` メソッドで読み込みのタイムアウトを設定できます。`SetDeadline` は読み込みと書き込みの両方のタイムアウトを設定できます。

それでは、先程の `client.go` にタイムアウトを設定できるようにします。`SetDeadline` は読み書きのデッドラインの時刻を指定します。デッドラインはそれぞれの操作ごとにリセットされるわけではなく、固定の値を取ります。それぞれの `Read` や `Write` の操作ごとのタイムアウトを設定するには、それぞれデッドラインを設定する必要があります。

```diff
func (c *Client) Hello(b []byte) (string, error) {
-	conn, err := net.Dial("tcp", c.addr)
+	conn, err := net.DialTimeout("tcp", c.addr, c.Timeout)
	if err != nil {
		return "", err
	}
	defer conn.Close()

+	if c.Timeout > 0 {
+		if err = conn.SetDeadline(time.Now().Add(c.Timeout)); err != nil {
+			return "", err
+		}
+	}
	_, err = conn.Write(b)
	if err != nil {
		return "", err
	}

	buf := make([]byte, 1024)
+	if c.Timeout > 0 {
+		if err = conn.SetDeadline(time.Now().Add(c.Timeout)); err != nil {
+			return "", err
+		}
+	}
	n, err := conn.Read(buf)
	if err != nil {
		panic(err)
	}

	return string(buf[:n]), nil
}
```

デッドラインを設定すると、例えば `main.go` で異様に短いタイムアウトを設定した場合にエラーが返ってくることがわかります。

```diff
func main() {
	c := app.NewClient(":5000")
+	c.Timeout = 1 * time.Nanosecond

	resp, err := c.Hello([]byte("hello"))
	if err != nil {
		panic(err)
	}

	fmt.Println(resp)
}
```

実行すると以下のエラーが出力されるでしょう。タイムアウトを設定することによって、コネクションの確立時や読み書きのタイミングで無限にブロッキングすることはなくなりました。

```console
panic: dial tcp :5000: i/o timeout
```

#### 2.コネクションプーリング

続いてTCPクライアントのコネクションプーリングを考えましょう。現状の `client.go` は `Hello` メソッドを呼び出すたびにTCPサーバとコネクションを確立し、TCPサーバからEchoされた文字列を読み込んだらコネクションをクローズしています。`net.Dial` によるTCPのコネクション確立は3ウェイハンドシェイクによって確立されるため、TCPクライアントからTCPサーバへデータを送受信する場合と比較して、コストの高い処理になります。TCPクライアントが1回だけTCPサーバと通信する場合は問題ありませんが、複数回の呼び出しを高速に行いたい場合は `net.Dial` がボトルネックになる可能性があります。

このような場合、一度確立したコネクションを保持しておき、複数回TCPクライアントからTCPサーバへデータを送受信する場合に、確立済のコネクションが存在すればそのコネクションを使い回すといった、コネクションプーリングを行うことが有効になります。実装的には `net.Conn` を `Client` 構造体のフィールドとして保持することがポイントです。

TCPクライアントの実装は以下のような感じです。TCPクライアントが複数のゴルーチンで共有されても安全に読み書きが行えるように、コネクションの確立やクローズ、読み書きを `sync.Mutex` で排他制御をする必要があります。また、エラーが発生した場合は速やかにプールしているコネクションを開放するのが良いでしょう。リトライなどを考慮して、サーバ側からなんらかの理由で切断された不都合のあるコネクションに対して行った操作でエラーが発生した場合はそのコネクションを開放して、再度新しいコネクションを接続できるようにするためです。

- client.go

```go
package app

import (
	"net"
	"sync"
	"time"
)

type Client struct {
	addr string

	Timeout time.Duration

	// TCP connection
	mu   sync.Mutex
	conn net.Conn
}

func NewClient(addr string) *Client {
	return &Client{addr: addr}
}

func (c *Client) Hello(b []byte) (string, error) {
	c.mu.Lock()
	defer c.mu.Unlock()

	if err := c.connect(); err != nil {
		return "", err
	}

	if c.Timeout > 0 {
		if err := c.conn.SetDeadline(time.Now().Add(c.Timeout)); err != nil {
			_ = c.conn.Close()
			return "", err
		}
	}
	_, err := c.conn.Write(b)
	if err != nil {
		_ = c.conn.Close()
		return "", err
	}

	buf := make([]byte, 1024)
	n, err := c.conn.Read(buf)
	if err != nil {
		_ = c.conn.Close()
		return "", err
	}

	return string(buf[:n]), nil
}

func (c *Client) Close() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.close()
}

func (c *Client) Connect() error {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.connect()
}

// connectはコネクションがnilの場合はコネクションを試みます。
// すでにコネクションが確立されている場合は、そのコネクションを使い回します。
func (c *Client) connect() error {
	if c.conn == nil {
		conn, err := net.DialTimeout("tcp", c.addr, c.Timeout)
		if err != nil {
			return err
		}
		c.conn = conn
	}
	return nil
}

// closeはコネクションがnilでない場合にコネクションをCloseします。
func (c *Client) close() error {
	var err error
	if c.conn != nil {
		err = c.conn.Close()
		c.conn = nil
	}
	return err
}
```

- cmd/main.go

```go
package main

import (
	"fmt"

	"github.com/d-tsuji/go-sandbox/tcpsample/app"
)

func main() {
	c := app.NewClient(":5000")
	defer c.Close()

	// コネクションを確立してRead/Writeを実施
	resp, err := c.Hello([]byte("hello"))
	if err != nil {
		panic(err)
	}

	// 2回目以降は確立済のコネクションを使ってRead/Writeを実施
	resp, err = c.Hello([]byte("hello"))
	if err != nil {
		panic(err)
	}

	fmt.Println(resp)
}
```

#### 3.リトライ

何らかの理由で一時的なエラーになった場合を考慮して、一定回数リトライできるようにしておくとより信頼性が高まるでしょう。リトライのアルゴリズムといえば、AWSのSDKでも採用されている [^1] ようなExponential Backoffが有名です。Goのライブラリだと [cenkalti/backoff](https://github.com/cenkalti/backoff) が有名です。しかし高速な通信が必要なTCPクライアントの場合はExponential Backoffではなく、単にエラーが発生した場合はシンプルに再試行あるいは、短い一定時間だけ待機して再試行するだけで十分な場合も多いでしょう。Exponential Backoffが有効なポイントの一つは、サーバ負荷を軽減する、ということがありますが、今回はループによるリトライでもサーバ側が問題ないような状況を仮定します。Exponential Backoffを使わずにシンプルにリトライすることにします。

またリトライをTCPクライアント側(`client.go`)に組み込むか、呼び出し側(`main.go`)に任せるかは選択の余地があります。今回は呼び出し側である `main.go` でリトライすることにします。

- retry.go

一定回数ループでリトライの実装はこんな感じ

```go
func Retry(ctx context.Context, attempts uint, interval time.Duration, fn func() error) error {
	var err error
	for attempts > 0 {
		attempts--
		if err = fn(); err == nil {
			break
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(interval):
		}
	}
	return err
}
```

- cmd/main.go

呼び出し側の `main.go` でリトライします。以下はエラーがあった場合は最大3回試行、待機時間0秒でリトライする実装例です。

```go
func main() {
	ctx := context.Background()

	c := app.NewClient(":5000")
	defer c.Close()

	var resp string
	err := app.Retry(ctx, 3, 0, func() error {
		var ierr error
		resp, ierr = c.Hello([]byte("hello"))
		return ierr
	})
	if err != nil {
		panic(err)
	}

	fmt.Println(resp)
}
```

## まとめ

TCPの概要とGoによるTCPクライアントの作り方を紹介しました。Goはネットワーク周りの標準ライブラリが充実していて、サードパーティのライブラリを使わずとも高速で信頼できるTCPクライアントを実装できます。もちろんTCPクライアントで重要なのはTCPの `data` に書き込むペイロードです。TCPクライアントを作成して `memchached` やMCプロトコルなどのプロトコル実装に挑戦してみてください。本記事がTCPクライアントを作成する際に参考になれば幸いです。

 [^1]: [AWS でのエラー再試行とエクスポネンシャルバックオフ](https://docs.aws.amazon.com/ja_jp/general/latest/gr/api-retries.html)