🌉

Cloud Storageにアップロードされた画像のExifをCloud Functionsで削除する

2021/10/20に公開
4

意外とあまり参考になる記事が見つからなかった & ハマりポイントが多かったので記事にしておきます。

前提

  • Cloud Functions for Firebaseではなく、ただのCloud Functionsを使います。画像処理のところはfor Firebaseの方でも同じように書けるはずです。
  • Cloud Functionsの実行環境にはImageMagickがインストールされています。今回はgmというNode.jsのラッパーを使って画像のExifの検出と削除を行います。

packageのインストール

まず必要なpackageをインストールしていきます。

dependencies

まずdependenciesから。

$ npm i gm @google-cloud/storage

@google-cloud/storageはCloud Storageのファイルを読み書きするために使います。

devDependencies

次にdevDependenciesをインストールします。TypeScriptを使います。

$ npm i --save-dev typescript @types/gm @types/node firebase-functions

firebase-functionsは、TypeScriptの型定義のためだけにインストールしています。

tsconfig.json

tsconfig.jsonは今回はこんな形にしました

{
  "compilerOptions": {
    "module": "commonjs",
    "noImplicitReturns": true,
    "noUnusedLocals": true,
    "strict": true,
    "target": "ES2020"
  },
  "exclude": ["node_modules"],
  "include": ["**/*.ts"]
}

画像のExifを削除するfunctions

まずはコード全体を貼っておきます。あとでポイントを解説します。

index.ts
import * as crypto from 'crypto';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
import { Storage } from '@google-cloud/storage';

/**
 * Firebase上で動くわけではないがイベント発生時に
 * コンテキストとして渡されるObjectMetadataの型定義を利用するために
 * `firebase-functions`をimportする
 */
import type { ObjectMetadata } from 'firebase-functions/lib/providers/storage';
import * as _gm from 'gm';
const gm = _gm.subClass({ imageMagick: true });

const gcs = new Storage();
const metaAlreadyProcessedKey = 'already-processed'; // 既に処理されたことを示すカスタムメタデータのkeyの名前

export async function cloudStorageRemoveExif(object: ObjectMetadata) {
  const gcsFilePath = object?.name;
  if (!gcsFilePath) return; // 関数のテスト実行時などにはobjectが空になる

  // GCSでのファイル更新トリガーによる無限ループを防ぐためにカスタムメタデータを持たせておき、既に処理済みかどうかを判定する
  const alreadyProcessed = !!object.metadata?.[metaAlreadyProcessedKey];

  if (alreadyProcessed) {
    return console.log(`😸 ${gcsFilePath}は既に処理されているため終了します`);
  }

  if (object.contentType !== "image/jpeg") {
     return console.log(`😸 ${gcsFilePath}はJPEGでないため終了します`);
  }

  // 一時ファイルを配置するパスをランダムで生成
  const tmpFileName =
    crypto.randomBytes(20).toString('hex') + path.extname(gcsFilePath);
  const tmpFilePath = path.join(os.tmpdir(), tmpFileName);

  // Bucketからファイルを一時的に保存
  const bucket = gcs.bucket(object.bucket);
  await bucket.file(gcsFilePath).download({ destination: tmpFilePath });

  const hasExif = await checkHasExif(tmpFilePath);
  if (!hasExif) {
    return console.log(`😸 ${gcsFilePath}には位置情報が含まれていないため終了します`);
  }

  // Exifを削除する
  await stripExif(tmpFilePath);

  // 念のためExifが消えていることを確認
  if (await checkHasExif(tmpFilePath)) {
    throw new Error(`😇 ${gcsFilePath}の処理後もExifデータが残っています。何かがおかしいです。`);
  }

  // 再アップロードされた画像のメタデータを維持しつつ、処理済みであることを示すカスタムメタデータを設定
  const metadata = {
    contentType: object.contentType,
    cacheControl: object.cacheControl,
    // @google-cloud/storageの仕様上、カスタムmetadataはmetadata.metadataとしてセットする必要がある
    // ref: https://github.com/googleapis/nodejs-storage/issues/222
    metadata: {
      [metaAlreadyProcessedKey]: '1',
    },
  };

  try {
    await bucket.upload(tmpFilePath, { destination: gcsFilePath, metadata });
  } catch (err) {
    console.error(err);
    throw new Error(
      `${gcsFilePath}のExif削除後のファイルのアップロードに失敗しました`
    );
  }

  cleanup(tmpFilePath);
  return console.log(`🎉 ${gcsFilePath}のExif削除が完了しました`);
}

