03.1 クライアント・サーバーモデルと TCP — ネットワーク通信の基本単位¶
ここまでの Module 1(OOP)・Module 2(データベース)は、すべて1つのプロセスの中で完結していました。 Module 3 からは、2つの別々のプログラム(クライアントとサーバー)がネットワーク越しに会話する世界に入ります。
- サーバー: 接続を「待ち受け」、来たリクエストに応答するプログラム
- クライアント: サーバーへ「接続しにいき」、リクエストを送るプログラム
- ポート: 同じマシン上で複数のプログラムを区別するための番号(0〜65535)。IPアドレスが 「建物の住所」なら、ポートは「部屋番号」に相当します
このレッスンのゴール:
- クライアント・サーバーモデルの役割分担を説明できる
- TCP(信頼できる・順序保証あり・接続指向)とUDP(信頼保証なし・接続レス)の違いを説明できる
- Go の
netパッケージで、実際に TCP のエコー(送った文字列がそのまま返る)サーバーとクライアントを動かせる
非自明ポイント¶
net.Listen("tcp", "127.0.0.1:0")のポート0は「OSに空いているポートを選ばせる」という意味です。 実運用のサーバーは決まったポート(例: 8080)を使いますが、教材やテストでは「今どのポートが 空いているか」を気にせず済むポート0が便利です。実際に使われたポートはln.Addr()から取得します。- TCP接続は「コネクション」という状態を持ちます。
net.Dialで接続すると、サーバー側ではListener.Accept()が1つのnet.Connを返します。このConnは双方向のバイトストリームで、Writeで送った内容が相手のReadで(順序を保ったまま)読めることが TCP の保証です。 UDP にはこの保証がありません(届く順序が入れ替わったり、届かなかったりし得ます)。 Readは「送った分だけ返ってくる」とは限りません。 TCPはバイトストリームなので、 1回のWriteが複数回のReadに分かれて届くことがあります(このレッスンの短いメッセージでは 1回で収まりますが、実務では「区切り」をアプリケーション側で決める必要があります)。- ぶら下がり goroutine を残さない。 サーバーを goroutine で起動したら、
doneチャネルのcloseで終了を通知し、呼び出し元は終了を待ってからセルを終えます(無限に待たず、 タイムアウト付きで待ちます)。
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つの関数にまとめます。
// 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
}
%%
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.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 |
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 を使っても構いません。
// 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 できないため、各ノートブックにこの定義を置いています)
// 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
}
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))
}
%%
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 の構造を見ます。