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