MongoDB: ブログコメントシステムCRUD実践

最終更新:2026-08-26

ブログコメントシステムは、MongoDB実践スキルを学ぶ最適な題材です。

1. このレッスンで学ぶこと


100%
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回の読み取りで完結 コメント数増加でサイズ超過リスク
参照 独立ページネーション可能 追加クエリ必要

採用案: 記事とは別のコレクションにコメントを格納(参照パターン)

JAVASCRIPT
// === 記事スキーマ ===
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) 記事の作成

JAVASCRIPT
// 記事作成
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) 記事一覧取得

JAVASCRIPT
// 記事一覧(ページネーション付き)
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) コメント追加

JAVASCRIPT
// トップレベルコメント
const comment = await Comment.create({
  postId: post._id,
  author: userId,
  content: '素晴らしい記事ですね!',
  parentId: null
});

// 記事のコメント数をインクリメント
await Post.updateOne({ _id: post._id }, { $inc: { commentCount: 1 } });

(4) 返信の追加

JAVASCRIPT
// コメントへの返信
const reply = await Comment.create({
  postId: post._id,
  author: anotherUserId,
  content: 'ありがとうございます!',
  parentId: comment._id    // 親コメントへの参照
});

await Post.updateOne({ _id: post._id }, { $inc: { commentCount: 1 } });

(5) コメントツリー取得

JAVASCRIPT
// ツリー構造コメント取得
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:コメントいいねのトグル(難易度 ⭐)

JAVASCRIPT
// いいねのトグル実装
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:記事いいね(難易度 ⭐⭐)

JAVASCRIPT
// 記事へのいいね(詳細機能付き)
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つのドキュメントになり、キーがサブパイプライン名、値がその結果です。

100%
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 タグ出現頻度順
JAVASCRIPT
// === ブログ統計 ===
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

JAVASCRIPT
// === 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

JAVASCRIPT
// カーソルベースページネーション
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
  }
}

❓ よくある質問

Q コメントのネスト深度に制限はある?
A MongoDBのデフォルトのネスト深度は100レベル。本番環境では3〜5レベルに制限し、ドキュメントサイズ肥大化を防止推奨。
Q コメントのページネーションはどう実装する?
A カーソルベースページネーション({ _id: { $gt: lastId } })を使用。skipを使わず、深度ページネーションのパフォーマンス問題を回避。
Q 承認済みコメントを編集できる?
A できるが、isEdited: trueフィールドを更新して編集済みマークを付ける必要がある。

📖 まとめ


📝 練習問題

  1. 基本問題(⭐):PostとCommentのスキーマを完全に定義せよ(全フィールド、バリデーション、インデックス含む)。
  2. 基本問題(⭐):CRUD APIを実装せよ(記事作成、記事一覧取得、コメント追加、コメントツリー取得)。
  3. 応用問題(⭐⭐):コメントへの「いいね」機能を実装せよ(トグル式)。
  4. 応用問題(⭐⭐):ブログ統計を実装せよ(人気記事、人気タグ、アクティブな著者)。
  5. チャレンジ問題(⭐⭐⭐):完全なブログシステム(ユーザー、記事、コメント、いいね、統計を含む)を構築せよ。多階層コメント返信に対応すること。
Web-Tutorial.com

Web-Tutorial 技術チーム

複数の開発者によって共同維持されているプログラミングチュートリアルプラットフォーム。各チュートリアルは専門分野の開発者が執筆・レビューしています。正確で信頼性の高いコンテンツを目指しています — 問題を見つけた場合はお知らせください。

100%