Node.jsとMongoDBのためのODM Mongooseとは?基本的な使い方を解説

Node.jsとMongoDBのためのODM Mongooseとは?基本的な使い方を解説

はじめに

近年のWebアプリケーション開発において、Node.jsはその非同期ノンブロッキングI/Oと高速性から、サーバーサイドJavaScript環境として広く利用されています。一方、データベースとしてNoSQLの代表格であるMongoDBは、その柔軟なドキュメント指向モデルとスケーラビリティから、多くの開発者に選ばれています。Node.jsとMongoDBは、言語的な親和性(両方ともJavaScript/JSONベース)が高く、非常に相性の良い組み合わせとして人気を博しています。

しかし、Node.jsアプリケーションから直接MongoDBのネイティブドライバーを使用してデータベースを操作する場合、スキーマの定義がないためにデータの整合性を保つのが難しかったり、複雑なクエリをJavaScriptのオブジェクトや配列操作で記述する必要があったりするなど、いくつかの課題が生じます。特に大規模なアプリケーション開発においては、コードの可読性、保守性、そして開発効率の低下につながりかねません。

ここで登場するのが、ODM(Object Data Mapping)という概念です。ODMは、アプリケーションのオブジェクトとデータベースのドキュメント(MongoDBの場合)の間でデータの変換を行う役割を担います。リレーショナルデータベースの世界におけるORM(Object-Relational Mapping)と似た概念ですが、NoSQL特有のドキュメント構造に対応しています。ODMを利用することで、開発者はデータベースの詳細な操作方法を意識することなく、オブジェクトを扱うような直感的な方法でデータの永続化や取得を行うことができるようになります。

Mongooseは、Node.js環境で動作する、MongoDBのための最も人気のあるODMの一つです。Mongooseを使うことで、MongoDBの柔軟性を保ちつつ、データのスキーマ定義、型チェック、バリデーション、参照(リレーション)の管理、ミドルウェア(フック)の実行など、様々な便利機能を利用できるようになります。これにより、より構造化された、保守性の高いコードを書くことが可能になります。

この記事では、Mongooseの基本的な概念から、インストール、接続、スキーマ定義、モデルの作成、ドキュメントの操作(CRUD:作成、読み込み、更新、削除)、そして高度な機能(参照、サブドキュメント、アグリゲーション、トランザクションなど)までを、豊富なコード例とともに詳細に解説します。この記事を読むことで、Mongooseを使って効率的かつ堅牢なNode.js + MongoDBアプリケーションを開発するための知識を習得できるでしょう。

Mongooseの基本概念

Mongooseを理解するために、まずその核となる概念を把握することが重要です。Mongooseは以下の3つの主要な構成要素から成り立っています。

  1. スキーマ (Schema)
  2. モデル (Model)
  3. ドキュメント (Document)

これらの関係性は、「スキーマはデータの構造を定義する設計図であり、モデルはその設計図に基づいてデータベースとやり取りするためのクラス、ドキュメントはそのモデルから生成される個々のデータインスタンス」と考えることができます。

ODM (Object Data Mapping) とは?

MongooseはODMです。ODMは、プログラミング言語のオブジェクトとデータベースのドキュメント(MongoDBの場合)の間でマッピングを行います。これにより、開発者はデータベースの低レベルなAPIを直接扱う代わりに、使い慣れたオブジェクト指向プログラミングのアプローチでデータベース操作を行うことができます。

MongoDBはスキーマレスなデータベースですが、大規模なアプリケーションでデータの整合性を保つためには、ある程度の構造定義やバリデーションが必要になることがあります。Mongooseのスキーマ機能は、まさにこのニーズに応えるものです。スキーマを定義することで、予期しない形式のデータがデータベースに保存されるのを防ぎ、アプリケーションコード内でデータの型を予測しやすくなります。

リレーショナルデータベースのORM(例:SQLAlchemy, Hibernate, TypeORM)は、オブジェクトとリレーショナルテーブル間のマッピングを行います。一方、ODMはオブジェクトとドキュメントデータベースのドキュメント間のマッピングを行います。MongoDBはリレーションを持たないドキュメント指向データベースであるため、ORMの「リレーション」にあたる概念はMongooseでは「参照(Reference)」や「埋め込みドキュメント(Embedded Documents)」として扱われます。

スキーマ (Schema)

スキーマは、MongoDBコレクション内のドキュメントの構造、データ型、デフォルト値、バリデーションルール、インデックスなどを定義するものです。Mongooseにおいてスキーマは非常に重要な役割を果たします。

例えば、ブログ記事を表すドキュメントを保存したい場合、そのドキュメントには「タイトル」「本文」「作成者」「公開日」「タグ」「コメント」などのフィールドが必要になるでしょう。これらのフィールドがどのようなデータ型を持つべきか(文字列、日付、配列など)、必須かどうか、特定の条件を満たす必要があるか(例:タイトルは空でない)といったルールをスキーマで定義します。

スキーマは mongoose.Schema クラスのインスタンスとして定義されます。

“`javascript
const mongoose = require(‘mongoose’);

const blogPostSchema = new mongoose.Schema({
title: {
type: String,
required: true, // このフィールドは必須
trim: true, // 前後の空白を削除
minlength: 5 // 最小文字数
},
content: {
type: String,
required: true
},
author: {
type: mongoose.Schema.Types.ObjectId, // 別のコレクションのIDを参照
ref: ‘User’, // ‘User’ モデルを参照
required: true
},
publishedDate: {
type: Date,
default: Date.now // デフォルト値は現在の日時
},
tags: [String], // 文字列の配列
comments: [{ // コメントのサブドキュメント配列
text: String,
postedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: ‘User’
},
createdAt: {
type: Date,
default: Date.now
}
}],
isPublished: {
type: Boolean,
default: false
}
});
“`

この例では、blogPostSchema というスキーマを定義しています。各フィールドに対して、型 (type) やその他のオプション(required, default, trim など)が設定されています。特に注目すべきは、author フィールドや comments 内の postedBy フィールドで、mongoose.Schema.Types.ObjectId を型として指定し、ref オプションで別のモデル(ここでは User モデルを想定)を参照している点です。これは、MongoDBにおけるドキュメント間の「リレーション」を表現するMongooseの方法です。また、comments はサブドキュメントの配列として定義されています。

スキーマには、フィールド定義だけでなく、仮想プロパティ (Virtuals)インスタンスメソッド (Instance Methods)スタティックメソッド (Static Methods)ミドルウェア (Middleware/Hooks) など、様々な機能を定義することもできます。これらについては後ほど詳しく解説します。

モデル (Model)

モデルは、スキーマから生成されるクラスのようなものです。特定のスキーマに関連付けられ、そのスキーマを持つドキュメントの集合体であるMongoDBのコレクションとやり取りするためのインターフェースを提供します。モデルを使って、データベースからドキュメントを検索したり、新しいドキュメントを作成したり、既存のドキュメントを更新/削除したりします。

モデルは mongoose.model() メソッドを使って作成します。このメソッドは2つの引数を取ります。最初の引数はモデル名(文字列)、2つ目の引数は使用するスキーマです。

javascript
const BlogPost = mongoose.model('BlogPost', blogPostSchema);

慣習として、モデル名は大文字で始め、単数形にします(例: BlogPost, User, Product)。Mongooseはモデル名から自動的にコレクション名を生成します。この例の場合、’BlogPost’ というモデル名は、MongoDBでは ‘blogposts’ という名前のコレクションに対応します(Mongooseはモデル名を小文字にし、複数形にします)。

一度モデルを作成すれば、それを使ってデータベース操作が可能になります。

“`javascript
// 新しいドキュメントを作成 (モデルのインスタンスを生成)
const newPost = new BlogPost({
title: ‘初めてのブログ記事’,
content: ‘この記事ではMongooseの使い方を解説します。’,
author: ‘5f7d9f3b7d7b7b7b7b7b7b7b’, // 仮のユーザーID
tags: [‘Mongoose’, ‘Node.js’, ‘MongoDB’]
});

// ドキュメントをデータベースに保存
newPost.save()
.then(doc => {
console.log(‘記事が保存されました:’, doc);
})
.catch(err => {
console.error(‘保存エラー:’, err);
});

// 記事を検索
BlogPost.find({ title: ‘初めてのブログ記事’ })
.then(posts => {
console.log(‘検索結果:’, posts);
})
.catch(err => {
console.error(‘検索エラー:’, err);
});
“`

モデルは、find, findOne, findById, create, updateOne, updateMany, deleteOne, deleteMany といった、コレクションレベルの操作を行うためのスタティックメソッドを多数提供します。

ドキュメント (Document)

ドキュメントは、モデルのインスタンスです。これは、MongoDBコレクション内に実際に保存される個々のデータレコードに相当します。Mongooseのドキュメントは、対応するスキーマによって定義された構造と挙動を持ちます。

ドキュメントは、モデルのコンストラクタを使って生成したり、データベースから検索して取得したりすることで得られます。

