MongoDB: ブログコメントシステムCRUD実践
最終更新:2026-08-26
ブログコメントシステムは、MongoDB実践スキルを学ぶ最適な題材です。
1. このレッスンで学ぶこと
- Post + Commentデータモデル設計
- ツリー構造コメント(埋め込み vs 参照)
- CRUD操作(作成・読み取り・更新・削除)
- コメントの「いいね」機能($addToSet/$pull)
- ブログ統計ダッシュボード($facet)
- Express REST API設計
graph TB
A[ブログシステム] --> B[記事<br/>posts]
A --> C[コメント<br/>comments]
B --> B1[タイトル/本文]
B --> B2[著者]
B --> B3[タグ]
B --> B4[統計: 閲覧数/いいね数/コメント数]
C --> C1[ツリー構造<br/>parentId]
C --> C2[いいね配列]
C --> C3[ステータス<br/>承認済み/保留中]
style A fill:#e1f5fe
style B fill:#d4edda
style C fill:#cce5ff
2. 要件分析
概念説明: ブログコメントシステムは、最も一般的なWebアプリケーション機能の一つです。記事の投稿、コメントの追加、返信、いいねなどの基本機能をカバーし、MongoDBの主要な操作を網羅的に学べます。
機能要件:
| 機能 | 詳細 | MongoDB操作 |
|---|---|---|
| 記事管理 | 作成・一覧取得・詳細取得・削除 | CRUD |
| コメント機能 | トップレベルコメント・返信 | 埋め込み配列 |
| ツリー構造 | 多階層返信(parentId) | 参照関係 |
| いいね機能 | トグル式(重複防止) | $addToSet/$pull |
| 統計ダッシュボード | 人気記事・アクティブユーザー | $facet集計 |
3. データモデル設計
概念説明: ブログシステムのコアは「記事」と「コメント」の2つです。記事はユーザー投稿コンテンツ、コメントは読者とのやり取りを表します。設計上の重要な決定は、コメントを記事に埋め込むか、独立したコレクションにするかです。
設計判断:
| 設計案 | メリット | デメリット |
|---|---|---|
| 埋め込み | 1回の読み取りで完結 | コメント数増加でサイズ超過リスク |
| 参照 | 独立ページネーション可能 | 追加クエリ必要 |
採用案: 記事とは別のコレクションにコメントを格納(参照パターン)
- コメント数が不定(0件〜数千件)
- ページネーション必要
- コメント単独でのCRUD操作
// === 記事スキーマ ===
const postSchema = new mongoose.Schema({
title: { type: String, required: true },
slug: { type: String, unique: true },
content: String,
excerpt: { type: String, maxlength: 300 },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
tags: [{ type: String }],
category: { type: String, default: 'general' },
status: {
type: String,
enum: ['draft', 'published', 'archived'],
default: 'draft'
},
viewCount: { type: Number, default: 0 },
likeCount: { type: Number, default: 0 },
commentCount: { type: Number, default: 0 },
publishedAt: Date,
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date }
});
// インデックス
postSchema.index({ title: 'text', content: 'text' });
postSchema.index({ slug: 1 });
postSchema.index({ author: 1, createdAt: -1 });
postSchema.index({ tags: 1 });
// === コメントスキーマ ===
const commentSchema = new mongoose.Schema({
postId: { type: mongoose.Schema.Types.ObjectId, ref: 'Post', required: true },
author: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true },
content: { type: String, required: true, maxlength: 2000 },
parentId: { type: mongoose.Schema.Types.ObjectId, ref: 'Comment', default: null },
likes: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }],
likeCount: { type: Number, default: 0 },
status: {
type: String,
enum: ['pending', 'approved', 'rejected'],
default: 'approved'
},
isEdited: { type: Boolean, default: false },
createdAt: { type: Date, default: Date.now },
updatedAt: { type: Date }
});
// インデックス
commentSchema.index({ postId: 1, createdAt: -1 });
commentSchema.index({ parentId: 1 });
commentSchema.index({ author: 1 });
4. CRUD操作
(1) 記事の作成
// 記事作成
const post = await Post.create({
title: 'MongoDB 7.0 新機能完全ガイド',
slug: 'mongodb-7-new-features-guide',
content: 'MongoDB 7.0では多くの新機能が追加されました...',
excerpt: 'MongoDB 7.0の主な新機能を解説します',
author: userId,
tags: ['mongodb', 'database', 'nosql'],
category: 'database',
status: 'published',
publishedAt: new Date()
});
console.log(post._id);
(2) 記事一覧取得
// 記事一覧(ページネーション付き)
async function getPosts({ page = 1, limit = 10, tag, status = 'published' }) {
const query = { status };
if (tag) query.tags = tag;
const [posts, total] = await Promise.all([
Post.find(query)
.populate('author', 'username avatar')
.sort({ publishedAt: -1 })
.skip((page - 1) * limit)
.limit(limit)
.lean(),
Post.countDocuments(query)
]);
return { posts, total, page, limit, totalPages: Math.ceil(total / limit) };
}
(3) コメント追加
// トップレベルコメント
const comment = await Comment.create({
postId: post._id,
author: userId,
content: '素晴らしい記事ですね!',
parentId: null
});
// 記事のコメント数をインクリメント
await Post.updateOne({ _id: post._id }, { $inc: { commentCount: 1 } });
(4) 返信の追加
// コメントへの返信
const reply = await Comment.create({
postId: post._id,
author: anotherUserId,
content: 'ありがとうございます!',
parentId: comment._id // 親コメントへの参照
});
await Post.updateOne({ _id: post._id }, { $inc: { commentCount: 1 } });
(5) コメントツリー取得
// ツリー構造コメント取得
async function getCommentTree(postId) {
// 1. トップレベルコメント
const topComments = await Comment.find({
postId,
parentId: null,
status: 'approved'
})
.populate('author', 'username avatar')
.sort({ createdAt: -1 })
.lean();
// 2. 全返信を一括取得
const topIds = topComments.map(c => c._id);
const replies = await Comment.find({
parentId: { $in: topIds }
})
.populate('author', 'username avatar')
.sort({ createdAt: 1 })
.lean();
// 3. ツリー構造に組み立て
const repliesByParent = {};
replies.forEach(r => {
const pid = r.parentId.toString();
if (!repliesByParent[pid]) repliesByParent[pid] = [];
repliesByParent[pid].push(r);
});
return topComments.map(c => ({
...c,
replies: repliesByParent[c._id.toString()] || []
}));
}
5. いいね機能
概念説明: いいね機能は「トグル」動作が特徴です。同じユーザーが2回クリックした場合、1回目はいいね追加、2回目は取り消しとなります。これをMongoDBの$addToSetと$pullで実現します。
動作原理: $addToSetは配列に要素が存在しない場合のみ追加(冪等性あり)。$pullは配列から要素を削除。いずれもアトミック操作のため、同時実行の安全性が保証されます。
▶ サンプル 1:コメントいいねのトグル(難易度 ⭐)
// いいねのトグル実装
async function toggleLike(commentId, userId) {
// 現在の状態を確認
const comment = await Comment.findOne({
_id: commentId,
likes: userId
});
let update;
if (comment) {
// 既にいいね済み → 取り消し
update = {
$pull: { likes: userId },
$inc: { likeCount: -1 }
};
} else {
// まだいいねしていない → 追加
update = {
$addToSet: { likes: userId },
$inc: { likeCount: 1 }
};
}
return Comment.findByIdAndUpdate(commentId, update, { new: true });
}
// 使用例
await toggleLike('64a1b2c3...', '64a1b2c3d4e5...');
出力:
TEXT 📖 参照専用ユーザーが既にいいねしている場合は取り消し、していない場合は追加される。likeCountも同時に更新される。
▶ サンプル 2:記事いいね(難易度 ⭐⭐)
// 記事へのいいね(詳細機能付き)
async function likePost(postId, userId) {
const post = await Post.findById(postId);
if (!post) throw new Error('記事が見つかりません');
// 重複チェック(MongoDB側で保証)
const result = await Post.updateOne(
{ _id: postId, likes: { $ne: userId } },
{
$addToSet: { likes: userId },
$inc: { likeCount: 1 }
}
);
if (result.modifiedCount === 0) {
return { success: false, message: '既にいいね済みです' };
}
return { success: true, likeCount: post.likeCount + 1 };
}
出力:
TEXT 📖 参照専用重複いいねを防止し、likeCountを同期更新。結果として成功/失敗のステータスを返す。
6. 集計統計ダッシュボード
概念説明: ブログシステムでは多角的な統計データが必要です。記事総数、アクティブな著者、人気タグ、コメント推移など。$facetを使えば1回の集計クエリで複数の統計を並列取得でき、複数回のデータベースクエリを回避できます。
動作原理: $facetは同じ入力ドキュメントに対して複数のサブパイプラインを並列実行し、各サブパイプラインが独立にデータを処理して結果を返します。最終出力は1つのドキュメントになり、キーがサブパイプライン名、値がその結果です。
graph TB
A[posts コレクション] --> B[$facet]
B --> C[サブパイプライン1<br/>totalPosts<br/>$count]
B --> D[サブパイプライン2<br/>publishedPosts<br/>$match + $count]
B --> E[サブパイプライン3<br/>topAuthors<br/>$group + $sort + $limit + $lookup]
B --> F[サブパイプライン4<br/>popularTags<br/>$unwind + $group + $sort]
C --> G[多次元結果<br/>1クエリで返却]
D --> G
E --> G
F --> G
style B fill:#d4edda
style G fill:#cce5ff
| 統計次元 | 集計パイプライン | 説明 |
|---|---|---|
| 記事総数 | $count |
下書き・公開済み・アーカイブ全て含む |
| 公開記事数 | $match({status:'published'}) + $count |
公開済みのみ |
| アクティブな著者 | $group({author}) + $sort({views:-1}) + $lookup(users) |
閲覧数順 |
| 人気タグ | $unwind('$tags') + $group({tags}) + $sort |
タグ出現頻度順 |
// === ブログ統計 ===
async function getBlogStats() {
const stats = await Post.aggregate([
{
$facet: {
totalPosts: [{ $count: 'count' }],
publishedPosts: [
{ $match: { status: 'published' } },
{ $count: 'count' }
],
topAuthors: [
{ $match: { status: 'published' } },
{
$group: {
_id: '$author',
postCount: { $sum: 1 },
totalViews: { $sum: '$viewCount' }
}
},
{ $sort: { totalViews: -1 } },
{ $limit: 10 },
{
$lookup: {
from: 'users',
localField: '_id',
foreignField: '_id',
as: 'authorInfo'
}
}
],
popularTags: [
{ $unwind: '$tags' },
{
$group: {
_id: '$tags',
count: { $sum: 1 }
}
},
{ $sort: { count: -1 } },
{ $limit: 10 }
]
}
}
]);
return stats[0];
}
7. Express APIルーティング
RESTful API設計: ブログシステムのAPIはREST原則に従って設計します。リソースは名詞で命名(/posts、/comments)、操作はHTTPメソッドで表現(GET、POST、PUT、DELETE)。
| APIエンドポイント | HTTPメソッド | 機能 | Mongoose操作 |
|---|---|---|---|
/api/posts |
POST | 記事作成 | Post.create() |
/api/posts |
GET | 記事一覧(ページネーション) | Post.find().skip().limit() |
/api/posts/:id |
GET | 記事詳細 | Post.findById().populate() |
/api/posts/:id/comments |
POST | コメント追加 | Comment.create() + $inc |
/api/comments/:id/like |
POST | いいね/取り消し | $addToSet/$pull + $inc |
⚙️ 必要:
npm install mongoose express
// === Express ルーティング ===
app.post('/api/posts', async (req, res) => {
const post = await Post.create({
...req.body,
author: req.user._id
});
res.status(201).json(post);
});
app.get('/api/posts', async (req, res) => {
const { page = 1, limit = 10, tag, status } = req.query;
const query = {};
if (tag) query.tags = tag;
if (status) query.status = status;
else query.status = 'published';
const posts = await Post.find(query)
.populate('author', 'username avatar')
.sort({ createdAt: -1 })
.skip((page - 1) * limit)
.limit(parseInt(limit))
.lean();
const total = await Post.countDocuments(query);
res.json({ data: posts, total, page, limit });
});
app.post('/api/posts/:id/comments', async (req, res) => {
const comment = await Comment.create({
postId: req.params.id,
author: req.user._id,
content: req.body.content,
parentId: req.body.parentId || null
});
await Post.updateOne(
{ _id: req.params.id },
{ $inc: { commentCount: 1 } }
);
res.status(201).json(comment);
});
▶ サンプル 3:コメント一覧のページネーションとパフォーマンス最適化(難易度 ⭐⭐)
ページネーション設計の判断: 記事に数百〜数千件のコメントがある場合、一括読み込みはパフォーマンス問題を引き起こします。ページネーションで解決します。重要な判断:トップレベルコメント単位でページネーション(全コメントではなく)、各表示トップレベルコメントの返信は全て読み込む。
カーソルベースページネーションを採用する理由: オフセットページネーション(skip(N).limit(M))はNが大きくなるほど遅くなります。カーソルベースページネーションは_id: { $gt: lastId }で直接ターゲットにジャンプし、ページ深度に関わらずO(1)のパフォーマンスを維持します。
⚙️ 必要:
npm install mongoose express
// カーソルベースページネーション
async function getCommentsPaginated(postId, cursor, limit = 20) {
const query = { postId: mongoose.Types.ObjectId(postId), parentId: null };
// カーソルがあれば、このID以降を取得
if (cursor) {
query._id = { $lt: mongoose.Types.ObjectId(cursor) };
}
// 1. トップレベルコメントを取得
const comments = await Comment.find(query)
.populate('author', 'username avatar')
.sort({ _id: -1 })
.limit(limit + 1)
.lean();
// 2. 次ページ有無をチェック
const hasMore = comments.length > limit;
const results = hasMore ? comments.slice(0, limit) : comments;
// 3. 各トップレベルコメントの返信を取得(並列)
const commentIds = results.map(c => c._id);
const replies = await Comment.find({
parentId: { $in: commentIds }
})
.populate('author', 'username avatar')
.sort({ createdAt: 1 })
.lean();
// 4. 親ごとに返信をグループ化
const repliesByParent = {};
replies.forEach(r => {
const parentId = r.parentId.toString();
if (!repliesByParent[parentId]) repliesByParent[parentId] = [];
repliesByParent[parentId].push(r);
});
// 5. コメントツリーを組み立て
const commentsWithReplies = results.map(comment => ({
...comment,
replies: repliesByParent[comment._id.toString()] || []
}));
return {
comments: commentsWithReplies,
nextCursor: hasMore ? results[results.length - 1]._id : null,
hasMore
};
}
// Express APIエンドポイント
app.get('/api/posts/:postId/comments', async (req, res) => {
const { postId } = req.params;
const { cursor, limit = 20 } = req.query;
const result = await getCommentsPaginated(postId, cursor, parseInt(limit));
res.json({
data: result.comments,
pagination: {
nextCursor: result.nextCursor,
hasMore: result.hasMore
}
});
});
出力:
TEXT 📖 参照専用{ data: [ { _id: '...', content: '素晴らしい記事ですね!', author: {...}, replies: [...] }, { _id: '...', content: '共有ありがとうございます', author: {...}, replies: [] } ], pagination: { nextCursor: '64a1b2c3d4e5f6g7h8i9j0k1', hasMore: true } }
❓ よくある質問
{ _id: { $gt: lastId } })を使用。skipを使わず、深度ページネーションのパフォーマンス問題を回避。isEdited: trueフィールドを更新して編集済みマークを付ける必要がある。📖 まとめ
- Post + Commentデータモデルを設計(埋め込み vs 参照)
- Mongooseスキーマ定義 + populate結合クエリ
- 完全なCRUD操作(作成・読み取り・更新・削除)
- コメントツリー構造(トップレベル + 返信)
- いいね機能($addToSet/$pull)
- 集計統計($facet、マルチチャネル)
📝 練習問題
- 基本問題(⭐):PostとCommentのスキーマを完全に定義せよ(全フィールド、バリデーション、インデックス含む)。
- 基本問題(⭐):CRUD APIを実装せよ(記事作成、記事一覧取得、コメント追加、コメントツリー取得)。
- 応用問題(⭐⭐):コメントへの「いいね」機能を実装せよ(トグル式)。
- 応用問題(⭐⭐):ブログ統計を実装せよ(人気記事、人気タグ、アクティブな著者)。
- チャレンジ問題(⭐⭐⭐):完全なブログシステム(ユーザー、記事、コメント、いいね、統計を含む)を構築せよ。多階層コメント返信に対応すること。