Ruby
Rails
api
GoogleAnalytics
設計
PearDay 4

外部APIと仲良くするためのリクエストモジュール化

この記事は株式会社Pearによるアドベントカレンダーの4日目の記事です。
APIサーバーを構築する際、自社製のAPI以外にも、サードパーティ製の外部APIを利用する場面は多々あると思います。
そういったときに、皆さんAPIごとにクラスを分けたり、名前空間を与えたりしているでしょう。
この記事では、Pearがどのように外部APIのリクエストを管理しているか、例としてGoogle Analytics(GA)を取り上げて、Ruby on Railsからリクエストする例をご紹介します。

この記事で目標とすること

  • 外部APIごとに、そのクライアントを作成しモジュール化することで、API独自のパラメータフォーマットへの依存がアプリケーション内に流入してしまうことを防ぐ
  • 外部APIからのレスポンスに含まれるキーワードがアプリケーションに依存しないよう、自社APIとの境界線でマッピングを行う

対象

  • 外部APIを利用しているが、そのコードがアプリケーション中に垂れ流されている状態をなんとかしたい方
  • 言語やフレームワークに依存する話ではないので、ネイティブアプリでも適用できる手法だと思います (実際アプリで主流だと思われる設計です)

本記事での実行環境

Google Analytics APIに関する記事

Google Analytics API を叩いてデータを取得するまでの流れ(Ruby)
[Ruby] Google アナリティクス Reporting API v4で、本日のページごとのユーザ数を取得する

Google Analytics APIの特徴

基本的には、上記の2記事の手順通りに進めることで、目的の値を得ることができます。
しかし、スクリプト的に用いる用途ではなく、自社APIから利用する外部APIという位置づけで利用するのであれば、リクエストの管理とレスポンスのパースという点で抽象化する必要があります(した方が健康的になれます)。
Google Analytics APIの特徴として、

  • dimensionsmetricsという概念がある
  • dimensionsを指定していない場合、レスポンスに含まれる値はArrayになる
  • dimensionsmetricsを指定した場合、レスポンスに含まれる値はHashになる
  • dimensionsmetricsに馴染みのない単語が使われている(自社サービスとは異なる単語が用いられている)

最後のは自分の英語のセンスがないからかもしれませんが、、、
とはいえ、Google Analytics APIのコンテキストにおけるmetricsと自社APIのコンテキストにおけるmetricsが同じものを指していても、単語が違っていれば齟齬が起きる可能性は十分にあります。そういった表現方法の違いは、レスポンスがアプリケーションのコードに注入される前に、外部APIと自社APIの境界部分でマッピングを行う必要があります。
これは、Google Analyticsに限った話ではなく、様々な外部APIを使用する上で気をつけなければならないことかと思います。

ここで問題として挙げたいのは、

  • 外部APIのリクエストに依存したパラメータの生成が、アプリケーションコードに流入してしまうのが嫌だ
  • 外部APIのレスポンスに依存した値の取り出し方、整形の仕方、キーワードがアプリケーションコードに流入してしまうのが嫌だ

ということです。逆に、これらを1つのモジュールにまとめてしまい、外部APIの仕様についての詳細を隠蔽してしまえば、モジュール強度が上がり、アプリケーション内からの再利用性などが向上します。

Google Analytics API用のモジュールを作成する

app/lib/utils/ga/以下にAPI用のクラスなどをまとめてみます。

app/lib/utils/ga
├── api_client.rb
├── api_request.rb
├── date_range.rb
├── indicator.rb
└── request.rb

0 directories, 5 files

それぞれのクラスの中身を見ながら、ざっくりとした説明をしてみます。
まずは、外部APIとの境界で使うマッピングを定義します。

indicator.rb
module GA
  class Indicator
    def self.metrics_mapper_in
      proc do |metrics|
        table = {
          "users":                 :access,
          "transactions":          :order_count,
          "transactionRevenue":    :revenue,
          "avgSessionDuration":    :avg_session_duration,
          "visitBounceRate":       :visit_bounce_rate
        }
        metrics.collect { |idc| table[idc.to_sym] || idc }
      end
    end

    def self.metrics_mapper_out
      proc do |metrics|
        table = {
          "access":                 :users,
          "order_count":            :transactions,
          "revenue":                :transactionRevenue,
          "avg_session_duration":   :avgSessionDuration,
          "visit_bounce_rate":      :visitBounceRate
        }
        metrics.collect { |idc| table[idc.to_sym] || idc }
      end
    end
  end
