Closed8

Remixでファイルのアップロード機能を作る

おたきおたき

やりたいこと

  • 画像ファイルをアップロードさせる
  • ストレージにはCloudinaryを使用する
  • 複数ファイルを同時にアップロード可能にする
おたきおたき

unstable_parseMultipartFormData

アプリケーションでのファイルアップロードを処理できるAPI。
このAPIを使う上ではFile APIを理解する必要があるみたいだな。

使い方

const formData = await unstable_parseMultipartFormData(request, uploadHandler);
export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const formData = await unstable_parseMultipartFormData(request, uploadHandler);
  const avatarUrl = formData.get("avatar");
  await updateUserAvatar(request, avatarUrl);
  return redirect("/account");
}


export default function AvatarUploadRoute() {
  return (
    <Form method="post" encType="multipart/form-data">
      <label htmlFor="avatar-input">Avatar</label>
      <input id="avatar-input" type="file" name="avatar" />
      <button>Upload</button>
    </Form>
  );
}

アップロードされたファイルを読み取るには、Blob APIから継承した.text().arrayBuffer()などを使う。

uploadHandler

  • ファイルアップロード機能における最も重要な役割を持つ
  • ファイルの保存(ディスクやメモリなど)や外部のファイルストレージに送信するプロキシとして機能する

uploadHandlerを作成するには?

Remixは以下2つのユーティリティを提供してるので、これを使う感じっぽい。

  • unstable_createFileUploadHandler
  • unstable_createMemoryUploadHandler

https://remix.run/docs/en/main/utils/parse-multipart-form-data

おたきおたき

unstable_createFileUploadHandler

  • Node.jsのアップロードハンドラー
  • ファイル名を持つ部分をディスクに書き込み、メモリに残さない。
  • ファイル名のない部分は解析されない。

使い方

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const uploadHandler = unstable_composeUploadHandlers(
    unstable_createFileUploadHandler({
      maxPartSize: 5_000_000, // 最大アップロードサイズ(byte)
      file: ({ filename }) => filename, // ディレクトリ内のファイル
    }),
    unstable_createMemoryUploadHandler()
  );
  const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler
  );

  const file = formData.get("avatar");

  // file is a "NodeOnDiskFile" which implements the "File" API
  // ... etc
};

各オプションについて

  • avoidFileConflicts
    • ディスク上にすでに存在する場合は、ファイル名の最後にタイムスタンプを付加することで、ファイルの衝突を避ける。
  • directory
    • アップロードを書き込むディレクトリ
  • file
    • objectを受け取り、stringを返す。
    • ディレクトリ内のファイル名。相対パスも可能で、存在しない場合はディレクトリ構造が作成される。
  • maxPartSize
    • 許容される最大アップロードサイズ(バイト)。このサイズを超えた場合は MaxPartSizeExceededError がスローされます。
  • fileter
    • fileName, name, contentType, 名に基づいて、、ブール値またはPromiseを返す。
    • アップロードされたファイルが保存されないようにするための関数。falseを返すとファイルは無視されます。
おたきおたき

Exampleにいいリポジトリあった!

  • Remixビルトインの uploadHandler と Form をマルチパートデータで使って、組み込みのローカルアップローダでファイルをアップロードし、カスタムアップローダで画像ファイルを cloudinary にアップロードして表示する簡単な例があったぞ。

https://github.com/remix-run/examples/tree/main/file-and-cloudinary-upload

Cloudinaryのドキュメント

下記ページがおそらく参考になりそう
https://cloudinary.com/documentation/upload_images#landingpage
https://cloudinary.com/documentation/node_image_and_video_upload#node_js_upload_stream

おたきおたき

サンプルを参考に実装してみる

下記を参考にした
https://remix.run/docs/en/main/guides/file-uploads
Cloudinaryにアップロードする用の関数

cloudinary.server.ts
import type { UploadApiResponse, UploadStream } from "cloudinary";
import { writeAsyncIterableToWritable } from "@remix-run/node";
import cloudinary from "cloudinary";

cloudinary.v2.config({
  cloud_name: process.env.CLOUD_NAME,
  api_key: process.env.API_KEY,
  api_secret: process.env.API_SECRET,
});
console.log("configs", cloudinary.v2.config());

export async function uploadImageToCloudinary(data: AsyncIterable<Uint8Array>) {
  const uploadPromise = new Promise<UploadApiResponse>(
    async (resolve, reject) => {
      const uploadStream: UploadStream = cloudinary.v2.uploader.upload_stream(
        {
          folder: "HotSprings",
        },
        (error, result) => {
          if (error) {
            reject(error);
            return;
          }
          // resultの型エラーを防ぐために記述した
          if (result === undefined) {
            return;
          }
          resolve(result);
        },
      );
      await writeAsyncIterableToWritable(data, uploadStream);
    },
  );

  return uploadPromise;
}

実際にactionから呼び出す

hotsprings.new.tsx
export const action = async ({ request }: ActionFunctionArgs) => {
  const uploadHandler: UploadHandler = composeUploadHandlers(
    async ({ name, data }) => {
      console.log({ name, data });
      if (name !== "image") {
        return undefined;
      }

      // アップロードした画像URLを返す
      const uploadedImage = await uploadImageToCloudinary(data);
      return uploadedImage.secure_url;
    },
    createMemoryUploadHandler(),
  );

  const formData = await parseMultipartFormData(request, uploadHandler);
  const formDataObj = Object.fromEntries(formData);

  const validationResult = CreateHotSpringSchema.safeParse(formDataObj);
 
  if (!validationResult.success) {
    return json({
      validationErrors: validationResult.error.flatten().fieldErrors,
    });
  }

  const user = await authenticator.isAuthenticated(request, {
    failureRedirect: "/login",
  });

  const newHotSpring = await createHotSpring({
    ...validationResult.data,
    authorId: user.id,
  });

  return null;
};
おたきおたき

サンプルをもとに実装したがいくつか問題直面した

現状の課題

バリデーションの結果に画像の情報が含めたいが、画像情報を取ってこれない。Zod側での型定義に問題あり?それともフォームデータの持ち方に問題がある?

  const formData = await parseMultipartFormData(request, uploadHandler);
  const formDataObj = Object.fromEntries(formData); 

  // ここに画像の情報が含まれない🤔
  const validationResult = CreateHotSpringSchema.safeParse(formDataObj); 

formDataObjvalidationResultの値を出力させてみた

対応1

  • getAllメソッドで画像URLを配列で取得する
hotsprings.new.tsx

  const formData = await parseMultipartFormData(request, uploadHandler);
  // 画像URL値が格納された配列を取得
  const imgUrls = formData.getAll("image");
  const formDataObj = { ...Object.fromEntries(formData), images: imgUrls };
  const validationResult = CreateHotSpringSchema.safeParse(formDataObj);
  if (!validationResult.success) {
    return json({
      validationErrors: validationResult.error.flatten().fieldErrors,
    });
  }

しかし、formDataObjの中身を見ると必要のないimageプロパティが残っている。これを削除したいがどうしようか..

対応2

deleteメソッドでFormDataオブジェクトからキーと値を削除してみる

const formData = await parseMultipartFormData(request, uploadHandler);
const imgUrls = formData.getAll("image");
formData.delete("image");
const formDataObj = { ...Object.fromEntries(formData), images: imgUrls };

imageプロパティを削除できた

このスクラップは2ヶ月前にクローズされました