🦔

【Firebase Storage】busboyを使って写真をstorageに保存してみよう

2023/09/15に公開

フロントとバックエンド構成

フロント

・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

https://developer.mozilla.org/ja/docs/Learn/Forms/Sending_and_retrieving_form_data
上記のmozillaにあるように、フロントのデータの多くはテキストデータであるのに、ファイルはバイナリデータであるため、headersのContent-typeには既定値のapplication/x-www-form-urlencodedではなく、multipart/form-dataを設定してあげます。

https://qiita.com/takamura_s/items/b7cbfbb5ba2879b81f5c#post-multipartform-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の使い方を公式のサンプルコードをもとに解説します。

busboy.ts
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が受け取ったデータを変換できるようにしています。

フロントエンドを書く

front.ts
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を使って前回選んだ写真は消すようにしてます。
※以下参考
https://developer.mozilla.org/ja/docs/Web/API/URL/revokeObjectURL_static

バックエンドを書く

backend.ts
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とエラーが出ます。

間違いコード.tsx
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にそのことを明示的に書くなと書いてあります。
https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object

なぜContent-typeを明示するとエラーなの?

https://muffinman.io/blog/uploading-files-using-fetch-multipart-form-data/
上記の記事にわかりやすく答えが纏まっています。

要約すると、ファイルデータを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に注意です。
https://firebase.google.com/docs/storage/admin/start?hl=ja
上記にあるように、gs://bucket-name.appspot.comがfirebase storageのパスだとしたら、gs://を取ってあげて、それをstorageBucketに渡してあげる必要があります!

参考記事

https://blog.h13i32maru.jp/entry/2022/08/15/160835
https://qiita.com/takamura_s/items/b7cbfbb5ba2879b81f5c#post-multipartform-data-の動き

Discussion