概要
画像オブジェクトの更新処理を設計・実装する際に悩むことが多いので、まとめてみました
目次
API 設計パターン
HTTP メソッドの選択
画像オブジェクトの更新には、以下の HTTP メソッドを使い分けます:
PUT メソッド
- 用途: 画像オブジェクト全体の置き換え
- 特徴: 冪等性がある(同じリクエストを何度実行しても結果が同じ)
- 使用例: 画像ファイル自体を新しいファイルで完全に置き換える場合
PUT /api/images/{id}
Content-Type: multipart/form-data
file: [新しい画像ファイル]
PATCH メソッド
- 用途: 画像オブジェクトの一部フィールドのみを更新
- 特徴: 部分更新に適している
- 使用例: メタデータ(タイトル、説明、タグなど)のみを更新する場合
PATCH /api/images/{id}
Content-Type: application/json
{
"title": "新しいタイトル",
"description": "更新された説明"
}
エンドポイント設計パターン
パターン 1: 単一エンドポイント(推奨)
画像の更新を 1 つのエンドポイントで処理する方法。メタデータとファイルの更新を分離する。
# メタデータのみ更新
PATCH /api/images/{id}
Content-Type: application/json
# 画像ファイルを更新
PUT /api/images/{id}/file
Content-Type: multipart/form-data
# メタデータとファイルを同時に更新
PUT /api/images/{id}
Content-Type: multipart/form-data
パターン 2: 分離エンドポイント
メタデータとファイルの更新を完全に分離する方法。
# メタデータ更新
PATCH /api/images/{id}/metadata
Content-Type: application/json
# ファイル更新
PUT /api/images/{id}/file
Content-Type: multipart/form-data
パターン 3: 2 段階アップロード
大容量ファイルのアップロードに適した方法。
# ステップ1: アップロード用のプリサインドURLを取得
POST /api/images/{id}/upload-url
Content-Type: application/json
Response:
{
"upload_url": "https://storage.example.com/upload/...",
"expires_at": "2025-01-15T10:00:00Z"
}
# ステップ2: プリサインドURLに直接アップロード
PUT {upload_url}
Content-Type: image/jpeg
# ステップ3: アップロード完了を通知
POST /api/images/{id}/upload-complete
RESTful API 設計の原則
- リソース指向: 画像をリソースとして扱い、URL で一意に識別できるようにする
-
HTTP ステータスコードの適切な使用:
-
200 OK: 更新成功 -
204 No Content: 更新成功(レスポンスボディなし) -
400 Bad Request: リクエストが不正 -
401 Unauthorized: 認証が必要 -
403 Forbidden: 権限がない -
404 Not Found: リソースが存在しない -
409 Conflict: 競合状態(例: 同時更新) -
422 Unprocessable Entity: バリデーションエラー -
500 Internal Server Error: サーバーエラー
-
画像更新の実装パターン
パターン 1: 直接アップロード
クライアントからサーバーを経由して画像をアップロードする方法。
メリット:
- 実装がシンプル
- サーバー側で完全に制御可能
- バリデーションや処理をサーバー側で実行可能
デメリット:
- サーバーの負荷が高い
- 大容量ファイルのアップロードに時間がかかる
- タイムアウトのリスクがある
実装例:
// クライアント側
const updateImage = async (imageId: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`/api/images/${imageId}`, {
method: "PUT",
body: formData,
});
return response.json();
};
パターン 2: プリサインド URL(推奨)
クライアントが直接ストレージサービスにアップロードできる一時的な URL を発行する方法。
メリット:
- サーバーの負荷を軽減
- 大容量ファイルのアップロードに適している
- スケーラブル
デメリット:
- 実装が複雑
- ストレージサービスの設定が必要
実装例:
// ステップ1: プリサインドURLを取得
const getUploadUrl = async (imageId: string) => {
const response = await fetch(`/api/images/${imageId}/upload-url`, {
method: "POST",
});
return response.json();
};
// ステップ2: ストレージに直接アップロード
const uploadToStorage = async (uploadUrl: string, file: File) => {
await fetch(uploadUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
};
// ステップ3: アップロード完了を通知
const notifyUploadComplete = async (imageId: string) => {
await fetch(`/api/images/${imageId}/upload-complete`, {
method: "POST",
});
};
パターン 3: チャンクアップロード
大容量ファイルを複数のチャンクに分割してアップロードする方法。
メリット:
- 大容量ファイルのアップロードに対応
- ネットワークエラー時の再試行が容易
- 進捗状況を表示可能
デメリット:
- 実装が最も複雑
- サーバー側の処理も複雑になる
メタデータとファイルの分離
分離のメリット
- 柔軟性: メタデータとファイルを独立して更新可能
- パフォーマンス: メタデータの更新が軽量
- キャッシュ戦略: メタデータとファイルで異なるキャッシュ戦略を適用可能
- バージョン管理: ファイルのバージョン管理が容易
実装例
// メタデータのみ更新(軽量)
const updateMetadata = async (imageId: string, metadata: ImageMetadata) => {
const response = await fetch(`/api/images/${imageId}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(metadata),
});
return response.json();
};
// ファイルのみ更新(重い処理)
const updateFile = async (imageId: string, file: File) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`/api/images/${imageId}/file`, {
method: "PUT",
body: formData,
});
return response.json();
};
バージョン管理とキャッシュ戦略
バージョン管理
画像の更新履歴を管理することで、以下のメリットがあります:
- ロールバック: 誤った更新を元に戻せる
- 監査: 更新履歴を追跡可能
- 差分表示: 変更内容を確認可能
実装例:
interface ImageVersion {
id: string;
imageId: string;
version: number;
fileUrl: string;
metadata: ImageMetadata;
createdAt: string;
updatedBy: string;
}
// バージョン付きで更新
const updateImageWithVersion = async (
imageId: string,
file: File,
metadata: ImageMetadata
) => {
const response = await fetch(`/api/images/${imageId}`, {
method: "PUT",
body: createFormData(file, metadata),
headers: {
"X-Expected-Version": currentVersion.toString(),
},
});
return response.json();
};
キャッシュ戦略
キャッシュキーの設計
画像の更新後、古いキャッシュが表示されないよう、適切なキャッシュキーを設定します。
パターン 1: バージョン番号を含める
/images/{id}?v={version}
パターン 2: 更新日時を含める
/images/{id}?updated_at={timestamp}
パターン 3: ETag を使用
ETag: "abc123"
If-None-Match: "abc123"
キャッシュ無効化
画像更新時に、以下の方法でキャッシュを無効化します:
-
Cache-Control ヘッダーの設定
Cache-Control: no-cache, must-revalidate -
CDN のパージ API を呼び出す
const invalidateCache = async (imageId: string) => { await fetch(`/api/images/${imageId}/invalidate-cache`, { method: "POST", }); }; -
URL にクエリパラメータを追加
const imageUrl = `/images/${imageId}?v=${Date.now()}`;
セキュリティ対策
認証・認可
- 認証: API キー、OAuth、JWT トークンなどを使用
- 認可: オブジェクトレベルでのアクセス制御を実装
- レート制限: 過度なリクエストを防ぐ
実装例:
// 認証ヘッダーの付与
const updateImage = async (imageId: string, file: File) => {
const token = await getAuthToken();
const response = await fetch(`/api/images/${imageId}`, {
method: "PUT",
headers: {
Authorization: `Bearer ${token}`,
},
body: createFormData(file),
});
if (response.status === 403) {
throw new Error("この画像を更新する権限がありません");
}
return response.json();
};
入力データの検証
ファイルタイプの検証
const ALLOWED_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
const validateFileType = (file: File): boolean => {
return ALLOWED_MIME_TYPES.includes(file.type);
};
ファイルサイズの制限
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const validateFileSize = (file: File): boolean => {
return file.size <= MAX_FILE_SIZE;
};
画像の内容検証
// サーバー側で画像の実際の内容を検証
// ファイル拡張子やMIMEタイプだけでは不十分
const validateImageContent = async (file: File): Promise<boolean> => {
// 画像の実際の内容を読み込んで検証
// 例: マジックナンバーの確認
return true;
};
ウイルススキャン
アップロードされたファイルにマルウェアが含まれていないか確認します。
// サーバー側でウイルススキャンを実行
const scanFile = async (file: File): Promise<boolean> => {
const response = await fetch("/api/scan", {
method: "POST",
body: file,
});
const result = await response.json();
return result.isSafe;
};
SSL/TLS の使用
すべての通信を HTTPS で暗号化します。
エラーハンドリング
適切な HTTP ステータスコードの返却
// サーバー側の実装例
app.put("/api/images/:id", async (req, res) => {
try {
const image = await findImage(req.params.id);
if (!image) {
return res.status(404).json({ error: "画像が見つかりません" });
}
if (!hasPermission(req.user, image)) {
return res.status(403).json({ error: "権限がありません" });
}
const file = req.files?.file;
if (!file) {
return res.status(400).json({ error: "ファイルが指定されていません" });
}
if (!validateFile(file)) {
return res.status(422).json({ error: "無効なファイル形式です" });
}
const updatedImage = await updateImage(image.id, file);
return res.status(200).json(updatedImage);
} catch (error) {
console.error(error);
return res.status(500).json({ error: "サーバーエラーが発生しました" });
}
});
クライアント側のエラーハンドリング
const updateImage = async (imageId: string, file: File) => {
try {
const response = await fetch(`/api/images/${imageId}`, {
method: "PUT",
body: createFormData(file),
});
if (!response.ok) {
const error = await response.json();
switch (response.status) {
case 400:
throw new Error(`リクエストが不正です: ${error.message}`);
case 401:
throw new Error("認証が必要です");
case 403:
throw new Error("この画像を更新する権限がありません");
case 404:
throw new Error("画像が見つかりません");
case 409:
throw new Error("同時更新の競合が発生しました。再度お試しください");
case 422:
throw new Error(`バリデーションエラー: ${error.message}`);
case 500:
throw new Error("サーバーエラーが発生しました");
default:
throw new Error(`予期しないエラー: ${response.status}`);
}
}
return await response.json();
} catch (error) {
if (error instanceof TypeError && error.message === "Failed to fetch") {
throw new Error("ネットワークエラーが発生しました");
}
throw error;
}
};
リトライ戦略
ネットワークエラーや一時的なサーバーエラーに対して、リトライを実装します。
const updateImageWithRetry = async (
imageId: string,
file: File,
maxRetries = 3
) => {
let lastError: Error;
for (let i = 0; i < maxRetries; i++) {
try {
return await updateImage(imageId, file);
} catch (error) {
lastError = error as Error;
// リトライ不可能なエラー(400, 401, 403, 404など)は即座にスロー
if (error instanceof Error && error.message.includes("400")) {
throw error;
}
// 指数バックオフでリトライ
await new Promise((resolve) =>
setTimeout(resolve, Math.pow(2, i) * 1000)
);
}
}
throw lastError!;
};
パフォーマンス最適化
画像の最適化
圧縮とリサイズ
アップロード時に画像を適切に圧縮・リサイズします。
// クライアント側で事前にリサイズ
const resizeImage = async (
file: File,
maxWidth: number,
maxHeight: number
): Promise<File> => {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (e) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
let width = img.width;
let height = img.height;
if (width > maxWidth || height > maxHeight) {
const ratio = Math.min(maxWidth / width, maxHeight / height);
width *= ratio;
height *= ratio;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d")!;
ctx.drawImage(img, 0, 0, width, height);
canvas.toBlob(
(blob) => {
resolve(new File([blob!], file.name, { type: "image/jpeg" }));
},
"image/jpeg",
0.9
);
};
img.src = e.target?.result as string;
};
reader.readAsDataURL(file);
});
};
複数サイズの生成
サムネイルや複数の解像度を自動生成します。
// サーバー側で複数サイズを生成
const generateImageVariants = async (file: File) => {
const variants = [
{ width: 150, height: 150, suffix: "thumbnail" },
{ width: 800, height: 600, suffix: "medium" },
{ width: 1920, height: 1080, suffix: "large" },
];
return Promise.all(
variants.map((variant) =>
resizeImage(file, variant.width, variant.height, variant.suffix)
)
);
};
CDN の活用
画像の配信に CDN を使用することで、以下のメリットがあります:
- 高速な配信: ユーザーに近いサーバーから配信
- サーバー負荷の軽減: オリジンサーバーへの負荷を削減
- グローバル対応: 世界中のユーザーに高速配信
遅延読み込み(Lazy Loading)
必要なタイミングで画像を読み込むことで、初期表示速度を向上させます。
// 画像の遅延読み込み
<img src={imageUrl} loading="lazy" alt={imageTitle} />
進捗表示
大容量ファイルのアップロード時に、進捗状況を表示します。
const updateImageWithProgress = async (
imageId: string,
file: File,
onProgress: (progress: number) => void
) => {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
const progress = (e.loaded / e.total) * 100;
onProgress(progress);
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`HTTP ${xhr.status}`));
}
});
xhr.addEventListener("error", () => {
reject(new Error("ネットワークエラー"));
});
const formData = new FormData();
formData.append("file", file);
xhr.open("PUT", `/api/images/${imageId}`);
xhr.send(formData);
});
};
まとめ
画像オブジェクトの更新処理を設計・実装する際は、以下のポイントを考慮することが重要です:
API 設計
- HTTP メソッドの適切な使用: PUT(全体更新)と PATCH(部分更新)を使い分ける
- RESTful な設計: リソース指向で一貫性のあるエンドポイントを設計
- メタデータとファイルの分離: 柔軟性とパフォーマンスの向上
実装パターン
- プリサインド URL: 大容量ファイルやスケーラビリティが必要な場合に推奨
- 2 段階アップロード: アップロード URL 取得 → ストレージに直接アップロード → 完了通知
セキュリティ
- 認証・認可: 適切な権限管理を実装
- 入力検証: ファイルタイプ、サイズ、内容の検証
- SSL/TLS: すべての通信を暗号化
パフォーマンス
- 画像の最適化: 圧縮、リサイズ、複数サイズの生成
- CDN の活用: 高速な配信とサーバー負荷の軽減
- キャッシュ戦略: 適切なキャッシュキーと無効化の実装
エラーハンドリング
- 適切な HTTP ステータスコード: エラーの種類を明確に伝える
- リトライ戦略: 一時的なエラーに対する再試行の実装
- ユーザーフレンドリーなエラーメッセージ: 原因を特定しやすいメッセージ
これらのベストプラクティスを適用することで、効率的で安全、かつ使いやすい画像更新 API を実装できます。