// Exifを持っているかどうかを確認するメソッド
function checkHasExif(filePath: string): Promise<boolean> {
  return new Promise((resolve) => {
    gm(filePath).identify('%[EXIF:*]', (err, exifData) => {
      if (err) {
        console.error(err);
        throw new Error(`${filePath}のExif取得時にエラーが発生しました`);
      } else {
        const result = !!exifData
        resolve(result);
      }
    });
  });
}

// 画像からExifを削除するメソッド
function stripExif(filePath: string) {
  return new Promise((resolve) => {
    gm(filePath)
      .autoOrient() // Exifの値に応じて画像の向きを補正
      .noProfile() // exifを削除
      .write(filePath, async (err) => {
        if (err) {
          console.error(err);
          throw new Error(`${filePath}のExif削除に失敗しました`);
        }
        resolve(true);
      });
  });
}

// 一時ファイルを削除するためのメソッド
// ref: https://github.com/firebase/functions-samples/blob/main/generate-thumbnail/functions/index.js
function cleanup(tmpFilePath: string) {
  fs.unlinkSync(tmpFilePath);
}

ポイント1: 無限ループに注意する

Cloud Storageへのファイルアップロード時にトリガーを設定するため、少し間違えると無限ループが発生します(トリガーの設定は後述)。

画像がアップロードされる
→ functionsが起動
→ Exifが削除された画像が自動アップロードされる
→ functionsが起動
→ 以下ループ

上のサンプルコードでは2箇所で無限ループを防ぐようにしています。

  1. Cloud Storageへのアップロード時に「処理済みであること」を示すカスタムメタデータを持たせる
    • メタデータのkeyの書き間違えを防ぐためにmetaAlreadyProcessedKeyという変数を用意し、設定する側も参照する側も同じ変数を読むようにしています。
  2. すでにExifが削除されているファイルについては処理しない

また、非同期関数を呼び出す部分でうっかりawaitを書き忘れると、画像のチェックや処理が完了しないままCloud Storageへのアップロードまで進んでしまい無限ループが発生する可能性があるので注意してください。

ポイント2: JPEGのみをExifの削除対象とする

負荷を減らすためにこのサンプルではcontentTypeimage/jpegの画像のみを処理対象にしています。

厳密にはPNGもExifを持たせることはできますが、スマホやデジタルカメラで撮影された画像がExifを持ったままPNGとしてアップロードされる可能性は低いため今回はこのような形にしています。

また、contentTypeについては空の状態でアップロードされる可能性や、実際のファイル形式とは異なる値が設定される可能性もあります。「拡張子での判断よりは信頼できるだろう」ということでこの形にしましたが、このあたりはプロジェクトによって判断するのが良いと思います。

Cloud Storageにファイルがアップロードされたときにfunctionsがトリガーされるような形でデプロイ

デプロイ前にtscコマンドが実行されるように設定します。

Cloud Functionsではgcp-buildという名前のnpmスクリプトを自動で実行してくれます(Cloud Functions for Firebaseだとpredeployという名前になる模様)。

package.json
  "scripts": {
    "build": "tsc -p .",
    "gcp-build": "yarn run build"
  },

最後にデプロイコマンドを実行します。

※ ローカルから実行するためにはgcloudをインストールしておく必要があります。

gcloud functions deploy cloudStorageRemoveExif \
  --runtime=nodejs14 \
  --source=.\
  --entry-point=cloudStorageRemoveExif \
  --trigger-resource=[トリガー対象とするバケット名]\
  --trigger-event=google.storage.object.finalize\
  --region=asia-northeast1 \
  --memory=256\
  --timeout=30s\
  --project=[Google Cloudプロジェクト名]

[]の部分をプロジェクトに応じて設定してください。runtimeregionmemorytimeoutなども必要に応じて書き換えてください。

大事なポイントは以下です。

  • --trigger-resource: ここで設定したバケット名にファイルがアップロードされたときに関数が実行される
  • --trigger-event=google.storage.object.finalize: この値に設定することでファイルが新しくアップロードor更新されたときにトリガーが走る

これでCloud Storageの対象バケットにファイルがアップロードされたときに自動でExifが削除されるようになります。

Discussion

hama24hama24

バケット単位でしかトリガ仕掛けられないのが残念。
Firebaseだと一つのバケットにDir掘ってリソース置くイメージなんですよね。。

catnosecatnose

そうなんですよねー。無駄にFunctionsの実行回数が多くなってしまうんですよね。