AppBrew Tech Blog

AppBrewのエンジニアチームの日々です

Firebaseで作る!リアルタイム画像変換CDN【Firebase Hosting + Cloud Functions】

AppBrew新規事業部の開発責任者をしています吉野です. 前回記事を書いてから書きたくないと駄々をこねていたら歳月が経ち その間に猫を飼い始め配偶するなど様々なライフステージの変化がありました.

ところで,アップロードされた画像をそのまま表示する時代は平成とともに終わりを告げたわけですが[※要出典]皆さんいかがお過ごしでしょうか.

今回の記事では,まずはじめにFirebase Consoleポチポチだけで出来る「Cloud Functionsのみを利用する例」を解説した後に,そこで生じた問題点を解決した「Cloud Functions + Firebase Hosting を利用する例」を順に説明していきます.

これからFirebaseを作ってアプリを作る方,Firebaseを利用していていくつかのサイズの画像を柔軟に扱いたい方などの参考になればと思います.

記事後半の「Cloud Functions + Firebase Hosting を利用する例」におけるコードはこちらになります. github.com

Cloud Functionsのみを利用する例

最初に公式ドキュメントでも詳しく載っているCloud Functionsのみを利用したリサイズの例について紹介します. Cloud Functionsのみを利用する場合は下図のようにStorageのuploadのイベントにフックしてCloud Functionsを起動させ画像を生成します.

f:id:yosshi0774:20200105191520p:plain
CloudFunctionを利用したリサイズ例(公式ドキュメントより)

セットアップ

以前までは自分でCloud Functionsをデプロイする必要がありJavaScriptに慣れていない場合,やや手間でしたがFirebase Extensionという公式のExtensionを使うことでコンソールからポチポチするだけでできるようになりました. firebase.google.com

具体的には下図のようにリサイズする画像のサイズやCache-Control,保存先を指定するだけです. (ここでのサイズは正方形の切り抜きではなく正方形に収まるようにアスペクトを維持して収縮のようです)

f:id:yosshi0774:20200105204525p:plain
Resize Imagesの設定

拡張機能をインストール を選択するとあとはFirebase側で勝手にCloud Functionsに関数を作成してくれます. CloudStorageに画像を上げるとリサイズされsuffixのついた画像達が出来上がっていることが確認できるかと思います.

f:id:yosshi0774:20200105204828p:plain
Cloud Functionsによってリサイズされた画像達

問題点

上記の方法は以下のようなメリット/デメリットがあるかと思います.

  • メリット

    • コンソールからぽちぽちするだけでできる
  • デメリット

    • 非同期であること,実行環境初期化の時間によって画像のアップロード直後数秒はサムネイルの存在を保証できない
    • 生成すべきリサイズ画像の種類・大きさが変わった場合、それまでにアップロードされた画像は手動で上げ直す必要がある

ここで個人的に気になるのがデメリットの2つ目です.

開発初期のUIが頻繁に変わるような段階でサイズ決め打ちでサムネイルを生成しているとほしいサイズが無いケースがあるかもしれません.*1 これを解消する手段として紹介するのが次項のFirebase Hostingをかませる方法です.

Cloud Functions + Firebase Hosting を利用する例

Firebase Hostingは静的コンテンツ配信のみでなく,特定のURLパターンをコンテンツを動的に取得して処理するような関数(Cloud Functions)に向け,結果をキャッシュすることができます. これを利用することで下図のようにリサイズ付きのCDNを構築します.

f:id:yosshi0774:20200105223657p:plain
FirebaseHosting + Cloud Functionsを利用したリサイズ例

前述の通り,Cloud Functionsの結果はキャッシュされるので初めてリサイズ画像をリクエストする場合に限り関数が走り(図中②)それ以降はFirebase Hostingがリサイズ画像を返すようになります(図中①).

セットアップ

まずはFirebase Hostingの設定についてですがこれだけです

{
 
"functions": {
   
"predeploy": [
     
"npm --prefix \"$RESOURCE_DIR\" run lint",
     
"npm --prefix \"$RESOURCE_DIR\" run build"
   
]
 
},
 
"hosting": {
   
"public": "public",
   
"rewrites": [
     
{
       
"source": "/images/**",
       
"function": "onRequestResizedImage"
     
}
   
]
 
}
}

