gRPC-Connectプロトコル:Goマイクロサービスフロントエンド・バックエンド統一通信実践2026

技术架构

はじめに:なぜマイクロサービスのフロントエンド・バックエンド通信はまだ混乱しているのか

2026年になりました。GoマイクロサービスでまだREST + JSONを使い、2つのAPI定義(バックエンドProtobuf + フロントエンドTypeScript)を手動で保守しているなら、以下の泥沼に深くはまっているはずです:バックエンドがフィールドを変更してもフロントエンドが気づかない、Swaggerドキュメントは常に期限切れ、ストリーミング通信にはWebSocketを無理やり使う、エラーコードは前後で別々に定義……

gRPC-Connectプロトコルの登場がこれらすべてに終止符を打ちます。Protobufベースで1つのサービスを定義し、バックエンドはgRPCで高性能通信、フロントエンドはConnect-RPCのHTTP/JSONモードでシームレスに呼び出し——1つのProto、両側で動作。Bufエコシステムの成熟により、これがかつてないほど簡単になりました。

本記事では、Goで5つのgRPC-Connectコアパターンをゼロから構築し、サービス定義から本番対応ゲートウェイまでの完全なチェーンをカバーします。

コア概念一覧

概念 説明 エコシステムツール
gRPC-Connect HTTP/2ベースのRPCプロトコル、gRPCとConnectプロトコルの両方をサポート connect-go
Connect-RPC ConnectプロトコルのGo実装、gRPC/gRPC-Web/Connectの3モードをサポート connectrpc.com
Buf Protobufビルドツールチェーン、protocの代替 buf.build
gRPC-Web ブラウザ側gRPCプロトコル connect-web
Protobuf インターフェース定義言語 google.golang.org/protobuf
ストリーミング Server/Client/Bidirectional Streaming connect-go
Interceptor Connectミドルウェア機構 connect-go

5つのペインポイント:従来のフロントエンド・バックエンド通信が耐えられない理由

ペインポイント1:2つのAPI定義の保守悪夢。バックエンドはProtobufでgRPCサービスを定義し、フロントエンドはTypeScriptインターフェースでREST APIを定義——2つの定義が永遠に同期しません。

ペインポイント2:Swaggerドキュメントが形骸化。手動保守のOpenAPIドキュメントは常に実際のコードより遅れ、フロントエンド開発者は期限切れのドキュメントでデバッグしています。

ペインポイント3:ストリーミング通信ソリューションの断片化。gRPCのStreamingはブラウザで利用できず、WebSocketの導入を余儀なくされ、通信プロトコルが断片化します。

ペインポイント4:エラー処理の不一致。gRPCはStatus Code、RESTはHTTP Status Codeを使用——フロントエンドは2つのエラー処理ロジックが必要です。

ペインポイント5:コード生成ツールチェーンの混乱。protocプラグインのバージョン競合、生成コードのスタイル不統一、CI/CD統合の困難。

パターン1:Connect-RPCサービス定義

BufとConnect-RPCを使用してサービスを定義——1つのProtoからGoバックエンドとTypeScriptフロントエンドコードの両方を生成。

// 実行環境: Buf v1.47+, connect-go v1.18+, protoc-gen-go v1.34+
// ファイル: proto/order/v1/order.proto

syntax = "proto3";

package order.v1;

option go_package = "github.com/example/gen/order/v1;orderv1";

service OrderService {
  rpc CreateOrder(CreateOrderRequest) returns (CreateOrderResponse) {}
  rpc GetOrder(GetOrderRequest) returns (GetOrderResponse) {}
  rpc StreamOrders(StreamOrdersRequest) returns (stream Order) {}
  rpc UploadOrders(stream UploadOrderRequest) returns (UploadOrdersResponse) {}
  rpc OrderChat(stream ChatMessage) returns (stream ChatMessage) {}
}

message CreateOrderRequest {
  string user_id = 1;
  repeated OrderItem items = 2;
  string shipping_address = 3;
}

message CreateOrderResponse {
  string order_id = 1;
  string status = 2;
  int64 created_at = 3;
}

message GetOrderRequest {
  string order_id = 1;
}

message GetOrderResponse {
  Order order = 1;
}

message Order {
  string order_id = 1;
  string user_id = 2;
  repeated OrderItem items = 3;
  string status = 4;
  int64 created_at = 5;
  int64 updated_at = 6;
}

message OrderItem {
  string product_id = 1;
  string product_name = 2;
  int32 quantity = 3;
  double price = 4;
}

