Cloud Storageにアップロードされた画像のExifをCloud Functionsで削除する
意外とあまり参考になる記事が見つからなかった & ハマりポイントが多かったので記事にしておきます。
前提
- 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
まずはコード全体を貼っておきます。あとでポイントを解説します。
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箇所で無限ループを防ぐようにしています。
- Cloud Storageへのアップロード時に「処理済みであること」を示すカスタムメタデータを持たせる
- メタデータのkeyの書き間違えを防ぐために
metaAlreadyProcessedKey
という変数を用意し、設定する側も参照する側も同じ変数を読むようにしています。
- メタデータのkeyの書き間違えを防ぐために
- すでにExifが削除されているファイルについては処理しない
また、非同期関数を呼び出す部分でうっかりawait
を書き忘れると、画像のチェックや処理が完了しないままCloud Storageへのアップロードまで進んでしまい無限ループが発生する可能性があるので注意してください。
ポイント2: JPEGのみをExifの削除対象とする
負荷を減らすためにこのサンプルではcontentType
がimage/jpeg
の画像のみを処理対象にしています。
厳密にはPNGもExifを持たせることはできますが、スマホやデジタルカメラで撮影された画像がExifを持ったままPNGとしてアップロードされる可能性は低いため今回はこのような形にしています。
また、contentType
については空の状態でアップロードされる可能性や、実際のファイル形式とは異なる値が設定される可能性もあります。「拡張子での判断よりは信頼できるだろう」ということでこの形にしましたが、このあたりはプロジェクトによって判断するのが良いと思います。
Cloud Storageにファイルがアップロードされたときにfunctionsがトリガーされるような形でデプロイ
デプロイ前にtsc
コマンドが実行されるように設定します。
Cloud Functionsではgcp-build
という名前のnpmスクリプトを自動で実行してくれます(Cloud Functions for Firebaseだとpredeploy
という名前になる模様)。
"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プロジェクト名]
[]
の部分をプロジェクトに応じて設定してください。runtime
、region
、memory
、timeout
なども必要に応じて書き換えてください。
大事なポイントは以下です。
-
--trigger-resource
: ここで設定したバケット名にファイルがアップロードされたときに関数が実行される -
--trigger-event=google.storage.object.finalize
: この値に設定することでファイルが新しくアップロードor更新されたときにトリガーが走る
これでCloud Storageの対象バケットにファイルがアップロードされたときに自動でExifが削除されるようになります。
Discussion
Exif に回転情報が含まれている場合のことを考慮して、
autoOrient
メソッドを実行した方がいいかもしれません。ご指摘ありがとうございます!
たしかに仰る通りですね。修正します。
バケット単位でしかトリガ仕掛けられないのが残念。
Firebaseだと一つのバケットにDir掘ってリソース置くイメージなんですよね。。
そうなんですよねー。無駄にFunctionsの実行回数が多くなってしまうんですよね。