FirebaseとCloudFunctionで作るサーバレスなファイル処理

はじめに

テクノロジー本部バックエンド開発グループの狭間です。所属はバックエンドですが、フロントエンドやインフラなど色んなことをやらせてもらってます。
今回はファイルの処理について書こうと思います。それなりに大きなファイルをオンラインで処理しようとするとタイムアウトや負荷の問題もあり、バックグラウンドで実行したいとは思うのですが、インフラの準備等を考えると結構手間だったりします。そういった手間をマネージドサービスの組み合わせで省けそうというのが今回の記事になります。
今回はFirebaseにホスティングされたフロントエンドからCloud Storageにファイルをアップロードし、その結果をCloud Firestoreに保存し、UIに表示するアプリケーションをサンプルとして実装してみました。

Firebaseの設定

プロジェクトの作成などFirebaseに関する操作は公式のドキュメント等にまとまっていると思うので、ここでは何を設定したかレベルの記述に留めます。
今回フロントエンドはReactを使うので、Webアプリを作成しました。データベースはCloud Firestoreを使用しました。認証はなしでもよかったのですが、後々使うこともあるかと思い、Googleの認証を設定しました。
認証を使用するのでデータベースは認証ユーザーのみ読み書きできる設定にしました(もっと細かく設定できますが、サンプルなので今回はこれで)。公式のままですが、下記のようなルールを設定してあります。

service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if request.auth.uid != null;
    }
  }
}

クライアントの実装

今回フロントエンドはReactで実装しました。Firebase関連の操作にはReact Firebase Hooksを使いました。
まずはCreate React Appでアプリケーションを作成します。TypeScriptを使いたかったので今回は下記のコマンドで作成しました。

npx create-react-app my-app --template typescript

雛形ができたらまずはFirebaseの初期化処理を追加します。
index.tsxに下記初期化処理を追加しました。Firebaseの設定値はprocess.envから取得するようにしました。

import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import * as serviceWorker from './serviceWorker';
import * as firebase from 'firebase/app';

const firebaseConfig = {
  apiKey: process.env.REACT_APP_API_KEY,
  authDomain: `${process.env.REACT_APP_PROJECT_ID}.firebaseapp.com`,
  databaseURL: `${process.env.REACT_APP_PROJECT_ID}.firebaseio.com`,
  projectId: process.env.REACT_APP_PROJECT_ID,
  storageBucket: `${process.env.REACT_APP_PROJECT_ID}.appspot.com`,
  messagingSenderId: process.env.REACT_APP_MESSAGING_SENDER_ID
}

// Initialize Firebase
firebase.initializeApp(firebaseConfig);
ReactDOM.render(<App />, document.getElementById('root'));
serviceWorker.unregister();

認証まわりの処理

これで初期化できたので、次は認証の処理を追加します。
実際に使うとなったら認証がないと困るのでまずは認証を実装しました。
React Firebase HooksのサンプルFirebaseの公式ドキュメントを参考に作ってあります。

import React from "react";
import "./App.css";
import firebase from "firebase";
import { useAuthState } from "react-firebase-hooks/auth";
import ExampleDataViewer from "./ExampleDataViewer";

const App: React.FC = () => {
  const [user, initialising] = useAuthState(firebase.auth());
  const login = async () => {
    const provider = new firebase.auth.GoogleAuthProvider();
    provider.addScope("https://www.googleapis.com/auth/contacts.readonly");
    await firebase.auth().signInWithPopup(provider);
  };
  const logout = () => {
    firebase.auth().signOut();
  };

  if (initialising) {
    return (
      <div>
        <p>Initialising User...</p>
      </div>
    );
  }
  if (user) {
    return (
      <div>
        <p>Current User: {user.email}</p>
       {/* ↓データを表示するコンポーネント。詳細は後述。 */}
        <ExampleDataViewer user={user.uid} />
      </div>
    );
  }
  return <button onClick={login}>Log in</button>;
};

export default App;

データ表示部分の実装

次にデータを表示するコンポーネントを実装します。
まず var storageRef = firebase.storage().ref(); ファイルアップデート用のStorageオブジェクトを取得します。このオブジェエクトを利用してCloud Storageにアップロードします。
次にCloud Firestoreのデータへの参照をHooks経由で取得します。
データの同期はライブラリ側がやってくれるので、サーバーのデータが書き換わるとクライント側のデータも更新されます。

const [values, loading, error] = useCollectionData<Data>(
    firebase.firestore().collection("example"),
    {
      idField: "id",
      snapshotListenOptions: { includeMetadataChanges: true }
    }
  );

データをファイル単位に識別したいので、ファイルアップデート時のファイル名をUUIDにして、それをデータベースとキーになるようにしています。

const fileId = uuidv4();
const file = e.target.files[0];
const child = storageRef.child(fileId);
await child.put(file);
firebase
  .firestore()
  .collection("example")
  .doc(fileId)
  .set({
    id: fileId,
    userId: user,
    fileName: file.name
  });

データを表示するコンポーネントの全体は下記のようになりました。

import React from "react";
import firebase from "firebase";
import { useCollectionData } from "react-firebase-hooks/firestore";
import { v4 as uuidv4 } from "uuid";

type Data = {
  id: string;
  userId: string;
  fileName: string;
  data: string[];
};

type Props = {
  user: string;
};

