← レッスン一覧に戻る

03.4 RPC — 遠隔手続き呼び出し¶

RPC(Remote Procedure Call) は、「別のプロセス(多くの場合は別のマシン)にある関数を、まるで ローカルの関数のように呼び出す」仕組みです。呼び出す側は client.Call("MathService.Add", args, &reply) のように書くだけで、実際にはネットワーク越しに引数がシリアライズされ、相手側でメソッドが実行され、 戻り値がまた送り返されてきます。

このレッスンのゴール:

  • Go の net/rpc で、サーバー側に登録したメソッドをクライアント側から Call で呼び出せる
  • RPC 境界を越えると、エラーの「型」ではなく「文字列」しか相手に伝わらないことを確認する
  • RPC 通信を1セル内で正しく開始・終了させる(ぶら下がり goroutine を作らない)

1. 非自明な点①: このレッスンは net.Pipe() を使う(実ネットワークではない)¶

03.1 では実際の TCP ソケット(net.Listen/net.Dial)でクライアント・サーバー通信を体験しました。 このレッスンでは代わりに net.Pipe()(同一プロセス内のインメモリな双方向接続)をトランスポートに 使います。

  • net.Pipe() は Go 標準ライブラリの実在の機能で、net.Conn と同じインターフェース(io.ReadWriteCloser) を満たします。net/rpc のクライアント/サーバー API(Register/NewClient/Call)は、実TCP版と 完全に同じ書き方になります
  • ただし net.Pipe() は同一プロセス・同一メモリ内の通信です。「別マシンに実際に転送される」体験 そのものは 03.1(実TCP)が担い、このレッスンが体験させるのは RPC の呼び出し境界(登録・ 引数のシリアライズ相当・dispatch・Call)です。「ローカル呼び出しと同じ書き方で、実行主体だけが 切り離される」という RPC の本質は、トランスポートが Pipe でも TCP でも変わりません

2. 非自明な点②: net.Pipe() は「クライアントが閉じるまでブロックし続ける」¶

net.Pipe() は同期・無バッファの接続です。サーバー側の rpc.ServeConn(conn) は、 そのコネクションが閉じられるまでずっとリクエストを待ち続けます(=ゴルーチンが終わりません)。 そのため、このレッスンの各実行セルは必ず次の手順を1セル完結で行います:

  1. net.Pipe() でクライアント側・サーバー側の接続を作る
  2. サーバーを goroutine で起動し、終了を知らせる done チャネルを defer close(done) で仕込む
  3. クライアントから Call を呼ぶ
  4. client.Close() でクライアント側を明示的に閉じる(これでサーバー側の ServeConn のブロックが解ける)
  5. done を有限の時間だけ待つ(select + time.After)。無限待機にはしない
In [1]:
import (
	"errors"
	"fmt"
	"net"
	"net/rpc"
	"strings"
	"time"

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

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

// MathService は RPC 経由で公開する計算サービス。
// net/rpc のルール: エクスポートされたメソッドは func (t *T) M(args T1, reply *T2) error の形。
type MathService struct{}

// AddArgs は Add メソッドの引数。
type AddArgs struct {
	A, B float64
}

// Add は2数の和を計算する。
func (m *MathService) Add(args AddArgs, reply *float64) error {
	*reply = args.A + args.B
	return nil
}

// DivideArgs は Divide メソッドの引数。
type DivideArgs struct {
	A, B float64
}

// Divide は2数の商を計算する。ゼロ除算はエラーを返す。
func (m *MathService) Divide(args DivideArgs, reply *float64) error {
	if args.B == 0 {
		return errors.New("division by zero")
	}
	*reply = args.A / args.B
	return nil
}

// newPipeRPC は net.Pipe() 越しに MathService を公開したサーバーと、
// それに繋がったクライアントを1組作る。呼び出し側は必ず client.Close() → <-done(有限待機)まで行うこと。
func newPipeRPC() (client *rpc.Client, done chan struct{}) {
	server := rpc.NewServer() // rpc.Register ではなく rpc.NewServer を使う: セル再実行のたびに
	// 新しいサーバーを作ることで「同じ型を二重登録した」エラーを避けられる。
	if err := server.Register(new(MathService)); err != nil {
		panic(err)
	}

	clientConn, serverConn := net.Pipe()
	done = make(chan struct{})
	go func() {
		defer close(done)
		server.ServeConn(serverConn) // clientConn が閉じられるまでブロックする
	}()

	client = rpc.NewClient(clientConn)
	return client, done
}

// waitDone は done チャネルを有限時間だけ待つ。タイムアウトしたら警告を表示する。
func waitDone(done chan struct{}) {
	select {
	case <-done:
		// 正常終了。
	case <-time.After(2 * time.Second):
		fmt.Println("⚠️ サーバーgoroutineがタイムアウトした(client.Close()が呼ばれたか確認してください)")
	}
}

func renderRPCCalls(rows [][4]string) 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 _, r := range rows {
		b.WriteString(fmt.Sprintf(`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td></tr>`, r[0], r[1], r[2], r[3]))
	}
	b.WriteString(`</table>`)
	return b.String()
}

3. RPC で Add と Divide を呼ぶ¶

サーバー側の MathService を起動し、クライアント側から2回 Call します。 client.Call("MathService.Add", args, &reply) の書き方は、ローカルで s.Add(args, &reply) を 呼ぶのとほとんど変わらないことに注目してください。

