はじめに
この記事はトレタ Advent Calendar 2021 の22日目の記事です。
こんにちは、昨年と同じ日の投稿になりました、サーバーサイドエンジニアの @shiroemons です。
GoとRuby on Railsを書いてます。
最近、RailsでBFF(Backends For Frontends)を構築しました。 BFFについての細かい説明は省略しますが、簡単に言いますとフロントエンドからのリクエストに応じて複数のバックエンドに対して、APIコールしデータを取得し内容を加工してフロントエンドに返却するAPIサーバーです。
構築した際に、複雑にならないように気をつけたポイントを3点紹介します。
バックエンド毎のClientをPOROで作成
データを取得するバックエンドは、GraphQLとRESTと異なるAPIが存在していました。
GraphQLと通信する際、graphql-clientを検討しましたが、AuthorizationヘッダーやIdempotency-Keyヘッダーをリクエスト毎に変える必要があったため、使い方がアンマッチでしたので不採用としました。 採用した方法は、HTTPクライアントライブラリの Faraday を使用し、各バックエンド用(GraphQL, REST)のClientをPORO(Plain Old Ruby Object) で作成して対応しました。
サンプルを以下に記載しています。
GraphQL用のClient
- 親クラス
# app/models/graphql_example_client/client.rb module GraphqlExampleClient class Client def initialize(auth_token, **options) @auth_token = auth_token @options = options end def call response = run JSON.parse(response.body) rescue JSON::ParserError { errors: [ { code: response.status, message: response.body } ] } end private def run Faraday.post(example_graphql_url, request_body, headers) end # https://example.com/graphql def example_graphql_url ENV['EXAMPLE_GRAPHQL_URL'] end def request_body { query: query, variables: variables }.to_json end def query raise 'query called on parent.' end def variables raise 'variables called on parent.' end def headers { 'Content-Type' => 'application/json', 'Authorization' => @auth_token, 'Idempotency-Key' => idempotency_key } end def idempotency_key ... end end end
- 親クラスを継承し、1クエリ単位でファイルを作成
- プレフィックスに
Query
やMutation
を付けておく
- プレフィックスに
# app/models/graphql_example_client/query_locations.rb module GraphQLExampleClient class QueryLocations < Client private def query <<~GRAPHQL.freeze query locations($where: LocationWhereClause!, $limit: Int, $offset: Int) { locations(where: $where, limit: $limit, offset: $offset) { locations { id name createdAt updatedAt } } } GRAPHQL end def variables { where: { createdAt: { gt: 0 } }, limit: limit, offset: offset } end def limit ... end def offset ... end end end
- 使い方
response_body = GraphQLExampleClient::QueryLocations.new(@auth_token, limit: params[:limit], offset: params[:offset]).call
REST用のClient
- 親クラス
# app/models/rest_example_client/client.rb module RestExampleClient class Client def initialize(auth_token, **options) @auth_token = auth_token @options = options end def call response = run JSON.parse(response.body) rescue JSON::ParserError { error: { code: response.status, message: response.body } } end private def rest_example_base_url ENV['REST_EXAMPLE_BASE_URL'] end def run raise 'run called on parent.' end def request_body raise 'request_body called on parent.' end def headers { 'Content-Type' => 'application/json', 'Authorization' => @auth_token } end end end
- 親クラスを継承し1API単位でファイルを作成
- プレフィックスに
Get
やPost
を付けておく
- プレフィックスに
# app/models/rest_example_client/post_example.rb module RestExampleClient class PostExample < Client private def run Faraday.post(url, request_body, headers) end def url "#{rest_example_base_url}/v1/example" end def request_body { hoge: hoge, ... }.to_json end def hoge @options[:hoge] end end end
- 使い方
response_body = RestExampleClient::PostExample.new(@auth_token, hoge: params[:hoge]).call
使い方は、new
してcall
するだけなので簡単です✨
1リクエスト毎にファイルが別れているのでコードをスッキリ書くことができます✨
Interactor層の導入
BFFの特性上、クライアントからの1リクエストで同じバックエンドに対し別々のAPIを呼び出したり、他のバックエンドに対してもAPIを呼び出すことがあります。 愚直に書くとすぐに複雑になりテストも書きづらくなります。 そこで、複雑なビジネスロジックを解消するために、Interactor層を導入しました。 Interactor層を導入することで、ビジネスロジックをカプセル化することができます。
使用したgemは、 collectiveidea/interactor-rails です。 導入方法は、READMEを参照ください。
サンプルを以下に記載しています。 良いサンプルを用意できなかったため、具体例を使って紹介します。
- 処理
- 請求書から領収書IDを取得する処理(GraphQL)
- 既存の領収書を無効化する処理(GraphQL)
- 領収書を再発行する処理(REST)
Interactorのサンプル
- 取りまとめ役のファイル
# app/interactors/invoice_receipt.rb class InvoiceReceipt include Interactor::Organizer # 上から順番に実行されます。 organize InvoiceReceipt::GraphqlExampleFetchInvoiceWithReceipt, InvoiceReceipt::GraphqlExampleVoidReceipts, InvoiceReceipt::RestExamplePostReissueReceipt end
- 請求書から領収書IDを取得する処理
# /app/interactors/invoice_receipt/graphql_example_fetch_invoice_with_receipt.rb class InvoiceReceipt::GraphqlExampleFetchInvoiceWithReceipt include Interactor def call result = fetch_invoice_with_receipt if result[:errors].present? context.fail!(error: result[:errors]) else context.receipt_id = result[:data][:receipt_id] end end private # GraphQLから請求書を取得する処理(GraphQLのQueryを実行する処理) def fetch_invoice_with_receipt ... end end
- 既存の領収書を無効化する処理
# app/interactors/invoice_receipt/graphql_example_void_receipts.rb class InvoiceReceipt::GraphqlExampleVoidReceipts include Interactor def call return if context.receipt_id.blank? result = void_receipts if result[:errors].present? context.fail!(error: result[:errors]) else context.void_receipt = result[:data] end end # 途中で失敗した場合、rollbackメソッドが呼び出される def rollback ... end private # 既存の領収書を無効化する処理(GraphQLのMutationを実行する処理) def void_receipts ... end end
- 領収書を再発行する処理
# app/interactors/invoice_receipt/rest_example_post_reissue_receipt.rb class InvoiceReceipt::RestExamplePostReissueReceipt include Interactor def call result = post_reissue_receipt if result[:error].present? context.fail!(error: result[:error]) else context.receipt = result end end private # 領収書を再発行する処理(RESTのPostを実行する処理) def post_reissue_receipt ... end end
- 実際の使われ方
class Invoices::ReceiptsController < ApplicationController def create # 領収書再発行処理 result = InvoiceReceipt.call( auth_token: @auth_token, location_id: params[:location_id], invoice_id: params[:invoice_id], mail_address: params[:email], name: params[:name] ) if result.success? render json: ReceiptPresenter.new.as_json, status: :ok else render json: ErrorsPresenter.new(result.error), status: :bad_request end end end
使い方は、取りまとめ役のファイルをcall
するだけなので簡単です✨
個別の責務を1ファイルで書くことで、可読性も向上し複雑にならずコントローラーもスッキリ書くことができます✨
Presenter層の導入
BFFということで、クライアントが必要とする形でレスポンスを返さないといけません。 APIモードで動かしているためViewファイルでJSONの組み立ては行わず、Presenter層を導入して対応しました。 こちらもPORO(Plain Old Ruby Object)で実装しました。
サンプルを以下に記載しています。
Presenterのサンプル
- 親クラス
# app/presenters/presenter.rb class Presenter def initialize(object = nil) @object = object end def as_json raise 'as_json called on parent.' end end
- 親クラスを継承してファイルを作成
- サフィックスに
Presenter
を付ける
- サフィックスに
# app/presenters/locations_presenter.rb class LocationsPresenter < Presenter def as_json(*) { locations: locations } end private def locations @object&.map { |o| location(o) } || [] end def location(object) { id: object[:id], name: object[:name], created_at: object[:created_at], updated_at: object[:updated_at] } end end
- 実際の使われ方
- mergeメソッドを使用したりして整形しています
# app/controllers/locations_controller.rb class LocationsController < ApplicationController def index response_body = GraphQLExampleClient::QueryLocations.new(@auth_token, limit: params[:limit], offset: params[:offset]).call status = :ok if response_body[:errors] response_json = ErrorsPresenter.new(response_body[:errors]) status = :bad_request else locations_data = response_body[:data][:locations] total = locations_data[:total] locations = LocationsPresenter.new(locations_data[:locations]) pagination = PaginationPresenter.new(Pagination.new(total, params[:per_page], params[:page])) response_json = locations.as_json.merge(pagination.as_json) end render json: response_json, status: status end end
使い方は、new
してas_json
するだけなので簡単です✨
おわりに
コードの紹介がメインとなりましたが、できるだけ複雑にならないように気をつけてコードを書いています。雰囲気だけでも読み取れてもらえたら幸いです。
トレタに少しでも興味を持っていただいた方がいれば、カジュアル面談などお気軽にご応募ください!