March 2016

Volume 31 Number 3

働くプログラマ - MEAN あれこれ: MongooseJS による強力な検証

Ted Neward | March 2016

Ted Neward2016 年 2 月号 (msdn.com/magazine/mt632276) では、MongoDB データベースに移行しました。Node.js と MongoDB はどちらも JSON ベースの性質を持つため、この移行はさほど難しくはありませんでした (同じ条件のものを移行するのであれば、データの操作は常に簡単です)。MongoDB には「スケール アップ」や「スケール アウト」が容易に行えるというすばらしいメリットがあり、もちろん着手も簡単です。ただし、大きな欠点もあります。MongoDB は「スキーマレス」データベースで、スキーマはあらかじめ定義されています。つまり、データベースはコレクションを保持し、コレクションはドキュメントを保持し、ドキュメントは基本的に単なる JSON オブジェクトと定められています。これが大きな問題の種をはらんでいます

まず、MongoDB のクエリは、本質的には、クエリ ドキュメント (find 呼び出しの最初のパラメーター) です。このドキュメントには検索対象のフィールドを含み、このフィールドを使ってコレクションをスキャンします。したがって、「{'fristName': 'Ted'}」というクエリを既存のデータベースの「persons」コレクションに対して実行しても何も返されません。クエリ ドキュメントのフィールド名のスペルが誤っているため (「fristName」ではなく正しくは「firstName」です)、コレクション内のどのドキュメントとも一致しません (もちろん、コレクション内のドキュメントにもスペルミスがあれば、話は別です)。これはスキーマレス データベースの最大のデメリットの 1 つです。コードやユーザーの入力にちょっとした誤りがあるだけで、非常に困った性質を持つバグが思いがけなく発生します。このため、コンパイラからでもインタープリタからでも、なんらかの言語サポートを受けるのが適切です。

次に、前回のコラムで示したコードを見ると、20 年間も続いた「クライアント/サーバー」コンピューティング時代に一般的だった「2 層」アプリケーションによく似ているのがわかります。ユーザー (今回は API コンシューマー) から直接入力を受け取って、それを適用し、データベースから簡単なデータ構造を再取得する (データベースはデータ構造を呼び出し元に直接返す) コード層があります。このコードには「オブジェクト指向」という概念がまったくありません。大きな解決とはなりませんが、サーバー側コードに「オブジェクト性」という厳密な概念を持たせて、たとえばさまざまなプロパティのなんらかの検証が 1 か所に集中できると便利です。論点は、1 人の人物が空の firstName や lastName を持つことはできるのか、その人物の状態ではありとあらゆることが可能なのか、「既定」の状態が存在するのか、故意に状態を提供していないのか、空の状態が可能なのかということです。

Node.js コミュニティはこれらの問題に長く取り組んできました (Node.js は、大規模に MongoDB を採用した最初の言語エコシステムの 1つでした)。言うまでもなくこの問題には、MongooseJS という洗練されたソリューションが導き出されました。これは MongoDB の「上部」に位置するソフトウェア層で、スキーマに似た言語で検証される検証層を提供するだけでなく、サーバー側コードで「ドメイン オブジェクト」の層を構築できるようにもします。したがって、MEAN スタックにおけるもう 1 つの「M」のようなものと言えます。

MongooseJS の概要

いつものように、簡単な、お決まりの質問「今回は何を npm しますか」から開始しましょう。答えを言ってしまえば「npm install --save mongoose」ですが、正確なパッケージがわからない場合 (Node 開発者は、パッケージ名に「thing」と「thingjs」のどちらも使用する傾向があります)、「npm find」か「npm search」を使用すれば、npm レジストリを検索して、コマンド ラインに続くすべての用語に一致するパッケージを見つけることができます。または、好みの検索エンジンで「MongooseJS」と入力して、Mongoose の Web サイト (mongoosejs.com、英語) にアクセスすれば、適切な npm や、MongooseJS の使い方についての多数のドキュメントが公開されています (ブラウザーのお気に入りに追加しておくと便利です)。