In [2]:
%%
client, done := newPipeRPC()

var sumResult float64
addErr := client.Call("MathService.Add", AddArgs{A: 3, B: 4}, &sumResult)
fmt.Printf("Add(3, 4) = %v (err=%v)\n", sumResult, addErr)

var quotResult float64
divErr := client.Call("MathService.Divide", DivideArgs{A: 10, B: 2}, &quotResult)
fmt.Printf("Divide(10, 2) = %v (err=%v)\n", quotResult, divErr)

client.Close()
waitDone(done)
Add(3, 4) = 7 (err=<nil>)
Divide(10, 2) = 5 (err=<nil>)

4. 非自明な点③: RPC境界を越えるとエラーの「型」は消え「文字列」だけが残る¶

ゼロ除算のようなサーバー側のエラーを、RPC越しに受け取ってみます。

In [3]:
%%
client, done := newPipeRPC()

var zeroResult float64
zeroErr := client.Call("MathService.Divide", DivideArgs{A: 10, B: 0}, &zeroResult)
fmt.Printf("Divide(10, 0) の結果: %v, エラー: %v (型: %T)\n", zeroResult, zeroErr, zeroErr)

client.Close()
waitDone(done)
Divide(10, 0) の結果: 0, エラー: division by zero (型: rpc.ServerError)

net/rpc は、サーバー側で発生した error をそのままの型では送れません(ネットワーク越しに Go の型情報は転送できないため)。実際に転送されるのは err.Error() の文字列だけで、 クライアント側では rpc.ServerError という別の型に包み直されます。つまり:

  • ✅ divErr.Error() でメッセージの中身は読める
  • ❌ errors.Is(divErr, someSentinelErr) は成立しません(元のエラー値との同一性は失われている)

この後の練習問題でも、この性質を踏まえて「文字列比較」で未回答を判定します。

In [4]:
%%
gonbui.DisplayHTML(renderRPCCalls([][4]string{
	{"MathService.Add", "{A:3, B:4}", "7", "nil"},
	{"MathService.Divide", "{A:10, B:2}", "5", "nil"},
	{"MathService.Divide", "{A:10, B:0}", "0", "division by zero(rpc.ServerError型)"},
}))
gonbui.Sync()
メソッド引数戻り値エラー
MathService.Add{A:3, B:4}7nil
MathService.Divide{A:10, B:2}5nil
MathService.Divide{A:10, B:0}0division by zero(rpc.ServerError型)

5. 直感・類推¶

RPC は「電話でリモートの担当者に計算を頼む」ことに似ています。電話をかける側は 「3足す4は?」と聞くだけで、相手が暗算しようが電卓を使おうが気にしません(呼び出し方が ローカル関数と同じ)。ただし電話越しでは、相手が「ゼロで割ろうとしたらエラーになった」と 言葉で伝えてくることはできても、エラーオブジェクトそのものを渡すことはできません (エラーは文字列に落ちる)。

net.Pipe() はこの電話を「同じ部屋にいる二人が紙を回して会話している」状態に置き換えたもの です。会話の作法(Call の書き方)は実際の電話(実TCP、03.1)と同じですが、声は物理的に外へは 出ていきません。

練習問題 3.4: Multiply メソッドを実装しよう¶

MathService に Multiply(掛け算)メソッドを追加してください。

仕様:

func (m *MathService) Multiply(args AddArgs, reply *float64) error
  • args.A * args.B を *reply にセットする
  • エラーは返さない(常に nil)
In [5]:
// YOUR CODE HERE
// MathService に Multiply メソッドを実装してください。
// (未実装のままだと ErrUnanswered を返します。RPC越しではエラーの「型」ではなく
//   「文字列」だけが届くため、下のチェックセルは文字列比較で未回答を判定します)
func (m *MathService) Multiply(args AddArgs, reply *float64) error {
	return ErrUnanswered
}

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

答え合わせに使う小さなヘルパー mustEqual を定義します。 (GoNB はローカルパッケージを import できないため、各ノートブックにこの定義を置いています)

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]:
%%
client, done := newPipeRPC()

var product float64
mulErr := client.Call("MathService.Multiply", AddArgs{A: 6, B: 7}, &product)

client.Close()
waitDone(done)

// RPC越しでは errors.Is が使えない(§4参照)ため、文字列で未回答を判定する。
if mulErr != nil && mulErr.Error() == ErrUnanswered.Error() {
	fmt.Println("⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください")
} else {
	mustEqual(mulErr, nil, "Multiply はエラーなし")
	mustEqual(product, float64(42), "Multiply(6, 7) = 42")
	fmt.Println("🎉 すべてのチェックが通りました")
}
⚠️ 未回答: 練習問題を解いてから、このセルを再度実行してください

まとめ¶

  • net/rpc はエクスポートされたメソッド(func (t *T) M(args T1, reply *T2) error)を登録し、 クライアントから Call("Type.Method", args, &reply) で呼び出す
  • トランスポートは実TCP(03.1)でも net.Pipe() でも同じ API で書ける。呼び出しの書き方が 変わらないことが RPC の本質
  • RPC境界を越えるとエラーは型ではなく文字列になる(errors.Is は使えない)
  • net.Pipe() はクライアントが閉じるまでブロックし続けるため、client.Close() → 有限待機の 終了契約を必ず書く

次は Module 4 で「API設計とアーキテクチャ」を扱います。