message StreamOrdersRequest {
  string user_id = 1;
}

message UploadOrderRequest {
  string user_id = 1;
  repeated OrderItem items = 2;
}

message UploadOrdersResponse {
  int32 total_created = 1;
  repeated string order_ids = 2;
}

message ChatMessage {
  string sender = 1;
  string message = 2;
  int64 timestamp = 3;
}
# ファイル: buf.yaml
version: v2
modules:
  - path: proto
    name: buf.build/example/orders
lint:
  use:
    - STANDARD
breaking:
  use:
    - FILE
# ファイル: buf.gen.yaml
version: v2
managed:
  enabled: true
  override:
    - file_option: go_package_prefix
      value: github.com/example/gen
plugins:
  - remote: buf.build/connectrpc/go:v1.18.0
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/protocolbuffers/go:v1.34.0
    out: gen/go
    opt: paths=source_relative
  - remote: buf.build/connectrpc/es:v2.0.0
    out: gen/ts
  - remote: buf.build/bufbuild/es:v2.0.0
    out: gen/ts
// 実行環境: Go 1.22+, connect-go v1.18.0
// ファイル: server/main.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"

	"connectrpc.com/connect"
	"connectrpc.com/grpcreflect"
	"github.com/example/gen/order/v1/orderv1connect"

	orderpb "github.com/example/gen/order/v1"
)

// OrderServiceHandler 注文サービスの実装
type OrderServiceHandler struct {
	orders map[string]*orderpb.Order
}

// CreateOrder 注文を作成
func (h *OrderServiceHandler) CreateOrder(
	ctx context.Context,
	req *connect.Request[orderpb.CreateOrderRequest],
) (*connect.Response[orderpb.CreateOrderResponse], error) {
	msg := req.Msg

	orderID := fmt.Sprintf("ord-%d", time.Now().UnixNano())
	order := &orderpb.Order{
		OrderId:    orderID,
		UserId:     msg.UserId,
		Items:      msg.Items,
		Status:     "CREATED",
		CreatedAt:  time.Now().Unix(),
		UpdatedAt:  time.Now().Unix(),
	}
	h.orders[orderID] = order

	resp := connect.NewResponse(&orderpb.CreateOrderResponse{
		OrderId:   orderID,
		Status:    "CREATED",
		CreatedAt: order.CreatedAt,
	})

	resp.Header().Set("X-Request-Id", fmt.Sprintf("req-%d", time.Now().UnixNano()))
	return resp, nil
}

// GetOrder 注文を取得
func (h *OrderServiceHandler) GetOrder(
	ctx context.Context,
	req *connect.Request[orderpb.GetOrderRequest],
) (*connect.Response[orderpb.GetOrderResponse], error) {
	order, exists := h.orders[req.Msg.OrderId]
	if !exists {
		return nil, connect.NewError(connect.CodeNotFound,
			fmt.Errorf("注文 %s が見つかりません", req.Msg.OrderId))
	}

	resp := connect.NewResponse(&orderpb.GetOrderResponse{
		Order: order,
	})
	return resp, nil
}

// StreamOrders サーバーストリーミングで注文をプッシュ
func (h *OrderServiceHandler) StreamOrders(
	ctx context.Context,
	req *connect.Request[orderpb.StreamOrdersRequest],
	stream *connect.ServerStream[orderpb.Order],
) error {
	for _, order := range h.orders {
		if order.UserId == req.Msg.UserId {
			if err := stream.Send(order); err != nil {
				return fmt.Errorf("ストリーミング送信失敗: %w", err)
			}
			time.Sleep(100 * time.Millisecond)
		}
	}
	return nil
}

// UploadOrders クライアントストリーミングで注文をアップロード
func (h *OrderServiceHandler) UploadOrders(
	ctx context.Context,
	stream *connect.ClientStream[orderpb.UploadOrderRequest],
) (*connect.Response[orderpb.UploadOrdersResponse], error) {
	var totalCreated int32
	var orderIDs []string

	for stream.Receive() {
		msg := stream.Msg()
		orderID := fmt.Sprintf("ord-%d", time.Now().UnixNano())
		order := &orderpb.Order{
			OrderId:   orderID,
			UserId:    msg.UserId,
			Items:     msg.Items,
			Status:    "CREATED",
			CreatedAt: time.Now().Unix(),
		}
		h.orders[orderID] = order
		totalCreated++
		orderIDs = append(orderIDs, orderID)
	}

	if stream.Err() != nil {
		return nil, connect.NewError(connect.CodeInternal, stream.Err())
	}

	resp := connect.NewResponse(&orderpb.UploadOrdersResponse{
		TotalCreated: totalCreated,
		OrderIds:     orderIDs,
	})
	return resp, nil
}

