← レッスン一覧に戻る

03.1 クライアント・サーバーモデルと TCP — ネットワーク通信の基本単位¶

ここまでの Module 1(OOP)・Module 2(データベース)は、すべて1つのプロセスの中で完結していました。 Module 3 からは、2つの別々のプログラム(クライアントとサーバー)がネットワーク越しに会話する世界に入ります。

  • サーバー: 接続を「待ち受け」、来たリクエストに応答するプログラム
  • クライアント: サーバーへ「接続しにいき」、リクエストを送るプログラム
  • ポート: 同じマシン上で複数のプログラムを区別するための番号(0〜65535)。IPアドレスが 「建物の住所」なら、ポートは「部屋番号」に相当します

このレッスンのゴール:

  • クライアント・サーバーモデルの役割分担を説明できる
  • TCP(信頼できる・順序保証あり・接続指向)とUDP(信頼保証なし・接続レス)の違いを説明できる
  • Go の net パッケージで、実際に TCP のエコー(送った文字列がそのまま返る)サーバーとクライアントを動かせる

非自明ポイント¶

  1. net.Listen("tcp", "127.0.0.1:0") のポート 0 は「OSに空いているポートを選ばせる」という意味です。 実運用のサーバーは決まったポート(例: 8080)を使いますが、教材やテストでは「今どのポートが 空いているか」を気にせず済むポート0が便利です。実際に使われたポートは ln.Addr() から取得します。
  2. TCP接続は「コネクション」という状態を持ちます。 net.Dial で接続すると、サーバー側では Listener.Accept() が1つの net.Conn を返します。この Conn は双方向のバイトストリームで、 Write で送った内容が相手の Read で(順序を保ったまま)読めることが TCP の保証です。 UDP にはこの保証がありません(届く順序が入れ替わったり、届かなかったりし得ます)。
  3. Read は「送った分だけ返ってくる」とは限りません。 TCPはバイトストリームなので、 1回の Write が複数回の Read に分かれて届くことがあります(このレッスンの短いメッセージでは 1回で収まりますが、実務では「区切り」をアプリケーション側で決める必要があります)。
  4. ぶら下がり goroutine を残さない。 サーバーを goroutine で起動したら、done チャネルの close で終了を通知し、呼び出し元は終了を待ってからセルを終えます(無限に待たず、 タイムアウト付きで待ちます)。
In [1]:
import (
	"errors"
	"fmt"
	"net"
	"strings"
	"sync"
	"time"

	"github.com/janpfeifer/gonb/gonbui"
)

// ErrUnanswered は、練習問題が未回答のときにプレースホルダ関数が返す特別なエラー。
var ErrUnanswered = errors.New("未回答: この関数はまだ実装されていません")

// connEvent は、クライアント/サーバー間で起きた1つの出来事(Listen/Accept/Read/Write/Close)を表す。
type connEvent struct {
	time   string
	actor  string
	action string
	data   string
}

var demoEvents []connEvent