const ExampleDataViewer: React.FC<Props> = ({ user }) => {
  var storageRef = firebase.storage().ref();
  const [values, loading, error] = useCollectionData<Data>(
    firebase.firestore().collection("example"),
    {
      idField: "id",
      snapshotListenOptions: { includeMetadataChanges: true }
    }
  );
  if (loading) {
    return <div>Loading...</div>;
  }
  if (error) {
    return <div>{`Error: ${error.message}`}</div>;
  }

  return (
    <>
      {values && (
        <ul>
          {values.map(value => (
            <li key={value.fileName}>
              {value.id} {value.fileName}
              <ul>
                {value.data && (value.data.map((data, idx) => (
                  <li key={idx}>{data}</li>
                )))}
              </ul>
            </li>
          ))}
        </ul>
      )}
      <div>
        <input
          type="file"
          onChange={async e => {
            if (e.target.files?.length) {
              const fileId = uuidv4();
              const file = e.target.files[0];
              const child = storageRef.child(fileId);
              await child.put(file);
              firebase
                .firestore()
                .collection("example")
                .doc(fileId)
                .set({
                  id: fileId,
                  userId: user,
                  fileName: file.name
                });
            }
          }}
        />
      </div>
    </>
  );
};

export default ExampleDataViewer;

サーバーサイドの実装

サーバーサイドはGoogle Cloud Storage トリガーを使って起動します。Cloud Functionsを起動するトリガーは4種類あるのですが、今回はオブジェクトのファイナライズ(オブジェクトの作成完了)を使用します。(ファイナライズ以外のトリガーはこちら)
トリガーの種類はデプロイ時に指定します。

まずは動かしてみる

今回は一番簡単そうだったNode.jsを使うことにします。
まずは公式のサンプルを動かしてみます。
特定のディレクトリにindex.jsを作り、そのディレクトリで下記の実行するだけでデプロイできます。

gcloud functions deploy helloGCSGeneric --runtime nodejs8 --trigger-resource <<プロジェクトID>>.appspot.com --trigger-event google.storage.object.finalize

デプロイが終わったらフロントエンドをyarn startで起動しファイルをアップロードしてみると、デプロイしたサーバーサイドの処理が起動されます。
Firebaseの管理画面の開発>Functions>ログのメニューからログを確認できます。そこでconsole.logで出力されたファイル名等を確認できると思います。
次にファイルをCloud Storageから取得する処理を実装します。
まずfirebase-adminyarn addします。Cloud Functionsではデプロイ時に依存関係を解決してくれるようなので、通常のNode.jsのアプリと同じくyarn addしていくだけでOKです。

ファイルを読む処理ですが、まずfirebase-adminを初期化しファイルをダウンロードします。Cloud Functionsでは/tmpにのみ書き込み権限が付与されているので、/tmp配下にファイルを保存します。

const admin = require('firebase-admin');
admin.initializeApp();
const filePath = `/tmp/${file.name}`;
await admin.storage()
  .bucket(file.bucket)
  .file(file.name)
  .download({
    'destination': filePath
  });

保存してしまえばあとは普通に読み込むだけです。今回はテキストファイルが送信される前提で1行ずつ読むようにしました。

const stream = fs.createReadStream(filePath, 'utf8');
const reader = readline.createInterface({ input: stream });
const fileRecords = [];
reader.on('line', (data) => {
  fileRecords.push(data);
});

読み込んだ内容をFirestoreに書き戻して完了です。
ファイル名とレコードのキーを同じにしたあるので、admin.firestore().collection('example').doc(file.name).get(); で更新対象のレコードを取得しています。

const record = await admin.firestore().collection('example').doc(file.name).get();
  console.log(record);
  const newValue = {
    ...record.data(),
    data: fileRecords
  };

上記の処理を全部つなげるとこんな感じになります。
フロントエンドがFirestoreの内容を常に同期しているので、バックエンドから更新すればフロントエンドの表示内容も更新されます。

const admin = require('firebase-admin');
const fs = require('fs');
const readline = require("readline");

exports.readBucketFile = async (data, context) => {
  const file = data;
  admin.initializeApp();
  console.log('app initialized.');
  const filePath = `/tmp/${file.name}`;
  await admin.storage()
    .bucket(file.bucket)
    .file(file.name)
    .download({
      'destination': filePath
    });
  console.log('file downloaded');
  const stream = fs.createReadStream(filePath, 'utf8');
  const reader = readline.createInterface({ input: stream });
  const fileRecords = [];
  reader.on('line', (data) => {
    fileRecords.push(data);
  });
  console.log(`file read ${fileRecords}`);
};

まとめ

FirebaseとCloud Functionsを組み合わせることで、これだけ簡単にファイル処理を実現することができました。関数の呼び出しが複数回行われることを許容できない処理など、これだけでは実現できないものもありますが、そういった制約がない場合においては有効な方法ではないかと思います。
また一つの処理を複数に分割できる場合(今回のようなテキストファイルの処理で行ごとに並列に動かせる場合など)においてはGoogle Cloud Pub/Sub トリガーを使って並列に処理させることも可能なので、色々応用させることもできそうです。
早くリリースしてスピーディーにPDCAをまわせるということはエンジニアにとって重要なことだと思うので、こういった手法も取り入れながらより価値を出せるようになっていきたいと改めて思いました。