“`javascript
// 新しいドキュメントインスタンスを生成
const newPost = new BlogPost({
title: ‘新しい記事’,
content: ‘内容はここに入ります。’,
// author など他のフィールドも指定
});

// データベースからドキュメントを取得 (例: findOneの結果)
BlogPost.findOne({ title: ‘初めてのブログ記事’ })
.then(existingPost => {
if (existingPost) {
console.log(‘取得したドキュメント:’, existingPost);
// ドキュメントインスタンスに対して操作を行う
existingPost.content = ‘内容を更新しました。’;
existingPost.save() // インスタンスメソッド
.then(updatedDoc => console.log(‘ドキュメントを更新しました:’, updatedDoc))
.catch(err => console.error(‘更新エラー:’, err));
}
})
.catch(err => {
console.error(‘検索エラー:’, err);
});
“`

ドキュメントインスタンスは、そのドキュメント固有の操作を行うためのインスタンスメソッドを持ちます(例: save(), remove() など)。また、スキーマで定義された仮想プロパティやインスタンスメソッドも利用可能です。

Mongooseのインストールと接続

Mongooseを使うには、まずNode.jsプロジェクトにMongooseライブラリをインストールし、アプリケーションからMongoDBデータベースへ接続する必要があります。

インストール

npmを使ってMongooseをプロジェクトに追加します。

bash
npm install mongoose

これでプロジェクトの node_modules ディレクトリにMongooseがインストールされ、package.json の依存関係に追加されます。

MongoDBサーバーの準備

Mongooseを接続するためには、動作しているMongoDBサーバーが必要です。
* ローカルにMongoDBをインストールして実行する。
* MongoDB AtlasのようなクラウドベースのMongoDBサービスを利用する。

この記事では、MongoDBサーバーが稼働していることを前提とします。デフォルトでは、MongoDBサーバーは mongodb://localhost:27017 というアドレスで待ち受けています。

データベースへの接続

Mongooseを使ってMongoDBへ接続するには、主に mongoose.connect() メソッドを使用します。このメソッドは非同期操作なので、Promiseまたはasync/awaitを使って処理します。

基本的な接続方法:

“`javascript
const mongoose = require(‘mongoose’);

// MongoDBの接続URLを指定
const dbURI = ‘mongodb://localhost:27017/mydatabase’; // 接続するデータベース名も指定

mongoose.connect(dbURI, {
// オプション(後述)
// useNewUrlParser: true, // 現在のMongooseではデフォルトで有効
// useUnifiedTopology: true, // 現在のMongooseではデフォルトで有効
// useFindAndModify: false, // findOneAndUpdateなどの古いオプションは非推奨
// useCreateIndex: true // ensureIndexの古いオプションは非推奨
})
.then(() => {
console.log(‘MongoDBに接続しました’);
// ここでモデルを定義したり、データベース操作を開始したりする
// …
})
.catch(err => {
console.error(‘MongoDB接続エラー:’, err);
});
“`

mongoose.connect() メソッドは、接続が成功すると解決されるPromiseを返します。.then() ブロックで接続成功時の処理を、.catch() ブロックで接続失敗時のエラー処理を記述します。

接続URLの形式:

接続URLは通常 mongodb://[username:password@]host[:port][/database][?options] の形式を取ります。
* mongodb://: プロトコル
* username:password@: 認証が必要な場合のユーザー名とパスワード (オプション)
* host: MongoDBサーバーのホスト名またはIPアドレス
* port: MongoDBサーバーのポート番号 (デフォルトは27017) (オプション)
* /database: 接続するデータベース名 (必須)
* ?options: クエリパラメータ形式の接続オプション (オプション)

例:
* ローカルの mydatabase データベース: mongodb://localhost:27017/mydatabase
* 認証付きローカルデータベース: mongodb://myuser:mypassword@localhost:27017/mydatabase
* MongoDB Atlasの接続文字列: mongodb+srv://<username>:<password>@cluster0.xxxx.mongodb.net/<dbname>?retryWrites=true&w=majority (+srv はSRVレコードを使用する場合)

mongoose.connect() のオプション:

以前のMongooseでは useNewUrlParser, useUnifiedTopology, useFindAndModify, useCreateIndex などのオプションを指定する必要がありましたが、Mongoose 6以降ではこれらはデフォルトで有効になっているか、あるいは関連するメソッドが廃止されたため、通常は明示的に指定する必要がなくなりました。

その他の有用なオプションとして以下のようなものがあります。
* serverSelectionTimeoutMS: サーバー選択タイムアウト(ミリ秒)。指定した時間内にMongoDBサーバーが見つからない場合、エラーを発生させます。
* connectTimeoutMS: 接続タイムアウト(ミリ秒)。
* socketTimeoutMS: ソケットタイムアウト(ミリ秒)。アイドル状態のソケットを閉じるまでの時間。
* family: IPアドレスファミリー (4または6)。IPv6を優先する場合などに指定します。
* authSource: 認証に使用するデータベース名。通常は認証情報を保存しているデータベース(デフォルトは admin)。
* ssl: SSL/TLS接続を使用するかどうか。

これらのオプションは、アプリケーションの要件やデプロイ環境に応じて調整します。

接続イベントのハンドリング

mongoose.connect() はPromiseを返しますが、Mongooseの接続オブジェクト自体はイベントエミッターでもあります。これを利用して、接続状態の変化に応じた処理を記述することができます。

mongoose.connection オブジェクトを使って、以下のイベントをリッスンできます。
* open: 接続が成功し、データベースとのやり取りが可能になったときに発生。mongoose.connect() のPromiseが解決されるのと同じタイミングです。
* connected: 接続が確立されたときに発生。通常 open と同じタイミングですが、レプリカセット接続の場合はセカンダリへの接続時などでも発生しうる。
* reconnected: 接続が一度切断された後に再接続されたときに発生。
* disconnected: 接続が切断されたときに発生。
* close: 接続が閉じられたときに発生。
* error: 接続中にエラーが発生したときに発生。

これらのイベントは mongoose.connection.on() または mongoose.connection.once() メソッドでリッスンできます。once() は一度だけ実行したい場合に便利です。

“`javascript
const mongoose = require(‘mongoose’);

// 接続エラーが発生した場合
mongoose.connection.on(‘error’, err => {
console.error(‘Mongoose 接続エラー:’, err);
// 必要に応じてプロセスを終了させるなど、エラー処理を行う
// process.exit(1);
});

// 接続が確立された場合(一度だけ)
mongoose.connection.once(‘open’, () => {
console.log(‘Mongoose データベース接続が確立されました’);
// ここでアプリケーションの初期化処理などを続けることができる
});

// 接続切断時
mongoose.connection.on(‘disconnected’, () => {
console.log(‘Mongoose 接続が切断されました’);
});

// アプリケーション終了時などに接続を閉じる場合
process.on(‘SIGINT’, () => {
mongoose.connection.close(() => {
console.log(‘Mongoose 接続がアプリケーション終了により切断されました’);
process.exit(0);
});
});

// データベースに接続
const dbURI = ‘mongodb://localhost:27017/mydatabase’;
mongoose.connect(dbURI)
.then(() => console.log(‘connect() Promise resolved’)) // このログは ‘open’ イベントの後に表示されることが多い
.catch(err => console.error(‘connect() Promise rejected:’, err)); // 接続が確立できなかった場合のエラー

// Note: connect() Promise が解決されたからといって、open イベントが発生するとは限らない。
// open イベントは、connect() が成功し、かつサーバーとの初期ハンドシェイクが完了した後に発生する。
// 一般的には open イベントをリッスンして接続完了を判断するのがより確実。
“`

実際には、mongoose.connect() のPromise(.then()) を使うか、または mongoose.connection.once('open', ...) を使うかのどちらかで接続成功時の処理を開始するのが一般的です。両方を使うと、接続成功時に処理が二重に実行される可能性があります。通常は once('open', ...) の方が、より低レベルの接続確立を待つため確実とされます。

再接続の管理:

Mongooseはデフォルトで基本的な再接続ロジックを持っていますが、プロダクション環境ではより堅牢な再接続戦略が必要になることがあります。これは通常、接続オプションや外部ライブラリ、あるいは独自の実装によって行われます。Mongooseの接続オプションで autoReconnect (非推奨) や reconnectInterval, reconnectTries (非推奨) がありましたが、これらは現在のドライバでは非推奨または削除されています。ドライバが提供するデフォルトの再接続挙動に依存するか、サードパーティの接続マネージャーライブラリを使用することが推奨されます。

接続エラーの処理

接続エラーはアプリケーションの起動に失敗する原因となるため、適切に処理する必要があります。mongoose.connect() のPromiseの.catch() ブロックや mongoose.connection.on('error', ...) イベントハンドラでエラーを捕捉し、ユーザーにエラーメッセージを表示したり、アプリケーションをシャットダウンしたりといった対応を行います。

スキーマの定義

Mongooseにおけるスキーマは、ドキュメントの構造とそれに付随するメタデータ(型、バリデーション、デフォルト値など)を定義するための設計図です。mongoose.Schema クラスを使って定義します。

