404 Not Found

404 Not Found


nginx

「Mongoose ODM:MongoDBのデータモデリングと検証」

ネイティブのMongoDBドライバーを使用して書かれたボブのデータ検証コードは、さまざまなルートに分散していました。登録エンドポイントではメールアドレスの形式をチェックし、記事公開エンドポイントではタイトルの長さを検証し、パスワード変更エンドポイントでは新旧のパスワードが異なることを確認していました。新しいフィールドが追加されるたびに、彼は対応するコントローラーにif (!field)ロジックを追加しなければならず、その結果、同じメール検証用の正規表現が3つのルートで重複して使用されることになっていました。Mongooseに切り替えた後、ボブはすべての検証ルールをスキーマ定義に一元化しました。つまり、一度定義すればどこでも適用できるようにしたことで、コントローラーのコードを60%削減することができました。

学習内容:


1. Mongooseのコアアーキテクチャ

Mongoose は、MongoDB 向けのオブジェクト・ドキュメント・マッピング(ODM)ライブラリであり、ネイティブドライバの上にスキーマ駆動型のデータモデリング層を提供します。その中核となる概念は 3 つの層で構成されています。すなわち、スキーマが構造を定義し → モデルがコンストラクタにコンパイルされ → ドキュメントはモデルのインスタンスとなります。

(1) スキーマ → モデル → ドキュメントの関係

100%
graph LR
    A["Schema<br/>Defining Structures and Verification"] -->|mongoose.model() Compilation| B["Model<br/>Constructor + Query Interface"]
    B -->|new Model() Instantiation| C["Document<br/>Examples of Verified Documents"]
    C -->|.save() Persistence| D[("MongoDB<br/>Gathering")]
    B -->|Model.find() etc.| D
    D -->|Back| C

(2) 設置と接続

▶ 例:MongooseのインストールとMongoDBへの接続

BASH
npm install mongoose
JAVASCRIPT
const mongoose = require('mongoose');

mongoose.connect('mongodb://localhost:27017/myapp')
  .then(() => console.log('MongoDB connected'))
  .catch(err => console.error('Connection error:', err));

(3) 最小限のスキーマとモデル

▶ 例:ユーザースキーマの定義とモデルの作成

JAVASCRIPT
const userSchema = new mongoose.Schema({
  name: String,
  email: String,
  age: Number
});

const User = mongoose.model('User', userSchema);
概念 役割 例え
スキーマ 設計図/構造定義 建築図面
モデル コンストラクタ + データベース操作インターフェース 建設チーム
資料 認証済み資料の例 完成事例

2. スキーマのフィールド型

Mongoose は各フィールドに対して豊富な型マッピングを提供しており、ネイティブドライバーにおける型制約の欠如をはるかに上回っています。

(1) フィールド型のクイックリファレンス

Mongooseの型 対応するJSの型 説明
String 文字列 name: String 自動トリミング(設定が必要)
Number 番号 age: Number 最小値・最大値に対応
Boolean ブール値 active: Boolean 自動的に 0/1/"true" に変換されます
Date 日付 createdAt: Date 組み込みの日付メソッド
ObjectId ObjectId author: mongoose.Schema.Types.ObjectId 他の文書への参照
Array 配列 tags: [String] 子ドキュメントの配列、または型の配列
Mixed オブジェクト meta: mongoose.Schema.Types.Mixed 任意の型、検証なし
Buffer バッファ avatar: Buffer バイナリデータ
Map Map prefs: { type: Map, of: String } ES6のMap構造
Decimal128 128進数 price: mongoose.Schema.Types.Decimal128 高精度128進数

(2) フィールド定義の完全な構文

▶ 例:フィールドオプションの詳細な説明

