🚀

React + Cloud Functionsで、Cloud Storageに画像をアップロード

2021/09/27に公開

はじめに

いくつかハマった点があったのでメモします。
たまたま同じことで詰まっている方のご参考になれば、なお嬉しいです。

前提条件

すでに、以下の作業をしていることを前提として話を進めていきます。
・React(Typescript)プロジェクトを作成している
・Firebaseにプロジェクトを作成して、アプリを登録している
・Firebaseプロジェクトに、Cloud functions、Cloud Storage、エミュレータを追加している
本記事は、画像アップロードに主眼を置いているため、上記の過程の説明は割愛させていただきます。

本題

ここでは、画像をCloud Storageに追加するシステムの実装を仮定して、説明をしていきます。

ディレクトリ構造は、以下のようにします。

project
├── frontend(フロントエンド側)
    └──src
        └──Firebase.ts(Firebase設定ファイル)
	└──index.tsx(エントリーポイント)
	└──views
	    └──Test.tsx(画像アップロード用コンポーネント)
    └──.env(Firebase設定用の機密情報)
    └──.gitignore(git管理下から外すリスト)
    └──build
    └──node_modules
    └──package.json
    └──tsconfig.json
│
│
├── functions(Cloud Functions側)
│    └──src
│        └──index.ts(関数の定義場所)
├── firebase.json(ここで、StorageやFunctionsなどの各サービスのホスト名やポートを設定する)
├── storage.rules

まずは、クライアント側から実装を進めてまいりましょう。
クライアント側からCloud Functionsの関数を呼び出すためには、Firebase SDKを使えるようにする必要があります。
以下のようなFirebase設定ファイルを記述します。

Firebase.ts
import firebase from 'firebase';

/**
 * firebase接続のための設定
 */
const config = {
    // Firebaseにアプリを登録した際に、Firebase SDK snippetに記載されていた情報を、それぞれ当てはめる。
    // ただし、ApiKeyなどの値は、公開してはいけない情報なので、プロジェクト下に「.env」ファイルを作成して、そこに値を記述しておく。.
    // .envファイル自体は、gitignoreでgit管理下に置かないようにする
    // そして、以下のように.envファイルに設定した環境変数を呼び出す。
    apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
    authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
    projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
    storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
    messagingSenderId: process.env.REACT_APP_FIREBASE_SENDER_ID,
    appId: process.env.REACT_APP_FIREBASE_APP_ID,
    measurementId: process.env.REACT_APP_FIREBASE_MEASUREMENT_ID
};
  
firebase.initializeApp(config);

// エミュレータを使う場合、useEmulatorをする必要がある
const functions = firebase.functions();
functions.useEmulator("0.0.0.0", 5001);
 
const storage = firebase.storage();
storage.useEmulator("0.0.0.0", 9199);

export default firebase;

ターミナルのprojectディレクトリにて、

npx firebase emulators:start 

を実行すれば、エミュレータが立ち上がるはずです。

続いて、画像アップロード用のコンポーネントを記述していきます。

Test.tsx
import React, { useState } from "react";
import firebase from './Firebase';

/**
 * クライアント側の画像アップロード用のコンポーネント
 * @returns 画像アップロードフォームが画面に表示される
 */
export const Test = () => {
    const [imageFile, setImateFile] = useState<File | null>()

    const submit = async (e: React.FormEvent<HTMLFormElement>) => {
        e.preventDefault();

        /**
         * 画像ファイルが空の場合、アラートを出すだけで、何もせずに返す
         */
        if (!imageFile)
        {
            alert("画像ファイルが入力されていません")
            return
        }

        // 画像ファイルをBase64形式に変換するために、FileReaderを使う
        const reader = new FileReader();

        /**
         * readerによってimageFileが読み込まれる度に、onloadが発動する
         */
        reader.onload = async() => {
            /**
             * DataURLに変換された画像データから、余分な箇所を削ぎ落とす
             */
            const imageBase64Data = reader.result?.toString()?.replace(/data:.*\/.*;base64,/, '');

            // Cloud Functionsに送るデータ
            const requestData = { imageBase64Data : imageBase64Data, imageFileName: imageFile?.name }

            try
            {
                const httpsCallable = firebase.functions().httpsCallable("updateImage");

                await httpsCallable(requestData)
                console.log("画像をアップロードしました");
            }
            catch(error)
            {
                console.log(error);
                alert("画像のアップロードに失敗しました");
            }
        }

        /**
         * 画像ファイルをDataURLに変換
         */
        reader.readAsDataURL(imageFile);
    }

    return (
        <div>
            {/* 画像アップロード用のフォーム */}
            <form onSubmit={submit}>
                <input multiple id="imageFile" name="imageFile" type="file" accept="image/jpeg, image/png" onChange={(e) => setImateFile(e.target.files?.[0])}/>
                <button>アップロード</button>
            </form>
        </div>
    )
}

上記のコンポーネントを、ルートで表示されるようにします。

index.tsx
import React from 'react';
import ReactDOM from 'react-dom';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';
import { Switch, Route } from 'react-router-dom';
import { Test } from './views/Test';

ReactDOM.render(
  <React.StrictMode>
      <BrowserRouter>
        <Switch>
            <Route exact path="/" component={Test}></Route>
        </Switch>
      </BrowserRouter>
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

さて、次はFunctions側の実装をしていきます。
Test.tsxのhttpsCallableで呼び出した関数を、ここで定義します

index.ts
import * as functions from "firebase-functions";
import * as admin from "firebase-admin";

admin.initializeApp(functions.config().firebase)

const db = admin.firestore();

const storage = admin.storage();

exports.updateImage = functions.https.onCall(async (data) => {
    // 画像をBase64からBufferに変換する
    const buffer = Buffer.from(data.imageBase64Data, "base64")

    // Cloud Storageへの画像の追加
    const bucketFilePath = `Images/${data.imageName}`
    const file = storage.bucket().file(bucketFilePath)
    await file.save(buffer);
    
})

これで、仮定したものが完成したはずです。
projectディレクトリで、

npx firebase emulators:start 

を実行して、エミュレータを起動し、ルートにアクセスすると、画像アップロードフォームが表示されるはずです。

まとめ

駆け足になりましたが、いかがだったでしょうか。
本記事で紹介したものの他、Authenticationで認証をしたり、Hostingでデプロイしたりすると、より本格的なアプリに近づいていきます。

Discussion