# Backend For Frontend(BFF)
Backend For Frontend(BFF)とは、フロントエンドとマイクロサービスで構成された複数のAPIとの複雑性を解決するためのアーキテクチャです。 名前が指す通り、フロントエンドのUI/UXをサポートするためのサーバで、フロントエンドとバックエンドの中間に設置されます。複数のバックエンドの仕様を吸収しつつ、クライアントへのデータを取得、加工するために使われます。
# BFFの目的とは
BFFの目的は、フロントエンドとマイクロサービスで構築されたバックエンドとの複雑性を解決することです。BFFの必要性を理解するために、まずはマイクロサービスの複雑性について見てみましょう。
マイクロサービスが登場する前は、一つのエンドポイントで全ての機能を実装していました。データベースへの取得、認証、全文検索などを一つのAPIで提供し、フロントエンドはこのAPIとデータのやり取りをしていました。いわゆるモノリシックと呼ばれるアーキテクチャです。
このようなモノリシックなアーキテクチャは、少人数の開発やサービスの立ち上げ時は有効です。一箇所に全て集約されるので、設計や開発のコストを抑えることができます。
しかし、サービスや組織が拡大するとその分機能が増えて、運用コストが増加します。開発規模が大きくなることで設計や開発のスピードが落ち、リリースまでのリードタイムが遅れる可能性があります。 また、いずれかのモジュールでエラーが発生するとアプリケーション全体の可用性に影響を及ぼす可能性があります。
マイクロサービスは、このようなモノリシックアーキテクチャの問題を解決するために生まれました。
マイクロサービスは、一つのリソースや機能に特化するAPIを独立して開発することで、モノリシックの複雑性を分解し管理しやすくします。また、機能を分割し小さなチームに分けることでそれぞれの責務を明確にすることができます。それぞれが独立することでデプロイがしやすくなり、より柔軟で拡張性の高い開発が可能となります。
フロントエンド側では、モノリシックの場合一つのAPIとデータのやり取りをしていました。しかし、マイクロサービスになると複数のAPIとやり取りをする必要があります。その場合、フロントエンドで個々のAPIへ接続する処理を実装しなければなりません。
また、クライアントが多様化し要求が増えると、API側でそれぞれに対応したレスポンスを作る必要があります。例えば、WebアプリケーションやモバイルアプリケーションではUIが異なるためレスポンスの構造も変わる可能性があります。また、それぞれが要求する機能も変わる可能性があります。 このような要求に全てのマイクロサービスで実装すると開発コストが段々と大きくなっていくという問題が発生します。
BFFはこのようなAPIとフロントエンド側の複雑性を回避することを目的としています。
フロントエンドとAPIサーバの中間にサーバを設置し、フロントエンドからの要求はすべてBFFが処理します。また、バックエンドの仕様をBFFが吸収することで、フロントエンド側でバックエンドの仕様変更の対応をする必要がなくなります。 クライアントはモノリシックのときのように一つのAPIに対してアクセスすればいいので、APIリクエストの実装コストを下げることもできます。
# BFFのユースケース
BFFはフロントエンドとバックエンドAPIを仲介する役割を持ちます。フロントエンドとAPIの複雑性を解決することを目的としていますが、具体的にどのようなケースで使われるか見てみましょう。
# APIの集約
BFFで一番使われるケースは複数のAPIを一つにまとめることです。APIからのレスポンスを集約して、フロントエンドへデータを整形し配信します。APIからのデータを一部キャッシュしたり、フィルタリングする機能を提供します。いわゆるAPI Gatewayという役割です。
# クライアントのパーソナライズ
複数のマイクロサービスのデータを処理して、クライアントごとに最適化したデータを送信することでユーザが利用している端末に合わせて最適な情報を提供することができます。
例えばショッピングサイトの場合、ユーザの登録している情報から適切なデータを取得しそのユーザに合った商品を提供することができます。また、利用しているクライアントに合わせて柔軟に表示方法を変えたり、キャンペーンを配信したりなど、パーソナライズしたデータをBFFで一元的に管理することができます。
# SSR(Server-Side Rendering)
SPAのWebアプリケーションの場合、BFFでSSRを実装するケースも考えられます。BFFはビジネスロジックやアプリケーションの機能に関する処理は提供しません。あくまでフロントエンドのUI/UXをサポートするためのサーバなので、レンダリングを最適化する目的でSSRが実装されるケースも想定されます。
# メリット
# APIを一元化
BFFの大きなメリットは複数のAPIを一元化できることです。
マイクロサービスの場合、複数のAPIを利用してUIを構築します。APIが増えるについてフロントエンドの実装コードは多くなります。また、リクエストの量も増え、通信コストが発生します。
BFFを導入すればこれらの処理を一箇所にまとめることができます。そのため、フロントエンドでの処理を少なくすることができます。また、マイクロサービス毎に異なるレスポンスをまとめて処理することで、効率的にクライアントへの配信ができます。
マイクロサービスで発生したエラーにおいても、BFFでまとめて管理することができます。フロントエンドではBFFから返ってくるエラーレスポンスだけ処理すればいいので、一貫性のあるエラーハンドリングを実装することができます。
# バックエンドとの分離
マイクロサービスでは複数の言語やプロトコルで実装されるケースがあります。そのため、フロントエンドもそれに合わせる必要があります。
しかし、BFFを挟むことで、フロントエンドからの通信を柔軟に調整することができます。
例えば、バックエンドがgRPCやRESTで構成されている場合、BFFとバックエンドのやりとりはプロトコルを一致しなければなりません。しかし、フロントエンドとBFFの通信は自由に実装することができます。GraphQLに統一したり、tRPCに置き換えたりなど、フロントエンドの都合に合わせた実装が可能となります。
# デメリット
# 実装・設計コスト
単一のモノリシックアーキテクチャと比べて、サーバを一つ増やされければならないので実装コストが高くなる可能性があります。
BFFのAPIとマイクロサービスが提供するAPIのレスポンスがほぼ同じ場合、似たようなコードを書かなければならずコードが冗長化する可能性があります。
また、マイクロサービスと密結合になることからデプロイのタイミングを調整する必要があります。例えば、一部のマイクロサービスのデプロイに合わせてBFFのデプロイもしないと障害発生のポイントとなり得るでしょう。
# BFFの肥大化
BFFに様々な処理を追加しすぎて肥大化する可能性があります。
APIの集約、SSRの実装、キャッシュ管理、バックエンドに不具合があったときのサーキットブレイカーの実装など、BFFへ負荷が集中し過ぎることはアンチパターンになります。
BFFの目的と役割をはっきりさせて、責務を明確にすることが大事です。
# BFFの実装
では、実際にBFFサーバを実装して仕組みを見てみましょう。
gRPCで構成されたマイクロサービスからデータを取得し、BFFではGraphQLを実装してクライアントへ配信します。
# マイクロサービスの実装
はじめにマイクロサービスのサーバを実装しましょう。このサービスではUserに関するデータを返すものとします。
{
"id": 1,
"name": "Taro",
"age": 20,
"email": "xxx@example.com"
}
以下のようなGoとgRPCサーバで構成されたアプリケーションを想定して実装しましょう。
backend/
├── cmd
│ └── server
│ └── main.go
├── go.mod
├── go.sum
├── pkg
│ └── grpc
│ ├── user.pb.go
│ └── user_grpc.pb.go
└── proto
└── user.proto
まずは、gRPCサーバを作っていきましょう。gRPCサーバではクライアントから呼び出されるためのプロシージャ(関数の定義や戻り値)を定義する必要があります。このプロシージャをprotoファイルという形式で定義します。
syntax = "proto3";
option go_package = "pkg/grpc";
package myapp;
service UserService {
rpc Get (UserRequest) returns (UserResponse);
}
message UserRequest {
optional int64 id = 1;
}
message UserResponse {
int64 id = 1;
string name = 2;
int64 age = 3;
string email = 4;
}
protoファイルはUserデータに関する情報を記述します。この場合、UserService.Get
を介してUserデータを取得できます。
そして、このprotoファイルをもとにgPPCサーバのコードを生成します。コードの生成はprotoc
コマンドを実行します。
protoc --go_out=../pkg/grpc --go_opt=paths=source_relative \
--go-grpc_out=../pkg/grpc --go-grpc_opt=paths=source_relative \
user.proto
Note
protocコマンド実行するにはパッケージをインストールする必要があります。
$ brew install protobuf
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.28
$ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
protoc
コマンドを実行後にpkg/grpc
以下に二つのファイルが生成されました。
├── pkg
│ └── grpc
│ ├── user.pb.go // リクエストとレスポンス型が生成
│ └── user_grpc.pb.go // サービスとして使うコードの生成
この二つのファイルを使ってサーバを実装します。cmd/server/main.go
を次のように実装しましょう。
package main
import (
"context"
"fmt"
"google.golang.org/grpc"
"google.golang.org/grpc/reflection"
"log"
"net"
"os"
"os/signal"
userpb "mygrpc/pkg/grpc"
)
type userServer struct {
userpb.UnimplementedUserServiceServer
}
func (s *userServer) Get(ctx context.Context, req *userpb.UserRequest) (*userpb.UserResponse, error) {
return &userpb.UserResponse{
Id: 1,
Name: "Taro",
Age: 20,
Email: "xxx@example.com",
}, nil
}
func NewUserServer() *userServer {
return &userServer{}
}
func main() {
port := 8080
listener, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
log.Fatal(err)
}
s := grpc.NewServer()
// gRPCサーバの登録
userpb.RegisterUserServiceServer(s, NewUserServer())
reflection.Register(s)
go func() {
log.Printf("Listening on: %v", port)
s.Serve(listener)
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt)
<-quit
log.Println("Stopping server...")
s.GracefulStop()
}
先ほど生成されたファイルにRegisterUserServiceServer
という関数があるので、それを使用してgRPCサーバを起動します。
userpb.RegisterUserServiceServer(s, NewUserServer())
サービスのインターフェースには先ほどproto
ファイルに記述したGet
が定義されています。Get
インターフェースの実装は次のようにUserデータをレスポンスします。
type userServer struct {
userpb.UnimplementedUserServiceServer
}
func (s *userServer) Get(ctx context.Context, req *userpb.UserRequest) (*userpb.UserResponse, error) {
return &userpb.UserResponse{
Id: 1,
Name: "Taro",
Age: 20,
Email: "xxx@example.com",
}, nil
}
アプリケーションを起動するとhttp://localhost:8080
に対してgRPCサーバが起動されているのが分かります。
$ go run main.go
Listening on: 8080
これでマイクロサービスのサーバの実装ができたので、実際にリクエストを送信してみましょう。gRPCサーバにはgRPCクライアントから送信するか、gRPCurlを使ってリクエストを送信できます。ここではgRPCurlで簡単な動作確認をします。
$ brew install grpcurl
まずは、実装されているサーバの一覧を確認します。
$ grpcurl -plaintext localhost:8080 list
grpc.reflection.v1alpha.ServerReflection
myapp.UserService
myapp.UserService
がproto
ファイルで定義したサービスです。このサービスからメソッドの一覧を取得しましょう。
$ grpcurl -plaintext localhost:8080 list myapp.UserService (git)-[main]
myapp.UserService.Get
Get
メソッドが取得できました。では、Get
メソッドを呼び出してUserデータを取得してみましょう。grpcurl
にmyapp.UserService.Get
を指定するとgRPCサーバへリクエストを送信することができます。
$ grpcurl -plaintext localhost:8080 myapp.UserService.Get (git)-[main]
{
"id": "1",
"name": "Taro",
"age": "20",
"email": "xxx@example.com"
}
上記で実装したUserデータが返ってくることを確認できました。このようにgRPCサーバに対してサービスとメソッドを指定することでデータを取得することができます。BFFサーバでは、gRPCクライアントを実装してコード上からgRPCサーバへリクエストを送信します。
# BFFの実装
マイクロサービスのgRPCサーバができたので、次にBFFサーバを実装しましょう。
BFFサーバの構成は次のようになります。
bff/
├── generated
│ └── proto
│ └── user.ts
├── index.ts
├── package.json
├── proto
│ └── user.proto
├── tsconfig.json
└── yarn.lock
BFFで実装する機能は次の二つになります。
- フロントエンドと通信するGraphQL
- マイクロサービスと通信するgRPCクライアント
フロントエンドの通信にはGraphQLサーバを実装します。フロントエンドのリクエストからデータを取得するには、マイクロサービスと通信する必要があります。そのために、gRPCクライアントを実装し、マイクロサービスのgRPCサーバへアクセスします。
では、一つずつ見てみましょう。
# GraphQLの実装
まずは、フロントエンドと通信するためのGraphQLを実装します。今回はNode.js製のexpressサーバを使ってGraphQLを構築します。
$ yarn add express cors express-graphql graphql
GraphQLサーバの実装は以下のようになります。
import express from 'express'
import cors from 'cors'
import { graphqlHTTP } from 'express-graphql'
import { buildSchema } from 'graphql'
import { UserServiceClient } from "./generated/proto/user";
import { credentials } from '@grpc/grpc-js'
const schema = buildSchema(`
type Query {
user(id: ID): User
}
type User {
id: ID!
name: String!
age: Int!
email: String!
}
`);
const root = {
user: () => {
return new Promise((resolve) => {
resolve(null)
})
},
};
const app = express();
app.use(cors())
app.use('/graphql', graphqlHTTP({
schema: schema,
rootValue: root,
graphiql: true,
}));
app.listen(4000);
console.log('Running a GraphQL API server at http://localhost:4000/graphql');
http:localhost:4000/graphql
に対してGraphQLサーバを起動しています。Playgroundで確認すると次のようなスキーマを確認することができます。
ここでは、まだuser
クエリに対してはnull
を返すだけになっています。
# gRPCクライアントの実装
では、次にgRPCクライアントを実装してマイクロサービスへアクセスしましょう。gRPCクライアントのコードはgRPCサーバを実装したときのようにproto
ファイルから生成します。同時にTypeScriptの型も生成したいのでts-proto (opens new window)というツールを使用します。併せてgRPCクライアントの実装部分を生成する@grpc/grpc-js (opens new window)もインストールします。
$ yarn add -D ts-proto
$ yarn add @grpc/grpc-js
コードの生成は先ほどマイクロサービスで定義したproto
ファイルを使用したいので一時的にコピーして使用するものとします。次のようなスクリプトをpackage.json
に定義しましょう。
{
"scripts": {
"start": "ts-node index.ts",
"proto": "cp ../backend/proto/* proto/ && protoc --proto_path=. --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_opt=outputServices=grpc-js --ts_proto_opt=esModuleInterop=true --ts_proto_out=./generated ./proto/*.proto"
}
}
そして、スクリプトを実行しましょう。
$ yarn proto
スクリプトが完了するとgenerated/
以下にファイルが生成されます。
bff/
├── generated
│ └── proto
│ └── user.ts
このファイルにはUserのリクエストの型やレスポンスの型が生成されています。同時にgRPCクライアントのコードも生成されます。このコードを使ってマイクロサービスへアクセスします。
export interface UserRequest {
id?: number | undefined;
}
export interface UserResponse {
id: number;
name: string;
age: number;
email: string;
}
export interface UserServiceClient extends Client {
get(request: UserRequest, callback: (error: ServiceError | null, response: UserResponse) => void): ClientUnaryCall;
get(
request: UserRequest,
metadata: Metadata,
callback: (error: ServiceError | null, response: UserResponse) => void,
): ClientUnaryCall;
get(
request: UserRequest,
metadata: Metadata,
options: Partial<CallOptions>,
callback: (error: ServiceError | null, response: UserResponse) => void,
): ClientUnaryCall;
}
export const UserServiceClient = makeGenericClientConstructor(UserServiceService, "myapp.UserService") as unknown as {
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): UserServiceClient;
service: typeof UserServiceService;
};
では、先ほど実装したGraphQLサーバにマイクロサービスへアクセスする処理を追加しましょう。
import express from 'express'
import cors from 'cors'
import { graphqlHTTP } from 'express-graphql'
import { buildSchema } from 'graphql'
import { UserServiceClient } from "./generated/proto/user";
import { credentials } from '@grpc/grpc-js'
const schema = buildSchema(`
type Query {
user(id: ID): User
}
type User {
id: ID!
name: String!
age: Int!
email: String!
}
`);
const client = new UserServiceClient('localhost:8081', credentials.createInsecure())
const root = {
user: () => {
return new Promise((resolve) => {
client.get({}, (err, response) => {
resolve(response)
})
})
},
};
const app = express();
app.use(cors())
app.use('/graphql', graphqlHTTP({
schema: schema,
rootValue: root,
graphiql: true,
}));
app.listen(4000);
console.log('Running a GraphQL API server at http://localhost:4000/graphql');
まずは、gRPCクライアントを生成します。第一引数にはマイクロサービスのエンドポイントを指定してください。
const client = new UserServiceClient('localhost:8080', credentials.createInsecure())
このクライアントにはマイクロサービスで実装したGet
メソッドの型が定義されています。そのため、TypeScriptの型チェックや補完が効くようになっています。
const root = {
user: () => {
return new Promise((resolve) => {
client.get({}, (err, response) => {
resolve(response)
})
})
},
};
これで、GraphQLサーバを経由してマイクロサービスのデータを取得できるようになりました。試しにPlaygroundから動作確認をしてみましょう。
Userデータが返ってきているのが確認できます。
# フロントエンドの実装
では、最後にフロントエンドを実装しましょう。今回はNext.jsでフロントエンドを実装し、GraphQLクライアントにはApollo Client (opens new window)を使用します。また、GraphQLスキーマから自動的にTypeScriptの型とQueryのコードを生成したいので、GraphQL Code Generator (opens new window)を使用します。
# GraphQL Code Generatorの実装
はじめにGraphQL Code Generatorを実装します。
まずは必要なパッケージをインストールします。
$ yarn add -D @graphql-codegen/cli @graphql-codegen/add @graphql-codegen/typescript @graphql-codegen/typescript-operations @graphql-codegen/typescript-react-apollo
インストール後にcodegen.yml
というファイルを作成し、以下のように記述しましょう。
schema: 'http://localhost:4000/graphql'
documents:
- "./src/graphql/**/*.gql"
generates:
./src/graphql/types/index.ts:
plugins:
- add:
content: "/* eslint-disable @typescript-eslint/no-redeclare */"
- typescript
- typescript-operations
config:
enumsAsConst: true
./src/graphql/hooks/index.ts:
plugins:
- typescript-react-apollo
config:
withComponent: false
withHOC: false
withHooks: true
apolloClientVersion: 3
reactApolloVersion: 3
importOperationTypesFrom: import('../types')
config:
scalars:
Time: string
Cursor: string
skipTypename: true
preResolveTypes: true
maybeValue: T | null
avoidOptionals:
field: true
このファイルはTypeScriptの型を生成したり、Queryのコードを生成するなどのオプションを定義します。
次に、GraphQLファイルを定義します。src/graphql/queries/user.gql
というファイルを作成しましょう。
query User($id: ID) {
user(id: $id) {
id
email
age
name
}
}
GraphQL Code Generatorの準備ができたので、最後にスクリプトを追加して実行しましょう。
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"codegen": "graphql-codegen"
}
}
$ yarn codegen
$ graphql-codegen
✔ Parse Configuration
✔ Generate outputs
✨ Done in 1.23s.
スクリプトの実行が完了するとsrc/graphql/types/index.ts
にGraphQLスキーマの型が生成されます。
export type User = {
age: Scalars['Int'];
email: Scalars['String'];
id: Scalars['ID'];
name: Scalars['String'];
};
そして、src/graphql/hooks/index.ts
にはQueryを発行するためのコードが自動生成されます。
export function useUserQuery(baseOptions?: Apollo.QueryHookOptions<import('../types').UserQuery, import('../types').UserQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useQuery<import('../types').UserQuery, import('../types').UserQueryVariables>(UserDocument, options);
}
export function useUserLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions<import('../types').UserQuery, import('../types').UserQueryVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useLazyQuery<import('../types').UserQuery, import('../types').UserQueryVariables>(UserDocument, options);
}
...
では、このコードを使用してコンポーネントを実装しましょう。
まずは、src/pages/_app.tsx
にてApollo ClientのProviderとクライアントを設定します。
import type { AppProps } from 'next/app'
import { ApolloProvider } from '@apollo/client'
import { createApolloClient } from 'src/shared/apollo/apollo'
export default function App({ Component, pageProps }: AppProps) {
return (
<ApolloProvider client={createApolloClient()}>
<Component {...pageProps} />
</ApolloProvider>
)
}
次に、src/pages/index.tsx
を以下のように実装します。
import Head from 'next/head'
import styles from 'src/styles/Home.module.css'
import {useUserQuery} from "src/graphql/hooks";
export default function Home() {
const { data } = useUserQuery()
return (
<>
<Head>
<title>Create Next App</title>
<meta name="description" content="Generated by create next app" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
</Head>
<main className={styles.main}>
<h2>User</h2>
<div>
id: {data?.user?.id}<br />
name: {data?.user?.name}<br />
age: {data?.user?.age}<br />
email: {data?.user?.email}
</div>
</main>
</>
)
}
自動生成されたuseUserQuery
からGraphQLサーバへクエリを問い合わせます。useUserQuery
の返り値にはすでにUserデータの型付けがされているのでTypeScriptの型チェックが有効になります。
Next.jsサーバを起動して動作確認をしてみましょう。
$ yarn dev
$ next dev
ready - started server on 0.0.0.0:3000, url: http://localhost:3000
event - compiled client and server successfully in 1129 ms (410 modules)
Userデータが正常に取得できました。これでフロントエンドからBFFを介してマイクロサービスへアクセスすることができました。フロントエンドでは一つのエンドポイント(http:localhost:4000/graphql
)のみ定義をして、実際のデータを取得する処理はBFFに寄せることができます。マイクロサービスのエンドポイントが増えたとしてもBFFに追加するだけでいいので、フロントエンドの実装を追加する必要はありません。このようにしてBFFではAPIの集約を実現します。
# BFFはいつ使うべきか
単一のモノリシックなサーバに対してBFFを設置しても実装コストと設計コストが増えるだけの可能性があります。
BFFはマイクロサービスで構成されたAPIに対して複数のクライアントからの要求がある場合に効果的と言えるでしょう。既存のマイクロサービスがあり新規サービスを立ち上げる場合や、APIから複数クライアントへの実装コストが高くなった場合に検討するといいでしょう。
そうでない場合は、最初からBFFを導入するのではなく、まずはシンプルな構成でスタートし、必要性が生じたら検討するのがいいでしょう。