JAVASCRIPT
const productSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'The product name cannot be left blank.'],
    trim: true,
    minlength: 2,
    maxlength: 100
  },
  price: {
    type: Number,
    required: true,
    min: [0, 'Prices cannot be negative.'],
    default: 0
  },
  category: {
    type: String,
    enum: ['electronics', 'books', 'clothing', 'food'],
    lowercase: true
  },
  tags: [String],
  metadata: {
    type: mongoose.Schema.Types.Mixed,
    default: {}
  }
});

3. バリデータ

バリデーションはMongooseの中核をなす機能であり、データバリデーションをコントローラーから切り離し、スキーマ宣言に一元化します。

(1) バリデータ クイックリファレンス

バリデータ 適用対象 説明
required すべて 必須項目 required: [true, 'Cannot be empty']
enum 文字列 列挙型の値の制約 enum: ['A', 'B', 'C']
min 番号/日付 最小値 min: 0
max 番号/日付 最大値 max: 150
minlength 文字列 最小長 minlength: 6
maxlength 文字列 最大長 maxlength: 200
match 文字列 正規表現による一致 match: [/^\S+@\S+\.\S+$/, 'Invalid email format']
validate すべて カスタム検証関数 validate: v => v > 0

(2) カスタムバリデータ

▶ 例:カスタムバリデータとエラーメッセージ

JAVASCRIPT
const userSchema = new mongoose.Schema({
  password: {
    type: String,
    required: true,
    validate: {
      validator: function(v) {
        return /^(?=.*[A-Z])(?=.*\d).{8,}$/.test(v);
      },
      message: props => `${props.value} Password does not meet requirements: at least 8 characters, includes uppercase and digits`
    }
  },
  phone: {
    type: String,
    validate: {
      validator: function(v) {
        return /^1[3-9]\d{9}$/.test(v);
      },
      message: 'The phone number format is incorrect'
    }
  }
});

(3) トリガーのタイミングを確認する

検証は、new Model().save() および Model.create() の時点で自動的に実行されます。validate() メソッドを使用すると、手動で実行することもできます。updateOne()updateMany() などについては、検証は自動的に実行されません。これらの場合は、runValidators: true オプションを設定する必要があります。

▶ 例:更新操作に対する検証を有効にする

JAVASCRIPT
User.updateOne(
  { email: 'bob@test.com' },
  { age: -5 },
  { runValidators: true }
);

4. 仮想プロパティ

仮想属性はデータベースには保存されず、クエリ実行時にのみ動的に計算されるため、派生フィールドに適しています。

(1) 定義と用法

▶ 例:「ユーザーの氏名」仮想属性

JAVASCRIPT
const userSchema = new mongoose.Schema({
  firstName: String,
  lastName: String,
  email: String
});

userSchema.virtual('fullName')
  .get(function() {
    return `${this.firstName} ${this.lastName}`;
  })
  .set(function(v) {
    const parts = v.split(' ');
    this.firstName = parts[0];
    this.lastName = parts[1] || '';
  });

const User = mongoose.model('User', userSchema);
const user = new User({ firstName: 'Bob', lastName: 'Smith' });
console.log(user.fullName);
TEXT
Bob Smith

(2) virtual および toJSON

仮想属性は、デフォルトでは toJSON() および toObject() の出力には含まれません。スキーマオプションで明示的に有効にする必要があります:

JAVASCRIPT
const userSchema = new mongoose.Schema({
  firstName: String,
  lastName: String
}, {
  toJSON: { virtuals: true },
  toObject: { virtuals: true }
});

5. フック(ミドルウェア)

フック(ミドルウェア)は、ドキュメントのライフサイクルの特定の段階で自動的に実行され、データの事前処理、ロギング、カスケード操作などに使用されます。

(1) フックの種類と発動条件