end

次に、api_request.rbですが、Google Analytics APIへのリクエストの骨格を表すクラスになります。
リクエストのためには、tokenview_idなどが必要となりますが、今回はuserに持たせているものとして話を進めます。
userとデータの期間指定は、動的にリクエストに注入するとして、リクエストに必要なのは後、dimensionsmetricsになります。
この2つに関しては、それぞれリクエストごとに様々な形をとるため、リクエストクラスに静的に与えて管理します。
その際、以下のクラスを継承することで、実際に作成するリクエストでは基本的にdimensionsmetricsのみを実装すればいいことになります。

api_request.rb
require "google/apis/analyticsreporting_v4"
require "google/api_client/client_secrets"

module GA
  class APIRequest
    def self.opts
      raise NotImplementedError
    end

    def self.init_client(user, date_range = nil)
      client_secrets = Google::APIClient::ClientSecrets.load
      auth_client    = client_secrets.to_authorization

      auth_client.update!(refresh_token: user.google_refresh_token)
      auth_client.fetch_access_token!

      analytics = Google::Apis::AnalyticsreportingV4
      client    = analytics::AnalyticsReportingService.new
      client.authorization = auth_client

      { analytics: analytics, client: client }
    end

    def self.build(user, date_range = nil)
      self.built_in({
        user:       user,
        date_range: date_range
      }.merge(self.opts))
    end

    def self.built_in(builtin)
      mapper  = GA::Indicator.metrics_mapper_out
      metrics = mapper.call(builtin[:metrics])

      metrics_names   = builtin[:metrics]
      init_info       = self.init_client(builtin[:user])
      analytics       = init_info[:analytics]
      client          = init_info[:client]
      date_range      = builtin[:date_range] || [analytics::DateRange.new(start_date: "yesterday", end_date: "yesterday")]
      dimensions      = builtin[:dimensions].collect { |d| analytics::Dimension.new(name: "ga:#{d}") }
      metrics         = metrics.collect { |m| analytics::Metric.new(expression: "ga:#{m}") }

      {
        client:          client,
        view_id:         builtin[:user].view_id,
        metrics:         metrics,
        dimensions:      dimensions,
        metrics_names:   metrics_names,
        date_range:      date_range
      }
    end
  end
end

次に、上のAPIRequestクラスを継承した、具体的なリクエストを見てみたいと思います。

request.rb
module GA
  module Request
    class DailyAccessRequest < APIRequest
      def self.opts
        dimensions = %w(day)
        metrics    = %w(access sessions revenue order_count avg_session_duration visit_bounce_rate)
        { dimensions: dimensions, metrics: metrics }
      end
    end

    class RegionalRequest < APIRequest
      def self.opts
        dimensions = %w(city)
        metrics    = %w(access order_count)
        { dimensions: dimensions, metrics: metrics }
      end
    end

  end
end

とってもスッキリしてリクエストの管理がしやすくなりました。
実際にリクエストを生成するときは、GA::Request::DailySalesRequest.build(user, date_range)だけで終わります。
ここでいうuserとは、Google Analytics APIを使う自社サービスを想定しているため、外部APIであるGoogle Analyticsの連携をしているユーザーを表現しています。また、date_rangeはデータを取得する期間を表現しています。
さて、このdate_rangeですが、配列ではありません。つまりDate.yesterday..Date.todayのような生成ができないということになります。
外部API用の仕様に従って作成する必要のあるパラメータなので、これはGoogle Analyticsモジュール内に隠してしまう方が良いです(モジュールの情報隠蔽)。

date_range.rb
require "google/apis/analyticsreporting_v4"
require "google/api_client/client_secrets"