“`javascript
const mongoose = require(‘mongoose’);
const Schema = mongoose.Schema; // mongoose.Schema を別名で使うと便利

const userSchema = new Schema({
// フィールド定義
username: {
type: String,
required: true, // 必須フィールド
unique: true, // 一意である必要がある
trim: true, // 前後の空白を削除
lowercase: true // 保存前に全て小文字に変換
},
email: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
// カスタムバリデーション (例: メールアドレス形式のチェック)
validate: {
validator: function(v) {
// 簡単な正規表現でのメール形式チェック
return /^[\w-.]+@([\w-]+.)+[\w-]{2,4}$/.test(v);
},
message: props => ${props.value} は有効なメールアドレス形式ではありません!
}
},
password: {
type: String,
required: true,
minlength: 6 // 最小文字数
},
age: {
type: Number,
min: 0, // 最小値
max: 120 // 最大値
},
isActive: {
type: Boolean,
default: true // デフォルト値
},
roles: [{ // 文字列の配列(例: [‘admin’, ‘editor’])
type: String,
enum: [‘user’, ‘editor’, ‘admin’] // 許容される値のリスト
}],
createdAt: {
type: Date,
default: Date.now, // ドキュメント作成時の日時を自動設定
index: true // このフィールドにインデックスを作成
},
updatedAt: {
type: Date
},
// 別のドキュメントへの参照
// ユーザーが作成したブログ記事のIDを格納する配列を想定
blogPosts: [{
type: Schema.Types.ObjectId,
ref: ‘BlogPost’ // ‘BlogPost’ モデルを参照
}],
// 埋め込みドキュメント (ユーザーのアドレス情報など)
address: {
street: String,
city: String,
zipCode: String
},
// 型が確定しない可能性があるデータ (Mixed型)
settings: Schema.Types.Mixed
});
“`

基本的なデータ型 (Schema Types)

MongooseはMongoDBのBSONタイプに加えて、いくつかの便利なスキーマタイプを提供します。
* String: 文字列
* Number: 数値 (整数または浮動小数点数)
* Boolean: 真偽値 (true または false)
* Date: 日付と時間 (Dateオブジェクト)
* Array: 配列 (指定した型の要素の配列、またはMixed型の配列)
* Buffer: バイナリデータ
* ObjectId: MongoDBのドキュメントID (_id) に使用されるタイプ。別のドキュメントを参照する場合によく使われます。mongoose.Schema.Types.ObjectId または Schema.Types.ObjectId と書きます。
* Mixed: スキーマで型が定義されていないデータ(どんな型でも許容)。Schema.Types.Mixed または {} と書きます。
* Decimal128: 高精度な10進数(特に金融計算などで利用)
* Map: キーと値のペアを持つMapオブジェクト

スキーマタイプのオプション

各スキーマタイプには、そのフィールドの挙動を制御するための様々なオプションを指定できます。一般的なオプションには以下のようなものがあります。

  • type: フィールドのデータ型 (必須)
  • required: true に設定すると、このフィールドは必須になります。ドキュメント保存時に存在しない場合、ValidationErrorが発生します。
  • default: フィールドのデフォルト値を指定します。値、または値を返す関数を指定できます。
  • unique: true に設定すると、このフィールドの値はコレクション全体で一意である必要があります。MongoDBのユニークインデックスが作成されます。注: これはバリデーションではなくインデックスレベルの制約です。Mongooseのバリデーションはドキュメント保存前に実行されますが、unique はMongoDB側で保証されます。競合状態により、複数のドキュメントが同時に同じユニークな値を保存しようとしてエラーになる可能性があります。
  • index: true に設定すると、このフィールドに単一フィールドインデックスが作成されます。クエリパフォーマンスの向上に役立ちます。
  • enum: 文字列型または数値型フィールドに設定できます。フィールドの値は指定された配列内のいずれかの値である必要があります。
  • min, max: 数値型フィールドに設定できます。最小値と最大値を指定します。
  • minlength, maxlength: 文字列型フィールドに設定できます。最小文字数と最大文字数を指定します。
  • trim: 文字列型フィールドに設定できます。true に設定すると、保存前に文字列の前後の空白が削除されます。
  • lowercase, uppercase: 文字列型フィールドに設定できます。それぞれ保存前に全て小文字または大文字に変換します。
  • validate: カスタムバリデーションルールを定義します。関数またはバリデーションオブジェクトの配列を指定できます。バリデーション関数は、フィールドの値を引数に取り、検証結果(真偽値またはPromise)を返します。

カスタムバリデーション

validate オプションを使うと、Mongooseが提供する組み込みのバリデーション(required, enum, min/maxなど)だけでは表現できない複雑な検証ルールを実装できます。

javascript
const userSchema = new Schema({
// ... 他のフィールド ...
phoneNumber: {
type: String,
validate: {
validator: function(v) {
// ここにカスタムバリデーションロジックを記述
// 例: 特定の形式の電話番号かチェック
return /\d{3}-\d{3}-\d{4}/.test(v);
},
message: props => `${props.value} is not a valid phone number!` // バリデーション失敗時のメッセージ
},
required: [true, 'User phone number required'] // 必須と同時にカスタムエラーメッセージも指定可能
}
});

validate は関数だけでなく、バリデーション設定オブジェクト、またはそれらの配列としても指定できます。

javascript
validate: [
{ validator: function(v) { return v.length > 10; }, message: 'Password must be longer than 10 characters.' },
{ validator: function(v) { return /\d/.test(v) && /[a-zA-Z]/.test(v); }, message: 'Password must contain letters and numbers.' }
]

バリデーションは、ドキュメントの save() メソッドや validate() メソッドが呼び出されたときに実行されます。クエリメソッド (update, findOneAndUpdate など) ではデフォルトではバリデーションは実行されません。実行したい場合はオプションで指定する必要があります(例: { runValidators: true })。

仮想プロパティ (Virtuals)

仮想プロパティは、データベースに保存されないプロパティですが、ドキュメントインスタンス上でアクセスしたり、設定したりできるプロパティです。他のフィールドの値に基づいて計算されるプロパティや、複数のフィールドを結合した値などを表現するのに便利です。

仮想プロパティは schema.virtual() メソッドを使って定義します。get 関数でプロパティにアクセスされたときの挙動を、set 関数でプロパティに値が設定されたときの挙動を定義します。