フックの種類 作動条件 一般的な用途
pre('save') 保存前 パスワードのハッシュ化、データの整形、更新タイムスタンプ
post('save') 保存後 通知の送信、ログの記録
pre('remove') 削除前 関連文書の連鎖削除
post('remove') 削除後 リソースとログのクリーンアップ
pre('find') クエリ実行前 デフォルトのフィルタ条件(例:ソフト削除)
post('find') クエリ実行後 データの匿名化
pre('updateOne') 更新前 更新日時
post('aggregate') 集計後 ログ記録

(2) フックについて

▶ 例:保存前にパスワードを自動的にハッシュ化する

JAVASCRIPT
const bcrypt = require('bcrypt');

userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

(3) 投稿フック

▶ 例:保存後にウェルカムメールを送信する

JAVASCRIPT
userSchema.post('save', function(doc, next) {
  console.log(`User ${doc.email} Saved`);
  next();
});

(4) クエリフック

▶ 例:クエリ実行時に削除済みのドキュメントを自動的に除外する

JAVASCRIPT
userSchema.pre('find', function() {
  this.where({ deletedAt: null });
});

6. クエリビルダー

Mongooseのクエリビルダーは、ネイティブドライバで使用されるオブジェクトパラメータよりも直感的な、チェーン呼び出しをサポートしています。

(1) 連鎖クエリ法

▶ 例:クエリビルダーの連鎖呼び出し

JAVASCRIPT
const users = await User.find()
  .where('age').gte(18).lte(65)
  .where('role').equals('admin')
  .sort({ createdAt: -1 })
  .select('name email age')
  .limit(10)
  .skip(0);

console.log(users);

(2) 一般的なクエリ手法の比較

ネイティブドライバーの実装 Mongoose クエリビルダー 説明
db.users.find({ age: { $gte: 18 } }) User.find().where('age').gte(18) 条件検索
db.users.find().sort({ name: 1 }) User.find().sort({ name: 1 }) 並べ替え
db.users.find().limit(10) User.find().limit(10) 登録件数の上限は
db.users.find().skip(20) User.find().skip(20) スキップ
db.users.find({}, { name: 1 }) User.find().select('name') フィールドフィルター

(3) ページネーション付きクエリのカプセル化

▶ 例:ページネーション付きクエリ用の補助関数

JAVASCRIPT
async function paginate(Model, filter = {}, page = 1, limit = 10) {
  const skip = (page - 1) * limit;
  const [docs, total] = await Promise.all([
    Model.find(filter).skip(skip).limit(limit).sort({ createdAt: -1 }),
    Model.countDocuments(filter)
  ]);
  return {
    data: docs,
    total,
    page,
    totalPages: Math.ceil(total / limit)
  };
}

const result = await paginate(User, { role: 'user' }, 2, 10);

7. 索引

インデックスは、データベースのクエリパフォーマンスにとって極めて重要です。Mongoose は、スキーマ内での宣言型インデックス定義をサポートしています。

(1) 単一カラムインデックスと複合インデックス

▶ 例:スキーマでのインデックスの定義

JAVASCRIPT
const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: true,
    unique: true
  },
  username: {
    type: String,
    index: true
  },
  region: String,
  status: String
});

userSchema.index({ region: 1, status: 1 });
インデックスの種類 定義 説明
一意インデックス unique: true フィールドの値は一意でなければならない
通常インデックス index: true 高速クエリ
総合指数 schema.index({ a: 1, b: -1 }) 多分野総合指数
テキスト索引 schema.index({ title: 'text' }) 全文検索

(2) 開発環境におけるインデックスの自動作成

JAVASCRIPT
mongoose.connect(uri, { autoIndex: true });

本番環境では、起動時の遅延を防ぐため、autoIndex を無効にし、移行スクリプトを使用して手動でインデックスを作成することをお勧めします。


8. インスタンスメソッドと静的メソッド

Mongoose では、カスタムメソッドを使ってスキーマを拡張することができます。これらのメソッドは、インスタンスメソッドと静的メソッドの 2 つのカテゴリに分類されます。

(1) インスタンスメソッド

