Go Conference 2021 Spring (A9-S) のセッションで使用した資料です。 - セッションの詳細: https://gocon.jp/sessions/session-a9-s/ - 発表者: https://twitter.com/d_tutuz
資料に誤りがあればtwitterでご連絡ください。
実務で役立つTCPクライアントの作り方2021/04/24(Sat)Go Conference Online 2021 SpringFuture Tsuji Daishiro
View Slide
Who are you?辻 大志郎(つじ だいしろう) @d_tutuz渋谷区役所(~2014/9)Future(2014/10~)✓ 所属Technology Innovation Group競技プログラミング部2
目次✓ netパッケージでTCP通信するときの基本✓ 実務で使えるようなTCPクライアントを設計実装するためのポイント✓ タイムアウト✓ コネクションプーリング✓ エラーハンドリング✓ リトライ3
go-mcprotocol✓ go-mcprotocol (@develop)✓ PLCというハードウェア制御をソフトウェアで制御できる機器と通信✓ TCP上に、MELSECコミュニケーションプロトコル(通称:MCプロトコル)というプロトコルで通信✓ Go製のTCPクライアントライブラリ4https://emb.macnica.co.jp/articles/4277/PLC
話さないこと✓ TCPプロトコルの仕様の詳細✓ ソケットシステムコールの詳細5
6netパッケージでTCP通信するときの基本
netパッケージを使ったTCPの通信の基本7クライアントサーバー
netパッケージを使ったTCPの通信の基本8クライアントサーバーListen()Accept()
netパッケージを使ったTCPの通信の基本9クライアントサーバーListen()Accept() Dial()net.Connnet.Conn
netパッケージを使ったTCPの通信の基本10クライアントサーバーListen()Accept() Dial()Write()Read()Read()Write()net.Connnet.Conn
netパッケージを使ったTCPの通信の基本11クライアントサーバーListen()Accept() Dial()Write()Read()Read()Write()Close()net.Connnet.ConnClose()
参考 -netパッケージがシステムコールを内包-12クライアントサーバーListen()Accept() Dial()Write()Read()Read()Write()Close()net.Connnet.ConnClose()• socket()• bind()• listen()• accept()• read()• write()• close()• socket()• connect()
どういう場合にnetパッケージを使うか✓ HTTPの場合はnet/httpを使う✓ 標準ライブラリでサポートされていないプロトコル実装✓ MCプロトコル✓ memcachedクライアントなどなど13
GoでTCP Echoサーバ✓ net.Listenを使って簡単にできる14func main() {ln, err := net.Listen("tcp", ":8080")if err != nil {log.Fatal(err)}for {conn, err := ln.Accept()if err != nil {log.Fatal(err)}go echoHandler(conn)}}func echoHandler(conn net.Conn) {defer conn.Close()io.Copy(conn, conn)}
GoでTCPクライアント接続✓ net.Dialを使って簡単にできる15func main() {conn, err := net.Dial("tcp", "localhost:8080")if err != nil {log.Fatal(err)}_, err = conn.Write([]byte("hello 世界"))if err != nil {log.Fatal(err)}buf := make([]byte, 32)n, err := conn.Read(buf)if err != nil {log.Fatal(err)}fmt.Println(string(buf[:n]))}
16簡単だがいくつか考慮する必要あり
17なお、TCPサーバ側の考慮点は『堅牢なTCPサーバを作るために』がおすすめhttps://speakerdeck.com/fujiwara3/kamakura-dot-go-number-5
タイムアウトを見込む✓ コネクションを確立できない場合✓ サーバからレスポンスが返ってこない場合18タイムアウトは、応答がなさそうだと判断したら待つの中止できるようにする単純なメカニズムうまく配置されたタイムアウトは障害の分離を可能にしてくれる。つまり、別のシステムやサブシステムやデバイスの問題があなたの問題にならずに済む『Release IT!』
TCPでタイムアウトを実装しない場合✓ デフォルトではタイムアウトは設定されない✓ Read()やDial()などはブロッキングAPI✓ レスポンスは返ってこない場合、クライアントはエラーにならず、待ち続ける✓ 運用上、生きているふりをするくらいであれば、死んだほうがまし19
netパッケージを使ったタイムアウト(1/2)✓ コネクションの確立時✓ net.DialTimeout(もしくはDialer構造体にTimeoutを設定)を用いる20func DialTimeout(network, address string, timeout time.Duration) (Conn, error) {d := Dialer{Timeout: timeout}return d.Dial(network, address)}
netパッケージを使ったタイムアウト(2/2)✓ net.Connを使った読み書き時✓ SetDeadline(もしくはSetReadDeadlineやSetWriteDeadline)を用いる✓ I/Oオペレーションの期限であって、タイムアウトではない✓ 1つのI/Oごとにタイムアウトを設けるのであれば、期限を更新する必要あり21type Conn interface {Read(b []byte) (n int, err error)Write(b []byte) (n int, err error)Close() errorLocalAddr() AddrRemoteAddr() AddrSetDeadline(t time.Time) errorSetReadDeadline(t time.Time) errorSetWriteDeadline(t time.Time) error}
参考 -go-mcprotocolの場合-✓ 構造体の中にタイムアウト時間を保持22// client3E is 3E frame mcp clienttype client3E struct {// PLC addresstcpAddr *net.TCPAddr// PLC stationstn *station// Connect & Read Write timeouttimeout time.Duration// TCP connectionmu sync.Mutexconn net.Conn}
参考 -go-mcprotocolの場合-✓ タイムアウトが設定されていれば、最初に期限を設定23// Connection established if not connectif err = c.connect(); err != nil {return nil, err}// Set write and read timeout if set timeoutif c.timeout > 0 {deadline := time.Now().Add(c.timeout)if err = c.conn.SetDeadline(deadline); err != nil {_ = c.close()return nil, err}}// Send messageif _, err = c.conn.Write(payload); err != nil {_ = c.close()return nil, err}
コネクションプーリング✓ コネクションを保持して再利用する✓ 高速な通信が必要な場合✓ 高速化が不要であれば、都度コネクションを確立するのも一つの方法✓ 実装はよりシンプルに✓ TCPのコネクション確立はコストが高い✓ 3ウェイハンドシェイク✓ サーバ側のコネクション確立+α24// client3E is 3E frame mcp clienttype client3E struct {// ...// TCP connectionmu sync.Mutexconn net.Conn}
参考 -3ウェイハンドシェイク-✓ コネクション確立時の通信フロー25受信側送信側SYNSYN ACKACKACKデータ送信
並列処理される場合の考慮26✓ net.Connはゴルーチンセーフ✓ Write()やRead()している間に他のゴルーチンからClose()を呼び出すことが可能✓ Write()している間にClose()されて困る場合はmutexで処理を保護func (c *client3E) Write(deviceName string, offset, numPoints int64, writeData[]byte) ([]byte, error) {c.mu.Lock()defer c.mu.Unlock()requestStr := c.stn.BuildWriteRequest(deviceName, offset, numPoints, writeData)// …
プールしたコネクションの状態が悪くなる場合27✓ プールしたコネクションはクライアントが気づかない間に状態が悪くなっている可能性がある。✓ 状態の悪いコネクションに対して読み書きするとエラーになる
エラーハンドリング28✓ io.EOF や syscall.EPIPE や net.ErrClosed(Go1.16~) などのハンドリング✓ net.ErrClosedのおかげで“use of closed network connection”の文字列が含まれるかどうかでチェックしなくて良くなった✓ エラーが発生した場合に、プールしているコネクションをリリースする✓ TCPライブラリとして提供する場合は、err != nil なときにプールしているコネクションをリリースするのが親切✓ ライブラリを利用する側でエラー発生時にリリースすることもできるが
go-mcprotocolの実装例29// Send messageif _, err = c.conn.Write(payload); err != nil {_ = c.close()return nil, err}✓ エラーが発生した場合はライブラリ側でコネクションをリリースfunc (c *client3E) close() error {var err errorif c.conn != nil {err = c.conn.Close()c.conn = nil}return err}
リトライ30✓ TCPの場合、プロトコルレイヤーで再送の機構がある✓ 再送タイマー✓ 重複ACK
参考 -TCPの再送タイマー-31✓ RTO(再送タイムアウト)による再送https://eh-career.com/engineerhub/entry/2020/02/13/103000#%E5%86%8D%E9%80%81%E5%88%B6%E5%BE%A1
参考 -TCPの重複ACK-32https://eh-career.com/engineerhub/entry/2020/02/13/103000#%E5%86%8D%E9%80%81%E5%88%B6%E5%BE%A1✓ 同じシーケンス番号を要求するACKが複数回返ってきたことによる再送
リトライ33✓ 瞬断はTCPの機構で救えそう✓ (経験上)即時のリトライで救えることは少ない✓ クライアントのリクエストが間違っている✓ サーバが死んでいる✓ ただし、コネクションプーリングしている場合は抱えているコネクションが悪くなっている場合がある✓ アプリケーションの呼び元の責務でリトライするか判断すべき✓ go-mcprotocol(TCPライブラリ)側では実装していない
リトライ方法のあれこれ34✓ 基本はExponential Backoff+Jitter✓ 接続元が限られているのであればシンプルな即時リトライも検討✓ github.com/Songmu/retry など
参考 -TCPキープアライブ-35✓ TCPキープアライブ✓ 確立したコネクションの死活を確認し、通信できない場合はコネクションをクローズ✓ TCPでnet.Dial()を行った場合はデフォルトでTCPキープアライブが有効(Go1.12~
まとめ36✓ GoでTCPクライアントは簡単に実装できる✓ TCPクライアントを実装するときに以下の考慮ポイントがある✓ タイムアウト✓ コネクションプーリング✓ エラーハンドリング✓ リトライ