← レッスン一覧に戻る

解答 03.1 — クライアント・サーバーモデルと TCP(sendAndReceive)¶

このノートブックは 03.1-client-server-tcp.ipynb の練習問題の解答です。 まず問題の前提コードを再掲し、次に解答、最後にチェックを実行します。

先に自分の力で解いてから、答え合わせに使ってください。

前提コード(問題ノートブックと同じ定義)¶

In [1]:
import (
	"fmt"
	"net"
	"sync"
	"time"
)

// startPersistentEchoServer は、stop() が呼ばれるまで複数の接続を受け続けるTCPエコーサーバーを起動する。
// stop() はリスナーを閉じたうえで、受け付けループ + 各接続のハンドラの終了を待ってから返る
// (ぶら下がり goroutine を残さない)。
func startPersistentEchoServer() (addr string, stop func()) {
	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}

	var wg sync.WaitGroup
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			conn, err := ln.Accept()
			if err != nil {
				return // リスナーが閉じられた
			}
			wg.Add(1)
			go func() {
				defer wg.Done()
				defer conn.Close()
				conn.SetDeadline(time.Now().Add(5 * time.Second))
				buf := make([]byte, 1024)
				n, err := conn.Read(buf)
				if err != nil {
					return
				}
				conn.Write(buf[:n])
			}()
		}
	}()

	stop = func() {
		ln.Close()
		waitDone := make(chan struct{})
		go func() {
			wg.Wait()
			close(waitDone)
		}()
		select {
		case <-waitDone:
		case <-time.After(5 * time.Second):
			fmt.Println("⚠️ サーバーgoroutineがタイムアウトした")
		}
	}
	return ln.Addr().String(), stop
}

解答: sendAndReceive¶

net.DialTimeout で接続し、書き込み → 読み込み → 接続を閉じる、という流れです。 「接続できない」「読み書きに失敗した」のどちらもそのままエラーとして返します。

In [2]:
func sendAndReceive(addr string, message string) (string, error) {
	conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
	if err != nil {
		return "", err
	}
	defer conn.Close()

	if _, err := conn.Write([]byte(message)); err != nil {
		return "", err
	}

	conn.SetDeadline(time.Now().Add(3 * time.Second))
	buf := make([]byte, 1024)
	n, err := conn.Read(buf)
	if err != nil {
		return "", err
	}
	return string(buf[:n]), nil
}

チェック(問題ノートブックと同じ期待値)¶

答え合わせ用のヘルパー mustEqual を定義します。

In [3]:
import "reflect"

func mustEqual(got, want any, name string) {
	if reflect.DeepEqual(got, want) {
		fmt.Printf("✅ Passed: %s\n", name)
		return
	}
	panic(fmt.Sprintf("❌ %s\n  got  = %v (%T)\n  want = %v (%T)", name, got, got, want, want))
}
In [4]:
%%
addr, stop := startPersistentEchoServer()
defer stop()

resp1, err1 := sendAndReceive(addr, "ping")
mustEqual(err1, nil, `sendAndReceive("ping") はエラーなし`)
mustEqual(resp1, "ping", "レスポンスは送信した文字列と同じ")

resp2, err2 := sendAndReceive(addr, "こんにちは")
mustEqual(err2, nil, "日本語文字列もエラーなし")
mustEqual(resp2, "こんにちは", "日本語文字列も echo される")

// 一度開いてすぐ閉じたアドレス = 「今は誰も listen していない」ことが保証されたアドレス。
deadLn, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
	panic(err)
}
deadAddr := deadLn.Addr().String()
deadLn.Close()

_, err3 := sendAndReceive(deadAddr, "x")
mustEqual(err3 != nil, true, "接続できないアドレスはエラーを返す")

fmt.Println("🎉 すべてのチェックが通りました")
✅ Passed: sendAndReceive("ping") はエラーなし
✅ Passed: レスポンスは送信した文字列と同じ
✅ Passed: 日本語文字列もエラーなし
✅ Passed: 日本語文字列も echo される
✅ Passed: 接続できないアドレスはエラーを返す
🎉 すべてのチェックが通りました

解説¶

  1. net.DialTimeout で接続の待ちすぎを防ぐ: net.Dial でも動きますが、相手が応答しない ネットワーク環境ではハングし得るため、タイムアウト付きの DialTimeout を使うと安全です
  2. defer conn.Close() を最初に書く: 接続確立に成功した直後に defer しておけば、 途中の Write/Read でエラーが起きて早期 return しても、必ず接続が閉じられます
  3. エラーはそのまま伝播させる: 「接続できない」(Dial のエラー)と「応答が読めない」 (Read のエラー)を区別せず、どちらもそのまま呼び出し元に返しています。呼び出し元 (チェックセル)は err != nil だけを見ればよく、原因の詳細は Go のエラーメッセージに 含まれています
  4. 「今は誰も listen していない」アドレスの作り方: 外部の決め打ちポート番号を使うと、 実行環境によっては別のプロセスがそのポートを使っている可能性がありますが、 「一度 Listen して Addr() を読み、即座に Close() する」ことで、 その瞬間には確実に誰も listen していないアドレスを再現性高く作れます