インスタンスメソッドは単一のドキュメントに対して動作します。現在のドキュメントにアクセスするには、this を使用してください。

▶ 例:パスワード照合の例となる手法

JAVASCRIPT
userSchema.methods.comparePassword = function(candidate) {
  return bcrypt.compare(candidate, this.password);
};

const user = await User.findOne({ email: 'bob@test.com' });
const isMatch = await user.comparePassword('mypassword');

(2) 静的メソッド

静的メソッドはModelクラス上で定義され、ドキュメントのインスタンスに依存しないため、クエリのサポートに適しています。

▶ 例:役割による静的メソッドの検索

JAVASCRIPT
userSchema.statics.findByRole = function(role) {
  return this.find({ role }).sort({ createdAt: -1 });
};

const admins = await User.findByRole('admin');
タイプ 定義 呼び出し 本リファレンス
インスタンスメソッド schema.methods.xxx = function doc.xxx() ドキュメントインスタンス
静的メソッド schema.statics.xxx = function Model.xxx() モデル

9. 結合クエリへのデータ投入

Mongooseのpopulate()は、SQLのJOINと同様に、MongoDBドキュメント間の参照の解決を実装しています。

(1) 参照定義と結合

▶ 例:著者と関連する記事

JAVASCRIPT
const postSchema = new mongoose.Schema({
  title: String,
  content: String,
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  }
});

const Post = mongoose.model('Post', postSchema);

const posts = await Post.find().populate('author', 'firstName lastName email');

(2) マルチレベルでのデータ投入と条件付きフィルタリング

▶ 例:多階層の結合とフィルタ

JAVASCRIPT
const commentSchema = new mongoose.Schema({
  content: String,
  author: { type: mongoose.Schema.Types.ObjectId, ref: 'User' },
  post: { type: mongoose.Schema.Types.ObjectId, ref: 'Post' }
});

const Comment = mongoose.model('Comment', commentSchema);

const comments = await Comment.find()
  .populate('author', 'firstName lastName')
  .populate({
    path: 'post',
    select: 'title content',
    match: { status: 'published' }
  });

(3) populate の性能に関する考慮事項

populate() 基本的には、追加のクエリを実行してその結果を結合するものであり、厳密な意味でのJOINではありません。N+1問題は依然として残っています。つまり、100件の記事が100人の異なる著者と関連付けられている場合、101件のクエリが実行されます。関連付けが頻繁に行われるシナリオでは、ドキュメントの埋め込みや$lookup集約を検討してください。


10. Mongoose とネイティブドライバの比較

ディメンション MongoDBネイティブドライバー Mongoose ODM
データ検証 コントローラーの至る所に散在する手書きのif/else文 スキーマに基づく宣言型検証(一元管理)
型の制約 なし。どのフィールドにも任意の値を格納可能 スキーマによる型の制約。自動変換
結合クエリ 手動 $lookup 集計 populate() 1行で完了
ライフサイクルフック なし プリ/ポストフック
仮想属性 なし 動的に計算される仮想フィールド
インデックス管理 マニュアル createIndex() スキーマ宣言 + 自動作成
クエリ API オブジェクトパラメータ find({ age: { $gte: 18 } }) チェーンビルダー + オブジェクトパラメータ
習得の難易度 低;MongoDBのネイティブ構文と非常に似ている 中;スキーマ、モデル、ドキュメントに関する理解が必要
柔軟性 高い、完全に制御可能 中程度、スキーマ外のフィールドはデフォルトで無視される
パフォーマンス わずかに向上、中間層なし わずかに低下、検証やフックによるオーバーヘッド

11. 包括的な例:ユーザーおよび記事のデータモデル

スキーマ、検証、フック、仮想属性、インスタンスメソッド、および結合クエリを組み合わせて、完全なデータモデルシステムを構築します。

▶ 例:models/User.js

JAVASCRIPT
const mongoose = require('mongoose');
const bcrypt = require('bcrypt');

