😀

【Node.js × Express × TypeScript × React】multerを使用してファイルをアップロ

2023/05/09に公開

はじめに

今回は、バックエンドがNode.js(TypeScript)のファイルアップロード機能をユーザー更新APIに含めて実装した際に、multerの記事はあっても、画面側に画像表示させるまでの記事がなかったため、実装過程を記事にしたいと思います。

multerとは

Node.js のためのミドルウェアで、HTTP の multipart/form-data という形式で送信されるフォームデータを処理するために使用され、主に、画像、ビデオ、オーディオ、その他のファイルをアップロードするために使用されるライブラリです。

要するに、ファイルアップロード処理を簡単に実装するためのライブラリです。

APIの実装

::: note info
multerの導入
:::

npm install multer
npm install @types/multer

::: note info
ファイルアップロードの設定
:::

multerHandler.ts
import multer from "multer";
import fs from "fs";
import path from "path";

// ファイルの保存先とファイル名を指定
const storage = multer.diskStorage({
	destination: (req, file, cb) => {
		// 画像がuploadされるパス
		const uploadPath = path.resolve(__dirname, "../uploads");

		// uploadPathにディレクトリが存在するかどうかを確認
		if (!fs.existsSync(uploadPath)) {
			// uploadPathにディレクトリが存在しない場合、ディレクトリを作成
			fs.mkdirSync(uploadPath, { recursive: true });
		}

		cb(null, uploadPath);
	},
	// アップロードされるファイル名を作成
	filename: (req, file, cb) => {
		const uniqueSuffix = Math.random().toString(26).substring(4, 10);
		cb(null, `${Date.now()}-${uniqueSuffix}-${file.originalname}`);
	},
});

const upload = multer({
	storage,
	fileFilter: (req, file, cb) => {
		console.log(file.mimetype);
		// ファイルのMIMEタイプが以下のいずれかの場合のみファイルアップロードを許可
		if (
			["video/mp4", "image/png", "image/jpeg", "audio/mpeg"].includes(
				file.mimetype
			)
		) {
			cb(null, true);
			return;
		}
		cb(new TypeError("Invalid File Type"));
	},
});

export default upload;

順に説明します。
まず、storageですが、ここでコメントにあるように、「ファイルの保存先とファイル名を定義」しています。

ディレクトリが存在しない場合のみ、mkdirSyncでディレクトリを自動作成するようにしています。

私の場合は、backend/src/uploads/にファイルがアップロードされます。
そして、ファイル名は一意になるように、ランダムな文字列を生成し、日時とアップロードされるファイル名と組み合わせています。
(uniqueSuffixは、ランダムなa-z・0-9の文字列を生成し、インデックスが4から10までの文字列を取得しています)
↑結果的にユニークになるようにしています

次にuploadですが、こちらは変数名のまんまで、storageの定義に沿ってアップロード処理を実行します。
ただし、fileFilter関数では、MIMEタイプをフィルタリングしています。

このMIMEタイプの時にコールバックでアップロードを実行します。

これで、multerの定義は終わりで、uploadを呼び出すとアップロード処理が走るので、APIと組み合わせて使用できるようになりました。

それでは、実際のAPIの処理を見てみましょう。

multerのアップロード処理の使用例

:::note info
ユーザー更新APIの場合
:::
私の場合は、ユーザー更新APIで上記の処理を呼び出したので、それを例にします。

:::note info
ルーティング設定
:::

auth.ts
import express from "express";
import { updateUser} from "../services/userService";
import upload from "../middleware/multerHandler";

const router = express.Router();

// ユーザー更新APIの呼出
router.patch(
	"/update",
	upload.fields([{ name: "profileImg" }, { name: "icon" }]),
	(req: express.Request, res: express.Response) => {
		updateUser(req, res);
	}
);

export default router;

ここで着目するのは、uploadの呼び出しです。
upload.fileds()に配列を渡すことで、複数のファイルを同時にアップロードすることができます。

私の場合、ユーザースキーマにpforileImgiconをString型で定義しているので、このようにオブジェクトを2つ配列として渡しています。

これでAPI(ビジネスロジック)でprofileImgにリクエストされたファイルを格納すれば、画像がDBに保存されます。

:::note info
ユーザー更新APIのビジネスロジック
:::

userService.ts
import express from "express";
import User from "../models/User";

export const updateUser = async (
	req: express.Request,
	res: express.Response
) => {
	const { userId, profileName, description } = req.body;
	try {
		if (!userId) {
			return res.status(401).json({
				errors: [
					{
						param: "userId",
						msg: "無効なリクエストです",
					},
				],
			});
		}

		const user = await getUser(userId, undefined, undefined);

		if (!user) {
			return res.status(404).json({
				errors: [
					{
						param: "user",
						msg: "ユーザーが存在しません",
					},
				],
			});
		}

		let profileImgUrl: any = null;
		let iconUrl: any = null;

		const files: any = req.files;
		if (files) {
			if (files["profileImg"]) {
				profileImgUrl = files["profileImg"][0].filename;
			}

			if (files["icon"]) {
				iconUrl = files["icon"][0].filename;
			}
		}

		const updatedUser = await User.findOneAndUpdate(
			{ _id: user._id, __v: user.__v },
			{
				$set: {
					profileName: profileName,
					description: description,
					icon: iconUrl,
					profileImg: profileImgUrl,
				},
				$inc: { __v: 1 },
			},
			{ new: true, returnOriginal: false }
		);

		if (!updatedUser) {
			return res.status(409).json({
				errors: [
					{
						param: "version",
						msg: "最新のデータに更新してから実行してください",
					},
				],
			});
		}

		return res.status(200).json({ updatedUser });
	} catch (err) {
		console.log(err);
		return res.status(500).json(err);
	}
};