インストールしたら、さっそく Mongoose の「スキーマ」オブジェクトを定義することができます。これにより、MongoDB コレクションに格納するオブジェクトの種類を定義します。

Mongoose と「Person」

Mongoose は、MongoDB データベース API 上で JavaScript オブジェクト モデルを定義する事実上 2 ステップのプロセスに対し、興味深い表現を使います。まず、「スキーマ」を定義します。これは、従来のクラスベースの言語 (C#、C++、Java、Visual Basic) におけるクラスのようなものです。このスキーマはフィールドを持ち、そのフィールドの型を定義します。また、値をフィールドに代入するときのため、フィールドに関するなんらかの検証規則を含めます (これは省略可能です)。さらに、インスタンス メソッドや静的メソッドを追加することもできます (後ほど取り上げます)。スキーマ オブジェクトを定義したら、それを Model にコンパイルします。Model は、これらのオブジェクトのインスタンス構築に使用されます。

重要なのは、相変わらず JavaScript だということです。さらに重要なことに、そのバージョンは、正確に言えば ECMAScript 5 です。ここには、いかなる「クラス」の概念もありません。したがって Mongoose のアプローチがたとえ「クラス」を定義しているという幻覚を見せようとも、これは依然として、皆さんお好みの (またはその逆の) 見慣れたプロトタイプベースの言語だということです。これが、2 つのステップを踏まなければならない理由の 1 つです。まず、クラスとして機能するオブジェクトを定義します。次に、「新しい」演算子に関する JavaScript/ECMAScript 5 の規則に基づき「コンストラクター」か「ファクトリ」として暗黙的に機能する、オブジェクトを定義します。

したがって次のように、(JavaScript コード上で、通常の「mongoose」ローカル変数に Mongoose ライブラリを「require()」した後に) このプロセスをコードに変換すれば、新しい Schema オブジェクトを定義するのに使用できます。

// Define our Mongoose Schema
var personSchema = mongoose.Schema({
  firstName: String,
  lastName: String,
  status: String
});
var Person = mongoose.model('Person', personSchema);

personSchema のフィールドではさまざまなことを実行できますが、まずは簡潔に始めましょう。こうすれば、コードをすばやく実行できるだけでなく、その過程で Mongoose の細かな点に気付くこともできます。またここでも、モデルを定義する 2 つのステップに注目してください。まず、Schema 関数/コンストラクターを使用してスキーマを定義します。そして、model 関数/コンストラクターに、このモデルの名前と共にスキーマ オブジェクトを渡します。model 呼び出しから返されるオブジェクトは定義されている型と同じになるという Mongoose ユーザー間での取り決めがあり、これが「クラス」という幻想を作り出すのに寄与しています。

Mongoose の動作

モデルを定義したら、後はモデルを使用するさまざまなルート メソッドを書き換えるだけです。最も簡単に開始できるのは getAllPersons ルートです (図 1 参照)。これはフィルターなしに、データベースからコレクション全体を返します。

図 1 getAllPersons ルートの書き換え

var getAllPersons = function(req, res) {
  Person.find(function(err, persons) {
    if (err) {
     debug("getAllPersons--ERROR:",err);
      res.status(500).jsonp(err);
    }
    else {
      debug("getAllPersons:", persons);
      res.status(200).jsonp(persons);
    }
});
};

基本的には、書き換え前と似ています。クエリを実行し、渡されたコールバック関数 (今回も、パラメーターとして「err, result」規約があります) を完了時に呼び出しています。ただし、この新しいコードでは、Mongoose が Personmodel オブジェクト経由でアクセスをルーティングしており、その周辺が少々構造化されているため、多くの優れたメリットが生まれています (この後すぐに説明します)。

次は personId ミドルウェアです。これはルートの残りの部分ほぼすべてで使用しています (図 2 参照)。

図 2 personld ルートの書き換え

app.param('personId', function (req, res, next, personId) {
  debug("personId found:",personId);
  if (mongodb.ObjectId.isValid(personId)) {
    Person.findById(personId)
      .then(function(person) {
        debug("Found", person.lastName);
        req.person = person;
        next();
      });
  }
  else {
    res.status(404).jsonp({ message: 'ID ' + personId + ' not found'});
  }
});

これもミドルウェア関数なので、目的は、コレクションから Person オブジェクトを見つけて要求オブジェクト (req) に格納することです。ですが、着信方向の personId が適切な MongoDB ObjectId/OID であることを検証した後に注目してください。Person オブジェクトが再び出現し、今回は findById を呼び出して、渡された ObjectID/OID を渡しています。ここで興味深いのは、Mongoose が、JavaScript の今後のリリースに付属するであろう「promise」構文/スタイルをサポートしていることです (findById から返されるオブジェクトは Promise オブジェクトです)。これにより、「then」を呼び出して、クエリが構成を完了したときに実行するコールバックを渡すことができます。

その後、この 2 ステップのアプローチは、クエリの実行前に多くのことを実行できるようにします。たとえば、結果内のフィールドに基づき特定の順序でクエリ結果を並べ替えたり、(クライアントが受け取るべきではない機密情報を隠すために) フィールドのサブセットのみを選択したりすることが可能です。それらは以下のように、メソッド呼び出しとして find と then の間に含めます。

Person.find({ })
  .sort({ 'firstName':'asc', 'lastName':'desc' })
  .select('firstName lastName status')
  .then(function(persons) {
    // Do something with the returned persons
  });

この場合、firstName によって結果を昇順に並べ替えたら、lastName によって結果を降順に並べ替え (これにはあまり意味があるわけではありません)、そして「firstName」、「lastName」、「status」フィールド以外のものを取り除きます。「modifiers」クエリのラインナップはすばらしく、limit (結果の特定の数字を切り捨てる)、skip (返される最初の n 個の結果をスキップする)、count (返されるドキュメントの合計数を返す) など多くの便利なメソッドを含みます。

ですが、読者の皆さんがそのことに気を取られる前に、残りのルート メソッドを扱うことにします。

更新および削除する Person オブジェクトを取得するためにミドルウェアを使用したことがあれば、オブジェクト上の Mongoose によって提供される save メソッドや delete メソッドを使用するのは実に簡単です。図 3 のように、新しい Person を挿入するには、新しい Person モデルのインスタンスを作成し、そこで save メソッドを使用するだけです。

図 3 新しい Person モデルのインスタンス作成

var updatePerson = function(req, res) {
  debug("Updating",req.person,"with",req.body);
  _.merge(req.person, req.body);
  // The req.person is already a Person, so just update()
  req.person.save(function (err, person) {
    if (err)
      res.status(500).jsonp(err);
    else {
      res.status(200).jsonp(person);
    }
  });
};
var insertPerson = function(req, res) {
  var person = new Person(req.body);
  debug("Received", person);
  person.save(function(err, person) {
    if (err)
      res.status(500).jsonp(err);
    else
      res.status(200).jsonp(person);
  });
};
var deletePerson = function(req, res) {
  debug("Removing", req.person.firstName, req.person.lastName);
  req.person.delete(function(err, result) {
    if (err) {
      debug("deletePerson: ERROR:", err);
      res.status(500).jsonp(err);
    }
    else {
      res.status(200).jsonp(req.person);
    }
  });
};

概念上、これは以前の非 Mongoose バージョンとよく似ていますが、わずかながら非常に重要な変更点があります。つまり、Person についてのロジックが、Person モデル (および、Mongoose 固有の永続化の実装) にさらに局所化されています。

Mongoose の検証

面白半分で、アプリケーションにいくつかの新しい規則を追加してみましょう。「firstName も lastName も空にできず、状態は決められた値 (列挙型) の 1 つにしかできない」とします。ここで Mongoose の持つ、サーバー側でドメイン オブジェクト モデルを作成する能力が特に役立ちます。従来のオブジェクト指向の考えにおいて、その種の規則は、周囲で使用されているコードではなくオブジェクト型そのものにカプセル化しなければならないとされるためです。

Mongoose では、これはある意味簡単です。スキーマ オブジェクト内で、フィールドは単純な「型のペア」から複雑な「オブジェクト記述子のペア」になり、検証規則はこれらのオブジェクト記述子で指定することが可能です。一般的な数値検証 (min と max) と String の検証 (min length と max length) の多くに加え、「status」フィールドに許容できる値の配列を指定できます。また、none が指定される場合の既定値を各フィールドに定義することが可能です。これは、(言うまでもなく) 新しい要件にぴったり当てはまります (図 4 参照)。

図 4 Mongoose の検証

// Define our Mongoose Schema
var personSchema = mongoose.Schema({
  firstName: {
    type: String,
    required: true,
    default: "(No name specified)"
  },
  lastName: {
    type: String,
    required: true,
    default: "(No name specified)"
  },
  status: {
    type: String,
    required: true,
    enum: [
      "Reading MSDN",
      "WCFing",
      "RESTing",
      "VBing",
      "C#ing"
    ],
    default: "Reading MSDN"
  },
});
var Person = mongoose.model('Person', personSchema);

コード ベースで変更すべき箇所は他にありません。「Person 性」に関するこれらの規則はすべて、スキーマ オブジェクトの定義において、あるべき場所で取得されます。

ここで JSON を挿入しようとすると、次のように、状態の適切なリストに従いません。

{
  "firstName":"Ted",
  "lastName":"Neward",
  "status":"Javaing"
}

insertPerson ルート内で呼び出される save メソッドは、返される JSON 本文を読み取りながら、500 個の応答を生成します (図 5 参照)。

図 5 save での検証の失敗によるエラー結果

{
  "message":"Person validation failed",
  "name":"ValidationError",
  "errors":{
    "status":{
      "properties":{
        "enumValues":[
          "Reading MSDN",
          "WCFing",
          "RESTing",
          "VBing",
          "C#ing"
        ],
        "type":"enum",
        "message":"`{VALUE}` is not a valid enum value for path `{PATH}`.",
        "path":"status",
        "value":"Javaing"
      },
      "message":"`Javaing` is not a valid enum value for path `status`.",
      "name":"ValidatorError",
      "kind":"enum",
      "path":"status",
      "value":"Javaing"
    }
  }
}

ここでは、何が誤っているのかが非常に適切に指摘されています。

Mongoose のメソッド

もちろんオブジェクト指向とは、状態と動作を結合することです。したがってこれらのドメイン オブジェクトは、オブジェクト インスタンスまたは「クラス」全体にメソッドをアタッチできない限り、実際にはオブジェクトではないことになります。実のところ、アタッチするのは非常に簡単です。次のように、メソッドを定義する Mongoose メソッド (従来のインスタンスベースのメソッドの Mongoose メソッド、またはクラスベースのものの静的な Mongoose メソッド) を呼び出し、メソッド本体として使用する関数を渡します。

personSchema.method('speak', function() {
  console.log("Don't bother me, I'm", status);
});

C# でクラスを記述するのとは厳密には異なりますが、非常によく似ています。これらは、Schema オブジェクトへのその他のあらゆる変更と共に、Schema がモデル オブジェクトにコンパイルされる前にすべて完了する必要があります。したがって、この呼び出しは mongoose.model 呼び出しの前に出現しなければなりません。

Mongoose のバージョン管理

ちなみに /persons に簡単な GET を実行すると、以下のように、結果の出力にはちょっとした予期せぬ内容が含まれるようになります。

[{"_id":"5681d8bfddb73cd9ff445ec2",
  "__v":0,
  "status":"RESTing",
  "lastName":"Castro",
  "firstName":"Miguel"}]

Mongoose が暗黙のうちに追加している (そして、データベースまでずっと維持される) 「__v」フィールドはバージョン フィールドで、Mongoose では versionKey フィールドとして知られています。これは、Mongoose がドキュメントへの変更を認識できるようにします。(同時変更を検出する) ソース コード ファイルのバージョン番号と考えてください。通常、このフィールドは Mongoose の内部の詳細にすぎませんが、最初に目にしたときは少々驚くことかと思います。

これは難しい問題も引き起こします。システムが、ドキュメントの作成時や変更時の検出を求めることはよくあります。周囲のコードがドキュメントを操作したり保存するたびにフィールドに格納しなければならないのは明らかにたいへんです。

Mongoose には、これをサポートする機能があります。特定のライフサイクル メソッドを「フック」できるようにして、永続化を実行している呼び出しに関係なく、ドキュメントが MongoDB に書き込まれる直前にフィールドを更新します (図 6 参照)。

図 6 Mongoose でライフサイクル メソッドをフック

// Define our Mongoose Schema
var personSchema = mongoose.Schema({
  created: {
    type: Date,
    default: Date.now
  },
  updated: {
    type: Date,
  },
  // ... as before
});
personSchema.pre('save', function(next) {
  // Make sure updated holds the current date/time
  this.updated = new Date();
  next();
});
var Person = mongoose.model('Person', personSchema);

これで、Person オブジェクトが構築されたときは必ず、現在の日時を保持するために作成されたフィールドが既定になり、更新されたフィールドは現在の日付に設定されます。それらがすべて、MongoDB に送信および格納される直前に行われます。Mongoose は、「気持ちの上では」Express によって定義されるミドルウェア関数なのでこれらの「ミドルウェア」を呼び出しますが、Mongoose 定義のオブジェクトに完全に固有であることに注意してください。

これで、Person が作成または更新されたら、対応するフィールドが必要に応じて入力および更新されるようになります。また、より洗練された動作 (本格的な監査ログなど) が必要な場合は、かなり簡単に追加することができます。作成/更新されたフィールドの代わりに、配列である auditLog フィールドを使用します。「save」フックは、要件に基づいて、各時間に何が実行されたのか、およびだれが実行したのかを説明するためにその配列に繰り返し追加します。

まとめ

今回の内容もかなり難解でしたが、Mongoose については扱うべき点がもっとたくさんあるのは確かです。ですが、これは新しいテクノロジ スタックとプラットフォームについて探索する楽しみの半分を占めるので、読者の皆さんに提供しないのはきわめて失礼にあたります。理解すべき重要な点は、Mongoose と MongoDB が強力なコンビだということです。言語が強制する基本型の検証、およびランタイムが強制するデータ値の検証と、MongoDB データベースのスキーマレスの性質が結びつきます。これにより、静的な型指定と動的な型指定それぞれの、最も優れた部分を得ることができます。問題や障害がないわけではありませんが、概して、Mongoose のようなものがミスを食い止めてくれなければ、MongoDB で本格的なコーディングをするのは個人的には困難です。

サーバー側のあれこれについてはほとんど扱いましたが、ここでスペースが尽きました。ひとまずは、コーディングを楽しんでください。


Ted Neward は、シアトルを拠点に活躍している、ポリテクノロジーに関するコンサルタント、講演者、および指導者です。これまでに 100 本を超える記事を執筆している Ted は、F# MVP および INETA 講演者で、さまざまな書籍を執筆および共同執筆しています。仕事への協力を依頼する場合、連絡先は ted@tedneward.com (英語のみ) です。また、tedneward.com (英語) でブログを公開しています。

この記事のレビューに協力してくれた技術スタッフの Shawn Wildermuth に心より感謝いたします。