本記事は、NTTコミュニケーションズ Advent Calendar 2019 8日目の記事です。
昨日は、 @y-i さんの 社内ISUCONで優勝した時にやったこと でした。
これは何
- スポーツ解説アプリ SpoLive における分析基盤構築についてのノウハウ共有
- サーバーレスで、要件に応じて柔軟に分析できる基盤を構築した話
SpoLive について
SpoLive は、「スポーツファンとアスリートの距離を、デジタルの力で縮める」ことをビジョンに掲げた新サービスです。例えば試合中に、「なんで今のプレーが許されてるんだっけ?」「この選手どんな人だっけ?」といった気になることをすぐに解決できるのはもちろんのこと、より手軽に、より深く豊かな選手の情報に触れることができるようなアプリを目指して日々アップデートを重ねています。
本記事では、SpoLiveにおける分析基盤を構築したノウハウを述べます。構築の前提条件として下記がありました。
- データをビジネスサイドのメンバー(SQL を書いて分析できるメンバーがいる)が柔軟に分析できる基盤を用意したい
- 開発・運用コストはできるだけ抑えたい
分析基盤
SpoLive は、Firebase
と expo
で開発をしています。
基盤構築の方針として下記の方針が考えられますが、後述の理由で2
を採用しました。
-
- Firebase Analytics + BigQuery
-
- Google Analytics + BigQuery
-
- 別の分析ツール(Amplitude・Mix Panel 等)
理由: Firebase を利用していたら、 Firebase Analytics
を利用することが主流だと思いますが、 expo
において対応できるライブラリは(自分の知る限り)なく、別の分析ツールではなくまずは世の中の知見も多くシンプルな構成から入ろうと考えたためです。
Bigqueryは、データ分析にあたってはよく用いられているサービスで、SQLで複数データを統合して集計することも容易ですし、例えば、Googleデータポータルを利用して可視化すること などができ、柔軟に分析ができます。
技術要素
前述の方針に基づき、分析基盤を構築するにあたり、下記の要素に分割できます。
アプリ =(1)=> Google Analytics =(2)=|
|
Firebase =(3)==> BigQyery
上記のうち、(2)(3)のGoogle Analytics/FirebaseからBigqueryへ転送するタスクは、Node.jsで実装し、CloudFunctions の 定期ジョブ としてデプロイします。
理由として、BigQuery のNode.js SDKが世の中に知見が溜まっていそうな点、expo(ReactNative)と技術要素をjsで揃えることによる、メンバーのメンテしやすさの点がありました。また、後述する通り、CloudFunctionsの定期ジョブは、コードだけでスケジューリング可能で、コンソールで手動で設定・・といった必要がなく、変更管理のしやすさを感じています。
(1)アプリ から Google Analytics
ライブラリ expo-analytics
を利用して、アプリから GoogleNAalytics へアプリの利用状況に関するデータを収集します。
// analitics.js
import { Analytics as GoogleAnalytics, ScreenHit, Event } from "expo-analytics";
class Analytics {
constructor(code = null) {
this.ga = null;
this.code = code;
}
init = () => {
this.ga = new GoogleAnalytics(this.code);
};
EventHit = (category = null, action = null, label = null, value = 0) => {
if (category && action) {
const params = [category, action];
if (label) {
params[2] = label;
if (value >= 0) {
params[3] = value;
}
}
this.ga.event(new Event(...params));
}
};
}
export default new Analytics(gaId);
// someview.js
import Analytics from "app/src/libs/analytics";
Analytics.EventHit(
"categoryName",
"actionName",
"labelName",
);
転送タスクのデプロイ・自動起動
前述のとおり、(2)(3)のGoogle Analytics/FirebaseからBigqueryへ転送するタスクは、Node.jsで実装し、CloudFunctions の 定期ジョブ としてデプロイします。
# ディレクトリ構成
functions/src/index.ts
|
|--- modules/
|
|--- transferBigquery.ts
import * as functions from "firebase-functions";
import { transferBigquery } from "./modules/transferBigquery";
// bigqueryへの定期エクスポート
module.exports.transferBigquery = functions
.region("asia-northeast1")
.runWith({
timeoutSeconds: 9 * 60, // max: 9min
memory: "1GB"
})
.pubsub.schedule("0 4 * * *")
.timeZone("Asia/Tokyo")
.onRun(transferBigquery);
(2)Google Analytics から BigQuery
GAからのデータ取得には、GoogleAPIs の Node.js向け SDKを利用します。
(3)とあわせて、一つのFunctionsとして実装します。
API利用時の認証情報は、Functionsの環境変数として保存しますが、ローカルでの開発時も functionsディレクトリで、 firebase functions:config:get > .runtimeconfig.json
としておくと、ローカルでも環境変数をjsonファイルの値から利用できます。
Functionsのメイン処理は下記のイメージです
// transferBigquery.ts
import firebase from "./firebase-admin";
import moment from "moment";
import { Analytics } from "../libs/googleAnalytics";
import { BigQuery } from "@google-cloud/bigquery";
import { Storage } from "@google-cloud/storage";
// main関数で、class初期化・メソッド実行
export const transferBigquery = async () => {
const projectId = process.env.GCLOUD_PROJECT;
if (!projectId) {
console.error("projectId is invalid", projectId);
return;
}
// firestoreの環境変数としてGAの認証情報を保存
const { client_email, private_key, view_id } = functions.config().googleanalytics
const ga = new Analytics(client_email, private_key, view_id);
const tb = new TransferBigquery(projectId) // 後述
// GAのデータは過去1日分を入れ替え
const start: Moment = moment().add(-1, "days")
const end: Moment = moment().add(-1, "days")
await tb._delete_eventdata_from_bigquery(start,end)
await tb._export_eventdata_to_storage(start, end) // ここが本節で説明したい部分
await tb._save_eventdata_to_bigquery()
//firebaseの試合情報は、全件更新
await tb._export_gamedata_to_storage(); // 後述
await tb._save_gamedata_to_bigquery(); // 後述
};
export class TransferBigquery {
private projectId: string
private suffix: string
constructor(projectId: string) {
this.projectId = projectId
}
async _export_eventdata_to_storage(start: Moment, end: Moment) {
const eventData = await this.analytics._eventToJson(start, end) // GAからイベントデータを取得
return await this._exportJSON(eventData.join("\n"), `events.json`); //CloudStorageへ保存(後述)
}
// ...省略...
}
GAのイベントデータを取得する処理は下記のとおり
// libs/googleAnalytics.ts
import { google } from "googleapis";
import { Moment } from "moment";
export const Analytics = class {
private jwtClient: any;
private analytics: any;
private viewId: string;
constructor(clientEmail: string, privateKey: string, viewId: string) {
this.jwtClient = new google.auth.JWT(
clientEmail,
undefined,
privateKey,
["https://www.googleapis.com/auth/analytics.readonly"],
undefined,
);
this.analytics = google.analytics("v3");
this.viewId = viewId;
}
// event以外の情報を取得したい場合は、別のパラメータを指定する
// - Dimensions & Metrics Explorer: https://ga-dev-tools.appspot.com/dimensions-metrics-explorer/
_event_param(date: Moment) {
const params: any = {
"start-date": date.format("YYYY-MM-DD"),
"end-date": date.format("YYYY-MM-DD"),
metrics: "ga:uniqueEvents",
dimensions:
"ga:eventCategory,ga:eventAction,ga:eventLabel",
sort: "ga:eventCategory"
};
return params
}
// 指定期間のデータを取得しJSONを生成
async _eventToJson(start: Moment, end: Moment) {
const eventJson = []
while (start.diff(end) <= 0) {
const eventData = await this._fetch_report_data(this._event_param(start));
if (eventData == undefined) {
start.add(1, "days");
continue
}
console.log(`add gaEvent: ${start.format("YYYY-MM-DD")}`)
for (const e of eventData) {
eventJson.push(JSON.stringify({
date: start.format("YYYY-MM-DD"),
eventCategory: e[0],
eventAction: e[1],
eventLabel: e[2],
uniqueEvents: e[3],
}))
}
start.add(1, "days");
}
return eventJson
}
// GAからデータ取得
async _fetch_report_data(params: any): Promise<any[]> {
params.auth = this.jwtClient;
params.ids = `ga:${this.viewId}`;
return new Promise((resolve, reject) => {
this.jwtClient.authorize((err: any, tokens: any) => {
if (err) {
reject(err);
}
this.analytics.data.ga.get(params, (err: Error, resp: any) => {
if (err) {
reject(err);
}
resolve(resp.data.rows);
})
});
});
}
};
(3)Firebase から BigQuery
Bigqurey の Node.js向け SDK google-cloud/bigquery
を利用します。
Bigqureyへのデータインポートには、CloudStorageにあるファイルから一括インポートする方法と、 一度に 1 レコードずつ BigQuery にデータをストリーミング処理でインポートする方法がありますが、公式ドキュメントにもありますがコスト的には ストリーミング処理は必要がなければ避けるべきです。
上記の理由から、データを改行区切り JSON にしてCloudStorageへ出力し、Bigqueryへインポートさせます。
// transferBigquery.ts
// // 前述のメイン処理抜粋
// // firebaseの試合情報は、全件更新
// await tb._export_gamedata_to_storage();
// await tb._save_gamedata_to_bigquery();
export class TransferBigquery {
private projectId: string
private analytics: any
private location: string
constructor(projectId: string, analytics: any) {
this.projectId = projectId
this.analytics = analytics
this.location = 'US' //Bigquery>datasetのlocationを変更した場合はここも変更する
}
async _export_gamedata_to_storage(){
const gameData = await firebase
.firestore()
.collection(`games`)
.get()
.then(querySnapshot => {
const returnData: string[] = [];
querySnapshot.forEach(doc => {
const {
team_homeName = null,
team_awayName = null
} = doc.data();
// BigQueryがサポートしている改行区切りJSONを出力するために、ここで整形
returnData.push(
JSON.stringify({
gameId: doc.id,
team_homeName,
team_awayName
})
);
});
return returnData;
});
return await this._exportJSON(gameData.join("\n"), "games.json");
};
// Cloud Storageへの保存
async _exportJSON(jsonText: string, filename: string) {
const storage = new Storage();
const bucket = storage.bucket(`${this.projectId}.some_storage.com`);
bucket.file(`somedir/${filename}`).save(jsonText, err => {
if (!err) {
bucket.file(`somedir/${filename}`).setMetadata(
{
metadata: {
contentType: "application/json"
}
},
(err: any, apiResponse: any) => {
if (err) {
console.log("err", err);
} else {
console.log(`finish saving ${JSON.stringify(apiResponse)}`);
}
}
);
} else {
console.log("fail saving", err);
}
});
};
// Clound Storage からBigqueryへインポート
async _save_gamedata_to_bigquery() {
const schema: any = {
fields: [
{ name: "gameId", type: "STRING", mode: "NULLABLE" },
{ name: "team_homeName", type: "STRING", mode: "NULLABLE" },
{ name: "team_awayName", type: "STRING", mode: "NULLABLE" }
]
};
return await this._saveBigquery({
bqSchema: schema,
tabeleName: "games",
filename: `games.json`,
isAppendMode: false,
});
};
_loadStorageFile(filename: string) {
const storage = new Storage();
const bucket = storage.bucket(`${this.projectId}.some_storage.com`);
return bucket.file(`somedir/${filename}`);
};
async _saveBigquery(params: BigqueryParam) {
const { bqSchema, tabeleName, filename, isAppendMode } = params
const datasetId = "some_dataset";
if (!this.projectId) {
console.error("projectId is invalid", this.projectId);
return;
}
const bigquery = new BigQuery({ projectId: this.projectId });
const table = bigquery.dataset(datasetId).table(tabeleName);
// https://cloud.google.com/bigquery/docs/reference/auditlogs/rest/Shared.Types/WriteDisposition
const importMode: string = isAppendMode ? "WRITE_APPEND" : "WRITE_TRUNCATE";
const metadata: any = {
sourceFormat: "NEWLINE_DELIMITED_JSON",
schema: bqSchema,
// Set the write disposition to overwrite existing table data.
writeDisposition: importMode,
};
table.load(this._loadStorageFile(filename), metadata, (err, apiResponse) => {
if (err) {
console.log("err", err);
} else {
console.log(`finish saving: ${JSON.stringify(apiResponse)}`);
}
return;
});
};
}
最後に
本記事では、Firebase / Cloud Functins / BigQuery などのクラウド基盤上に、サーバーレスでユーザー分析基盤を構築するノウハウについて述べました。
スポーツ解説アプリSpoLiveでは、これからも利用者の方により楽しくスポーツを楽しんでいただけるアプリを目指して日々改善中です。今現在、ラグビーやサッカーの試合がお楽しみいただける他、今後も対応スポーツを拡大していく予定です。ぜひご利用ください。
明日は、 @tetrapod117 さんの記事です。