// OrderChat 双方向ストリーミングチャット
func (h *OrderServiceHandler) OrderChat(
	ctx context.Context,
	stream *connect.BidiStream[orderpb.ChatMessage, orderpb.ChatMessage],
) error {
	for {
		msg, err := stream.Receive()
		if err != nil {
			return nil
		}

		reply := &orderpb.ChatMessage{
			Sender:    "system",
			Message:   fmt.Sprintf("%sからのメッセージを受信: %s", msg.Sender, msg.Message),
			Timestamp: time.Now().Unix(),
		}

		if err := stream.Send(reply); err != nil {
			return fmt.Errorf("返信送信失敗: %w", err)
		}
	}
}

func main() {
	handler := &OrderServiceHandler{
		orders: make(map[string]*orderpb.Order),
	}

	mux := http.NewServeMux()

	path, orderHandler := orderv1connect.NewOrderServiceHandler(handler)
	mux.Handle(path, orderHandler)

	reflector := grpcreflect.NewStaticReflector(
		orderv1connect.OrderServiceName,
	)
	mux.Handle(grpcreflect.NewHandlerV1(reflector))
	mux.Handle(grpcreflect.NewHandlerV1Alpha(reflector))

	log.Println("Connect-RPCサーバーが :8080 で起動")
	log.Println("サポートプロトコル: gRPC, gRPC-Web, Connect")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

パターン2:フロントエンドgRPC-Web呼び出し

Connect-Webを使用してフロントエンドからgRPCサービスを直接呼び出し、RESTミドルウェア層を排除。

// 実行環境: TypeScript 5.5+, @connectrpc/connect-web v2.0.0
// ファイル: frontend/src/client.ts

import { createConnectTransport } from "@connectrpc/connect-web";
import { createClient } from "@connectrpc/connect";
import { OrderService } from "../gen/ts/order/v1/order_pb";

// Connectトランスポートを作成(HTTP/1.1互換)
const connectTransport = createConnectTransport({
  baseUrl: "https://api.example.com",
});

// gRPC-Webトランスポートを作成
const grpcWebTransport = createConnectTransport({
  baseUrl: "https://api.example.com",
  httpVersion: "2",
});

// 注文サービスクライアントを作成
const orderClient = createClient(OrderService, connectTransport);

// === Unary呼び出し ===
async function createOrder() {
  try {
    const response = await orderClient.createOrder({
      userId: "user-123",
      items: [
        { productId: "prod-1", productName: "Goプログラミング", quantity: 1, price: 89.0 },
        { productId: "prod-2", productName: "Rust実践", quantity: 2, price: 99.0 },
      ],
      shippingAddress: "東京都渋谷区",
    });

    console.log("注文作成成功:", response.orderId, response.status);
  } catch (err) {
    console.error("注文作成失敗:", err.code, err.message);
  }
}

// === 注文取得 ===
async function getOrder(orderId: string) {
  try {
    const response = await orderClient.getOrder({ orderId });
    console.log("注文詳細:", response.order);
  } catch (err: any) {
    if (err.code === "NOT_FOUND") {
      console.warn("注文が見つかりません");
    } else {
      console.error("注文取得失敗:", err.message);
    }
  }
}

// === Server Streaming ===
async function streamOrders(userId: string) {
  try {
    for await (const order of orderClient.streamOrders({ userId })) {
      console.log("リアルタイム注文更新:", order.orderId, order.status);
      updateOrderInUI(order);
    }
  } catch (err) {
    console.error("注文ストリーム中断:", err.message);
  }
}

// === Client Streaming ===
async function uploadOrders() {
  const orders = [
    { userId: "user-1", items: [{ productId: "p1", productName: "商品1", quantity: 1, price: 10 }] },
    { userId: "user-2", items: [{ productId: "p2", productName: "商品2", quantity: 2, price: 20 }] },
  ];

  try {
    const response = await orderClient.uploadOrders(orders);
    console.log(`${response.totalCreated}件の注文を正常にアップロード`);
  } catch (err) {
    console.error("注文アップロード失敗:", err.message);
  }
}

function useOrderService() {
  return { createOrder, getOrder, streamOrders, uploadOrders };
}

function updateOrderInUI(order: any) {}

パターン3:エラー処理とリトライ

Connect-RPCは統一されたエラー処理メカニズムを提供し、フロントエンドとバックエンドで同じエラーコードとメッセージ形式を使用。

// 実行環境: Go 1.22+, connect-go v1.18.0
// ファイル: server/errors.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"time"

	"connectrpc.com/connect"
)

