Closed9
100日チャレンジ day23 (画像リサイズアプリ)

昨日
100日チャレンジに感化されたので、アレンジして自分でもやってみます。
やりたいこと
- 世の中のさまざまなドメインの簡易実装をつくり、バックエンドの実装に慣れる(dbスキーマ設計や、関数の分割、使いやすいインターフェイスの切り方に慣れる
- 設計力(これはシステムのオーバービューを先に自分で作ってaiに依頼できるようにする
- 生成aiをつかったバイブコーティングになれる
- 実際にやったことはzennのスクラップにまとめ、成果はzennのブログにまとめる(アプリ自体の公開は必須ではないかコードはgithubにおく)
できたもの

画像リサイズプロキシAPI にする

承知しました。案A(サンプル画像利用)に基づき、「実際に画像をリサイズするAPI」の仕様を固めます。
アプリケーション名: Day 23 - Image Resizer API
目的:
事前にサーバーの public
ディレクトリに配置したサンプル画像(picsum.photos
から取得したもの)のIDと、希望のサイズ(幅・高さ)をパスパラメータで受け取り、画像処理ライブラリ sharp
を使って実際に画像をリサイズし、そのリサイズされた画像データをレスポンスとして返すAPIと、そのAPIをテストするための簡単なUIを提供します。
主要機能:
-
画像リサイズAPI エンドポイント:
-
パス:
/api/images/[id]/[width]/[height]
-
メソッド:
GET
-
パスパラメータ:
-
id
: 事前に配置したサンプル画像のファイル名 (拡張子を除く。例:cat
,mountain
) (必須、文字列) -
width
: 希望する画像の幅 (必須、数値、1以上) -
height
: 希望する画像の高さ (必須、数値、1以上)
-
-
処理:
- 受け取った
id
から、public/images/
ディレクトリ内の画像ファイルパスを特定します (例:public/images/cat.jpg
)。 注意: 拡張子は.jpg
固定とします。 -
fs.promises.access
などでファイルが存在するか確認します。存在しない場合は、ステータスコード404 Not Found
とエラーメッセージ ({ message: "Image not found" }
) をJSONで返します。 -
width
,height
が数値であり、かつ1以上であることをバリデーションします。無効な場合は、ステータスコード400 Bad Request
とエラーメッセージ ({ message: "Invalid width or height" }
) をJSONで返します。 -
fs.promises.readFile
で画像ファイルを読み込みます。 - 画像処理ライブラリ
sharp
を使用し、読み込んだ画像データを指定されたwidth
とheight
でリサイズします (sharp(buffer).resize(width, height).jpeg().toBuffer()
)。出力形式はJPEGとします。 - リサイズ後の画像データ (Buffer) をレスポンスボディとして返します。
- レスポンスヘッダーに
Content-Type: image/jpeg
を設定します。 - レスポンスステータスコードは
200 OK
とします。
- 受け取った
-
エラーハンドリング: 画像の読み込み、リサイズ処理中に予期せぬエラーが発生した場合は、
console.error
でログを出力し、ステータスコード500 Internal Server Error
とエラーメッセージ ({ message: "Internal server error" }
) をJSONで返します。
-
パス:
-
テスト用UI:
-
パス:
/
-
機能:
- タイトル (
Day23 - Image Resizer API
) を表示します。 - 利用可能なサンプル画像のID (
cat
,mountain
,abstract
など、準備したファイル名から拡張子を除いたもの) を表示または選択できるようにします(例: ラジオボタンやドロップダウン)。 - 幅 (
width
) と高さ (height
) を入力する数値入力フィールドを設けます。 - 「画像表示」ボタンなどを設置します。
- ボタンクリック時、または入力値変更時に、選択/入力された
id
,width
,height
を使ってAPIエンドポイントのURL (/api/images/{id}/{width}/{height}
) を動的に生成します。 - 生成されたURLを
<img>
タグのsrc
属性に設定し、APIから返されるリサイズされた画像を表示します。 - 初期表示やエラー時には、適切なメッセージやプレースホルダーを表示します。
- タイトル (
-
パス:
-
サンプル画像の準備:
-
picsum.photos
から適当な画像を 3枚 ダウンロードします。- 例1:
https://picsum.photos/seed/cat/600/400
->cat.jpg
- 例2:
https://picsum.photos/seed/mountain/800/600
->mountain.jpg
- 例3:
https://picsum.photos/seed/abstract/700/500
->abstract.jpg
- 例1:
-
public/images/
ディレクトリを作成し、ダウンロードした画像を上記のファイル名 (cat.jpg
,mountain.jpg
,abstract.jpg
) で保存します。
-
データモデル:
- データベースは使用しません。
prisma
ディレクトリや関連ファイルは使用しませんが、テンプレートからコピーされるため残っていても問題ありません。
技術スタック:
- フレームワーク: Next.js (App Router)
- 言語: TypeScript
- API: Next.js Route Handlers
- 画像処理:
sharp
- スタイリング: Tailwind CSS
- DB: なし
作業手順:
-
プロジェクト初期化:
-
template
ディレクトリをコピーしてday23_image_resizer_api
ディレクトリを作成します。 -
package.json
のname
フィールドをday23_image_resizer_api
に変更します。 -
README.md
にこの仕様を記載します。 -
npm install sharp
を実行してsharp
をインストールします。
-
-
サンプル画像準備:
-
public/images/
ディレクトリを作成し、上記の例のようにpicsum.photos
からダウンロードした画像を3枚 (cat.jpg
,mountain.jpg
,abstract.jpg
) 保存します。
-
-
APIエンドポイント実装:
-
app/api/images/[id]/[width]/[height]/route.ts
を作成し、上記の仕様通りにGET
ハンドラを実装します。fs/promises
とpath
,sharp
をインポートして使用します。
-
-
UI実装:
-
app/page.tsx
を編集し、上記の仕様に沿ったテスト用UIを実装します。useState
でフォームの状態を管理し、利用可能な画像IDをハードコードします。
-
-
動作確認:
- UIで各サンプル画像IDと様々な有効/無効なサイズを入力し、画像が正しく表示/エラーになることを確認します。
- ブラウザの開発者ツールでAPIのレスポンスヘッダー (
Content-Type
) とステータスコードを確認します。 -
curl -v http://localhost:3001/api/images/cat/100/150 -o resized_cat.jpg
のようなコマンドでAPIを直接叩き、画像ファイルがダウンロードできること、curl -v http://localhost:3001/api/images/invalid/100/100
やcurl -v http://localhost:3001/api/images/cat/0/100
でエラーが返ることを確認します。
-
ドキュメント更新:
-
README.md
を最終確認・更新します。 - (任意)
.cursor/rules/knowledge.mdc
に今回のアプリを追加します。
-
この仕様と手順で進めましょう。まずはプロジェクトの初期化から始めます。

画像を表示ボタンをおしても表示されないバグ進行中

できた

本番で気をつけること
今回の画像リサイズAPIを本番運用する場合、PoCの段階では省略していた多くの点を考慮する必要があります。主な注意点は以下の通りです。
-
セキュリティ:
-
入力バリデーションの強化:
-
画像ID:
id
パラメータに../
などが含まれていないか厳格にチェックし、ディレクトリトラバーサル攻撃を防ぎます。許可する文字種(英数字、ハイフン、アンダースコアなど)を正規表現で制限したり、存在するファイルIDのリストと照合したりするべきです。現状のpath.resolve
だけでは不十分な場合があります。 -
サイズ:
width
とheight
に現実的な上限値を設定します。非常に大きな値が指定されると、サーバーリソースを過剰に消費し、DoS攻撃につながる可能性があります。例えば、最大 4000x4000 ピクセルまで、といった制限を設けます。
-
画像ID:
-
レートリミット: 同一IPアドレスや(認証を導入する場合は)ユーザー/APIキーからのリクエスト数を一定時間内に制限します。これにより、ブルートフォース攻撃や過剰なリソース消費を防ぎます。(
ioredis
+ Redis やexpress-rate-limit
のようなライブラリを利用) - 認証/認可 (必要に応じて): 不特定多数に公開するAPIでなければ、APIキーやJWTトークンなどで認証を行い、許可されたクライアントのみが利用できるようにします。
-
入力バリデーションの強化:
-
パフォーマンスとスケーラビリティ:
-
キャッシュの実装: これが最も重要です。同じ画像IDとサイズに対するリクエストが頻繁に来る場合、毎回リサイズするのは非効率です。
-
CDN: CloudFront, Cloudflare などのCDNを利用し、リサイズ結果をエッジサーバーにキャッシュさせます。適切な
Cache-Control
ヘッダーを設定します。 -
サーバーサイドキャッシュ: CDNを使わない/使えない場合や、さらにパフォーマンスを上げたい場合、Redis や Memcached などのインメモリキャッシュ、あるいはディスクキャッシュにリサイズ済み画像を保存し、次回以降のリクエストではキャッシュから返します。キャッシュキーは
id
,width
,height
, (必要ならフォーマットや品質も) を含めます。
-
CDN: CloudFront, Cloudflare などのCDNを利用し、リサイズ結果をエッジサーバーにキャッシュさせます。適切な
-
リソース管理:
sharp
はCPUとメモリをそれなりに消費します。高負荷になった場合に備え、Node.js のクラスタモジュールを使ってプロセスを複数起動したり、コンテナ化 (Docker) して Kubernetes などでオートスケーリングできるようにしたりする構成を検討します。sharp.concurrency()
で同時処理数を制限することも有効です。 -
非同期処理/キューイング: リサイズに時間がかかる可能性がある場合(特に大きな画像や複雑な処理)、リクエストを受け付けたらすぐに
202 Accepted
を返し、実際の処理はバックグラウンドジョブキュー (BullMQ, Celeryなど) に投入する方式も考えられます。処理完了後に結果を取得するエンドポイントを用意したり、Webhookで通知したりします。
-
キャッシュの実装: これが最も重要です。同じ画像IDとサイズに対するリクエストが頻繁に来る場合、毎回リサイズするのは非効率です。
-
エラーハンドリングとロギング:
-
詳細なエラー分類:
sharp
が返す可能性のあるエラーを細かくハンドリングし、クライアントに適切なエラー情報(ただし、内部情報は漏洩させない)を返します。 - 構造化ロギング: リクエストID、ユーザー情報(認証導入時)、入力パラメータ、処理時間、エラー詳細などをJSON形式などでログ出力し、分析やデバッグを容易にします。
- 監視とアラート: エラー発生率、レスポンスタイム、リソース使用率などを監視し、異常値を検知したら通知する仕組み (Prometheus + Grafana, Datadog, Sentryなど) を導入します。
-
詳細なエラー分類:
-
画像処理オプション:
-
フォーマット指定: JPEG以外の出力フォーマット (PNG, WebP, AVIFなど) をクエリパラメータなどで指定できるようにし、
Content-Type
も動的に変更します。WebP/AVIFは多くの場合JPEGより効率的です。 - 品質指定: JPEGやWebPの品質パラメータを指定できるようにします。
-
リサイズオプション:
fit
(cover, contain, fill, inside, outside) などのリサイズ方法を指定できるようにします。 - メタデータ: EXIFなどのメタデータを保持するか削除するか選択できるようにします。
-
フォーマット指定: JPEG以外の出力フォーマット (PNG, WebP, AVIFなど) をクエリパラメータなどで指定できるようにし、
-
ファイル管理 (サンプル画像以外の場合):
- ユーザーがアップロードした画像を扱う場合は、S3などのオブジェクトストレージに保存する構成が一般的です。ファイル名の衝突回避、アクセス制御なども考慮が必要です。
特に、セキュリティ(入力バリデーション、レートリミット) と パフォーマンス(キャッシュ) は、本番環境では必須と言える対策です。PoCから本番に移行する際は、これらの点を重点的に強化する必要があります。

今回の肝
const resizedBuffer = await sharp(fileBuffer)
.resize(width, height)
.jpeg() // 出力形式をJPEGに指定
.toBuffer();
このスクラップは4ヶ月前にクローズされました