1. mtskhs

    No comment

    mtskhs
Changes in body
Source | HTML | Preview
@@ -1,164 +1,433 @@
本記事は、NTTコミュニケーションズ Advent Calendar 2019 8日目の記事です。
## これは何
- スポーツ解説アプリ [SpoLive](https://spo.live/) における分析基盤構築についてのノウハウ共有
- サーバーレスで、要件に応じて柔軟に分析できる基盤を構築した話
## SpoLive について
[SpoLive](https://spo.live/) は、「スポーツファンとアスリートの距離を、デジタルの力で縮める」ことをビジョンに掲げた新サービスです。例えば試合中に、「なんで今のプレーが許されてるんだっけ?」「この選手どんな人だっけ?」といった気になることをすぐに解決できるのはもちろんのこと、より手軽に、より深く豊かな選手の情報に触れることができるようなアプリを目指して日々アップデートを重ねています。
本記事では、SpoLiveにおける分析基盤を構築したノウハウを述べます。構築の前提条件として下記がありました。
- データをビジネスサイドのメンバー(SQL を書いて分析できるメンバーがいる)が柔軟に分析できる基盤を用意したい
- 開発・運用コストはできるだけ抑えたい
### 分析基盤
SpoLive は、`Firebase` と `expo` で開発をしています。
基盤構築の方針として下記の方針が考えられますが、後述の理由で`2` を採用しました。
- 1. Firebase Analytics + BigQuery
- 2. Google Analytics + BigQuery
- 3. 別の分析ツール(Amplitude・Mix Panel 等)
理由: Firebase を利用していたら、 `Firebase Analytics` を利用することが主流だと思いますが、 `expo` において対応できるライブラリは(自分の知る限り)なく、別の分析ツールではなくまずは世の中の知見も多くシンプルな構成から入ろうと考えたためです。
Bigqueryは、データ分析にあたってはよく用いられているサービスで、SQLで複数データを統合して集計することも用意ですし、例えば、[Googleデータポータルを利用して可視化すること](https://cloud.google.com/bigquery/docs/visualize-data-studio?hl=ja) などができ、柔軟に分析ができます。
## 技術要素
前述の方針に基づき、分析基盤を構築するにあたり、下記の要素に分割できます。
```
アプリ =(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 へアプリの利用状況に関するデータを収集します。
```js
// 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);
```
```js
// 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
```
```Node.js
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
-GoogleAnalytics の Node.js向け SDKを利用します。
+GAからのデータ取得には、GoogleAPIs の [Node.js向け SDK](https://googleapis.dev/nodejs/googleapis/latest/analyticsreporting/index.html)を利用します。
+(3)とあわせて、一つのFunctionsとして実装します。
+
-query は metrics / dimension をみるとよいです
-- サンプルコードは下記
+API利用時の認証情報は、[Functionsの環境変数](https://firebase.google.com/docs/functions/config-env?hl=ja)として保存しますが、ローカルでの開発時も functinsディレクトリで、 `firebase functions:config:get > .runtimeconfig.json` としておくと、ローカルでも環境変数が(jsonファイルの値から)利用できます。
+Functionsのメイン処理は下記のイメージです
-また、API利用時の認証情報
-~~しておくと、ローカルでも環境変数を利用できます
+```Node.js
+
+// 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のイベントデータを取得する処理は下記のとおり
```Node.js
+// libs/googleAnalytics
+
+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
- データ追加処理毎に課金されるので、データを改行区切り JSON に出力してインポートさせている
+Bigqurey の Node.js向け SDK `google-cloud/bigquery` を利用します。
+Bigqureyへのデータインポートには、CloudStorageにあるファイルから一括インポートする方法と、 [一度に 1 レコードずつ BigQuery にデータをストリーミング処理でインポートする方法](https://cloud.google.com/bigquery/streaming-data-into-bigquery?hl=ja)がありますが、公式ドキュメントにもありますがコスト的には [ストリーミング処理は必要がなければ避けるべき](https://cloud.google.com/bigquery/docs/best-practices-costs?hl=ja#use_streaming_inserts_with_caution)です。
+
+上記の理由から、データを改行区切り JSON にしてCloudStorageへ出力し、Bigqueryへインポートさせます。
+
- - サンプルコードは下記
+```Node.js
+
+// 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}.appspot.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}.appspot.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](https://spo.live/)では、これからも利用者の方により楽しくスポーツを楽しんでいただけるアプリを目指して日々改善中です。ぜひご利用ください。
明日は〜TODO〜