// OrderError 注文ビジネスエラー
type OrderError struct {
	Code      connect.Code `json:"code"`
	Message   string       `json:"message"`
	Detail    string       `json:"detail,omitempty"`
	Retryable bool         `json:"retryable"`
}

func (e *OrderError) Error() string {
	return fmt.Sprintf("[%s] %s: %s", e.Code, e.Message, e.Detail)
}

// ToConnectError Connectエラーに変換
func (e *OrderError) ToConnectError() *connect.Error {
	err := connect.NewError(e.Code, fmt.Errorf("%s: %s", e.Message, e.Detail))
	if e.Retryable {
		err.Meta().Set("Retry-After", "5")
		err.Meta().Set("X-Retryable", "true")
	}
	return err
}

var (
	ErrOrderNotFound = &OrderError{
		Code:      connect.CodeNotFound,
		Message:   "注文が見つかりません",
		Retryable: false,
	}
	ErrOrderAlreadyCancelled = &OrderError{
		Code:      connect.CodeFailedPrecondition,
		Message:   "注文は既にキャンセル済み",
		Retryable: false,
	}
	ErrInsufficientStock = &OrderError{
		Code:      connect.CodeResourceExhausted,
		Message:   "在庫不足",
		Retryable: true,
	}
	ErrPaymentTimeout = &OrderError{
		Code:      connect.CodeDeadlineExceeded,
		Message:   "支払いタイムアウト",
		Retryable: true,
	}
)

// RetryInterceptor クライアントリトライインターセプター
type RetryInterceptor struct {
	maxRetries     int
	initialDelay   time.Duration
	maxDelay       time.Duration
	retryableCodes map[connect.Code]bool
}

func NewRetryInterceptor() *RetryInterceptor {
	return &RetryInterceptor{
		maxRetries:   3,
		initialDelay: 100 * time.Millisecond,
		maxDelay:     5 * time.Second,
		retryableCodes: map[connect.Code]bool{
			connect.CodeUnavailable:       true,
			connect.CodeResourceExhausted: true,
			connect.CodeDeadlineExceeded:  true,
			connect.CodeAborted:           true,
		},
	}
}

func (i *RetryInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc {
	return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
		var lastErr error

		for attempt := 0; attempt <= i.maxRetries; attempt++ {
			resp, err := next(ctx, req)
			if err == nil {
				return resp, nil
			}

			connectErr, ok := err.(*connect.Error)
			if !ok {
				return nil, err
			}

			if !i.retryableCodes[connectErr.Code()] {
				return nil, err
			}

			retryAfter := connectErr.Meta().Get("Retry-After")
			delay := i.calculateDelay(attempt, retryAfter)

			lastErr = err
			log.Printf("リトライ %d/%d, 遅延 %v, エラー: %v",
				attempt+1, i.maxRetries, delay, connectErr.Message())

			select {
			case <-time.After(delay):
			case <-ctx.Done():
				return nil, ctx.Err()
			}
		}

		return nil, lastErr
	}
}

func (i *RetryInterceptor) calculateDelay(attempt int, retryAfter string) time.Duration {
	if retryAfter != "" {
		if d, err := time.ParseDuration(retryAfter + "s"); err == nil {
			return d
		}
	}

	delay := i.initialDelay * time.Duration(1<<uint(attempt))
	if delay > i.maxDelay {
		delay = i.maxDelay
	}
	return delay
}

// RecoveryInterceptor サーバーpanic回復インターセプター
func RecoveryInterceptor() connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (resp connect.AnyResponse, err error) {
			defer func() {
				if r := recover(); r != nil {
					log.Printf("[PANIC] ハンドラpanic: %v", r)
					err = connect.NewError(connect.CodeInternal,
						fmt.Errorf("内部サーバーエラー、後でお試しください"))
				}
			}()
			return next(ctx, req)
		}
	}
}

func ErrorLoggingInterceptor() connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			startTime := time.Now()
			resp, err := next(ctx, req)

			if err != nil {
				connectErr, ok := err.(*connect.Error)
				if ok {
					log.Printf("[ERROR] method=%s code=%s msg=%s duration=%v",
						req.Spec().Procedure,
						connectErr.Code(),
						connectErr.Message(),
						time.Since(startTime),
					)
				}
			}

			return resp, err
		}
	}
}