module GA
  class DateRange
    def self.generate(start_date, end_date)
      analytics = Google::Apis::AnalyticsreportingV4
      if start_date.nil? || end_date.nil?
        [analytics::DateRange.new(start_date: "yesterday", end_date: "yesterday")]
      else
        [analytics::DateRange.new(start_date: start_date, end_date: end_date)]
      end
    end

    def self.yesterday
      start_date = Date.yesterday
      end_date   = Date.yesterday
      self.generate(start_date, end_date)
    end

    def self.last_month
      start_date = Date.today.at_beginning_of_month.prev_month
      end_date   = start_date.at_end_of_month
      self.generate(start_date, end_date)
    end

    def self.this_month
      start_date = Date.today.at_beginning_of_month
      end_date   = Date.yesterday
      self.generate(start_date, end_date)
    end
  end
end

これで、先月のアクセス情報が欲しければ、

  1. date_range = GA::DateRange.last_month
  2. GA::Request:: DailyAccessRequest.build(user, date_range)

とすることで、目的のリクエストを生成することができるようになりました。
では、最後にこのリクエストを送信するためのクライアントを作成します。
Google Analyticsへのすべてのリクエストは、このAPIClientを通して行われ、レスポンスは自社サービスで用いられている名前にマッピングされるように配慮します。

api_client.rb
require "google/apis/analyticsreporting_v4"
require "google/api_client/client_secrets"

module GA
  class APIClient
    def self.send(request)
      analytics = Google::Apis::AnalyticsreportingV4
      mapper    = GA::Indicator.metrics_mapper_in

      built_request = analytics::GetReportsRequest.new(
        report_requests: [analytics::ReportRequest.new(
          view_id:             request[:view_id],
          metrics:             request[:metrics],
          dimensions:          request[:dimensions],
          date_ranges:         request[:date_range],
          include_empty_rows:  true,
        )],
      )

      response = request[:client].batch_get_reports(built_request).to_h[:reports][0][:data][:rows]
      response.present? ? response.collect do |r|
        {
          dimensions: r[:dimensions],
          metrics: mapper.call(request[:metrics_names]).zip(r[:metrics][0][:values].map { |v| v.to_f }).to_h
        }
      end
      :
      {}
    end
  end
end

indicatorを利用することで、レスポンスに含まれるGoogle Analyticsコンテキスト内のキーワードと、自社サービスコンテキスト内のキーワードのマッピングを行うので、アプリケーションコードに外部APIの依存性が流れ込むことはありません。

実際のリクエスト生成とレスポンス確認

これは自分のQiitaのプロフィールに関するアナリティクスデータです。めっちゃ少ねえ。。。

request  = GA::Request::DailyAccessRequest.build(user, date_range)
response = GA::APIClient.send(request)
#== [
#== {:dimensions=>["01"], :metrics=>{:access=>41.0, "sessions"=>44.0, :revenue=>0.0, :order_count=>0.0, :avg_session_duration=>8.340909090909092, :visit_bounce_rate=>86.36363636363636}},
#== {:dimensions=>["02"], :metrics=>{:access=>14.0, "sessions"=>14.0, :revenue=>0.0, :order_count=>0.0, :avg_session_duration=>0.0, :visit_bounce_rate=>100.0}},
#== ...]

目標は達成できたのか

  • 外部APIのコンテキストに含まれるキーワードの流入を防ぐことができた
  • 外部APIのリクエストパラメータ(GAだと期間指定やdimensionsmetricsなど)の生成がアプリケーションコードに流れ込まなくなった
  • 外部APIのレスポンスに依存するコード(データの整形や、今回は行っていないモデルへのマッピングなど)がアプリケーションコードに流れ込まなくなった

以上から、本記事の目標とした要素は達成できたように思います。特に、キーワードのマッピングはActiveRecordのモデルが持つカラムの名前と対応付けると、インスタンス生成がとても楽になります。

結びに

コードの貼り付けばかりでかなり投げやりな投稿になってしまいましたが、このリクエストのモジュール化は、Swift実践入門という本に記載されている、ネイティブアプリでよく用いられる設計に則っています。ネイティブアプリがサーバーにアクセスするように、自社サーバーが外部サーバーにアクセスするのであれば、アプリに適用されているこのような設計をサーバーサイドに導入するのも面白いかもしれません。