const getUser = async (
	userId: string | undefined,
	username: string | undefined,
	email: string | undefined
) => {
	const user = await User.findOne({
		$or: [{ _id: userId }, { username: username }, { email: email }],
	});

	return user;
};

今回のメインテーマとなる部分のコードは以下の部分です。

let profileImgUrl: any = null;
let iconUrl: any = null;

const files: any = req.files;
if (files) {
    if (files["profileImg"]) {
		profileImgUrl = files["profileImg"][0].filename;
	}

	if (files["icon"]) {
		iconUrl = files["icon"][0].filename;
	}
}

const updatedUser = await User.findOneAndUpdate(
    { _id: user._id, __v: user.__v },
    	{
			$set: {
				profileName: profileName,
				description: description,
				icon: iconUrl,
				profileImg: profileImgUrl,
			},
    		$inc: { __v: 1 },
		},
		{ new: true, returnOriginal: false }
);

リクエストからfiles(req.files)として複数のファイルを取得しています。
ここで、以下のupload.fileds(配列)と符号するようにしています。

upload.fields([{ name: "profileImg" }, { name: "icon" }])

あとは簡単で、オブジェクトとしてnameプロパティを指定しているので、その通りにアップロードファイルを取得し、条件分岐しています(files["profileImg"]などの部分)

そして、条件に合致した場合に、ファイル名を格納するようにしています。

配列は1つしか存在しないためインデックスの0を指定すればOKです。

あとは、ユーザーの更新処理時に値を代入し、置き換えればいいだけです。それが、クエリーの部分です。(findOneAndUpdate)

ちなみに、DBに保存されるのはファイル名なので、次のように「profile.png」などとなります。

このデータをフロントで表示する方法はまた、後ほど解説します。

:::note info
クエリについて
:::

このクエリですが、findOneAndUpdateとすることで、まず、コレクション内を検索し、合致するデータがあった場合に、更新するという処理が可能です。

ここでは、取得したuserのObjectIdとバージョンに合致するデータを更新するようにしています。
(楽観排他制御です)
ここで、アップロードファイルなどのファイルをDBに保存するようにしています。
以上がビジネスロジックです。

::: note info
アップロードファイルのエンドポイントを作成
:::

ここまでの実装で、uploadsフォルダにファイルがアップロードできる状態にはなりました。
しかし、このアップロードファイルをフロントエンドで表示したり、バックエンドで使用したいといった場合には、もう1つやることがあります。

それが、他のAPIのようにエンドポイントを設定することです。以下がコードです。

index.ts
import express from "express";

const app = express();
app.use("/uploads", express.static(path.join(__dirname, "./uploads/")));

このようにするとhttp://バックエンドURL/uploads/ファイル名でアップロードしたファイルにアクセスできます。

私の場合は、ローカルで4000万ポートを使用しているので、次のようになります。
http://localhost:4000/uploads/1683525692746-g5bkkf-img1.jpeg

フロントエンド実装

まずは、全体コードをお見せします。
以下、全体コードです。

.env
REACT_APP_IMAGE_URL='http://localhost:4000/uploads/'
Profile.tsx
import {
	Avatar,
	Box,
} from "@mui/material";

const IMAGE_URL = process.env.REACT_APP_IMAGE_URL as string;

const Profile = ( => {
    // グローバルContextで管理
    const { user } = useUserContext();

    return (
        <Box sx={{ mt: 2, mb: 3, textAlign: "center" }}>
			<Box
    			sx={{
    					backgroundImage: `url(${IMAGE_URL + user?.profileImg})`,
						width: "100%",
						height: "150px",
						position: "relative",
					}}
    		>
    			<Avatar
					src={IMAGE_URL + user?.icon}
        			alt={user?.profileName}
					sx={{
						width: 70,
						height: 70,
						position: "absolute",
						bottom: -30,
						left: 10,
					}}
				/>
			</Box>
        </Box>
    );
};

export default Profile;

先ほどの解説したように、ユーザーに紐づく、iconprofileImgにはファイル名が登録されているので、APIのエンドポイントとファイル名を合わせてAPIのuploadsフォルダに保存しているファイルにアクセスすることでファイルを表示することが可能です。

以上で、ファイルアップロードAPIの実装からファイル表示の一連の処理です。

おわりに

長くなりましたが、最後までお読みいただきありがとうございます。
所感としては、APIのエンドポイントを指定し、ファイル名もパスに含めないといけない点が注意かなと思いました。
(私は、ファイルが一覧で取得できると思い、/uploadsでアクセスしたので・・・)
予想以上に簡単にアップロード機能を実装できるので、ぜひやってみてください。
どなたかの、参考になれば幸いです。

参考文献

Discussion