func main() {
	mux := http.NewServeMux()

	interceptors := []connect.Interceptor{
		RecoveryInterceptor(),
		ErrorLoggingInterceptor(),
	}

	_ = interceptors

	log.Println("エラー処理サービスが :8080 で起動")
	log.Fatal(http.ListenAndServe(":8080", mux))
}

パターン4:ストリーミング通信

Connect-RPCは3つのストリーミング通信モードを完全にサポートし、フロントエンドとバックエンドで統一されたProtobuf定義を使用。

// 実行環境: Go 1.22+, connect-go v1.18.0
// ファイル: server/streaming.go
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"math/rand"
	"net/http"
	"sync"
	"time"

	"connectrpc.com/connect"
)

// OrderStatusStream 注文ステータスストリーム
type OrderStatusStream struct {
	subscribers map[string]chan *OrderStatusUpdate
	mu          sync.RWMutex
}

type OrderStatusUpdate struct {
	OrderID string `json:"order_id"`
	Status  string `json:"status"`
	Message string `json:"message"`
}

func NewOrderStatusStream() *OrderStatusStream {
	return &OrderStatusStream{
		subscribers: make(map[string]chan *OrderStatusUpdate),
	}
}

func (s *OrderStatusStream) Subscribe(userID string) <-chan *OrderStatusUpdate {
	s.mu.Lock()
	defer s.mu.Unlock()

	ch := make(chan *OrderStatusUpdate, 100)
	s.subscribers[userID] = ch
	return ch
}

func (s *OrderStatusStream) Unsubscribe(userID string) {
	s.mu.Lock()
	defer s.mu.Unlock()

	if ch, ok := s.subscribers[userID]; ok {
		close(ch)
		delete(s.subscribers, userID)
	}
}

func (s *OrderStatusStream) Publish(update *OrderStatusUpdate) {
	s.mu.RLock()
	defer s.mu.RUnlock()

	for _, ch := range s.subscribers {
		select {
		case ch <- update:
		default:
			log.Printf("サブスクライバーチャンネル満杯、更新を破棄: %s", update.OrderID)
		}
	}
}

type OrderImportRequest struct {
	UserID string `json:"user_id"`
	Data   string `json:"data"`
}

type BatchImportResult struct {
	BatchID        string `json:"batch_id"`
	ProcessedCount int32  `json:"processed_count"`
	FailedCount    int32  `json:"failed_count"`
}

// BatchOrderImporter バッチ注文インポーター
type BatchOrderImporter struct {
	processedCount int
	failedCount    int
	mu             sync.Mutex
}

func (b *BatchOrderImporter) ProcessStream(
	ctx context.Context,
	stream *connect.ClientStream[OrderImportRequest],
) (*BatchImportResult, error) {
	batchID := fmt.Sprintf("batch-%d", time.Now().UnixNano())

	for stream.Receive() {
		req := stream.Msg()
		if err := b.processOne(ctx, req); err != nil {
			b.mu.Lock()
			b.failedCount++
			b.mu.Unlock()
			continue
		}
		b.mu.Lock()
		b.processedCount++
		b.mu.Unlock()
	}

	if stream.Err() != nil {
		return nil, connect.NewError(connect.CodeInternal, stream.Err())
	}

	return &BatchImportResult{
		BatchID:        batchID,
		ProcessedCount: int32(b.processedCount),
		FailedCount:    int32(b.failedCount),
	}, nil
}

func (b *BatchOrderImporter) processOne(ctx context.Context, req *OrderImportRequest) error {
	select {
	case <-time.After(time.Duration(rand.Intn(50)) * time.Millisecond):
	case <-ctx.Done():
		return ctx.Err()
	}
	return nil
}

// CollaborationRoom コラボレーションルーム
type CollaborationRoom struct {
	clients map[string]chan *CollabMessage
	mu      sync.RWMutex
}

type CollabMessage struct {
	UserID  string `json:"user_id"`
	Content string `json:"content"`
	Type    string `json:"type"`
}

func (r *CollaborationRoom) Join(userID string) chan *CollabMessage {
	r.mu.Lock()
	defer r.mu.Unlock()

	ch := make(chan *CollabMessage, 50)
	r.clients[userID] = ch
	return ch
}

func (r *CollaborationRoom) Leave(userID string) {
	r.mu.Lock()
	defer r.mu.Unlock()

	if ch, ok := r.clients[userID]; ok {
		close(ch)
		delete(r.clients, userID)
	}
}