``javascript
userSchema.virtual('fullName').get(function() {
// firstName と lastName フィールドがあると仮定
return
${this.firstName} ${this.lastName}`;
});

// fullName を設定したときに firstName と lastName を分割して設定する場合
userSchema.virtual(‘fullName’).set(function(v) {
const parts = v.split(‘ ‘);
this.firstName = parts[0];
this.lastName = parts[parts.length – 1];
});

// JSON出力やオブジェクト変換時に仮想プロパティを含める設定
userSchema.set(‘toJSON’, { virtuals: true });
userSchema.set(‘toObject’, { virtuals: true });
“`

仮想プロパティはクエリでは直接使用できません(例: User.find({ fullName: '...' }) は機能しない)。もし仮想プロパティで検索したい場合は、$addFields などのアグリゲーションステージを使って計算するか、データベースに実際のフィールドとして保存することを検討する必要があります。

インスタンスメソッドとスタティックメソッド

スキーマには、そのモデルやドキュメントに関連付けられたカスタムメソッドを定義できます。

  • インスタンスメソッド (Instance Methods): 特定のドキュメントインスタンスに対して実行されるメソッドです。ドキュメントのデータを操作したり、ドキュメント固有のロジックを実行したりするのに使います。schema.methods オブジェクトに定義します。

    “`javascript
    // パスワード比較メソッドをインスタンスメソッドとして定義
    userSchema.methods.comparePassword = async function(candidatePassword) {
    // 実際には bcrypt などのライブラリを使ってハッシュ化されたパスワードと比較
    // ここでは単純な比較を例示
    return this.password === candidatePassword;
    };

    // 使い方
    const user = await User.findById(userId);
    const isMatch = await user.comparePassword(‘user_input_password’);
    “`

  • スタティックメソッド (Static Methods): モデル自身に対して実行されるメソッドです。コレクション全体に対する操作や、共通のクエリロジックなどを定義するのに使います。schema.statics オブジェクトに定義します。

    “`javascript
    // ユーザー名でユーザーを検索するスタティックメソッドを定義
    userSchema.statics.findByUsername = function(username) {
    return this.findOne({ username: username }); // this はモデル自体を指す
    };

    // 使い方
    const user = await User.findByUsername(‘john.doe’);
    “`

これらのメソッドを利用することで、関連するデータベース操作ロジックをモデル定義内にカプセル化し、コードの再利用性と保守性を向上させることができます。

ミドルウェア (Middleware/Hooks)

Mongooseのミドルウェア(またはフック)は、特定の操作(例: save, remove, validate, find など)の実行前 (pre) または実行後 (post) にカスタムロジックを挿入できる強力な機能です。例えば、ユーザーパスワードのハッシュ化や、ドキュメント削除時の関連データのクリーンアップなどに利用できます。

ミドルウェアは schema.pre() または schema.post() メソッドを使って定義します。

“`javascript
// save 操作の前に実行されるプリミドルウェア
userSchema.pre(‘save’, async function(next) {
// this は保存されるドキュメントインスタンスを指す
if (this.isModified(‘password’)) { // パスワードが変更された場合のみ実行
// ここでパスワードをハッシュ化するロジックを実装
// 例: const hashedPassword = await bcrypt.hash(this.password, 10);
// this.password = hashedPassword;
console.log(‘パスワードをハッシュ化します…’);
}
next(); // 次のミドルウェアまたは save 操作本体へ処理を移す
});

// remove 操作の後に実行されるポストミドルウェア
userSchema.post(‘remove’, async function(doc, next) {
// doc は削除されたドキュメントインスタンスを指す
console.log(${doc.username} が削除されました。関連データのクリーンアップを実行します...);
// 例: このユーザーが作成したブログ記事を全て削除
// await mongoose.model(‘BlogPost’).deleteMany({ author: doc._id });
next();
});
“`

ミドルウェアは非同期処理にも対応しており、async/await を使うか、または next() 関数にエラーオブジェクトを渡すことでエラーを伝播させることができます。

利用可能なフックタイプは多数あります(init, validate, save, remove, updateOne, updateMany, deleteOne, deleteMany, find, findOne, findOneAndUpdate, findOneAndDelete, findOneAndReplace など)。クエリ操作に関するフック(find, findOne など)では this はクエリオブジェクトを指し、ドキュメント操作に関するフック(save, remove など)では this はドキュメントオブジェクトを指します。

モデルの作成

スキーマを定義したら、それを使ってモデルを作成します。モデルはコレクションとインタラクトするための主要なインターフェースです。

mongoose.model() メソッドを使ってモデルを作成します。

“`javascript
const mongoose = require(‘mongoose’);
const Schema = mongoose.Schema;

const userSchema = new Schema({ / … スキーマ定義 … / });
const blogPostSchema = new Schema({ / … スキーマ定義 … / });

// モデルを作成
const User = mongoose.model(‘User’, userSchema);
const BlogPost = mongoose.model(‘BlogPost’, blogPostSchema);

// これで User および BlogPost モデルを使ってデータベース操作が可能になる
“`

mongoose.model() の最初の引数はモデル名です。Mongooseはこのモデル名を元にコレクション名を生成します(複数形、小文字)。例えば、’User’ というモデル名は ‘users’ というコレクションに対応します。もし異なるコレクション名を使いたい場合は、スキーマ定義の際にオプションを指定します。

javascript
const mySchema = new Schema({ /* ... */ }, { collection: 'my_custom_collection_name' });
const MyModel = mongoose.model('MyModel', mySchema); // これは 'my_custom_collection_name' コレクションにマッピングされる

mongoose.model() は、同じ名前のモデルが既に存在する場合は新しいモデルを作成せず、既存のモデルを返します。これは、複数のファイルで同じモデルを定義・インポートする場合に便利です。ただし、モデルの定義を一度だけ行うようにコードを構成するのが良いプラクティスです。

ドキュメントの操作 (CRUD)

モデルが作成できたら、それを使ってデータベースのドキュメントを作成(Create)、読み込み(Read)、更新(Update)、削除(Delete)する操作(CRUD操作)を行います。Mongooseはこれらの操作を行うための様々なメソッドを提供しています。

作成 (Create)

新しいドキュメントを作成するには、いくつかの方法があります。

  1. モデルのインスタンスを作成し、save() メソッドを呼び出す:

    “`javascript
    const newUser = new User({
    username: ‘jane.doe’,
    email: ‘[email protected]’,
    password: ‘securepassword’
    });

    newUser.save()
    .then(doc => {
    console.log(‘ドキュメントが保存されました:’, doc);
    // doc には保存されたドキュメント(_idなどが付与されたもの)が含まれる
    })
    .catch(err => {
    console.error(‘ドキュメント保存エラー:’, err);
    // バリデーションエラーなどもここで捕捉できる
    });
    ``save()メソッドはPromiseを返します。await` と組み合わせて使うこともできます。

    javascript
    try {
    const newUser = new User({
    username: 'jane.doe',
    email: '[email protected]',
    password: 'securepassword'
    });
    const doc = await newUser.save();
    console.log('ドキュメントが保存されました:', doc);
    } catch (err) {
    console.error('ドキュメント保存エラー:', err);
    }

    new Model(...) でインスタンスを生成した時点ではデータベースには保存されません。save() を呼び出すことで初めてデータベースに書き込まれます。

  2. Model.create() メソッドを使う:

    Model.create() は、新しいドキュメントを作成し、データベースに保存する操作を一度に行います。単一のドキュメントオブジェクト、またはドキュメントオブジェクトの配列を引数に取ることができます。

    “`javascript
    User.create({
    username: ‘john.doe’,
    email: ‘[email protected]’,
    password: ‘anotherpassword’
    })
    .then(doc => {
    console.log(‘ドキュメントが作成され保存されました:’, doc);
    })
    .catch(err => {
    console.error(‘ドキュメント作成エラー:’, err);
    });

    // 複数のドキュメントを作成
    User.create([
    { username: ‘user1’, email: ‘[email protected]’, password: ‘pass1’ },
    { username: ‘user2’, email: ‘[email protected]’, password: ‘pass2’ }
    ])
    .then(docs => {
    console.log(‘複数のドキュメントが作成され保存されました:’, docs);
    })
    .catch(err => {
    console.error(‘複数ドキュメント作成エラー:’, err);
    });
    ``create()メソッドはPromiseを返します。save()と同様に非同期処理です。create()を使うと、new Model(…).save()` の2段階を1段階にまとめることができます。

読み込み (Read)

データベースからドキュメントを読み込むには、主に以下のメソッドを使います。

  1. Model.find(): 検索条件に一致する全てのドキュメントを検索します。検索条件を指定しない場合は、コレクション内の全てのドキュメントを返します。

    “`javascript
    // 全てのユーザーを取得
    User.find({})
    .then(users => {
    console.log(‘全てのユーザー:’, users);
    })
    .catch(err => console.error(err));

    // 特定の条件に一致するユーザーを取得
    User.find({ age: { $gte: 18 }, isActive: true }) // age >= 18 かつ isActive が true のユーザー
    .then(adultActiveUsers => {
    console.log(‘アクティブな成人ユーザー:’, adultActiveUsers);
    })
    .catch(err => console.error(err));
    ``
    検索条件はMongoDBのクエリドキュメント形式で指定します。
    {}` は全てのドキュメントに一致します。

  2. Model.findOne(): 検索条件に一致する最初のドキュメントを1つだけ検索します。一致するドキュメントが見つからない場合は null を返します。

    javascript
    // 特定のユーザー名を検索
    User.findOne({ username: 'john.doe' })
    .then(user => {
    if (user) {
    console.log('見つかったユーザー:', user);
    } else {
    console.log('ユーザーは見つかりませんでした');
    }
    })
    .catch(err => console.error(err));

  3. Model.findById(): ドキュメントの _id を使って、特定のドキュメントを検索します。これは findOne({ _id: id }) の便利なショートカットです。

    javascript
    const userId = '5f7d9f3b7d7b7b7b7b7b7b7b'; // 実際のObjectIdに置き換えてください
    User.findById(userId)
    .then(user => {
    if (user) {
    console.log('IDで検索したユーザー:', user);
    } else {
    console.log('指定されたIDのユーザーは見つかりませんでした');
    }
    })
    .catch(err => console.error(err));

クエリビルダ (Query Builder):

find(), findOne(), findById() などの読み込みメソッドは、クエリオブジェクトを返します。このクエリオブジェクトに対してメソッドチェーンを使って、検索条件の絞り込み、フィールドの選択、ソート、件数制限などの様々なオプションを指定できます。

javascript
User.find({ isActive: true }) // アクティブなユーザーを検索
.where('age').gte(18) // 年齢が18歳以上
.limit(10) // 最大10件取得
.sort('username') // ユーザー名で昇順ソート
.select('username email') // username と email フィールドのみ取得
.exec() // クエリを実行し、Promiseを返す
.then(users => {
console.log('限定検索結果:', users);
})
.catch(err => console.error(err));

主要なクエリビルダメソッド:
* .where(fieldName): 特定のフィールドに対する条件を指定する開始点。
* .equals(value): フィールドが指定した値と等しい。
* .gt(value), .gte(value): 指定した値より大きい/以上。
* .lt(value), .lte(value): 指定した値より小さい/以下。
* .in([value1, value2, ...]): 指定した配列内のいずれかの値に一致。
* .nin([value1, value2, ...]): 指定した配列内のどの値にも一致しない。
* .exists(boolean): フィールドが存在するかしないか。
* .regex(regexp): 正規表現に一致。
* .limit(n): 結果の最大件数をnに制限。
* .skip(n): 結果のn件をスキップ(ページネーションに利用)。
* .sort(sortObject): 結果をソート。{ fieldName: 1 } (昇順) または { fieldName: -1 } (降順)。文字列で指定することも可能(例: 'username', '-createdAt')。
* .select(fieldString): 取得するフィールドを指定。空白区切りでフィールド名を指定(例: 'username email -_id')。_id を除外する場合はフィールド名の前に - を付けます。
* .populate(path): 参照(Reference)されているドキュメントを自動的に取得して埋め込みます(後述)。
* .lean(): Mongooseドキュメントオブジェクトではなく、プレーンなJavaScriptオブジェクト ({}) を返します。大規模な結果セットを扱う場合や、ドキュメントの仮想プロパティやメソッドが必要ない場合にパフォーマンスが向上します。

クエリビルダメソッドの最後には通常 .exec() を付けてクエリを実行します。.exec() はPromiseを返すため、.then()await と組み合わせて使えます。.find(), findOne(), findById() などは .exec() を付けなくてもPromiseを返しますが、.exec() を使うことでMongooseのクエリオブジェクトを操作していることを明確に示せます。

更新 (Update)

ドキュメントを更新する方法もいくつかあります。

  1. ドキュメントインスタンスを取得し、プロパティを変更して save() する:

    javascript
    User.findById('5f7d9f3b7d7b7b7b7b7b7b7b')
    .then(user => {
    if (!user) {
    console.log('ユーザーが見つかりません');
    return;
    }
    user.email = '[email protected]'; // フィールド値を変更
    user.isActive = false;
    user.save() // 変更をデータベースに保存
    .then(updatedUser => console.log('ユーザー情報を更新しました:', updatedUser))
    .catch(err => console.error('更新保存エラー:', err));
    })
    .catch(err => console.error('ユーザー検索エラー:', err));

    この方法の利点は、save() の前に pre('save') ミドルウェアが実行されること、そしてバリデーションが実行されることです。ただし、まずドキュメントを取得してから更新するため、操作が2段階になります。

  2. クエリを使って直接更新する:

    MongoDBの更新オペレーター($set, $inc, $push など)を使って、クエリに一致するドキュメントを直接更新します。Mongooseは以下のメソッドを提供します。

    • Model.updateOne(filter, update, options): 検索条件に一致する最初のドキュメントを更新します。
    • Model.updateMany(filter, update, options): 検索条件に一致する全てのドキュメントを更新します。
    • Model.findOneAndUpdate(filter, update, options): 検索条件に一致する最初のドキュメントを更新し、更新後のドキュメントを返します。(オプションで更新前のドキュメントも取得可能)
    • Model.findByIdAndUpdate(id, update, options): 指定したIDのドキュメントを更新し、更新後のドキュメントを返します。(オプションで更新前のドキュメントも取得可能)

    “`javascript
    // 特定のユーザーのメールアドレスを更新 (updateOne)
    User.updateOne({ username: ‘jane.doe’ }, { $set: { email: ‘[email protected]’ } })
    .then(result => {
    console.log(‘updateOne 結果:’, result); // { acknowledged: true, modifiedCount: 1, upsertedId: null, matchedCount: 1 } のような結果
    })
    .catch(err => console.error(‘updateOne エラー:’, err));

    // アクティブではない全てのユーザーを非アクティブに設定 (updateMany – 実質的な意味は薄いが例として)
    User.updateMany({ isActive: false }, { $set: { isActive: false, status: ‘inactive’ } })
    .then(result => {
    console.log(‘updateMany 結果:’, result); // { acknowledged: true, modifiedCount: N, upsertedId: null, matchedCount: N }
    })
    .catch(err => console.error(‘updateMany エラー:’, err));

    // 特定のユーザーを更新し、更新後のドキュメントを取得 (findOneAndUpdate)
    User.findOneAndUpdate(
    { username: ‘john.doe’ },
    { $set: { age: 35 } },
    { new: true } // オプション: 更新後のドキュメントを返すように指定 (デフォルトは更新前)
    )
    .then(updatedUser => {
    console.log(‘findOneAndUpdate で更新されたユーザー:’, updatedUser); // 更新後のドキュメント or null
    })
    .catch(err => console.error(‘findOneAndUpdate エラー:’, err));
    ``findOneAndUpdatefindByIdAndUpdateでは、オプションに{ new: true }` を指定しないと、更新前のドキュメントが返される点に注意が必要です。

    直接更新メソッドは、データベースレベルでアトミックに更新を実行するため、複数のクライアントが同時に同じドキュメントを更新しようとする場合に競合を防ぐのに役立ちます。ただし、デフォルトではスキーマバリデーションや pre('save') ミドルウェアは実行されません。これらを実行したい場合は、オプションに { runValidators: true } を追加する必要があります。

削除 (Delete)

ドキュメントを削除する方法も複数あります。

  1. ドキュメントインスタンスを取得し、remove() または deleteOne() メソッドを呼び出す:

    javascript
    User.findOne({ username: 'user_to_delete' })
    .then(user => {
    if (!user) {
    console.log('ユーザーが見つかりません');
    return;
    }
    user.deleteOne() // ドキュメントインスタンスを削除
    .then(result => console.log('ドキュメントを削除しました:', result)) // 削除結果オブジェクト
    .catch(err => console.error('削除エラー:', err));
    // 古いMongooseでは user.remove() も使えましたが、deleteOne/deleteMany が推奨されています。
    })
    .catch(err => console.error('検索エラー:', err));

    この方法を使うと、pre('remove')post('remove') ミドルウェアが実行されます。

  2. クエリを使って直接削除する:

    • Model.deleteOne(filter): 検索条件に一致する最初のドキュメントを削除します。
    • Model.deleteMany(filter): 検索条件に一致する全てのドキュメントを削除します。
    • Model.findOneAndDelete(filter, options): 検索条件に一致する最初のドキュメントを削除し、削除されたドキュメントを返します。
    • Model.findByIdAndDelete(id, options): 指定したIDのドキュメントを削除し、削除されたドキュメントを返します。

    “`javascript
    // 特定の条件に一致するドキュメントを全て削除 (deleteMany)
    User.deleteMany({ isActive: false })
    .then(result => {
    console.log(‘deleteMany 結果:’, result); // { acknowledged: true, deletedCount: N }
    })
    .catch(err => console.error(‘deleteMany エラー:’, err));

    // 特定のドキュメントを削除し、削除されたドキュメントを取得 (findOneAndDelete)
    User.findOneAndDelete({ username: ‘another_user_to_delete’ })
    .then(deletedUser => {
    console.log(‘findOneAndDelete で削除されたユーザー:’, deletedUser); // 削除されたドキュメント or null
    })
    .catch(err => console.error(‘findOneAndDelete エラー:’, err));
    ``
    直接削除メソッドは、
    pre(‘remove’)post(‘remove’)` ミドルウェアをトリガーしない点に注意が必要です。もしこれらのミドルウェアを実行したい場合は、まずドキュメントを取得してからインスタンスメソッドで削除する必要があります。

高度なトピック

基本的なCRUD操作に加えて、Mongooseは複雑なデータモデルの表現や、より高度なデータベース操作をサポートするための機能を提供しています。

参照 (References / Population)

MongoDBはリレーショナルデータベースのように厳密なリレーションを持ちませんが、ドキュメント間で関連を表現することは可能です。一般的な方法として、あるドキュメントに別のドキュメントの _id を格納する「参照」が使われます。Mongooseでは、スキーマでこの参照を定義し、populate() メソッドを使って参照先のドキュメントを自動的に取得(結合のような操作)することができます。これを「Population」と呼びます。

スキーマでの参照定義は、フィールドの typeSchema.Types.ObjectId に設定し、ref オプションで参照先のモデル名を指定します。

“`javascript
const commentSchema = new Schema({
text: String,
// このコメントを投稿したユーザーを参照
postedBy: {
type: Schema.Types.ObjectId,
ref: ‘User’ // ‘User’ モデルを参照
},
createdAt: {
type: Date,
default: Date.now
}
});

const blogPostSchema = new Schema({
title: String,
content: String,
// この記事を作成したユーザーを参照
author: {
type: Schema.Types.ObjectId,
ref: ‘User’ // ‘User’ モデルを参照
},
// この記事に付けられたコメントのリスト (ObjectIdの配列)
comments: [{
type: Schema.Types.ObjectId,
ref: ‘Comment’ // ‘Comment’ モデルを参照
}]
});

const Comment = mongoose.model(‘Comment’, commentSchema);
const BlogPost = mongoose.model(‘BlogPost’, blogPostSchema);
“`

参照先のドキュメントを取得(Population)するには、クエリビルダの .populate() メソッドを使います。

“`javascript
// ブログ記事を取得し、作成者 (‘author’) のユーザー情報も同時に取得する
BlogPost.findOne({ title: ‘初めてのブログ記事’ })
.populate(‘author’) // ‘author’ フィールドが参照するドキュメントを取得して埋め込む
.then(post => {
if (post) {
console.log(‘記事タイトル:’, post.title);
console.log(‘作成者ユーザー名:’, post.author.username); // post.author は User ドキュメントになる
console.log(‘作成者メールアドレス:’, post.author.email);
}
})
.catch(err => console.error(err));

// ブログ記事を取得し、作成者 (‘author’) とコメント (‘comments’) の両方を Population する
BlogPost.findOne({ title: ‘初めてのブログ記事’ })
.populate(‘author’)
.populate(‘comments’) // ‘comments’ 配列内の各ObjectIdが参照する Comment ドキュメントを取得
.then(post => {
if (post) {
console.log(‘記事タイトル:’, post.title);
console.log(‘作成者ユーザー名:’, post.author.username);

  console.log('コメントリスト:');
  post.comments.forEach(comment => {
    console.log(`- ${comment.text} (投稿者ID: ${comment.postedBy})`); // postedBy はまだObjectIdのまま
  });
}

})
.catch(err => console.error(err));

// コメントの投稿者 (‘postedBy’) も Population する (ネストされた Population)
BlogPost.findOne({ title: ‘初めてのブログ記事’ })
.populate(‘author’)
.populate({
path: ‘comments’, // Population するフィールド
populate: { // comments フィールド内のドキュメント(Comment)でさらに Population
path: ‘postedBy’, // Comment ドキュメント内の ‘postedBy’ フィールドを Population
select: ‘username’ // 取得するフィールドを限定
}
})
.then(post => {
if (post) {
console.log(‘記事タイトル:’, post.title);
console.log(‘作成者ユーザー名:’, post.author.username);

  console.log('コメントリスト:');
  post.comments.forEach(comment => {
    console.log(`- ${comment.text} (投稿者: ${comment.postedBy.username})`); // postedBy は User ドキュメントになる
  });
}

})
.catch(err => console.error(err));
“`

populate() メソッドは、文字列でフィールド名を指定する簡単な方法の他に、オプションオブジェクトを渡すことで、Populationするフィールド、取得するフィールドの限定 (select)、ソート (match)、条件付き取得 (match) などの詳細な設定を行うことも可能です。

Populationは便利な機能ですが、使いすぎるとパフォーマンス問題を引き起こす可能性があります。特に、多数のドキュメントをPopulationしたり、深くネストされたPopulationを行ったりする場合は注意が必要です。必要なフィールドだけを選択的にPopulationする (select)、またはPopulationの代わりにアグリゲーションの $lookup ステージを使うなどの代替手段を検討することも重要です。

サブドキュメント (Subdocuments)

サブドキュメントは、別のドキュメントの中にネストされたドキュメントです。MongoDBでは、オブジェクトや配列の中に他のオブジェクトを埋め込むことができます。Mongooseでは、これをスキーマ定義で表現できます。サブドキュメントは親ドキュメントの一部と見なされ、親ドキュメントと一緒に保存、更新、削除されます。

サブドキュメントは、スキーマ定義内で単にオブジェクトリテラルとして定義するか、または別のスキーマとして定義して埋め込むことができます。

例1: スキーマ定義内でオブジェクトとして定義

javascript
const userSchema = new Schema({
username: String,
address: { // address はサブドキュメント
street: String,
city: String,
zipCode: String
}
});

例2: 別途スキーマを定義して埋め込む

“`javascript
const addressSchema = new Schema({
street: String,
city: String,
zipCode: String
}, { _id: false }); // サブドキュメントに自動で _id を付与しないオプション

const userSchema = new Schema({
username: String,
address: addressSchema // addressSchema を埋め込む
});
“`

例3: サブドキュメントの配列

“`javascript
const commentSchema = new Schema({
text: String,
postedBy: String, // ここでは簡略化のためString
createdAt: { type: Date, default: Date.now }
}, { _id: false }); // 配列内の各サブドキュメントに _id が不要な場合

const blogPostSchema = new Schema({
title: String,
content: String,
comments: [commentSchema] // commentSchema を埋め込んだ配列
});
“`

サブドキュメントは親ドキュメントの一部として扱われるため、独立したモデルを持ちません。親ドキュメントを取得すれば、サブドキュメントにもアクセスできます。

“`javascript
const post = await BlogPost.findOne({ title: ‘新しい記事’ });
if (post) {
console.log(‘記事:’, post.title);
console.log(‘コメント数:’, post.comments.length);

// サブドキュメントの操作
post.comments.push({ text: ‘素晴らしい記事です!’, postedBy: ‘Alice’ }); // 新しいサブドキュメントを追加
post.comments[0].text = ‘最初のコメントを編集’; // 既存のサブドキュメントを変更

await post.save(); // 親ドキュメントを保存するとサブドキュメントの変更も一緒に保存される

// サブドキュメントの削除 (Mongoose配列メソッドを使用)
post.comments.pull(post.comments[0]._id); // IDを指定して削除
await post.save();
}
“`

サブドキュメントは、親ドキュメントとの関連が強く、単体でクエリされることが少ないデータ構造に適しています。一方、サブドキュメントが大きくなりすぎたり、頻繁に更新されたり、サブドキュメント単体で検索や集計が必要な場合は、別のコレクションとして分離し、参照(Reference)を使う方が効率的な場合があります。

アグリゲーション (Aggregation)

MongoDBのアグリゲーションフレームワークは、データの変換と集計を行うための強力なツールです。Mongooseは Model.aggregate() メソッドを使ってアグリゲーションパイプラインを実行できます。

アグリゲーションパイプラインは、ドキュメントに対して連続した処理ステージを適用していきます。各ステージはドキュメントストリームを入力として受け取り、処理後に次のステージにドキュメントストリームを出力します。

javascript
// 各ユーザーが作成したブログ記事の数をカウントするアグリゲーション
User.aggregate([
// 1. $lookup: users コレクションと blogposts コレクションを結合 (left outer join)
{
$lookup: {
from: 'blogposts', // 結合するコレクション名 (Mongooseモデル名ではなく実際のコレクション名)
localField: '_id', // users コレクションの結合フィールド
foreignField: 'author', // blogposts コレクションの結合フィールド
as: 'blogPosts' // 結合結果のフィールド名 (配列になる)
}
},
// 2. $project: 出力フィールドを整形・選択
{
$project: {
_id: 1,
username: 1,
numberOfPosts: { $size: '$blogPosts' } // blogPosts 配列のサイズ (=記事数) を計算
}
},
// 3. $sort: 結果を記事数の降順でソート
{
$sort: { numberOfPosts: -1 }
}
])
.then(results => {
console.log('ユーザー別記事数:', results);
// 例: [{ _id: ..., username: '...', numberOfPosts: 5 }, ...]
})
.catch(err => console.error(err));

主要なアグリゲーションステージ:
* $match: 検索条件に一致するドキュメントをフィルタリング(Mongooseの find に類似)
* $project: 出力するフィールドを選択、整形、新しいフィールドの計算
* $group: 指定したキーでドキュメントをグループ化し、集計関数を適用(例: $sum, $avg, $count, $min, $max
* $sort: 結果をソート
* $limit: 結果の数を制限
* $skip: 結果の最初のn件をスキップ
* $lookup: 別のコレクションと結合(リレーションに類似)
* $unwind: 配列フィールドを展開し、配列の要素ごとにドキュメントを作成
* $addFields: 既存のドキュメントに新しいフィールドを追加
* $out: アグリゲーション結果を新しいコレクションに書き出す

アグリゲーションは非常に強力ですが、パイプラインの構成を理解し、パフォーマンスを考慮して設計することが重要です。特に $lookup は、結合対象のコレクションのサイズやインデックスの使用状況によってパフォーマンスに大きな影響を与える可能性があります。

トランザクション (Transactions)

MongoDBは長い間、単一ドキュメントレベルでの原子性(Atomicity)を保証してきましたが、複数のドキュメントやコレクションにまたがる操作に対するトランザクション機能はありませんでした。しかし、MongoDB 4.0以降では、レプリカセット構成においてマルチドキュメントトランザクションがサポートされるようになりました。MongoDB 4.2以降では、シャードクラスター構成でもトランザクションが利用可能です。

Mongooseは mongoose.startSession() メソッドを使って、MongoDBのトランザクションをサポートしています。トランザクションを使うことで、一連のデータベース操作を不可分な単位として扱い、途中でエラーが発生した場合は全ての変更をロールバックすることができます。

“`javascript
const session = await mongoose.startSession();

try {
session.startTransaction(); // トランザクション開始

// トランザクション内で操作を実行。必ず { session } オプションを指定する。
const blogPost = await BlogPost.create([{
title: ‘トランザクションテスト記事’,
content: ‘トランザクション内で作成された記事です。’,
author: ‘someUserId’ // 仮のユーザーID
}], { session: session });

// 例: 記事作成後、ユーザーの blogPosts 配列に記事IDを追加
await User.updateOne(
{ _id: ‘someUserId’ },
{ $push: { blogPosts: blogPost[0]._id } },
{ session: session }
);

// 全ての操作が成功した場合
await session.commitTransaction();
console.log(‘トランザクションがコミットされました’);

} catch (error) {
// 途中でエラーが発生した場合
await session.abortTransaction();
console.error(‘トランザクションが中断されました:’, error);
// エラーに応じた処理(例: クライアントにエラーを返す)
} finally {
session.endSession(); // セッションを閉じる
console.log(‘セッションが終了しました’);
}
“`

トランザクション内で実行する全てのデータベース操作(create, save, updateOne, deleteMany など)には、必ず { session: session } オプションを渡す必要があります。これを忘れると、その操作はトランザクションのコンテキスト外で実行されてしまいます。

トランザクションは非常に強力ですが、オーバーヘッドも伴います。全ての操作に無闇にトランザクションを使うのではなく、複数の操作の原子性を保証する必要がある場合にのみ限定的に使用するのが良いでしょう。トランザクションを使用するには、MongoDBがレプリカセットまたはシャードクラスターとして構成されている必要があります。スタンドアロン構成ではトランザクションは利用できません。

ミドルウェアの詳細 (In-depth Middleware)

前述の通り、Mongooseミドルウェアは特定の操作の前後でコードを実行する強力なメカニズムです。利用可能なフックタイプはドキュメント操作とクエリ操作に大別されます。

ドキュメントミドルウェア:

  • init: ドキュメントがMongoDBからロードされ、Mongooseドキュメントインスタンスとして初期化された直後。post フックのみ。
  • validate: ドキュメントのバリデーションが実行される前(pre)と後(post)。
  • save: ドキュメントが保存される前(pre)と後(post)。create() メソッドも内部で save() を呼び出すため、このフックが実行されます。
  • remove / deleteOne: ドキュメントが削除される前(pre)と後(post)。document.deleteOne() インスタンスメソッドが呼び出されたときにトリガーされます。Model.deleteOne() / deleteMany() クエリメソッドではデフォルトではトリガーされません。

ドキュメントミドルウェア内では、this キーワードは操作対象のMongooseドキュメントインスタンスを指します。これにより、ドキュメントのプロパティにアクセスしたり、変更したりすることが可能です。

javascript
userSchema.pre('save', function(next) {
// パスワードが変更されたか確認
if (this.isModified('password')) {
// ここでパスワードハッシュ化
}
// 更新日時を現在時刻に設定
this.updatedAt = Date.now();
next();
});

非同期処理を含むミドルウェアの場合、next() を呼び出す前に await を使う必要があります。

クエリミドルウェア:

  • count, estimatedDocumentCount, countDocuments: 件数カウント操作の前(pre)と後(post)。
  • deleteMany, deleteOne: 複数または単一ドキュメント削除操作の前(pre)と後(post)。Model.delete... メソッドがトリガーします。
  • find, findOne, findOneAndDelete, findOneAndReplace, findOneAndUpdate: 検索および検索・更新/削除操作の前(pre)と後(post)。
  • updateMany, updateOne: 複数または単一ドキュメント更新操作の前(pre)と後(post)。Model.update... メソッドがトリガーします。
  • aggregate: アグリゲーションパイプライン実行の前(pre)と後(post)。
  • insertMany: 複数ドキュメント挿入操作の後(post)。

クエリミドルウェア内では、this キーワードはMongooseのクエリオブジェクトを指します。これにより、クエリの条件やオプションにアクセスしたり、変更したりすることが可能です。

“`javascript
// find クエリが実行される前に条件を追加
blogPostSchema.pre(‘find’, function(next) {
// 公開されている記事のみを検索対象とする条件を追加
this.where({ isPublished: true });
next();
});

// findOneAndUpdate クエリが実行される前に更新日時を設定
blogPostSchema.pre(‘findOneAndUpdate’, function(next) {
// $set オペレーターを使って updatedAt を設定
this.set({ updatedAt: Date.now() });
next();
});
“`

pre クエリミドルウェアでは、this を使ってクエリを操作できます。例えば、this.getQuery() で現在のクエリ条件を取得したり、this.setQuery({...}) で条件を変更したりできます。

ミドルウェアは強力ですが、副作用を伴う可能性があるため慎重に利用する必要があります。特にポストミドルウェアでデータベース操作を行う場合など、意図しない再帰やパフォーマンス問題に注意が必要です。

インデックス (Indexes)

データベースインデックスは、特定のフィールドに対する検索操作のパフォーマンスを大幅に向上させることができます。Mongooseではスキーマ定義時にインデックスを作成するように指定できます。

  • 単一フィールドインデックス: フィールド定義に index: true を設定します。

    javascript
    const userSchema = new Schema({
    email: { type: String, index: true, unique: true }, // email フィールドにユニークインデックス
    createdAt: { type: Date, index: true } // createdAt フィールドに通常インデックス
    });

  • 複合インデックス: 複数のフィールドの組み合わせに対してインデックスを作成します。schema.index() メソッドを使います。

    javascript
    // userId と productId の組み合わせに対する複合インデックス
    orderSchema.index({ userId: 1, productId: 1 }); // 1 は昇順、-1 は降順
    // background: true オプションを指定すると、インデックス作成中に他のデータベース操作をブロックしない(プロダクション環境推奨)
    orderSchema.index({ userId: 1, productId: 1 }, { background: true });
    // ユニーク複合インデックス
    orderSchema.index({ userId: 1, itemId: 1 }, { unique: true });

  • テキストインデックス: テキスト検索をサポートするためのインデックスです。

    javascript
    blogPostSchema.index({ title: 'text', content: 'text' });

Mongooseは、アプリケーション起動時に ensureIndexes() (または内部的に createIndexes()) を呼び出すことで、定義されたインデックスをデータベースに作成します。これは開発環境では便利ですが、プロダクション環境ではインデックス作成が長時間かかる可能性があり、手動でインデックスを作成・管理する方が推奨される場合があります。Mongooseの接続オプションやスキーマオプションで autoIndex: false を設定することで、Mongooseによる自動インデックス作成を無効にできます。

適切なインデックスを作成することは、データベースパフォーマンスを最適化する上で非常に重要です。クエリのパターンを分析し、よく検索条件やソートに使用されるフィールドにインデックスを作成することを検討してください。

エラーハンドリング (Error Handling)

Mongoose操作は非同期であり、失敗する可能性があるため、適切なエラーハンドリングが必要です。Mongooseは、MongoDBドライバからのエラーや、Mongoose独自のバリデーションエラーなどを発生させます。

  • Promiseベースのエラーハンドリング: .catch() メソッドを使用します。

    javascript
    User.create({ username: 'test' }) // email required のバリデーションに失敗する場合
    .then(doc => console.log('ユーザー作成成功:', doc))
    .catch(err => {
    console.error('エラーが発生しました:', err);
    if (err.name === 'ValidationError') {
    console.error('バリデーションエラーの詳細:', err.errors);
    // err.errors は、フィールド名とそのエラー情報を含むオブジェクト
    } else if (err.code === 11000) { // MongoDB duplicate key error code
    console.error('重複エラー: このユーザー名は既に存在します');
    } else {
    console.error('その他のエラー:', err);
    }
    });

  • Async/await と try…catch: try...catch ブロックを使用します。

    “`javascript
    async function createUser(userData) {
    try {
    const user = await User.create(userData);
    console.log(‘ユーザー作成成功:’, user);
    return user;
    } catch (err) {
    console.error(‘ユーザー作成エラー:’, err);
    if (err.name === ‘ValidationError’) {
    console.error(‘バリデーションエラーの詳細:’, err.errors);
    // エラー情報を整形してクライアントに返すなどの処理
    throw new Error(‘バリデーション失敗: ‘ + Object.values(err.errors).map(e => e.message).join(‘, ‘));
    } else if (err.code === 11000) {
    throw new Error(‘指定されたユーザー名またはメールアドレスは既に存在します’);
    } else {
    throw new Error(‘ユーザー作成中に予期しないエラーが発生しました’);
    }
    }
    }

    createUser({ username: ‘test’, email: ‘[email protected]’ })
    .catch(err => console.error(‘関数呼び出し元でのエラー捕捉:’, err.message));
    “`

  • ミドルウェア内でのエラーハンドリング: プリミドルウェアでエラーが発生した場合、next(err) を呼び出すことでエラーを次のミドルウェアまたは操作メソッドの.catch() ブロックに伝播させることができます。ポストミドルウェアの場合は、エラーをそのまま throw するか、非同期ポストミドルウェアの場合は next(err) を呼び出します。

重要なMongooseエラーの種類:
* ValidationError: スキーマで定義されたバリデーションルールに違反した場合。err.errors プロパティに詳細が含まれます。
* CastError: フィールドの値がスキーマで定義された型にキャストできない場合(例: 数値フィールドに文字列を代入しようとした場合)。
* MongoServerError: MongoDBドライバから発生するエラー。err.code にMongoDBのエラーコードが含まれることがあります(例: 11000 はユニーク制約違反)。

適切なエラー処理を行うことで、アプリケーションの堅牢性が向上し、予期しないクラッシュを防ぐことができます。

Mongooseの設計パターン

ドキュメント指向データベースであるMongoDBとMongooseを使う場合、リレーショナルデータベースとは異なるモデリングの考え方が必要になります。大きく分けて、「埋め込み(Embedding)」と「参照(Referencing)」の2つのアプローチがあります。

  • 埋め込み (Embedding): 関連するデータを一つのドキュメント内にネストして格納する方法です。Mongooseのサブドキュメント機能を利用します。

    • 利点: 関連データ取得時のクエリが一度で済む(パフォーマンス)、原子性が単一ドキュメントレベルで保証される。
    • 欠点: ドキュメントサイズが大きくなりすぎる可能性、ネストが深くなりすぎる可能性、関連データ単体での操作がしにくい。
    • 適しているケース: 関連データが比較的小さく、頻繁に一緒に取得され、単体での検索・更新があまりない場合(例: ユーザーのアドレス、商品のコメント、注文の明細項目)。
  • 参照 (Referencing): あるドキュメントに別のドキュメントの _id を格納し、関連を示す方法です。MongooseのPopulation機能を利用します。

    • 利点: ドキュメントサイズが小さく保たれる、関連データが独立して操作できる、多対多のリレーションを表現しやすい。
    • 欠点: 関連データ取得のために追加のクエリ(Population)が必要になり、N+1問題などを引き起こす可能性。
    • 適しているケース: 関連データが大きい、頻繁に更新される、関連データ単体でクエリされる、または関連が多対多の場合(例: ユーザーとブログ記事、商品とカテゴリー)。

どちらのアプローチを選択するかは、アプリケーションのアクセスパターン(どのようなデータを一緒に取得することが多いか、どのデータを頻繁に更新するかなど)に基づいて決定する必要があります。場合によっては、パフォーマンスのためにデータを重複させて持つ(Denormalization)という選択肢もあります。

Mongooseを使ったアプリケーションのコード構造としては、RepositoryパターンやServiceパターンのような設計パターンを適用することが一般的です。

  • リポジトリパターン: モデルのCRUD操作や複雑なクエリロジックを、アプリケーションの他の部分から隠蔽する層として定義します。これにより、ビジネスロジックとデータベース操作を分離できます。

    “`javascript
    // usersRepository.js
    const User = require(‘../models/user’); // Mongooseモデルをインポート

    async function findUserById(userId) {
    return User.findById(userId);
    }

    async function findUsersByRole(role) {
    return User.find({ roles: role });
    }

    async function createUser(userData) {
    // ここで必要に応じてバリデーションや前処理
    const newUser = new User(userData);
    return newUser.save();
    }

    // … 他のCRUD操作 …

    module.exports = {
    findUserById,
    findUsersByRole,
    createUser,
    // …
    };
    “`

  • サービスパターン: ビジネスロジックをカプセル化する層です。リポジトリ層を利用してデータベース操作を行い、複数のリポジトリ操作を組み合わせて一つのビジネス機能を実現することもあります。

    “`javascript
    // usersService.js
    const userRepository = require(‘../repositories/usersRepository’);
    const blogPostRepository = require(‘../repositories/blogPostRepository’);

    async function registerUserWithFirstPost(userData, postData) {
    // トランザクションが必要な場合、ここでセッションを開始・管理する
    const session = await mongoose.startSession();
    session.startTransaction();

    try {
    const newUser = await userRepository.createUser(userData, { session }); // リポジトリメソッドもセッションを受け取るようにする
    postData.author = newUser._id;
    const newPost = await blogPostRepository.createPost(postData, { session });

    // ユーザーの blogPosts に新しい記事IDを追加 (必要に応じてリポジトリメソッド化)
    await User.updateOne({ _id: newUser._id }, { $push: { blogPosts: newPost._id } }, { session });
    
    await session.commitTransaction();
    session.endSession();
    return { user: newUser, post: newPost };
    

    } catch (error) {
    await session.abortTransaction();
    session.endSession();
    throw error;
    }
    }

    // … 他のサービス機能 …

    module.exports = {
    registerUserWithFirstPost,
    // …
    };
    “`

これらのパターンを採用することで、コードの責任範囲が明確になり、テストや変更が容易になります。

パフォーマンス最適化

Node.jsアプリケーションでMongooseとMongoDBを組み合わせる際、パフォーマンスは重要な考慮事項です。以下の点に注意することで、パフォーマンスを最適化できます。

  1. 適切なインデックスの利用: 前述の通り、クエリの検索条件やソートに使用されるフィールドにインデックスを作成することは必須です。アグリゲーションパイプラインの $match$sort ステージもインデックスを利用できます。explain() メソッドを使ってクエリの実行計画を確認し、インデックスが適切に使われているかを確認しましょう。

  2. クエリの最適化:

    • 必要なフィールドのみを取得: select() メソッドを使って、本当に必要なフィールドだけを取得することで、ネットワーク帯域幅とメモリ使用量を削減できます。
    • lean() の利用: 読み取り専用の操作で、Mongooseドキュメントオブジェクトのオーバーヘッドが不要な場合は、.lean() を使うことでプレーンなJavaScriptオブジェクトを取得し、パフォーマンスを向上させることができます。特に、大量のドキュメントを取得する場合や、取得したドキュメントを直接JSONとして返すようなAPIエンドポイントで有効です。
    • クエリ条件の絞り込み: 不要なドキュメントを取得しないよう、できるだけ具体的に検索条件を指定します。
  3. N+1問題と populate() の効率的な利用: 参照(Reference)を使用している場合、populate() は非常に便利ですが、安易に使うとN+1問題を引き起こす可能性があります。例えば、100件のブログ記事を取得し、それぞれに対して作成者(User)をPopulationする場合、Mongooseはまず記事100件を取得するクエリを実行し、次に各記事の author IDを使ってUserコレクションに対して100回の個別クエリを実行します。これは非効率です。
    Mongooseの populate() は、デフォルトで最適化されており、同じIDを持つドキュメントはまとめて取得しようとしますが、それでも大量の異なるIDをPopulationする場合はオーバーヘッドが発生します。Populationするフィールドを select() で限定する、あるいはPopulationの代わりにアグリゲーションの $lookup ステージを使うなど、状況に応じて最も効率的な方法を選択する必要があります。

  4. アグリゲーションの活用: 複雑な集計やデータ変換が必要な場合は、アプリケーション側でデータを取得して処理するよりも、アグリゲーションパイプラインを使ってデータベース側で処理する方が効率的なことが多いです。アグリゲーションステージの順序もパフォーマンスに影響します。例えば、大量のドキュメントを処理する場合、$match ステージをパイプラインの先頭に置くことで、後続のステージで処理されるドキュメント数を減らすことができます。

  5. スキーマ設計: 最初に述べた「埋め込み」と「参照」の選択は、アプリケーションのアクセスパターンに大きく依存します。どのデータを一緒に取得することが多いかを考慮し、パフォーマンス要件を満たすようなデータ構造を設計することが重要です。

  6. 接続プールの管理: Mongooseはデフォルトで接続プールを使用します。大量の同時接続が必要な場合は、接続オプションで接続プールのサイズ (maxPoolSize) を調整する必要があるかもしれません。

Mongooseのテスト

Mongooseを使用するアプリケーションのテストには、主に単体テストと結合テストがあります。

  • 単体テスト: Mongooseモデル自体(スキーマのメソッド、仮想プロパティ、バリデーションなど)や、データベースに依存しないロジック(例: リポジトリやサービスの純粋な関数部分)をテストします。Mongooseのモデルやドキュメントをモック化したり、スタブを利用したりすることがあります。
  • 結合テスト: Node.jsアプリケーションとMongoDBデータベース間の実際のインタラクションをテストします。テスト用のデータベースに接続し、テストデータを投入・操作し、結果を確認します。

結合テストを行う場合、以下の点に注意が必要です。

  1. テスト用データベース: 開発/プロダクション環境とは別の、テスト専用のMongoDBデータベースを用意します。
  2. テストデータの管理: 各テストケースの実行前に、データベースをクリーンアップし、必要なテストデータを投入する「フィクスチャ」の仕組みが必要です。Model.deleteMany({}) でコレクション全体を削除したり、Model.create() で初期データを投入したりします。
  3. Mongoose接続: テストスイート全体でMongooseへの接続を一元管理し、テストの開始時に接続、終了時に切断するようにします。MochaやJestなどのテストフレームワークの before, after, beforeEach, afterEach フックを利用します。
  4. インメモリMongoDB: mongodb-memory-server のようなライブラリを使うと、実際のMongoDBサーバーをインストール・起動することなく、メモリ上で一時的なMongoDBインスタンスを立ち上げてテストを実行できます。これはCI/CD環境などでのテストに便利です。

“`javascript
// 例: Mocha と mongodb-memory-server を使った結合テストの基本構造
const mongoose = require(‘mongoose’);
const { MongoMemoryServer } = require(‘mongodb-memory-server’);

let mongoServer;
let User; // モデルを保持する変数

before(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);

// テスト前にモデルを定義またはインポート
const userSchema = new mongoose.Schema({ // });
User = mongoose.model(‘User’, userSchema);
});

after(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});

beforeEach(async () => {
// 各テストケースの前にデータベースをクリーンアップ
await User.deleteMany({});
// テストデータを投入する場合はここで
});

describe(‘User Model Integration Tests’, () => {
it(‘should save a user successfully’, async () => {
const userData = { username: ‘testuser’, email: ‘[email protected]’, password: ‘password’ };
const user = new User(userData);
const savedUser = await user.save();

expect(savedUser._id).to.exist; // chai や assert を使う
expect(savedUser.username).to.equal('testuser');

});

it(‘should find a user by username’, async () => {
const userData = { username: ‘findme’, email: ‘[email protected]’, password: ‘password’ };
await User.create(userData);

const foundUser = await User.findOne({ username: 'findme' });
expect(foundUser).to.exist;
expect(foundUser.email).to.equal('[email protected]');

});

// … 他のテストケース …
});
“`

適切なテスト戦略を立てることで、Mongooseを使ったアプリケーションの品質を保証できます。

まとめ

この記事では、Node.jsとMongoDBのための強力なODMであるMongooseについて、その基本概念から実践的な使い方、さらには高度な機能や最適化、テストに至るまでを詳細に解説しました。

Mongooseを使うことで、スキーマによるデータ構造の定義、バリデーション、参照の管理、ミドルウェアによる処理の挿入など、MongoDBの柔軟性を損なわずに構造化された開発が可能になります。モデルとドキュメントというオブジェクト指向的なアプローチでデータベース操作を行うことができるため、コードの可読性、保守性、開発効率が向上します。

基本的なCRUD操作はもちろんのこと、Populationによる関連ドキュメントの取得、サブドキュメントによるネスト構造の表現、アグリゲーションによる複雑な集計、そしてMongoDB 4.0以降で利用可能なトランザクションなど、Mongooseは現代的なアプリケーション開発で求められる様々な機能をサポートしています。

Node.jsとMongoDBの組み合わせは依然として非常に人気があり、Mongooseはそのエコシステムにおいて欠かせないツールです。この記事で解説した内容を参考に、Mongooseを活用して堅牢でスケーラブルなアプリケーションを開発していただければ幸いです。

開発を進める際には、Mongooseの公式ドキュメントも非常に有用です。最新の情報や詳細なAPIリファレンスは、公式ドキュメントで確認することをお勧めします。

Node.jsとMongoDBの旅において、Mongooseがあなたの強力なパートナーとなることを願っています。

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール