この記事は株式会社Pearによるアドベントカレンダーの4日目の記事です。
APIサーバーを構築する際、自社製のAPI以外にも、サードパーティ製の外部APIを利用する場面は多々あると思います。
そういったときに、皆さんAPIごとにクラスを分けたり、名前空間を与えたりしているでしょう。
この記事では、Pearがどのように外部APIのリクエストを管理しているか、例としてGoogle Analytics(GA)を取り上げて、Ruby on Railsからリクエストする例をご紹介します。
この記事で目標とすること
- 外部APIごとに、そのクライアントを作成しモジュール化することで、API独自のパラメータフォーマットへの依存がアプリケーション内に流入してしまうことを防ぐ
- 外部APIからのレスポンスに含まれるキーワードがアプリケーションに依存しないよう、自社APIとの境界線でマッピングを行う
対象
- 外部APIを利用しているが、そのコードがアプリケーション中に垂れ流されている状態をなんとかしたい方
- 言語やフレームワークに依存する話ではないので、ネイティブアプリでも適用できる手法だと思います (実際アプリで主流だと思われる設計です)
本記事での実行環境
- 自社API: Ruby on Rails
- 外部API: Google Analytics
- 使用するライブラリ: google-api-ruby-client
Google Analytics APIに関する記事
Google Analytics API を叩いてデータを取得するまでの流れ(Ruby)
[Ruby] Google アナリティクス Reporting API v4で、本日のページごとのユーザ数を取得する
Google Analytics APIの特徴
基本的には、上記の2記事の手順通りに進めることで、目的の値を得ることができます。
しかし、スクリプト的に用いる用途ではなく、自社APIから利用する外部APIという位置づけで利用するのであれば、リクエストの管理とレスポンスのパースという点で抽象化する必要があります(した方が健康的になれます)。
Google Analytics APIの特徴として、
-
dimensions
とmetrics
という概念がある -
dimensions
を指定していない場合、レスポンスに含まれる値はArrayになる -
dimensions
とmetrics
を指定した場合、レスポンスに含まれる値はHashになる -
dimensions
やmetrics
に馴染みのない単語が使われている(自社サービスとは異なる単語が用いられている)
最後のは自分の英語のセンスがないからかもしれませんが、、、
とはいえ、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との境界で使うマッピングを定義します。
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へのリクエストの骨格を表すクラスになります。
リクエストのためには、token
やview_id
などが必要となりますが、今回はuser
に持たせているものとして話を進めます。
user
とデータの期間指定は、動的にリクエストに注入するとして、リクエストに必要なのは後、dimensions
とmetrics
になります。
この2つに関しては、それぞれリクエストごとに様々な形をとるため、リクエストクラスに静的に与えて管理します。
その際、以下のクラスを継承することで、実際に作成するリクエストでは基本的にdimensions
とmetrics
のみを実装すればいいことになります。
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
クラスを継承した、具体的なリクエストを見てみたいと思います。
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モジュール内に隠してしまう方が良いです(モジュールの情報隠蔽)。
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
これで、先月のアクセス情報が欲しければ、
date_range = GA::DateRange.last_month
GA::Request:: DailyAccessRequest.build(user, date_range)
とすることで、目的のリクエストを生成することができるようになりました。
では、最後にこのリクエストを送信するためのクライアントを作成します。
Google Analyticsへのすべてのリクエストは、このAPIClient
を通して行われ、レスポンスは自社サービスで用いられている名前にマッピングされるように配慮します。
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だと期間指定や
dimensions
、metrics
など)の生成がアプリケーションコードに流れ込まなくなった - 外部APIのレスポンスに依存するコード(データの整形や、今回は行っていないモデルへのマッピングなど)がアプリケーションコードに流れ込まなくなった
以上から、本記事の目標とした要素は達成できたように思います。特に、キーワードのマッピングはActiveRecordのモデルが持つカラムの名前と対応付けると、インスタンス生成がとても楽になります。
結びに
コードの貼り付けばかりでかなり投げやりな投稿になってしまいましたが、このリクエストのモジュール化は、Swift実践入門という本に記載されている、ネイティブアプリでよく用いられる設計に則っています。ネイティブアプリがサーバーにアクセスするように、自社サーバーが外部サーバーにアクセスするのであれば、アプリに適用されているこのような設計をサーバーサイドに導入するのも面白いかもしれません。