Goで堅牢なBFFを構築し、マイクロサービスを統合する
James Reed
Infrastructure Engineer · Leapcell

はじめに
進化し続ける現代のソフトウェアアーキテクチャの領域において、マイクロサービスは、スケーラブルで回復力があり、独立してデプロイ可能なアプリケーションを構築するための事実上の標準となっています。マイクロサービスの利点は否定できませんが、特にフロントエンド開発において、新たな課題をもたらします。単一のUIページは、多くの場合、複数の個別のマイクロサービスからデータを取得する必要があります。これにより、クライアントは多数のリクエストを送信し、レイテンシを増加させ、データ集約を複雑にし、フロントエンドと個々のマイクロサービス間の厳密な結合を生み出す「おしゃべりな」フロントエンドにつながる可能性があります。
これがまさに、Backend for Frontend(BFF)パターンが輝く場所です。BFFは、特に特定のフロントエンド(Web、モバイルなど)向けに調整された仲介レイヤーとして機能し、さまざまなダウンストリームマイクロサービスからデータを集約し、クライアントが直接消費できる形式に整形します。フロントエンドとマイクロサービスアーキテクチャの複雑さとの間の結合を解除し、フロントエンド開発を簡素化し、ネットワーク通信を最適化します。Goは、優れた同時実行プリミティブ、高いパフォーマンス、堅牢な標準ライブラリを備えており、このような重要なコンポーネントを構築するのに理想的な選択肢です。この記事では、Goを使用してダウンストリームマイクロサービスを統合するための強力で効率的なBFFレイヤーを構築する方法を掘り下げます。
BFFパターンの解明
実装の詳細に入る前に、BFFパターンとそのマイクロサービスエコシステムにおける役割に関連するいくつかのコアコンセプトを明確にしましょう。
マイクロサービス: アプリケーションを、疎結合され、独立してデプロイ可能なサービスのコレクションとして構造化するアーキテクチャスタイル。各サービスは通常、単一のビジネス機能に焦点を当てます。
Backend for Frontend(BFF): 特定のユーザーインターフェイス(UI)またはフロントエンドアプリケーションによって消費されるために構築されたバックエンドサービスのデザインパターン。単一の汎用バックエンドの代わりに、特定のクライアント(例:Web用、iOS用、Android用)に最適化された複数のBFFが存在する場合があります。
APIゲートウェイ: マイクロサービスシステムへのすべてのクライアントの単一のエントリポイント。ルーティング、認証、承認、レート制限、その他のクロスカッティングコンサーンを処理できます。BFFはAPIゲートウェイの機能を一部組み込むことができますが、その主な焦点は特定のフロントエンドのデータ集約と変換にありますが、APIゲートウェイはより汎用的であり、中央プロキシとして機能します。多くの場合、BFFはAPIゲートウェイの背後に配置されます。
ダウンストリームマイクロサービス: BFFがデータを取得および集約するためにやり取りする個々のマイクロサービス。
BFFのコアアイデアは、フロントエンド開発を簡素化する、統一されたクライアント固有のAPIを提供することです。フロントエンドが5つの異なるマイクロサービスを認識して呼び出す代わりに、BFFに1回の呼び出しを行い、BFFはその5つのサービスへの呼び出しをオーケストレーションし、結果を集約し、単一の構造化された応答を返します。
BFFとしてのGoの選択
Goの強みは、高性能BFFの要件に完璧に合致しています。
- 同時実行性(ゴルーチン&チャネル): BFFは多くの場合、さまざまなダウンストリームサービスに対して複数の同時リクエストを行う必要があります。Goの軽量なゴルーチンとチャネルは、同時実行プログラミングを非常に簡単かつ効率的にし、BFFが並列でデータを取得し、全体的な応答時間を大幅に短縮できるようにします。
- パフォーマンス: Goはネイティブマシンコードにコンパイルされるため、優れた実行時パフォーマンスと低レイテンシが実現され、応答を迅速に行う必要がある仲介サービスにとって重要です。
- 強力なネットワークサポート: Goの
net/http
パッケージは強力で使いやすく、堅牢なHTTPサーバーとクライアントを構築するために必要なすべてを提供します。 - シンプルさと可読性: Goの構文は簡潔で読みやすく、開発速度と保守性を向上させます。
- 小さなフットプリント: Goバイナリは静的にリンクされ、メモリフットプリントが比較的小さいため、コンテナ化された環境に効率的にデプロイできます。
Goでの基本的なBFFの実装
実例で概念を説明しましょう。eコマースアプリケーションを想像してください。製品詳細ページには以下を表示する必要があります。
- 製品の基本情報(
Product Service
から) - 顧客レビュー(
Review Service
から) - 利用可能な在庫(
Inventory Service
から)
BFFがない場合、フロントエンドは3つの別々のHTTPリクエストを行います。BFFを使用すると、1回の呼び出しを行います。
プロジェクトセットアップ
まず、Goモジュールを初期化します。
mkdir product-bff && cd product-bff go mod init product-bff
ダウンストリームサービスモックアップ
デモンストレーションのために、ダウンストリームサービスをモックするシンプルなGo HTTPサーバーを使用します。実際の世界では、これらは実際のマイクロサービスになります。
product_service/main.go
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) type Product struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Price int `json:"price"` } func main() { http.HandleFunc("/products/", func(w http.ResponseWriter, r *http.Request) { id := r.URL.Path[len("/products/"):] if id == "" { http.Error(w, "Product ID required", http.StatusBadRequest) return } // レイテンシをシミュレート time.Sleep(50 * time.Millisecond) product := Product{ ID: id, Name: fmt.Sprintf("Awesome Gadget %s", id), Description: "This is an awesome gadget that will change your life!", Price: 9999, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(product) }) log.Println("Product Service running on :8081") log.Fatal(http.ListenAndServe(":8081", nil)) }
review_service/main.go
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) type Review struct { ProductID string `json:"productId"` Rating int `json:"rating"` Comment string `json:"comment"` Author string `json:"author"` } func main() { http.HandleFunc("/reviews/", func(w http.ResponseWriter, r *http.Request) { productID := r.URL.Path[len("/reviews/"):] if productID == "" { http.Error(w, "Product ID required", http.StatusBadRequest) return } // レイテンシをシミュレート time.Sleep(80 * time.Millisecond) reviews := []Review{ {ProductID: productID, Rating: 5, Comment: "Love it!", Author: "Alice"}, {ProductID: productID, Rating: 4, Comment: "Pretty good.", Author: "Bob"}, } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(reviews) }) log.Println("Review Service running on :8082") log.Fatal(http.ListenAndServe(":8082", nil)) }
inventory_service/main.go
package main import ( "encoding/json" "fmt" "log" "net/http" "time" ) type Inventory struct { ProductID string `json:"productId"` Stock int `json:"stock"` } func main() { http.HandleFunc("/inventory/", func(w http.ResponseWriter, r *http.Request) { productID := r.URL.Path[len("/inventory/"):] if productID == "" { http.Error(w, "Product ID required", http.StatusBadRequest) return } // レイテンシをシミュレート time.Sleep(30 * time.Millisecond) inventory := Inventory{ ProductID: productID, Stock: 10 + len(productID)%5, // 動的在庫 } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(inventory) }) log.Println("Inventory Service running on :8083") log.Fatal(http.ListenAndServe(":8083", nil)) }
これらの3つのサービスを個別のターミナルで実行します。
BFFレイヤー(main.go
)
次に、BFFを構築します。
package main import ( "context" "encoding/json" "fmt" "log" "net/http" "time" ) // ダウンストリームサービス応答に一致する構造体を定義する type Product struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` Price int `json:"price"` } type Review struct { ProductID string `json:"productId"` Rating int `json:"rating"` Comment string `json:"comment"` Author string `json:"author"` } type Inventory struct { ProductID string `json:"productId"` Stock int `json:"stock"` } // フロントエンド向けの集約応答構造体を定義する type ProductDetails struct { Product Product `json:"product"` Reviews []Review `json:"reviews"` Inventory Inventory `json:"inventory"` Error string `json:"error,omitempty"` // 部分的なエラー用 } // タイムアウト付きHTTPクライアント var client = &http.Client{Timeout: 2 * time.Second} // fetchProductはProduct Serviceから製品詳細を取得する func fetchProduct(ctx context.Context, productID string) (Product, error) { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://localhost:8081/products/%s", productID), nil) if err != nil { return Product{}, fmt.Errorf("failed to create product request: %w", err) } resp, err := client.Do(req) if err != nil { return Product{}, fmt.Errorf("failed to fetch product: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return Product{}, fmt.Errorf("product service returned status %d", resp.StatusCode) } var product Product if err := json.NewDecoder(resp.Body).Decode(&product); err != nil { return Product{}, fmt.Errorf("failed to decode product response: %w", err) } return product, nil } // fetchReviewsはReview Serviceからレビューを取得する func fetchReviews(ctx context.Context, productID string) ([]Review, error) { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://localhost:8082/reviews/%s", productID), nil) if err != nil { return nil, fmt.Errorf("failed to create review request: %w", err) } resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch reviews: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("review service returned status %d", resp.StatusCode) } var reviews []Review if err := json.NewDecoder(resp.Body).Decode(&reviews); err != nil { return nil, fmt.Errorf("failed to decode reviews response: %w", err) } return reviews, nil } // fetchInventoryはInventory Serviceから在庫を取得する func fetchInventory(ctx context.Context, productID string) (Inventory, error) { req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://localhost:8083/inventory/%s", productID), nil) if err != nil { return Inventory{}, fmt.Errorf("failed to create inventory request: %w", err) } resp, err := client.Do(req) if err != nil { return Inventory{}, fmt.Errorf("failed to fetch inventory: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return Inventory{}, fmt.Errorf("inventory service returned status %d", resp.StatusCode) } var inventory Inventory if err := json.NewDecoder(resp.Body).Decode(&inventory); err != nil { return Inventory{}, fmt.Errorf("failed to decode inventory response: %w", err) } return inventory, nil } // getProductDetailsHandlerは集約された製品詳細のリクエストを処理する func getProductDetailsHandler(w http.ResponseWriter, r *http.Request) { productID := r.URL.Path[len("/product-details/"):] if productID == "" { http.Error(w, "Product ID required", http.StatusBadRequest) return } // 全ての集約操作にタイムアウト付きのコンテキストを使用する ctx, cancel := context.WithTimeout(r.Context(), 500*time.Millisecond) defer cancel() // 結果を並列で受信するためにチャネルを使用する productCh := make(chan struct { Product Product Err error }, 1) reviewsCh := make(chan struct { Reviews []Review Err error }, 1) inventoryCh := make(chan struct { Inventory Inventory Err error }, 1) // ゴルーチンを使用してデータを並列で取得する go func() { p, err := fetchProduct(ctx, productID) productCh <- struct { Product Product Err error }{p, err} }() go func() { r, err := fetchReviews(ctx, productID) reviewsCh <- struct { Reviews []Review Err error }{r, err} }() go func() { i, err := fetchInventory(ctx, productID) inventoryCh <- struct { Inventory Inventory Err error }{i, err} }() // 結果を集約する details := ProductDetails{} var bffError string select { case res := <-productCh: if res.Err != nil { log.Printf("Error fetching product for %s: %v", productID, res.Err) bffError = fmt.Sprintf("failed to get product info: %s", res.Err.Error()) } else { details.Product = res.Product } case <-ctx.Done(): log.Printf("Context cancelled/timed out while waiting for product for %s: %v", productID, ctx.Err()) http.Error(w, "Timeout fetching product data", http.StatusGatewayTimeout) return } select { case res := <-reviewsCh: if res.Err != nil { log.Printf("Error fetching reviews for %s: %v", productID, res.Err) // レビューが失敗しても部分的なデータを返したい場合がある details.Reviews = []Review{} // エラーの場合は空にデフォルト設定 } else { details.Reviews = res.Reviews } case <-ctx.Done(): log.Printf("Context cancelled/timed out while waiting for reviews for %s: %v", productID, ctx.Err()) http.Error(w, "Timeout fetching reviews data", http.StatusGatewayTimeout) return } select { case res := <-inventoryCh: if res.Err != nil { log.Printf("Error fetching inventory for %s: %v", productID, res.Err) // 在庫が失敗しても部分的なデータを返したい場合がある details.Inventory = Inventory{Stock: 0} // 在庫0にデフォルト設定 } else { details.Inventory = res.Inventory } case <-ctx.Done(): log.Printf("Context cancelled/timed out while waiting for inventory for %s: %v", productID, ctx.Err()) http.Error(w, "Timeout fetching inventory data", http.StatusGatewayTimeout) return } // 重大なエラーが発生した場合(例:製品詳細自体が失敗した場合) if bffError != "" { http.Error(w, bffError, http.StatusInternalServerError) return } // 製品データが必須のため、空の場合はエラーが発生したことを意味する if details.Product.ID == "" { http.Error(w, "Failed to get product details", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(details) } func main() { http.HandleFunc("/product-details/", getProductDetailsHandler) log.Println("BFF Service running on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) }
BFFの仕組み:
- リクエスト処理:
getProductDetailsHandler
は、/product-details/{productID}
へのリクエストを受信します。 - タイムアウト付きコンテキスト:
context.WithTimeout
を使用して、全体的な集約操作が定義された時間内に完了することを保証します。これは、遅いダウンストリームサービスがBFFをブロックするのを防ぐために重要です。 - 並列ダウンストリーム呼び出し:
fetchProduct
、fetchReviews
、fetchInventory
のためにゴルーチンが起動されます。各ゴルーチンは、その専用チャネルを介して結果(またはエラー)を通信します。- ゴルーチンを使用すると、これらの呼び出しを並列で行うことができます。それらがない場合、BFFは逐次的な呼び出しを行い、
T_product + T_review + T_inventory
の時間を要します。並列性がある場合、約max(T_product, T_review, T_inventory)
の時間がかかります。
- 結果集約: メインゴルーチンは
select
ステートメントを使用して各チャネルからの結果を待ちます。- エラー処理が組み込まれています。特定のダウンストリームサービスが失敗またはタイムアウトした場合(
ctx.Done()
がトリガーされたため)、BFFはリクエスト全体を失敗させるか、部分的なデータ(例:レビューなしの製品詳細)を返すかを選択できます。これにより、BFFはより回復力が高くなります。 - この例では、コア製品データが取得できない場合にエラーを発生させますが、オプションコンポーネント(レビュー、在庫)のそれぞれのサービスが失敗した場合に空のスライス/デフォルト値を返すことで、正常な縮退を示しています。
- エラー処理が組み込まれています。特定のダウンストリームサービスが失敗またはタイムアウトした場合(
- 応答整形: 結果は、フロントエンド向けに調整された単一の
ProductDetails
構造体に結合され、JSON応答にマーシャリングされます。
BFFの実行
- 3つのモックサービスを別々のターミナルで起動します。
- BFFを実行します:
product-bff
ディレクトリでgo run main.go
を実行します。 - ブラウザまたは
curl
でアクセスします:http://localhost:8080/product-details/P001
すべてのサービスからのデータを含む単一のJSON応答が得られます。いずれかのモックサービスに遅延を導入したり、1つを停止したりすると、BFFがタイムアウトや部分的な障害をどのように処理するかを確認できます。
高度な考慮事項とベストプラクティス
私たちの例は単純ですが、実際のBFFにはより多くの洗練が必要です。
- エラー処理と回復力:
- サーキットブレーカー: BFFが失敗したダウンストリームサービスに繰り返し呼び出しをかけないように、サーキットブレーカー(例:
sony/gobreaker
ライブラリを使用)を実装し、回復する時間を与えます。 - リトライ(指数バックオフ付き): 一時的なエラーの場合、自動リトライは信頼性を向上させることができます。
- 正常な縮退: 例に示されているように、ダウンストリームサービスが失敗した場合に省略できる部分と、クリティカルなデータ部分を決定します。
- サーキットブレーカー: BFFが失敗したダウンストリームサービスに繰り返し呼び出しをかけないように、サーキットブレーカー(例:
- 認証と承認: BFFは、ダウンストリームサービスへのリクエストをプロキシする前に、クライアント固有の認証および承認ルールを強制するのに理想的な場所です。プロパゲーションのために必要なヘッダーを追加できます。
- リクエスト/レスポンス変換: BFFの主な役割はデータを変換することです。これには、フィルタリング、マージ、フィールド名の変更、またはフロントエンドのロジックを単純化するための派生値の計算が含まれます。
- キャッシング: パフォーマンスをさらに向上させ、ダウンストリームサービスの負荷を軽減するために、頻繁にアクセスされる、ゆっくりと変化するデータに対してキャッシュメカニズム(例:Redis)をBFF内に実装します。
- ロギングとトレーシング: BFFの動作を監視し、マイクロサービスランドスケープ全体で問題を診断するために、構造化ロギングと分散トレーシング(例:OpenTelemetry)を統合します。
- ロードバランシングとスケーリング: トラフィックの増加に対応するために、ロードバランサーの後ろにBFFの複数のインスタンスをデプロイします。Goの効率性は、水平スケーリングに適しています。
- サービスディスカバリ: 動的なマイクロサービス環境では、BFFは、IPアドレスやポートをハードコーディングするのではなく、ダウンストリームサービスを見つけるためにサービスディスカバリメカニズム(例:Kubernetes DNS、Consul、Eureka)を使用する必要があります。
- 冪等性: BFFがリクエストをリトライする場合、意図しない副作用を避けるために、データを変更する操作の冪等性を確保します。
結論
Backend for Frontend(BFF)パターンは、洗練されたマイクロサービスと単純化されたフロントエンド開発の間のギャップを埋める強力なアーキテクチャツールです。インテリジェントなオーケストレーターおよびデータアグリゲーターとして機能することにより、Go搭載のBFFは、フロントエンドエクスペリエンスを大幅に向上させ、複雑さを軽減し、アプリケーション全体のパフォーマンスと回復力を強化します。Goの同時実行性、パフォーマンス、ネットワーキングにおける固有の強みは、堅牢でスケーラブルなBFFレイヤーを構築するのに優れた選択肢であり、開発者がマイクロサービスアーキテクチャの利点を維持しながら、より高速で応答性の高いユーザーインターフェイスを構築できるようになります。