これで (FirebaseProjectID).firebaseapp.com/images/path/to/your/image にリクエストを送ると onRequestResizedImage という名前の関数が発火するようになります.

続いてCloud Functionsの主なソースコードが以下になります

import * as functions from "firebase-functions";
import * as sharp from "sharp";
import * as os from "os";
import * as path from "path";
import * as fileType from "file-type";
import { storage } from "./firebaseAdmin";

// CloudFunction fired by http event must be located us-central1.
// ref https://firebase.google.com/docs/functions/locations#http_and_client_callable_functions
const region = "us-central1";
const runtimeOpts: functions.RuntimeOptions = {
  timeoutSeconds
: 300,
  memory
: "1GB",
};
// default width (height scales keeping aspect ratio)
const defaultWidth = 800;

export const onRequestResizedImage = functions
 
.runWith(runtimeOpts)
 
.region(region)
 
.https.onRequest((req, res) => {
   
const filePath = req.path.substr(1);
   
const size = req.query.size as string;
    let width
: number | undefined;
    let height
: number | undefined;
   
if (typeof size === "undefined") {
      width
= defaultWidth;
      height
= undefined;
   
} else {
     
const [_width, _height] = size.split("x");
      width
= _width ? Number(_width) : undefined;
      height
= _height ? Number(_height) : undefined;
   
}
   
const bucket = storage.bucket(functions.config().storage.bucket);
   
const tempFilePath = path.join(os.tmpdir(), `${Math.round(Math.random() * 10000)}`);
    bucket
     
.file(filePath)
     
.download({
        destination
: tempFilePath,
     
})
     
.then(() => {
        sharp
(tempFilePath)
         
.rotate()
         
.resize(width, height)
         
.toBuffer()
         
.then(data => {
           
const type = fileType(data);
            res
.set("Cache-Control", `public, max-age=${86400 * 365}`);
            res
.set("Content-Type", type ? type.mime : "image/jpeg");
            res
.status(200).send(data);
         
})
         
.catch((err: Error) => res.status(500).send(err));
     
})
     
.catch((err: Error) => {
        res
.status(500).send(err);
     
});
 
});

size=widthxheight という形をクエリパラメータを受け,それに基づいてリサイズしています(width, heightどちらか片方指定でも良いです)

以下注意点です.

  • Firebaseの都合により us-central1で関数を動かすこと
  • メモリの割当ては最低でも1GB(or 512MB...?)にすること
    • 256MBで最近のスマホで撮影した写真(数MB)とかを処理しようとするとOOMになります.
  • 複数のFirebase projectでコードを使いまわすためにストレージのバケット名は環境変数にセットして参照すること

あとはリポジトリを参考に他の必要なコードを書き

firebase deploy

でデプロイすれば完成です.

結果

f:id:yosshi0774:20200113011013j:plain
かわいい猫(元画像)

試しにリクエストを送ってみると以下のようになります

正方形切り抜き アス比保持リサイズ
f:id:yosshi0774:20200105231957p:plain:w300 f:id:yosshi0774:20200105232020p:plain:w300

良さそうですね リサイズできていること,猫がかわいいことがわかるかと思います.

おわりに

Firebase Hostingと Cloud Functionsを合わせることでリサイズのできるCDNを構築できました. これでUIの変更に際してサムネイルのサイズを気にすることが少なくなったかと思います.

本記事でも紹介したExtensionや各種機能改善はもちろんですが, 昨年あたりからわりと盛り上がっているFlutterコミュニティでもFirebaseの各サービスの対応は進んでいることもあり Firebaseは今後ますますモバイルアプリ開発の強い味方になっていきそうですね.

弊社AppBrewでは現在,400万DL突破のコスメ口コミアプリ「LIPS」をはじめとした「ユーザーが熱狂するプロダクト」を「再現性をもって開発すること」に携わるエンジニアを積極採用中です.

下記ページにぜひ一度、目を通してみてください

*1:サイズを細かく刻んでいっぱいサムネイルを生成すればそれでもいいかもしれません👶