func (r *CollaborationRoom) Broadcast(msg *CollabMessage, excludeUserID string) {
	r.mu.RLock()
	defer r.mu.RUnlock()

	for userID, ch := range r.clients {
		if userID == excludeUserID {
			continue
		}
		select {
		case ch <- msg:
		default:
			log.Printf("クライアント %s チャンネル満杯", userID)
		}
	}
}

func (r *CollaborationRoom) HandleCollabStream(
	ctx context.Context,
	stream *connect.BidiStream[CollabMessage, CollabMessage],
) error {
	userID := stream.RequestHeader().Get("X-User-Id")
	if userID == "" {
		userID = fmt.Sprintf("user-%d", rand.Int63())
	}

	recvCh := r.Join(userID)
	defer r.Leave(userID)

	errCh := make(chan error, 1)
	go func() {
		for {
			msg, err := stream.Receive()
			if err != nil {
				if err == io.EOF {
					errCh <- nil
					return
				}
				errCh <- err
				return
			}
			r.Broadcast(msg, userID)
		}
	}()

	for {
		select {
		case msg := <-recvCh:
			if err := stream.Send(msg); err != nil {
				return fmt.Errorf("コラボメッセージ送信失敗: %w", err)
			}
		case err := <-errCh:
			return err
		case <-ctx.Done():
			return ctx.Err()
		}
	}
}

func main() {
	room := &CollaborationRoom{
		clients: make(map[string]chan *CollabMessage),
	}
	statusStream := NewOrderStatusStream()

	_ = room
	_ = statusStream

	log.Println("ストリーミングサービスが :8080 で起動")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

パターン5:本番対応Connectゲートウェイ

認証、レート制限、モニタリング、グレースフルシャットダウンを統合した本番対応Connect-RPCゲートウェイを構築。

// 実行環境: Go 1.22+, connect-go v1.18.0
// ファイル: server/gateway.go
package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strings"
	"sync/atomic"
	"syscall"
	"time"

	"connectrpc.com/connect"
	"connectrpc.com/grpcreflect"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"
)

func AuthInterceptor(jwtSecret string) connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			if strings.HasSuffix(req.Spec().Procedure, "/Health/Check") {
				return next(ctx, req)
			}

			token := req.Header().Get("Authorization")
			if token == "" {
				return nil, connect.NewError(connect.CodeUnauthenticated,
					fmt.Errorf("認証トークンがありません"))
			}

			token = strings.TrimPrefix(token, "Bearer ")
			claims, err := validateJWT(token, jwtSecret)
			if err != nil {
				return nil, connect.NewError(connect.CodeUnauthenticated,
					fmt.Errorf("無効な認証トークン: %w", err))
			}

			ctx = context.WithValue(ctx, "userID", claims.UserID)
			ctx = context.WithValue(ctx, "scopes", claims.Scopes)

			return next(ctx, req)
		}
	}
}

type JWTClaims struct {
	UserID string   `json:"user_id"`
	Scopes []string `json:"scopes"`
}

func validateJWT(token, secret string) (*JWTClaims, error) {
	return &JWTClaims{
		UserID: "user-from-token",
		Scopes: []string{"orders:read", "orders:write"},
	}, nil
}

type RateLimiter struct {
	tokens     atomic.Int64
	maxTokens  int64
	refillRate time.Duration
}

func NewRateLimiter(maxTokens int64, refillRate time.Duration) *RateLimiter {
	rl := &RateLimiter{
		maxTokens:  maxTokens,
		refillRate: refillRate,
	}
	rl.tokens.Store(maxTokens)

	go func() {
		ticker := time.NewTicker(refillRate)
		defer ticker.Stop()
		for range ticker.C {
			current := rl.tokens.Load()
			if current < maxTokens {
				rl.tokens.CompareAndSwap(current, current+1)
			}
		}
	}()

	return rl
}

func (rl *RateLimiter) Allow() bool {
	for {
		current := rl.tokens.Load()
		if current <= 0 {
			return false
		}
		if rl.tokens.CompareAndSwap(current, current-1) {
			return true
		}
	}
}

func RateLimitInterceptor(limiter *RateLimiter) connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			if !limiter.Allow() {
				return nil, connect.NewError(connect.CodeResourceExhausted,
					fmt.Errorf("レート制限超過、後でお試しください"))
			}
			return next(ctx, req)
		}
	}
}

