トレタ開発者ブログ

飲食店向け予約/顧客台帳サービス「トレタ」など、飲食業界のVertical SaaS企業です。

RailsでBFFを構築した際、複雑にならないように気をつけたこと

はじめに

この記事はトレタ 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クエリ単位でファイルを作成
    • プレフィックスにQueryMutationを付けておく
# 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単位でファイルを作成
    • プレフィックスにGetPostを付けておく
# 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するだけなので簡単です✨

おわりに

コードの紹介がメインとなりましたが、できるだけ複雑にならないように気をつけてコードを書いています。雰囲気だけでも読み取れてもらえたら幸いです。

トレタに少しでも興味を持っていただいた方がいれば、カジュアル面談などお気軽にご応募ください!

www.wantedly.com

© Toreta, Inc.

Powered by Hatena Blog