const userSchema = new mongoose.Schema({
  firstName: {
    type: String,
    required: [true, 'The last name cannot be left blank.'],
    trim: true
  },
  lastName: {
    type: String,
    required: [true, 'The name cannot be empty'],
    trim: true
  },
  email: {
    type: String,
    required: [true, 'The email address cannot be left blank.'],
    unique: true,
    lowercase: true,
    match: [/^\S+@\S+\.\S+$/, 'The email address format is incorrect.']
  },
  password: {
    type: String,
    required: [true, 'The password cannot be empty.'],
    minlength: 8,
    validate: {
      validator: function(v) {
        return /^(?=.*[A-Z])(?=.*\d).{8,}$/.test(v);
      },
      message: 'Password must be at least 8 characters, must include uppercase and digits'
    }
  },
  role: {
    type: String,
    enum: ['user', 'admin'],
    default: 'user'
  },
  age: {
    type: Number,
    min: [0, 'Age cannot be a negative number.'],
    max: [150, 'Age must not exceed150']
  },
  createdAt: {
    type: Date,
    default: Date.now
  }
}, {
  toJSON: { virtuals: true },
  toObject: { virtuals: true }
});

userSchema.virtual('fullName').get(function() {
  return `${this.firstName} ${this.lastName}`;
});

userSchema.pre('save', async function(next) {
  if (!this.isModified('password')) return next();
  this.password = await bcrypt.hash(this.password, 10);
  next();
});

userSchema.methods.comparePassword = function(candidate) {
  return bcrypt.compare(candidate, this.password);
};

userSchema.statics.findByRole = function(role) {
  return this.find({ role }).sort({ createdAt: -1 });
};

module.exports = mongoose.model('User', userSchema);

▶ 例:models/Post.js

JAVASCRIPT
const mongoose = require('mongoose');

const postSchema = new mongoose.Schema({
  title: {
    type: String,
    required: [true, 'The title cannot be left blank.'],
    trim: true,
    minlength: [2, 'Title must be at least2characters'],
    maxlength: [200, 'Most Titles200characters']
  },
  content: {
    type: String,
    required: [true, 'Content cannot be empty']
  },
  author: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  status: {
    type: String,
    enum: ['draft', 'published', 'archived'],
    default: 'draft'
  },
  tags: [{
    type: String,
    lowercase: true
  }],
  viewCount: {
    type: Number,
    default: 0,
    min: 0
  },
  createdAt: {
    type: Date,
    default: Date.now
  },
  updatedAt: {
    type: Date,
    default: Date.now
  }
});

postSchema.index({ status: 1, createdAt: -1 });
postSchema.index({ tags: 1 });

postSchema.virtual('excerpt').get(function() {
  return this.content.substring(0, 100) + '...';
});

postSchema.pre('save', function(next) {
  if (this.isModified('content')) {
    this.updatedAt = new Date();
  }
  next();
});

postSchema.post('remove', async function(doc) {
  await mongoose.model('Comment').deleteMany({ post: doc._id });
});

postSchema.statics.findPublished = function() {
  return this.find({ status: 'published' })
    .populate('author', 'firstName lastName email')
    .sort({ createdAt: -1 });
};

module.exports = mongoose.model('Post', postSchema);

▶ 例:クエリと結合

JAVASCRIPT
const mongoose = require('mongoose');
const User = require('./models/User');
const Post = require('./models/Post');

async function main() {
  await mongoose.connect('mongodb://localhost:27017/blog');

  const user = await User.create({
    firstName: 'Bob',
    lastName: 'Smith',
    email: 'bob@example.com',
    password: 'Secure123',
    role: 'admin',
    age: 28
  });

  const post = await Post.create({
    title: 'Mongoose Getting Started Guide',
    content: 'Mongoose is MongoDB ODM library, providing schema-driven data modeling...',
    author: user._id,
    status: 'published',
    tags: ['mongodb', 'mongoose', 'nodejs']
  });

  const published = await Post.findPublished();
  console.log(published[0].excerpt);
  console.log(published[0].author.fullName);

  const match = await user.comparePassword('Secure123');
  console.log('Password match:', match);

  await mongoose.connection.close();
}