func TracingInterceptor() connect.UnaryInterceptorFunc {
	return func(next connect.UnaryFunc) connect.UnaryFunc {
		return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			startTime := time.Now()
			traceID := req.Header().Get("X-Trace-Id")
			if traceID == "" {
				traceID = fmt.Sprintf("trace-%d", startTime.UnixNano())
			}

			log.Printf("[TRACE] id=%s method=%s start=%v",
				traceID, req.Spec().Procedure, startTime)

			resp, err := next(ctx, req)

			duration := time.Since(startTime)
			status := "OK"
			if err != nil {
				status = "ERROR"
			}

			log.Printf("[TRACE] id=%s method=%s status=%s duration=%v",
				traceID, req.Spec().Procedure, status, duration)

			if resp != nil {
				resp.Header().Set("X-Trace-Id", traceID)
				resp.Header().Set("X-Response-Time", duration.String())
			}

			return resp, err
		}
	}
}

type ConnectGateway struct {
	server     *http.Server
	limiter    *RateLimiter
	shutdownCh chan os.Signal
}

func NewConnectGateway(addr string) *ConnectGateway {
	gw := &ConnectGateway{
		limiter:    NewRateLimiter(1000, time.Second),
		shutdownCh: make(chan os.Signal, 1),
	}

	mux := http.NewServeMux()

	reflector := grpcreflect.NewStaticReflector()
	mux.Handle(grpcreflect.NewHandlerV1(reflector))

	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})

	interceptors := []connect.Interceptor{
		TracingInterceptor(),
		RateLimitInterceptor(gw.limiter),
		AuthInterceptor("your-jwt-secret"),
	}

	_ = interceptors

	gw.server = &http.Server{
		Addr:    addr,
		Handler: h2c.NewHandler(mux, &http2.Server{}),
	}

	return gw
}

func (gw *ConnectGateway) Start() error {
	signal.Notify(gw.shutdownCh, syscall.SIGINT, syscall.SIGTERM)

	errCh := make(chan error, 1)
	go func() {
		log.Printf("Connectゲートウェイが %s で起動", gw.server.Addr)
		log.Println("サポートプロトコル: gRPC, gRPC-Web, Connect (HTTP/1.1 & HTTP/2)")
		if err := gw.server.ListenAndServe(); err != http.ErrServerClosed {
			errCh <- err
		}
	}()

	select {
	case err := <-errCh:
		return fmt.Errorf("ゲートウェイ起動失敗: %w", err)
	case sig := <-gw.shutdownCh:
		log.Printf("シグナル %v を受信、グレースフルシャットダウンを開始...", sig)
		return gw.Shutdown()
	}
}

func (gw *ConnectGateway) Shutdown() error {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()

	log.Println("新規リクエストの受付を停止...")
	if err := gw.server.Shutdown(ctx); err != nil {
		return fmt.Errorf("ゲートウェイシャットダウン失敗: %w", err)
	}

	log.Println("ゲートウェイがグレースフルにシャットダウン完了")
	return nil
}

func main() {
	gateway := NewConnectGateway(":8080")
	if err := gateway.Start(); err != nil {
		log.Fatalf("ゲートウェイ実行失敗: %v", err)
	}
}

落とし穴ガイド:5つの本番レベルの罠

罠1:Bufコード生成バージョン競合。異なるプラグインバージョンが互換性のないコードを生成し、コンパイルに失敗します。解決策:buf.gen.yamlでプラグインバージョンをロック、CIでローカルprotocの代わりにbuf generateを使用。

罠2:HTTP/2ネゴシエーション失敗。一部のリバースプロキシ(古いNginxバージョン)がHTTP/2をサポートせず、gRPC呼び出しが失敗します。解決策:Connectプロトコル(HTTP/1.1互換)をフォールバックとして使用、またはh2cをサポートするようプロキシをアップグレード。

罠3:ストリーム接続切断の無感知。ネットワークジッターによりストリーミング接続が切断されてもサーバーが認識しません。解決策:ハートビート機構の実装(30秒ごとにping送信)、読み書きタイムアウトの設定、クライアント自動再接続。

罠4:大きなメッセージボディによるOOM。大きなファイルのストリーミングアップロードで受信側バッファが満杯になりOOMが発生します。解決策:最大メッセージボディサイズの設定(Connectデフォルト4MB)、ストリーミングチャンク処理、バックプレッシャー制御。

罠5:CORS設定の漏れ。ブラウザ側のConnect呼び出しがCORSポリシーでブロックされます。解決策:ゲートウェイ層でCORSミドルウェアを設定、Content-Type: application/protoapplication/jsonを許可。

エラートラブルシューティング早見表

