【Firebase Storage】busboyを使って写真をstorageに保存してみよう
フロントとバックエンド構成
フロント
・react.js
・Next.js
・MUI
バックエンド
・firebase functions(関数の実行元)
・firebase storage(写真の保存先)
・firestore(No SQLデータベース)
目標
フロントでユーザーがローカルの写真をアップロードして、アップロードされた写真をバックエンドで受け取りbusboyライブラリを使って、firebase storageに写真を保存するのが目標です。
ファイルをフロントからアップロードする時の流れ
そもそもですがフロントから動画や写真などファイルをアップロードするときは、ファイルサイズが大きいのでバックエンドにファイルのデータを分割しながら送信します。
そのため、バックエンド側では分割されて送られてきたデータを開発者が扱える形に変換しながら元のデータになるようにしてあげる必要があります。
formData(multipart/form-data)とは?
フロントからデータをpostするときは、以下の3つのどれかに送信したいデータをエンコードしてあげなければいけません。
・application/x-www-form-urlencoded
・multipart/form-data
・text/plain
application/x-www-form-urlencoded
ではなく、multipart/form-data
を設定してあげます。
すると、上記の記事で解説されているようにboundary文字列が、bodyの部分の区切りとなってくれます。
// 適当なboundary文字列が用意される
boundary文字列 = abcddefghijklmn~~
abcddefghijklmn~~
Content-Disposition: form-data; name="fileのtitle"
// 写真の名前などユーザーがinputした文字列の情報
これはユーザーAの写真だよ
abcddefghijklmn~~
写真のバイナリデータ1
写真のバイナリデータ2
写真のバイナリデータ3
・・・
busboyとは何か?
さっきファイルのデータはバックエンドにバイナリデータとして分割されて送られてくると説明しました。その各バイナリデータが送られてきたタイミングで、バイナリデータをバックエンド側で扱える形に変換して全部のファイルデータがバックエンドに送られてきたらすぐにファイルデータをバックエンド側で使えるようにするのが、busboyというライブラリです。
firebase storageとは何か?
firebase storageはfirebaseが提供するオブジェクトストレージサービスです。写真や動画、音声を保存するのに使います。
今回はここに写真を保存していきます。
具体的なbusboyの使い方(POST)
具体的なbusboyの使い方を公式のサンプルコードをもとに解説します。
const http = require('http');
const busboy = require('busboy');
http.createServer((req, res) => {
console.log('POST request');
const bb = busboy({ headers: req.headers });
bb.on('file', (name, file, info) => {
const { filename, encoding, mimeType } = info;
console.log(
`File [${name}]: filename: %j, encoding: %j, mimeType: %j`,
filename,
encoding,
mimeType
);
file.on('data', (data) => {
console.log(`File [${name}] got ${data.length} bytes`);
}).on('close', () => {
console.log(`File [${name}] done`);
});
});
bb.on('field', (name, val, info) => {
console.log(`Field [${name}]: value: %j`, val);
});
bb.on('close', () => {
console.log('Done parsing form!');
res.writeHead(303, { Connection: 'close', Location: '/' });
res.end();
});
req.pipe(bb);
// POST request
// File [filefield]: filename: "logo.jpg", encoding: "binary", mime: "image/jpeg"
// File [filefield] got 11912 bytes
// Field [textfield]: value: "testing! :-)"
// File [filefield] done
// Done parsing form!
ReadableStreamとpipe
上記のサンプルコードで重要なのが、ReadableStreamとpipeです。
ReadableStreamは、細かくデータを読み込むためのものです。そしてpipeは細かくデータを受け取ったものを処理するものです。今回はpipeでbusboyに繋げてbusboyが受け取ったデータを変換できるようにしています。
フロントエンドを書く
export default function uploadLogo(){
const [imageFile, setImageFile] = useState<File | undefined>();
const selectImage = async (e: React.ChangeEvent<HTMLInputElement>) => {
let img: any;
if (!e.target.files) return;
if (imageFile?.length == 1) {
await URL.revokeObjectURL(img);
img = await URL.createObjectURL(e.target.files[0]);
setImageFile(img);
} else {
img = await URL.createObjectURL(e.target.files[0]);
setImageFile(img);
}
};
const uploadPhoto = async () => {
if (Boolean(logoFile)) {
const formDataM = new FormData();
const api_url = process.env.API_URL;
await fetch(api_url + '/uploadImage', {
method: 'POST',
body: formDataM,
});
}
};
return (
<>
<p>画像をアップロードする</p>
<input
hidden
accept="image/*"
type="file"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
selectImage(e)
}
/>
<button onClick={uploadPhoto}>写真を送信</button>
</>
)
}
上記でURL.createObjectURLを使ってimgファイルを生成してます。また、写真をユーザーが手元で変更できるように、revokeObjectURLを使って前回選んだ写真は消すようにしてます。
※以下参考
バックエンドを書く
const uploadImage = () => {
const bb = busboy({ headers: request.headers });
bb.on('file', (name: string, stream: Readable, info: any) => {
const { mimeType } = info;
stream
.on('data', async (data) => {
const path = `${randomUUID()}.${suffix}`;
await storage.bucket().file(path).save(data);
const f = storage.bucket().file(path);
await f.makePublic();
const url = f.publicUrl();
console.log('写真の登録完了');
})
.on('close', async () => {
console.log(`File [${name}] close`);
});
});
bb.on('error', (e: any) => {
console.log(' error');
console.log(e);
});
if (request.rawBody) {
bb.end(request.rawBody);
} else {
request.pipe(bb);
}
}
app.post('/uploadImage', async (request: any, response: any) => {
const db = admin.firestore();
const storage = getStorage();
try {
uploadImage(request, db, storage);
} catch (e) {
console.log(e);
return response.status(400).send('エラーが発生しました。');
}
}
フロントのfetchAPIでmultipart/form-dataを扱う時の注意点!
実はfetchAPIまたはXMLHttpRequestを使う場合、以下のようにフロントでファイルを渡そうとするとbad requestとエラーが出ます。
const uploadLogo = async () => {
if (Boolean(logoFile)) {
const formDataM = new FormData();
const api_url = process.env.API_URL;
await fetch(api_url + '/uploadImage', {
method: 'POST',
// headersオブジェクトでContenttypeを明示的に示すとエラー出る
headers: {
'Content-type': 'multipart/form-data',
},
body: formDataM,
});
}
};
以下の記事にも注意書きとして、fetch APIの場合でmultipart/form-dataを扱うときには、Content-typeにそのことを明示的に書くなと書いてあります。
なぜContent-typeを明示するとエラーなの?
上記の記事にわかりやすく答えが纏まっています。
要約すると、ファイルデータをpostする場合は、html側で自動的に以下のようなものを定義してくれているから。
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryInABCDEFG
おそらくエラーになる理由としては、上記で自動でContent-TypeをPOST時に定義してくれるのに、fetchAPIに渡すheadersオブジェクトでもContent-Typeを渡すとContent-Typeが2つ宣言されてしまうことになるからかなと思います。
firebase admin.initializeでのstorageの注意!
firebase storageを使う点で注意ですが、storageのbucketの名前をadmin.initializeのときに指定してあげないとfirebase functionsがどこに写真をアップロードしていいか分からずエラーが出ます。
admin.initializeApp({
credential: admin.credential.cert(serviceAccount),
storageBucket: process.env.STORAGE_BUCKET,
});
上記のようにfirebase functionsを設定しましょう。
またSTORAGE_BUCKETのenvに注意です。gs://bucket-name.appspot.com
がfirebase storageのパスだとしたら、gs://
を取ってあげて、それをstorageBucketに渡してあげる必要があります!
参考記事
Discussion