func renderConnEvents(events []connEvent) string {
	var b strings.Builder
	b.WriteString(`<table border="1" cellpadding="4" style="border-collapse:collapse">`)
	b.WriteString(`<tr><th>時刻</th><th>主体</th><th>動作</th><th>データ</th></tr>`)
	for _, e := range events {
		b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`,
			e.time, e.actor, e.action, e.data))
	}
	b.WriteString(`</table>`)
	return b.String()
}

実際に動かしてみる: 1回限りのTCPエコーサーバー¶

ポート0でリスナーを作り、goroutine でサーバーを起動 → クライアントが接続 → 送信 → サーバーがそのまま送り返す(エコー)→ クライアントが受信 → 切断、という一連の流れを1つの関数にまとめます。

In [2]:
// runEchoDemo は、ポート0(OSが空きポートを自動選択)で1回限りのTCPエコーサーバーを起動し、
// クライアントから 接続→送信→応答受信→切断 までを実演する。
// サーバー側は goroutine で動かし、処理が終わったら done チャネルを close して終了を通知する。
// 呼び出し元は select でタイムアウト付きに終了を待ってからセルを終える
// (ぶら下がり goroutine を残さない)。
func runEchoDemo() []connEvent {
	var events []connEvent
	record := func(actor, action, data string) {
		events = append(events, connEvent{
			time:   time.Now().Format("15:04:05.000"),
			actor:  actor,
			action: action,
			data:   data,
		})
	}

	ln, err := net.Listen("tcp", "127.0.0.1:0")
	if err != nil {
		panic(err)
	}
	record("server", "Listen", ln.Addr().String())

	done := make(chan struct{})
	go func() {
		defer close(done)
		conn, err := ln.Accept()
		if err != nil {
			return
		}
		record("server", "Accept", conn.RemoteAddr().String())

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

		conn.Write(buf[:n])
		record("server", "Write (echo)", received)

		conn.Close()
		record("server", "Close", "")
	}()

	conn, err := net.DialTimeout("tcp", ln.Addr().String(), 5*time.Second)
	if err != nil {
		panic(err)
	}
	record("client", "Dial", ln.Addr().String())

	msg := "hello, server"
	if _, err := conn.Write([]byte(msg)); err != nil {
		panic(err)
	}
	record("client", "Write", msg)

	conn.SetDeadline(time.Now().Add(5 * time.Second))
	buf := make([]byte, 1024)
	n, err := conn.Read(buf)
	if err != nil {
		panic(err)
	}
	echoed := string(buf[:n])
	record("client", "Read (echo)", echoed)

	conn.Close()
	record("client", "Close", "")

	// サーバー側 goroutine の終了を待ってからセルを終える(有限の待機上限つき)。
	select {
	case <-done:
	case <-time.After(5 * time.Second):
		fmt.Println("⚠️ サーバーgoroutineがタイムアウトした")
	}

	ln.Close()
	return events
}
In [3]:
%%
demoEvents = runEchoDemo()
for _, e := range demoEvents {
	fmt.Printf("[%s] %-6s %-14s %q\n", e.time, e.actor, e.action, e.data)
}
gonbui.DisplayHTML(renderConnEvents(demoEvents))
gonbui.Sync()
[17:42:49.764] server Listen         "127.0.0.1:59853"
[17:42:49.764] client Dial           "127.0.0.1:59853"
[17:42:49.764] client Write          "hello, server"
[17:42:49.764] server Accept         "127.0.0.1:59854"
[17:42:49.764] server Read           "hello, server"
[17:42:49.764] server Write (echo)   "hello, server"
[17:42:49.764] server Close          ""
[17:42:49.764] client Read (echo)    "hello, server"
[17:42:49.764] client Close          ""
時刻主体動作データ
17:42:49.764serverListen127.0.0.1:59853
17:42:49.764clientDial127.0.0.1:59853
17:42:49.764clientWritehello, server
17:42:49.764serverAccept127.0.0.1:59854
17:42:49.764serverReadhello, server
17:42:49.764serverWrite (echo)hello, server
17:42:49.764serverClose
17:42:49.764clientRead (echo)hello, server
17:42:49.764clientClose

client の Write に渡した文字列 "hello, server" が、server の Read にそのまま届き、 server の Write (echo) で送り返され、client の Read (echo) で同じ文字列として受け取れていることを、 上のテキスト出力と下の HTML 表の両方で確認してください。

可視化: 通信の時系列¶

同じイベント列を HTML 表で時系列に並べています。観察点: コネクション確立(Dial/Accept)→ 送信(Write)→ 応答(Write (echo))→ 受信(Read (echo))→ 切断(Close)の順序が、 クライアント側・サーバー側の視点を行き来しながら進むことです。

🔴 GoNB の罠: demoEvents はセル冒頭で宣言したパッケージ変数ですが、その値が別の %% セルへ 確実に引き継がれる保証はありません。上のセルでは、値を書き込む処理(runEchoDemo() の呼び出し)と 読む処理(gonbui.DisplayHTML)を同じセル内で完結させることで、この罠を回避しています (Module 2 の DB 契約で「セル間の状態共有はディスク上のファイル経由にする」としたのと同じ理由です)。

TCP と UDP¶

このレッスンで使った TCP は、次の性質を持ちます。

  • 接続指向: 通信の前に Dial/Accept でコネクションを確立する
  • 信頼性: 送ったバイト列は、順序を保ったまま、欠けずに届く(届かない場合はエラーになる)
  • コスト: 確立・維持にオーバーヘッドがある

対して UDP(net.ListenUDP / net.DialUDP で使えますが、このレッスンでは扱いません)は、

  • 接続レス: コネクションを確立せず、いきなりデータグラム(小包)を送る
  • 信頼性なし: 届く順序が入れ替わったり、届かないことがある(アプリ側で対処が必要)
  • 低オーバーヘッド: 確立コストが無く、動画配信・オンラインゲームなど「多少欠けても速さを優先したい」用途で使われる
# イメージ(実行はしません)
TCP: 電話 — 相手が出るまで呼び出し、通話中は会話の順序が保たれる
UDP: 手紙の速達を大量に投函 — 届く順序も、届くかどうかも保証されない

このコースでは、以降のレッスン(HTTP・RPC)はすべて TCP の上に構築されます。

直感・類推: 電話をかける¶

クライアント・サーバーモデルは、電話によく似ています。

  • ポート番号 = 内線番号。同じ建物(同じマシン)でも、内線番号(ポート)が違えば別の部署(別のプログラム)につながる
  • Dial = 電話をかける行為そのもの。相手(サーバー)が Listen していなければ、そもそもつながらない
  • Accept = 相手が電話に出ること。出た瞬間に「通話(コネクション)」が始まる
  • Write/Read = 話す・聞く。TCP では、話した順番のまま相手に聞こえることが保証される(UDPは早口の伝言ゲームのように、順番が入れ替わったり途中が抜け落ちたりし得る)
  • Close = 電話を切る

バックエンドでのつながり: 次のレッスン(03.2 HTTP解剖)では、この TCP コネクションの 上に「決まった書式のメッセージ(リクエスト/レスポンス)をやり取りする」という約束事を 乗せたものが HTTP であることを見ていきます。

練習問題 3.1: sendAndReceive を実装しよう¶

net.Dial で TCPサーバーへ接続し、メッセージを送って応答を受け取る関数 sendAndReceive を実装してください。

仕様:

func sendAndReceive(addr string, message string) (string, error)
  • addr(例: "127.0.0.1:54321")へ TCP接続する。接続できなければそのエラーを返す
  • message を書き込み、応答を読み取って文字列として返す。読み取りに失敗したらそのエラーを返す
  • 使い終わったら必ず接続を閉じる(defer conn.Close())

ヒント: 上の runEchoDemo のクライアント側処理(Dial → Write → Read → Close)と ほぼ同じ形です。タイムアウトが心配なら net.DialTimeout や conn.SetDeadline を使っても構いません。

In [4]:
// YOUR CODE HERE
// func sendAndReceive(addr string, message string) (string, error) を実装してください。
// (未実装のままチェックセルを実行すると「未回答」と表示されます)
func sendAndReceive(addr string, message string) (string, error) {
	return "", ErrUnanswered
}

チェックのためのヘルパー¶

チェックでは sendAndReceive を複数回呼びます。1回で終わる runEchoDemo とは別に、 複数の接続を受け続けられるエコーサーバーを用意します。stop() はリスナーを閉じたうえで、 起動していた全 goroutine(受け付けループ + 各接続のハンドラ)の終了を待ってから返ります (ぶら下がり goroutine を残さない)。答え合わせ用のヘルパー mustEqual も定義します。 (GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)

In [5]:
// startPersistentEchoServer は、stop() が呼ばれるまで複数の接続を受け続けるTCPエコーサーバーを起動する。
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
}
In [6]:
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 [7]:
%%
addr, stop := startPersistentEchoServer()
defer stop()

resp1, err1 := sendAndReceive(addr, "ping")
if errors.Is(err1, ErrUnanswered) {
	fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
	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("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください

まとめ¶

  • サーバーは Listen で待ち受け、クライアントは Dial で接続しにいく
  • ポートは同じマシン上のプログラムを区別する番号。ポート0はOSに空きポートを選ばせる指定
  • TCPは接続指向・順序保証あり、UDPは接続レス・順序保証なし
  • goroutine でサーバーを動かすときは、done チャネル + タイムアウト付き待機で ぶら下がり goroutine を残さない

答え合わせは 03.1-client-server-tcp-solutions.ipynb で行ってください。 次は 03.2 で「HTTP解剖」— TCPの上に乗る HTTP の構造を見ます。