はじめに
この記事はトレタ 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
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
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
を付けておく
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
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
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のサンプル
class InvoiceReceipt
include Interactor::Organizer
organize InvoiceReceipt::GraphqlExampleFetchInvoiceWithReceipt,
InvoiceReceipt::GraphqlExampleVoidReceipts,
InvoiceReceipt::RestExamplePostReissueReceipt
end
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
def fetch_invoice_with_receipt
...
end
end
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
def rollback
...
end
private
def void_receipts
...
end
end
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
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のサンプル
class Presenter
def initialize(object = nil)
@object = object
end
def as_json
raise 'as_json called on parent.'
end
end
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
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
するだけなので簡単です✨
おわりに
コードの紹介がメインとなりましたが、できるだけ複雑にならないように気をつけてコードを書いています。雰囲気だけでも読み取れてもらえたら幸いです。
トレタに少しでも興味を持っていただいた方がいれば、カジュアル面談などお気軽にご応募ください!
www.wantedly.com