第8回 2020年版 React+Firebaseで画像のアップロード（その３）

# 1. 概要
[前回の記事](https://qiita.com/tetsurotayama/items/5129f0cfb21f9ec9b9a0)でFirebaseに画像をアップロードするプログラムを開発しましたが、今回はFirebase Cloud Functionsを利用して、画像アップロードをトリガーにサムネイルを作成する方法について説明します。

# 2. 前提条件
## 作業日時
- 2020/3/24

## 環境
- MacBook Pro (15-inch, 2018)
- macOS Catalina 10.15.2（19C57）

## ソフトウェアのバージョン

| 分類 | ソフトウェア | バージョン |
|:-:|:-:|:-:|
|静的型付け     | [TypeScript](https://www.typescriptlang.org/) | 3.7.5   |
|Firebase CLI | firebase-tools                                | 7.15.1  |
|ライブラリ      | firebase-admin                                | 8.6.0   |
|ライブラリ      | firebase-functions                            | 3.3.0   |

# 3. 前提条件
前々回の記事で、Firebase SDK for Cloud Functions は初期化されている前提です。 `firebase init` で初期化をすると、以下のようなファイル群が作成されます。
＊functions以外のファイルについては割愛しています。

```console
firebase-storage-sample
 +- .firebaserc    # Hidden file that helps you quickly switch between
 |                 # projects with `firebase use`
 |
 +- firebase.json  # Describes properties for your project
 |
 +- functions/     # Directory containing all your functions code
      |
      +- .eslintrc.json  # Optional file containing rules for JavaScript linting.
      |
      +- package.json  # npm package file describing your Cloud Functions code
      |
      +- index.ts      # main source file for your Cloud Functions code
      |
      +- node_modules/ # directory where your dependencies (declared in
                       # package.json) are installed
```

# 4. Node.jsのバージョンをCloud Functionsの対応バージョンに合わせる
Cloud Functionsはv8かv10(ベータ)しか対応していないため、開発環境のNode.jsのバージョンをいずれかに合わせる必要があります。

## 4.1. Cloud functionsのNode.jsのバージョンの設定
以下funcions配下にある`package.json`の`engines`の設定で、Node.jsのバージョンを指定します。 今回はv10を利用するため、`engines`に`10` を指定します。

```functions/package.json
{
  "name": "functions",
  "scripts": {
    "lint": "tslint --project tsconfig.json",
    "build": "tsc",
    "serve": "npm run build && firebase serve --only functions",
    "shell": "npm run build && firebase functions:shell",
    "start": "npm run shell",
    "deploy": "firebase deploy --only functions",
    "logs": "firebase functions:log"
  },
  "engines": {
    "node": "10"
  },
  "main": "lib/index.js",
  "dependencies": {
    "firebase-admin": "^8.6.0",
    "firebase-functions": "^3.3.0"
  },
  "devDependencies": {
    "tslint": "^5.12.0",
    "typescript": "^3.2.2",
    "firebase-functions-test": "^0.1.6"
  },
  "private": true
}
```

## 4.2. 開発環境のNode.jsのバージョンの設定
次に開発環境のNode.jsのバージョンの設定します。まず、`nodebrew ls-remote`で使えるバージョンの一覧を表示します。

```console
$ node -v
v13.11.0
$ nodebrew ls-remote
```

Cloud Functions の Node.js 10 ランタイムは Node.js バージョン `10.15.3` に基づいているため、開発環境のバージョンも`10.15.3`に合わせます。

```console
$ nodebrew install v10.15.3
$ nodebrew use v10.15.3
use v10.15.3
$  nodebrew list
v10.15.3
v13.11.0

current: v10.15.3

$ node -v
v10.15.3
```

# 5. ライブラリの追加
`functions`のディレクトリでライブラリの追加を行う。
`child-process-promise` は

```
$ cd functions/
$ yarn add child-process-promise
```

＊もしかしたら、`yarn add @google-cloud/storage`で`@google-cloud/storage`も追加が必要だったかもしれません。

# 6. 関数の作成
## 6.1 関数ごとにファイルを分割可能とする
複数のエントリポイントをファイルを分割して開発が可能なように、`index.ts`から外部のモジュールを読み込めるように修正します。
以下のように修正し、関数を追加する際は`/func/`配下にファイルを追加します。

```typescript:/functions/src/index.ts
// The Firebase Admin SDK to access the Firebase Realtime Database.
const admin = require('firebase-admin');
admin.initializeApp(); // initializeAppは一回だけ実行する

/*
 * 配下にあるプログラムを読み込む。
 * エントリポイントを追加する際は、こちらにも追加する。
 */
const cloud_functions = {
  // Write function references
  addMessage: './func/addMessage',
  makeUppercase: './func/makeUppercase',
  generateThumbnail: './func/generateThumbnail',
};

const loadFunctions = (funcs: any) => {
  for (let name in funcs) {
    if (!process.env.FUNCTION_NAME || process.env.FUNCTION_NAME === name) {
      exports[name] = require(funcs[name]);
    }
  }
};

loadFunctions(cloud_functions);
```

## 6.2 各関数の作成
###  HTTPリクエストのトリガーの関数
サムネイルには関係ありませんが、練習を兼ねて、HTTPリクエストをトリガーとして、Realtime Databaseに書き込むプログラムを作成します。

プログラムの最初に `firebase-functions` および `firebase-admin` のモジュールを読み込みます。

HTTPのトリガでは、エンドポイントに対するリクエストを行うと、Express.JS スタイルの Request オブジェクトと Response オブジェクトが onRequest() コールバックに渡されます。このサンプルでは、HTTPリクエストで受けたテキスト値をRealtime Databaseの `/messages/:pushId/original`に挿入します。

リージョンは`asia-northeast1`を選択している。

```typescript:/functions/src/func/addMessage.ts
import * as functions from 'firebase-functions';
const admin = require('firebase-admin');

const region = 'asia-northeast1';

// Take the text parameter passed to this HTTP endpoint and insert it into the
// Realtime Database under the path /messages/:pushId/original
module.exports = functions.region(region).https.onRequest(async (request, response) => {
  try {
    // Grab the text parameter.
    const original = request.query.text;
    // Push the new message into the Realtime Database using the Firebase Admin SDK.
    const snapshot = await admin.database().ref('/messages').push({ original: original });
    // Redirect with 303 SEE OTHER to the URL of the pushed object in the Firebase console.
    response.redirect(303, snapshot.ref.toString());
  }
  catch (error) {
    console.log(error);
    response.status(500).send(error);
  }
});

```

### Realtime Databaseのトリガーの関数
このサンプルでは`addMessage`でRealtime Databaseにテキストが追加されたことを検知して、その文字列を大文字に変換します。

```typescript:/functions/src/func/makeUppercase.ts
import * as functions from 'firebase-functions';

// Realtime Databaseのイベントトリガーを利用する場合の推奨リージョンはus-central1となる。
const region = 'us-central1';

/*
  Realtime Database に書き込まれるときに実行される。
  {} で囲まれたものは、コールバックで利用可能な「パラメータ」となる。
*/
module.exports = functions.region(region).database.ref('/messages/{pushId}/original').onCreate((snapshot, context) => {
    // Grab the current value of what was written to the Realtime Database.
    const original = snapshot.val();
    console.log('Uppercasing', context.params.pushId, original);
    const uppercase = original.toUpperCase();
    // You must return a Promise when performing asynchronous tasks inside a Functions such as
    // writing to the Firebase Realtime Database.
    // Setting an "uppercase" sibling in the Realtime Database returns a Promise.
    if (snapshot.ref.parent !== null) {
      return snapshot.ref.parent.child('uppercase').set(uppercase);
    }
    else {
      return undefined;
    }
  });
```

### Storage トリガーの関数
やっとサムネイルのサンプルですが、Storageへの変更をトリガーとする関数を作成します。Storageにファイルが追加された時に本関数が実行されます。contentTypeで画像のみを処理するようにしています。

```typescript:/functions/src/func/generateThumbnail.ts
import * as functions from 'firebase-functions';
const admin = require('firebase-admin');

//import * as spawnts from 'child-process-promise';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';

const spawn = require('child-process-promise').spawn;

const region = 'asia-northeast1';
const THUMB_PREFIX = 'thumb_';

/*
 generateThumbnail
 */
module.exports = functions.region(region).storage.object().onFinalize(async (object) => {
    const filePath = object.name; // File path in the bucket. 
    const contentType = object.contentType; // File content type.
    const fileBucket = object.contentType; // File content type.
    const metageneration = object.metageneration; // Number of times metadata has been generated. New objects have a value of 1.
    console.log('File path:' + filePath); // images/thumb_Material-uiサンプル.png
    console.log('fileBucket:' + fileBucket); // react-sample-d086e.appspot.com
    console.log('contentType:' + contentType); // imaga/png
    console.log('metageneration:' + metageneration); // 1
    //
    if (filePath === undefined) {
        console.log('File path is undefined.');
        return null;
    }
    // 画像以外だったら何もしない
    if (contentType === undefined || !contentType.startsWith('image/')) {
        console.log('This is not an image.');
        return null;
    }
    // サムネイル画像であった場合何もしない
    const fileName = path.basename(filePath);
    if (fileName.startsWith(THUMB_PREFIX)) {
        console.log('Already a Thumbnail.');
        return null;
    }

    const fileDir = path.dirname(filePath);
    const thumbFilePath = path.normalize(path.join(fileDir, `${THUMB_PREFIX}${fileName}`));
    //    const extension = "png";
    //    const thumbFilePath2 = path.normalize(path.format({dir: fileDir, name: `${THUMB_PREFIX}${fileName}`, ext: extension}));
    const tempFilePath = path.join(os.tmpdir(), fileName);
    const metadata = { contentType: contentType };
    // 出力先のバケット
    const storage = admin.storage();
    const bucket = storage.bucket(fileBucket);
    const file = bucket.file(filePath);

    (async () => {
        // バケットにアップロードされたファイルを仮想マシンのテンプディレクトリにダウンロード
        await file.download({ destination: tempFilePath });
        console.log('The file has been downloaded to', tempFilePath); // tmp/react_icon.png
        // Generate a thumbnail using ImageMagick.
        await spawn('convert', [tempFilePath, '-thumbnail', '200x200>', tempFilePath]);
        console.log('Thumb image created at', tempFilePath);
        // リサイズされたサムネイルをバケットにアップロード
        await bucket.upload(tempFilePath, { destination: thumbFilePath, metadata: metadata });
        console.log('Thumb image uploaded to Storage at', thumbFilePath);
        // Once the thumbnail has been uploaded delete the local file to free up disk space.
        fs.unlinkSync(tempFilePath);
    })()
        .then(() => { console.log('Generate Thumbnail Success!'); })
        .catch((error) => { console.error(error); });
        
    return null;
});

```

# 7. functionのデプロイ
## 7.1. デプロイ
プロジェクトのルートフォルダで以下コマンドを実行し、functionをデプロイできる。各関数がデプロイされているロケーションがプログラムで指定しているものになっていることが確認できます。

```console
$ firebase deploy --only functions

=== Deploying to 'sample-9f36d'...

i  deploying functions
Running command: npm --prefix "$RESOURCE_DIR" run lint

> functions@ lint /Users/tayamat/Documents/00_mygit/hoshimado/firebase-storage-sample/functions
> tslint --project tsconfig.json

Running command: npm --prefix "$RESOURCE_DIR" run build

> functions@ build /Users/tayamat/Documents/00_mygit/hoshimado/firebase-storage-sample/functions
> tsc

✔  functions: Finished running predeploy script.
i  functions: ensuring necessary APIs are enabled...
✔  functions: all necessary APIs are enabled
i  functions: preparing functions directory for uploading...
i  functions: packaged functions (90 KB) for uploading
✔  functions: functions folder uploaded successfully
i  functions: updating Node.js 10 (Beta) function addMessage(asia-northeast1)...
i  functions: updating Node.js 10 (Beta) function makeUppercase(us-central1)...
i  functions: updating Node.js 10 (Beta) function generateThumbnail(asia-northeast1)...
✔  functions[addMessage(asia-northeast1)]: Successful update operation. 
✔  functions[makeUppercase(us-central1)]: Successful update operation. 
✔  functions[generateThumbnail(asia-northeast1)]: Successful update operation. 

✔  Deploy complete!

Project Console: https://console.firebase.google.com/project/sample-9f36d/overview
macbookpro-tt:firebase-storage-sample tayamat$ 
```

## 7.2 デプロイの確認
FirebaseのWebコンソールでfunctionsに関数が追加されていることを確認します。

<img width="1678" alt="2020-03-29_depoy-functions.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293373/dfa11a68-fe54-8dc2-4d52-84291a996043.png">

# 8. 動作確認
## 8.1. HTTPリクエスト
テキストクエリ パラメータを addMessage() URL に追加し、ブラウザで開きます。

```console
https://us-central1-MY_PROJECT.cloudfunctions.net/addMessage?text=uppercaseme
```

関数によりブラウザが実行され、テキスト文字列が格納されているデータベースの場所にある Firebase コンソールにリダイレクトされます。テキスト値がコンソールに表示されます。

```console
Function URL (addMessage): https://us-central1-MY_PROJECT.cloudfunctions.net/addMessage
```

## 8.2. 画像の変換
前回作成したプログラムを`yarn start`でサーバーを起動し、ブラウザで表示します。

![ReactDropzoneサンプル.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293373/89681c1b-65f5-3583-3b81-231883280855.gif)

FirebaseのWebコンソールでファイルがアップロードされていること、また、サムネイルが作成されていることを確認します。

<img width="1674" alt="2020-03-20_Firebase_storage.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293373/1ab4cfeb-b60b-567f-6eb7-b75075e4f982.png">

# 9. 最後に
今回はFirebase Cloud Functionsを利用して、画像アップロードをトリガーにサムネイルを作成する方法について説明しました。
ですが、実はサムネイルの作成だけであれば、Firebaseの拡張機能で同様のことが実現できます。あくまで学習のためにcloud functionsでサムネイルを作成してみました。

<img width="1680" alt="2020-04-02_firebase_extensions.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/293373/a5b7c8f2-6831-1c3f-5771-6388f86d5327.png">

# 10. 関連記事

Reactに関する記事です。

- [第1回 2020年版 Node.js+Reactのインストール](https://qiita.com/tetsurotayama/items/6dd91dc37941fab462a4)
- [第2回 2020年版 ReactのMaterial UI V4の使い方について](https://qiita.com/tetsurotayama/items/12d6c7b3463246306e87)
- [第3回 2020年版 React+Firebaseでアプリを作成する](https://qiita.com/tetsurotayama/items/179daca2cacb0caad0c5)
- [第4回 2020年版 既存のウェブサイトに React を追加する](https://qiita.com/tetsurotayama/items/a2e4edd5bc46bc592c32)
- [第5回 2020年版 ReactのRechartsで新型コロナウイルス感染症対策サイトのデータを可視化する](https://qiita.com/tetsurotayama/items/f5eda6dc2bbcd1f71b4e)
- [第6回 2020年版 React+Firebaseで画像のアップロード（その１）](https://qiita.com/tetsurotayama/items/30a0609a0cca5ba5ff0a)
- [第7回 2020年版 React+Firebaseで画像のアップロード（その２）](https://qiita.com/tetsurotayama/items/5129f0cfb21f9ec9b9a0)
- [第9回 2020年版 ReactにStoryshotsを導入する](https://qiita.com/tetsurotayama/items/dd637e90f4ea008fd878)