素晴らしい Nest.js ブログを構築する:画像のアップロード
Daniel Hayes
Full-Stack Engineer · Leapcell

前のチュートリアル (https://leapcell.io/blog/nestjs-blog-step-by-step-reply-comment) では、ブログのコメント返信機能を実装しました。
記事のコメント機能はかなり完成したので、記事自体が少し地味に見えます。結局のところ、プレーンテキストしかサポートしていません。
次のチュートリアルでは、記事が画像の挿入をサポートするようにして、表現力を豊かにします。
画像の挿入の原則は次のとおりです。
- ユーザーが画像を選択し、バックエンドにアップロードします。
- バックエンドは画像をどこかに保存し、画像リソースにアクセスするためのURLを返します。
- フロントエンドは画像URLを記事コンテンツに挿入します。
- 記事コンテンツは最終的にWebページとしてレンダリングされ、画像は画像URLに基づいて対応するデータを取得することによって表示されます。
ステップ 1: S3互換オブジェクトストレージの準備
コーディングを開始する前に、アップロードされた画像を保存する場所が必要です。画像をサーバーのディスクに直接保存する方法もありますが、最新のアプリケーションでは、高可用性、簡単なスケーラビリティ、低コストなどの利点から、オブジェクトストレージサービス(AWS S3など)を使用することがより推奨されます。
利便性のために、データベースとバックエンドホスティングを提供するだけでなく、S3互換オブジェクトストレージサービスも提供するLeapcellを引き続き使用します。
- バケットの作成: Leapcellコンソールにログインし、「オブジェクトストレージ」ページに移動して、「バケットの作成」をクリックします。グローバルに一意のバケット名(例:
my-nest-blog-images
)を入力し、リージョンを選択します。 - アクセス資格情報の取得: バケット詳細ページまたはアカウント設定で、アクセスキーIDとシークレットアクセスキーを見つけます。また、エンドポイントアドレスも書き留めておきます。
この情報(バケット名、エンドポイント、アクセスキー、シークレットキー)は非常に重要であり、後でバックエンドの設定で使用します。
ステップ 2: バックエンドでの画像アップロードAPIの実装
それでは、ファイルアップロードを処理するNest.jsバックエンドを構築しましょう。
1. 依存関係のインストール
S3互換ストレージサービスとの対話にはaws-sdk
(新しいバージョンは@aws-sdk/client-s3
)、multipart/form-data
リクエストの処理には@nestjs/platform-multer
が必要です。
npm install @aws-sdk/client-s3 npm install @nestjs/platform-multer
2. 環境変数の設定
セキュリティ上の理由から、S3資格情報をコードにハードコーディングしないでください。プロジェクトのルートディレクトリに.env
ファイル(まだない場合)を作成し、次のコンテンツを追加します。
# .env S3_ENDPOINT=https://objects.leapcell.io S3_ACCESS_KEY_ID=YOUR_ACCESS_KEY_ID S3_SECRET_ACCESS_KEY=YOUR_SECRET_ACCESS_KEY S3_BUCKET_NAME=my-nest-blog-images
*上記の値はご自身の情報に置き換えてください。
Nest.jsで.env
ファイルを読み込めるようにするには、configモジュールをインストールする必要があります。
npm install @nestjs/config
次に、app.module.ts
にConfigModule
をインポートします。
// src/app.module.ts import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; // ... その他のインポート @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), // グローバルモジュールとして設定 // ... その他のモジュール ], // ... }) export class AppModule {}
3. アップロードモジュールの作成
ファイルアップロード機能のために個別のモジュールを作成します。
nest generate module uploads nest generate controller uploads nest generate service uploads
uploads.service.ts
を記述
このサービスは、S3との通信のコアロジックを担当します。
// src/uploads/uploads.service.ts import { Injectable } from '@nestjs/common'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; import { ConfigService } from '@nestjs/config'; import { v4 as uuidv4 } from 'uuid'; @Injectable() export class UploadsService { private readonly s3: S3Client; constructor(private readonly configService: ConfigService) { this.s3 = new S3Client({ endpoint: this.configService.get<string>('S3_ENDPOINT'), region: 'us-east-1', // S3互換ストレージの場合、リージョンはしばしば形式的です credentials: { accessKeyId: this.configService.get<string>('S3_ACCESS_KEY_ID'), secretAccessKey: this.configService.get<string>('S3_SECRET_ACCESS_KEY'), }, }); } async uploadFile(file: Express.Multer.File): Promise<string> { const bucket = this.configService.get<string>('S3_BUCKET_NAME'); const endpoint = this.configService.get<string>('S3_ENDPOINT'); const uniqueFileName = `${uuidv4()}-${file.originalname}`; const command = new PutObjectCommand({ Bucket: bucket, Key: uniqueFileName, Body: file.buffer, ContentType: file.mimetype, ACL: 'public-read', // ファイルを公開読み取り可能に設定します }); try { await this.s3.send(command); // ファイルの公開URLを返します return `${endpoint}/${bucket}/${uniqueFileName}`; } catch (error) { console.error('Error uploading to S3:', error); throw new Error('File upload failed.'); } } }
uploads.controller.ts
を記述
このコントローラーはAPIルートを定義し、FileInterceptor
を使用してアップロードされたファイルを受け取ります。
// src/uploads/uploads.controller.ts import { Controller, Post, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import { UploadsService } from './uploads.service'; import { AuthenticatedGuard } from '../auth/authenticated.guard'; @Controller('uploads') export class UploadsController { constructor(private readonly uploadsService: UploadsService) {} @UseGuards(AuthenticatedGuard) // ログインユーザーのみがアップロードできます @Post('image') @UseInterceptors(FileInterceptor('file')) // 'file' はフォーム内のファイルフィールドの名前です async uploadImage(@UploadedFile() file: Express.Multer.File) { const url = await this.uploadsService.uploadFile(file); return { url }; } }
最後に、app.module.ts
にUploadsModule
をインポートして、アプリケーションがこの新しいモジュールを認識できるようにします。
ステップ 3: FilePicker APIとフロントエンドの統合
バックエンドの準備ができたので、フロントエンドのnew-post.ejs
ページを変更してアップロード機能を追加しましょう。
FilePicker API vs. 従来の<input type="file">
開始する前に、2つのフロントエンドファイル選択方法を簡単に比較しましょう。
-
従来のメソッド:
<input type="file">
- 長所: 優れた互換性、すべてのブラウザでサポート、実装が簡単。
- 短所: UIスタイルはブラウザによって決まり、カスタマイズできないため、かなり地味で時代遅れのユーザーエクスペリエンスにつながります。
-
モダンなメソッド: File System Access API (
window.showOpenFilePicker
)- 長所: モダンなシステムファイルピッカーを提供し、より強力なAPI(例: ファイルハンドルを取得できる、最後に開いたディレクトリを記憶する)を備え、ネイティブアプリケーションに近いユーザーエクスペリエンスを実現します。
- 短所:
- 互換性の問題: 現在、主にChromiumベースのブラウザ(Chrome、Edge)でサポートされています。FirefoxとSafariはまだサポートしていません。
- セキュリティ制限: セキュアなコンテキスト(HTTPS)で実行する必要があります。
私たちのブログはモダンなプロジェクトであるため、showOpenFilePicker
の使用を優先し、必要に応じてフォールバックソリューションを提供できます。
1. new-post.ejs
の更新
views/new-post.ejs
で、<textarea>
の隣に「画像アップロード」ボタンを追加しましょう。
<%- include('_header', { title: 'New Post' }) %> <form action="/posts" method="POST" class="post-form"> <div class="form-group"> <label for="title">Title</label> <input type="text" id="title" name="title" required /> </div> <div class="form-group"> <label for="content">Content</label> <div class="toolbar"> <button type="button" id="upload-image-btn">Upload Image</button> </div> <textarea id="content" name="content" rows="15" required></textarea> </div> <button type="submit">Submit</button> </form> <script> document.addEventListener('DOMContentLoaded', () => { const uploadBtn = document.getElementById('upload-image-btn'); const contentTextarea = document.getElementById('content'); uploadBtn.addEventListener('click', async () => { // ブラウザがFilePicker APIをサポートしているか確認します if (window.showOpenFilePicker) { try { const [fileHandle] = await window.showOpenFilePicker({ types: [{ description: 'Images', accept: { 'image/*': ['.png', '.jpeg', '.jpg', '.gif', '.webp'] }, }], }); const file = await fileHandle.getFile(); uploadFile(file); } catch (error) { // AbortError はユーザーがファイル選択をキャンセルした場合にスローされるため、無視します if (error.name !== 'AbortError') { console.error('FilePicker Error:', error); } } } else { // フォールバック: サポートされていないブラウザの場合、非表示の入力を作成してトリガーできます alert('Your browser does not support the modern FilePicker API.'); // const input = document.createElement('input'); // input.type = 'file'; // input.accept = 'image/*'; // input.onchange = (e) => uploadFile(e.target.files[0]); // input.click(); } }); function uploadFile(file) { if (!file) return; const formData = new FormData(); formData.append('file', file); // 簡単なローディングインジケーターを表示します uploadBtn.disabled = true; uploadBtn.innerText = 'Uploading...'; fetch('/uploads/image', { method: 'POST', body: formData, // 注意: FormData を使用する場合、Content-Type ヘッダーを自分で設定する必要はありません }) .then(response => response.json()) .then(data => { if (data.url) { // 返された画像URLをテキストエリアに挿入します const markdownImage = ``; insertAtCursor(contentTextarea, markdownImage); } else { alert('Upload failed. Please try again.'); } }) .catch(error => { console.error('Upload Error:', error); alert('An error occurred during upload.'); }) .finally(() => { uploadBtn.disabled = false; uploadBtn.innerText = 'Upload Image'; }); } // カーソル位置にテキストを挿入するヘルパー関数 function insertAtCursor(myField, myValue) { if (myField.selectionStart || myField.selectionStart === 0) { var startPos = myField.selectionStart; var endPos = myField.selectionEnd; myField.value = myField.value.substring(0, startPos) + myValue + myField.value.substring(endPos, myField.value.length); myField.selectionStart = startPos + myValue.length; myField.selectionEnd = startPos + myValue.length; } else { myField.value += myValue; } } }); </script> <%- include('_footer') %>
ステップ 4: 画像を含む記事のレンダリング
データベースに画像のMarkdownリンクを挿入することには成功しましたが、まだテキスト文字列として表示されます。記事詳細ページ post.ejs
でMarkdown形式をHTMLに変換する必要があります。
1. Markdown解析ライブラリのインストール
marked
という人気があり効率的なライブラリを使用して、バックエンドでの解析を処理します。
npm install marked npm install -D @types/marked
2. コントローラーでのMarkdown解析
src/posts/posts.controller.ts
のfindOne
メソッドを修正します。投稿データをテンプレートに渡す前に、marked
でコンテンツを解析します。
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, Request } from '@nestjs/common'; import { PostsService } from './posts.service'; import { CommentsService } from '../comments/comments.service'; import { marked } from 'marked'; // markedをインポート @Controller('posts') export class PostsController { constructor( private readonly postsService: PostsService, private readonly commentsService: CommentsService ) {} // ... その他のメソッド @Get(':id') @Render('post') async findOne(@Param('id') id: string, @Request() req) { const post = await this.postsService.findOne(id); const comments = await this.commentsService.findByPostId(id); // Markdownコンテンツの解析 if (post) { post.content = marked.parse(post.content) as string; } return { post, user: req.user, comments }; } }
3. post.ejs
ビューの更新
最後に、views/post.ejs
を修正して、解析されたHTMLが正しくレンダリングされるようにします。以前のバージョンでは、改行を処理するために<%- post.content.replace(/\n/g, '<br />') %>
を使用していましたが、コンテンツがすでにHTMLになっているため、直接出力できます。
<%- include('_header', { title: post.title }) %> <article class="post-detail"> <h1><%= post.title %></h1> <small><%= new Date(post.createdAt).toLocaleDateString() %></small> <div class="post-content"><%- post.content %></div> </article> <a href="/" class="back-link">← Back to Home</a> <%- include('_footer') %>
<%=
の代わりに<%-
を使用していることに注意してください。前者は生のHTMLを出力しますが、後者はエスケープします。
実行・テスト
これで、npm run start:dev
でアプリケーションを再起動し、次に実行します。
- ログインして「新規投稿」ページに移動します。
- 「画像アップロード」ボタンをクリックすると、モダンなファイルピッカーが表示されます。
- 画像を選択します。アップロードが完了すると、画像のMarkdownリンクがテキストエリアに自動的に挿入されます。
- 記事を公開します。
- 記事の詳細ページに移動すると、画像が正常にレンダリングされているのがわかります。
おめでとうございます。ブログで画像アップロードがサポートされるようになりました! これにより、ブログは間違いなくさらにエキサイティングになるでしょう。