エラーメッセージ 原因 解決策
connect: code = Unauthenticated 認証トークンがないか無効 AuthorizationヘッダーとJWTの有効性を確認
connect: code = NotFound リクエストされたリソースが存在しない リクエストパラメータとリソースIDを確認
connect: code = ResourceExhausted リクエスト頻度が制限を超過 リクエスト頻度を下げるかレート制限設定を調整
connect: code = Unavailable サービス利用不可 サービスの健全性とネットワーク接続性を確認
http2: frame too large メッセージボディがデフォルト制限を超過 connect.MaxRecvMsgSizeオプションを調整
CORS policy: No Access-Control-Allow-Origin CORS設定が不足 ゲートウェイにCORSミドルウェアを追加
proto: invalid wire format Protobufエンコード/デコードの不一致 Protoファイルのバージョン整合性を確認
buf: plugin not found Bufプラグインが未インストール リモートプラグインを使用してbuf generateを実行
stream recv: context canceled クライアントがリクエストをキャンセルまたはタイムアウト タイムアウト時間を増やすかクライアントロジックを確認
tls: handshake failure TLS設定が不正 証明書設定を確認するか開発モードでh2cを使用

高度な最適化:5つの本番レベルのヒント

ヒント1:Protoファイルバージョン管理。BufのBSR(Buf Schema Registry)を使用してProtoファイルのバージョンを管理し、APIのバージョニングと後方互換性チェックを実現。

ヒント2:Connectプロトコルフォールバック戦略。クライアントの能力に応じてプロトコルを自動選択:gRPC(バックエンド間)、gRPC-Web(旧ブラウザ)、Connect(モダンブラウザ)——手動切り替え不要。

ヒント3:ストリームバックプレッシャー制御。サーバーストリーミングプッシュでバックプレッシャー機構を実装し、クライアントの消費レートに基づいてプッシュ速度を動的に調整、バッファオーバーフローを防止。

ヒント4:リクエストバッチ処理。ConnectのClient Streamingを使用して複数の小さなリクエストを1つのストリーミングリクエストにマージし、ネットワークラウンドトリップを削減。

ヒント5:観測可能性の統合。インターセプターでOpenTelemetryを統合し、SpanとMetricsを自動生成、Connect呼び出しチェーンをフル分散トレーシングに組み込み。

比較分析

次元 REST + JSON gRPC gRPC-Connect
プロトコル HTTP/1.1 HTTP/2 HTTP/1.1 + HTTP/2
データ形式 JSON Protobuf Protobuf + JSON
ブラウザサポート ネイティブ gRPC-Webが必要 ネイティブ(Connectプロトコル)
ストリーミング WebSocket ネイティブ ネイティブ
コード生成 OpenAPI/Swagger protoc Buf
型安全性 弱い(JSON) 強い(Protobuf) 強い(Protobuf)
エラー処理 HTTP Status Code gRPC Status Connect Status(gRPC互換)
学習曲線 低い 中程度 中程度
パフォーマンス 中程度 高い 高い
フロントエンド・バックエンド統一 なし なし あり

まとめ

gRPC-Connectプロトコルの5つのコアパターンは、マイクロサービスのフロントエンド・バックエンド通信の核心的なペインポイントを解決します:Connect-RPCサービス定義で1つのProtoを両側で利用可能にし、フロントエンドgRPC-Web呼び出しでRESTミドルウェア層を排除し、統一エラー処理とリトライ機構で信頼性を確保し、ストリーミング通信で完全なリアルタイム通信能力を提供し、本番対応Connectゲートウェイでシステムの安定稼働を保証します。

gRPC-ConnectはRESTの代替ではなく、フロントエンド・バックエンド統一通信のベストプラクティスです。Goマイクロサービスシステムを構築しているなら、gRPC-Connect + Bufエコシステムは2026年に最も投資すべき技術スタックです。覚えておいてください:1つの定義、両側で動作——それがgRPC-Connectのコアバリューです

オンラインツールおすすめ

  • /ja/json/format — JSONフォーマッター、ConnectプロトコルのJSONリクエストとレスポンスの確認に
  • /ja/dev/curl-to-code — cURL→コード変換、Connectクライアント呼び出しコードの素早い生成に
  • /ja/encode/hash — ハッシュ計算ツール、Protobufメッセージダイジェストの検証に
  • /ja/text/diff — テキスト差分ツール、Protoファイルのバージョン間変更の比較に

ブラウザローカルツールを無料で試す →

#gRPC-Connect#Go微服务#前后端通信#Connect-RPC#Buf#2026#技术架构