main();
BASH
node app.js
TEXT
Mongoose is MongoDB ODM library, providing schema-driven data modeling......
Bob Smith
Password match: true

❓ よくある質問

Q Mongooseとネイティブドライバーのどちらを選べばよいですか?
A 小規模なプロジェクトや、最大限の柔軟性が必要な場合はネイティブドライバーを選択してください。中規模から大規模なプロジェクト、チームでの共同作業、あるいはバリデーションやフックが必要な場合はMongooseを選択してください。また、両方を併用することも可能です。MongooseはModel.collectionを介してネイティブコレクションにアクセスすることをサポートしています。
Q スキーマとモデルの違いは何ですか?
A スキーマは、フィールドの型、検証ルール、フックを定義する設計図です。一方、モデルは、スキーマから生成されるコンストラクタであり、CRUDメソッドのためのインターフェースを提供します。1つのスキーマから生成できるモデルは1つだけです。
Q 仮想フィールドはデータベースに保存されないのですか?
A はい、仮想フィールドはメモリ内でのみ計算され、MongoDBには書き込まれません。それらを永続化する必要がある場合は、通常のフィールドを使用し、pre-saveフックを使って値を計算・割り当ててください。デフォルトでは、仮想フィールドはJSON出力に表示されません。表示させるには、toJSON: { virtuals: true }を設定する必要があります。
Q populateのパフォーマンスはどのようになっていますか?
A populateはSQLのJOINではありません。基本的には、追加のクエリを実行し、その結果をマージする処理です。関連するドキュメントが少ない場合はパフォーマンスは良好ですが、関連するドキュメントの種類が多数あると、N+1クエリの問題が発生する可能性があります。高頻度で利用されるシナリオでは、ドキュメントの埋め込み、手動での$lookup、またはキャッシュの利用を検討してください。
Q バリデーションエラーにはどのように対処すればよいですか?
A バリデーションに失敗すると、MongooseはValidationErrorをスローします。このerrorsオブジェクトには、各フィールドのエラー詳細が含まれています。Object.values(err.errors).map(e => e.message)を使用してすべてのメッセージを抽出し、Expressのエラー処理ミドルウェアと組み合わせて、統一された400ステータスコードを返すことができます。
Q pre-save フックにおける this とは何を指しますか?
A this は、保存されようとしている Document のインスタンスを指します。注:矢印関数を使用すると、thisのバインディングが失われます。フック関数は通常の関数でなければなりません。this.isModified('field')を使用すると、フィールドが変更されたかどうかを確認できます。
Q スキーマを定義した後でもフィールドを追加できますか?
A はい、schema.add({ newField: String }) を使用すれば動的に追加できます。ただし、コンパイル済みのモデルは自動的に更新されません。再コンパイルを行うか、schema.plugin() 拡張機能を使用する必要があります。プロジェクトの初期段階でフィールド構造を慎重に計画することをお勧めします。

📖 まとめ


📝 練習問題

  1. このレッスンにあるすべてのコード例を完成させ、それぞれが正しく動作することを確認してください。
  2. 包括的な例を修正し、独自の拡張機能を追加する
  3. 公式ドキュメントを確認し、このレッスンで扱われていないAPIを1~2つ見つけ、それらのテストコードを作成してください。
  4. 振り返り:このレッスンで学んだことを、実際のプロジェクトにどのように活かしますか?
  5. このレッスンで学んだことと、これまでのレッスンの内容を組み合わせて、小さなプロジェクトを作成してみてください。
Web-Tutorial.com

Web-Tutorial 技術チーム

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

100%