Март 2016

ТОМ 31 НОМЕР 3

Работающий программист - MEAN: MongoDB и надежная проверка с помощью MongooseJS

Тэд Ньюард | Март 2016

Ted NewardС возвращением, дорогие «министы» («MEANies»). (Я решил, что это звучит лучше, чем «нодисты»; кроме того, мы перестаем говорить только о Node.)

Наш код достиг важной точки перехода: теперь, когда мы располагаем некоторыми тестами для проверки того, что функциональность остается прежней, вполне безопасно приступить к серьезному рефакторингу. В частности, текущая система хранения данных в памяти может быть хороша для коротких демонстраций, но ее нельзя со временем вертикально масштабировать хоть в каких-то значимых пределах. (Не говоря уже о том, что достаточно одного перезапуска или перезагрузки VM и все ваши данные, не «зашитые» в стартовый код, тут же потеряются.) Пришла пора исследовать «M» в MEAN: MongoDB. Это будет не единственная тема нашего разговора. При работе с MongoDB возникают некоторые проблемы у тех, кто пользуется Node.js. Но решение есть, и это будет другое «M» в MEAN: MongooseJS. О нем мы поговорим во второй половине данной статьи.

MongoDB

Прежде всего важно понимать, что, согласно популярной легенде, MongoDB на самом деле получила свое название от слова «humongous» (чудовищный). Правда это или нет, но факт, что MongoDB рассчитана совсем на другую функциональность, чем средняя реляционная база данных. Для MongoDB на первом месте масштабируемость, и в связи с этим она отчасти жертвует согласованностью данных, обменивая возможности ACID-транзакций в пределах кластера на «согласованность в конечном счете».

Эта система не обязательно достигает масштаба с невообразимым количеством записей; если честно, я был бы шокирован, если бы когда-нибудь вообще появилась такая необходимость. Однако MongoDB имеет другую, не менее важную особенность, связанную с ее моделью данных: это база данных, ориентированная на документы. То есть вместо традиционной модели реляционных таблиц и полей, вводимой схемой, MongoDB использует модель данных без схемы, а именно документы, собираемые в наборы. Эти документы представлены в виде JSON, и каждый такой документ состоит из пар «имя-значение», где значениями могут быть традиционные типы данных (строки, целые числа, числа с плавающей точкой, булевы значения и т. п.), а также составные типы данных (массивы любых только что перечисленных типов данных или дочерние объекты, которые в свою очередь могут иметь пары «имя-значение»). Это сходу означает, что моделирование данных будет несколько отличаться от того, что вы, возможно, ожидали, если ваш опыт ограничивается реляционными базами данных.

Примечание Более глубокое исследование MongoDB с точки зрения .NET-разработчика см. в моей статье из трех частей по MongoDB в этой рубрике за 2010 год (bit.ly/1J7DjOB).

Архитектура данных

С точки зрения проектирования, понять, как модель данных persons будет отображаться на MongoDB, несложно: у нас будет набор persons, где каждый документ внутри него станет JSON-группой пар «имя-значение», и т. д.

И это в основном все. Серьезно. Отчасти именно это является причиной того, что сообщество разработчиков так благоволит к документно-ориентированным базам данных: освоить основы работы с данными в них гораздо легче, чем в реляционных базах данных на основе схем. Конечно, документно-ориентированные базы данных имеют свои недостатки: одна опечатка, и вдруг все запросы, которые, как предполагалось, будут основаны на firstName, ничего не возвращают, потому что нет документа с полем firstName; однако позже я расскажу о нескольких способах, позволяющих снять остроту некоторых из таких проблем.

А пока давайте посмотрим, как помещать данные в MongoDB и получать их от нее.

Доступ к данным

Первый шаг — обеспечить взаимодействие приложения с MongoDB; естественно, это включает установку нового npm-пакета mongodb. К этому моменту такое упражнение должно выполняться почти на автомате:

npm install --save mongodb

Утилита npm проделает свою обычную работу, и после возврата драйвер MongoDB для Node.js будет установлен в каталог node_modules. Если вы получите предупреждение от npm о том, что пакет kerberos не установлен («mongodb-core@1.2.28 требует наличия kerberos@~0.0»), то это известный баг, который устраняется просто установкой kerberos напрямую через npm (npm install kerberos). Помимо этой, больше никаких проблем быть не должно, но, конечно, все зависит от следующего выпуска любого из этих пакетов — такова плата за разработку на переднем крае.

Затем код должен открыть соединение с экземпляром MongoDB. Однако то, где находится этот экземпляр, заслуживает отдельного рассмотрения.

Местонахождение данных

Как упоминалось в первой статье этой серии, есть два простых варианта работы с MongoDB. Один из них — запускать его локально, что очень удобно при разработке, но не годится для производственной среды, а второй — выполнять в облаке, что хорошо для производственной среды, но не для разработки. (Если я не могу запускать код, находясь в самолете и направляясь на какую-то конференцию, то, по моему мнению, это нельзя назвать хорошей средой для разработки.) Это не является чем-то необычным, и решение здесь во многом аналогично тому, как это делается для любых других приложений: запускайте его локально при разработке и в облаке при тестировании или производственной работе.

Как и для большинства баз данных, подключение к MongoDB потребует имени или IP-адреса DNS-сервера, имени базы данных и (необязательно) используемого порта. Обычно при разработке это будет localhost, имя базы данных и 27017 (порт для MongoDB по умолчанию), но настройки для экземпляра MongoDB в облаке, очевидно, будут отличаться. Например, сервер и порт для моего экземпляра Mongolab MongoDB с именем msdn-mean — ds054308.mongolab.com и 54308 соответственно.

Самый простой способ скрыть эти расхождения в мире Node — создать отдельный JS-файл (как правило, это config.js) и запрашивать его в коде app.js, например так:

// Загружаем модули
var express = require('express'),
  bodyParser = require('body-parser'),
  debug = require('debug')('app'),
  _ = require('lodash');
// Получаем параметры конфигурации
var config = require('./config.js');
debug("Mongo is available at",config.mongoServer,":",config.mongoPort);
// Создаем экземпляр express
var app = express();
app.use(bodyParser.json());
// ...Остальное, как и раньше

Тогда конфигурационному файлу остается лишь определить среду, в которой запускается приложение; обычно в среде Node.js для этого анализируется переменная окружения ENV, которая устанавливается равной одному из значений: prod, dev, или test (в последнем случае вы имеете дело со средой для тестирования). Поэтому конфигурационный код должен проанализировать переменную окружения ENV и поместить правильные значения в экспортированный объект module:

// Config.js: определение конфигурации
//
var debug = require('debug')('config');
debug("Configuring environment...");
// Используем эти значения по умолчанию
module.exports = {
  mongoServer : "localhost",
  mongoPort : "27017"
};
if (process.env["ENV"] === "prod") {
  module.exports.mongoServer = "ds054308.mongolab.com";
  module.exports.mongoPort = "54308";
}

Обратите внимание на использование объекта process — это стандартный объект Node.js, всегда неявно присутствующий в любом приложении, выполняемом Node.js; свойство env используется для получения значения переменной окружения ENV. (Внимательные читатели заметят, что код ExpressJS делает ровно то же самое, когда решает, какой порт нужно использовать; по-видимому, вы могли бы провести рефакторинг и этого фрагмента для использования параметров config.js, но это занятие я оставлю вам в качестве упражнения.)

Пока все идет неплохо. На самом деле даже лучше: при этом происходит и неявное отделение конфигурационного кода от основной кодовой базы.

Давайте приступим к добавлению и удалению данных.

MongoDB + Node.js

Как и в случае большинства баз данных, вы должны открыть соединение с MongoDB, удерживать этот объект и использовать его в последующих операциях с базой данных. Таким образом, очевиден первый шаг: создание этого объекта при запуске приложения и его сохранение на глобальном уровне, как показано на рис. 1.

Рис. 1. Создание объекта в MongoDB

// Получаем конфигурационные параметры
var config = require('./config.js');
debug("Mongo is available at ",config.mongoServer,":",config.mongoPort);
// Подключаемся к MongoDB
var mongo = null;
var persons = null;
var mongoURL = "mongodb://" + config.mongoServer +
  ":" + config.mongoPort + "/msdn-mean";
debug("Attempting connection to mongo @",mongoURL);
MongoClient.connect(mongoURL, function(err, db) {
  if (err) {
    debug("ERROR:", err);
  }
  else {
    debug("Connected correctly to server");
    mongo = db;
    mongo.collections(function(err, collections) {
      if (err) {
        debug("ERROR:", err);
      }
      else {
        for (var c in collections) {
          debug("Found collection",collections[c]);
        }
        persons = mongo.collection("persons");
      }
    });
  }
});
// Создаем экземпляр express
var app = express();
app.use(bodyParser.json());
// ...

Обратите внимание на то, что connect принимает URL и обратный вызов, который в свою очередь принимает объект error и объект соединения с базой данных, как принято по соглашению в Node.js. Если объект error является чем угодно, кроме undefined или null, это ошибка, иначе все прошло как по маслу. URL специфичен для MongoDB и использует схему mongodb, но в остальном очень похож на традиционный HTTP URL.

Однако в этом коде есть один нюанс, который можно не заметить с первого взгляда: обратный вызов запускается в точке, весьма отдаленной от завершения работы остального стартового кода, что станет очевиднее, когда вы присмотритесь к отладочному выводу (рис. 2).

Отладочный вывод
Рис. 2. Отладочный вывод

Видите, как сообщение «Example app listening» появляется до сообщения «Connected correctly to server» от обратного вызова? Учитывая, что это происходит при запуске приложения, эта проблема с одновременной обработкой не критична, но она никуда не девается, и это, несомненно, одна из самых хитроумных частей работы с Node.js. Это верно, что ваш код Node.js никогда не будет исполняться параллельно в двух потоках, но это не означает, что у вас не возникнет какой-нибудь проблемы с одновременной обработкой; она просто будет проявляться иначе, чем вы привыкли в .NET.

Кроме того, просто в качестве напоминания, когда этот код впервые выполняется применительно к совершенно новой базе данных MongoDB, цикл для collections будет пуст — MongoDB не создаст наборы (или даже базу данных!), пока в этом не будет абсолютной необходимости, что обычно происходит, когда кто-то пишет в нее. После выполнения вставки MongoDB создаст необходимые артефакты и структуры данных для хранения информации.

При любом раскладе на данный момент мы имеем соединение с базой данных. Пора обновить CRUD-методы, чтобы начать их использовать.

Вставка

Функция insertPerson будет использовать метод insert объекта-набора MongoDB, и вновь вам понадобится выдать обратный вызов с результатами операции над базой данных:

var insertPerson = function(req, res) {
  var person = req.body;
  debug("Received", person);
  // person.id = personData.length + 1;
  // personData.push(person);
  persons.insert(person, function(err, result) {
    if (err)
      res.status(500).jsonp(err);
    else
      res.status(200).jsonp(person);
  });
};

Обратите внимание на закомментированный код (от версии базы данных в памяти, от которой я сейчас избавляюсь); я оставил его специально, чтобы доказать свою правоту. MongoDB создаст поле идентификатора, _id, который является основным ключом для документа в базе данных, поэтому мой невероятно отстойный, доморощенный код генератора id не только больше не нужен, а вообще нежелателен.

Также заметьте, что последнее выражение в функции — это метод insert с сопоставленным обратным вызовом. Хотя это не обязательно должно быть последним выражением в блоке function, крайне важно понимать, что функция insertPerson завершится до того, как закончится операция вставки в базу данных. Природа Node.js, основанная на обратных вызовах, такова, что вам не нужно ничего возвращать вызвавшему коду, если только вам не известен результат операции над базой данных (успех или неудача); поэтому вызовы res не происходят где-либо вне обратного вызова. (Скептики должны убедиться в этом, поместив один вызов debug после вызова persons.insert и другой в самом обратном вызове, и увидеть, что первый выполняется до обратного вызова.)

Считать все

Операции вставки требуют проверки, поэтому, пока я не ушел от этой части, я переработаю getAllPersons, которой нужен просто запрос набора для нахождения всех документов в этом наборе:

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

Прежде чем продолжить, кратко отметим пару вещей. Во-первых, вызов find принимает предикат документа, описывающий критерии, по которым вы хотите запросить набор, и в данном случае я оставляю его пустым; если бы запрос делался по первому имени, этот предикат выглядел бы так:

"{ 'firstName':'Ted' }"

Во-вторых, возвращаемый из find объект пока не является собственно набором результатов, поэтому нужно вызвать toArray, чтобы преобразовать его во что-то, что можно использовать. Функция toArray принимает обратный вызов, и вновь каждая ветвь обратного вызова должна гарантировать возврат чего-либо вызвавшему коду, используя res.status().jsonp.

Промежуточный уровень

Прежде чем двигаться дальше, вспомним из предыдущей рубрики, что функции getPerson, updatePerson и deletePerson зависят от функции personId промежуточного уровня в поиске person по идентификатору. А значит, тот промежуточный уровень следует обновить для запроса набора по полю _id (который является MongoDB ObjectID, а не string!) вместо поиска по массиву в памяти, как показано на рис. 3.

Рис. 3. Обновление промежуточного уровня для запроса набора

app.param('personId', function (req, res, next, personId) {
  debug("personId found:",personId);
  if (mongodb.ObjectId.isValid(personId)) {
    persons.find({"_id":new mongodb.ObjectId(personId)})
      .toArray(function(err, docs){
        if (err) {
          debug("ERROR: personId:",err);
          res.status(500).jsonp(err);
        }
        else if (docs.length < 1) {
          res.status(404).jsonp(
            { message: 'ID ' + personId + ' not found'});
        }
        else {
          debug("person:", docs[0]);
          req.person = docs[0];
          next();
        }
      });
  }
  else {
    res.status(404).jsonp({ message: 'ID ' + personId + ' not found'});
  }
});

В драйвере MongoDB Node.js задокументирован метод findOne, который кажется более подходящим, но в документации на драйвер отмечается, что это устаревший метод.

Заметьте, что промежуточный уровень, получив недопустимый ObjectId, не вызывает next. Это удобный способ сэкономить несколько строк кода в самых разнообразных методах, которые так или иначе связаны с поиском объектов persons в базе данных, поскольку, если это недопустимый идентификатор, то искать дальше скорее всего нечего и возвращается код 404. То же самое относится и к случаю, когда результаты дают ноль документов (подразумевая, что идентификатор не был обнаружен в базе данных).

Получение одного объекта, удаление и обновление

Таким образом, промежуточный уровень делает getPerson тривиальной, так как сам обрабатывает все возможные ошибки и ситуации «документ не найден»:

var getPerson = function(req, res) {
  res.status(200).jsonp(req.person);
};

А deletePerson становится почти тривиальной:

var deletePerson = function(req, res) {
  debug(“Removing”, req.person.firstName, req.person.lastName);
  persons.deleteOne({“_id”:req.person._id}, function(err, result) {
    if (err) {
      debug(“deletePerson: ERROR:”, err);
      res.status(500).jsonp(err);
    }
    else {
      res.person._id = undefined;
      res.status(200).jsonp(req.person);
    }
  });
};

И обе они делают updatePerson весьма предсказуемой:

var updatePerson = function(req, res) {
  debug(“Updating”,req.person,”with”,req.body);
  _.merge(req.person, req.body);
    persons.updateOne({“_id”:req.person._id}, req.person, function(err, result) {
    if (err)
      res.status(500).jsonp(err);
    else {
      res.status(200).jsonp(result);
    }
  });
};

Кстати, вызов merge такой же, как и используемый функцией Lodash перед копированием свойств из тела запроса в объект person, загруженный из базы данных.

MongoDB имеет огромное преимущество в том, что она легко масштабируется как вертикально, так и горизонтально, не говоря уже о том, насколько тривиально приступить к работе с ней. Но у нее есть и существенный недостаток. Поскольку MongoDB — база данных без схемы (в том смысле, что схема уже предопределена: база данных хранит наборы, наборы хранят документы, а документы, по сути, являются просто JSON-объектами), в ней лежат семена собственного уничтожения.

Во-первых, вспомните, что запросы к MongoDB — это, по сути, запросы документов (первый параметр в вызове find), содержащих поля, по которым набор сканируется в поисках совпадений. Поэтому, если запрос {‘fristName’: ‘Ted’} выполняется применительно к набору persons в существующей базе данных, вы ничего не получите — поле имени в запросе содержит опечатку («fristName» вместо «firstName»), и никаких совпадений в наборе найдено не будет. (Если только и в документе в наборе нет такой же опечатки, конечно.) Это один из крупнейших недостатков базы данных без схемы: простая опечатка в коде или вводе пользователя может вызвать случайные ошибки таких масштабов, что вы потом долго будет чесать затылок. Здесь здорово бы пригодилась какая-то языковая поддержка, будь то со стороны компилятора или интерпретатора.

Во-вторых, заметьте, что код, показанный ранее в этой статье, напоминает «двухуровневые» приложения, которые были популярны пару десятилетий назад во времена вычислительных архитектур «клиент-сервер». Имеется уровень кода, который напрямую принимает ввод от пользователя (или, как в данном случае, от API потребителя), применяет его и получает обратно от базы данных простую структуру данных, передаваемую им непосредственно вызвавшему коду. В этом коде определенно нет смысла в «объектной ориентации». Хотя это не является таким уж камнем преткновения, было бы неплохо, если бы могли добиться более существенной «объектности» в коде на серверной стороне, чтобы, скажем, централизовать какие-то проверки различных свойств в одном месте. Вот показательный пример: допустимо ли для объекта person наличие пустого поля firstName или lastName? Может ли он делать абсолютно что угодно в своем состоянии? Есть ли состояние «по умолчанию», если этот объект ничего не предоставляет в одном из полей, или же приемлемо пустое состояние?

Сообщество Node.js некоторое время ломало голову над этими проблемами (это была одна из первых языковых экосистем, в которую внедрили MongoDB для использования в крупных масштабах) и — не удивительно — придумало элегантное решение под названием MongooseJS. Это программный уровень, размещаемый поверх MongoDB и предоставляющий не только уровень языковой проверки подобно схемам, но и возможность встраивать уровень объектов предметной области в серверный код. Так что это своего рода другая «M» в стеке MEAN.

MongooseJS: приступаем к работе

К этому моменту упражнение должно стать привычным и довольно простым: «Что на этот раз вы собираетесь установить с помощью npm?». Если коротко, то «npm install --save mongoose», но, когда вы не совсем уверены в том, какой именно пакет нужно установить (в сообществе Node наблюдается тенденция выбирать в качестве имен пакетов либо «какое-то имя», либо «какое-то имя плюс js»), команда npm find или npm search позволит выполнить в командной строке поиск по реестру npm в поисках пакетов, которые соответствуют заданным критериям. В качестве альтернативы ввод «Mongoose JS» в поисковую систему даст вам ссылку на веб-сайт Mongoose (mongoosejs.com), где вы найдете правильное воплощение npm наряду с тонной документации, поясняющей, как пользоваться Mongoose.

После установки вы можете начинать определение объектов «схем» Mongoose, которые будут определять виды объектов, сохраняемых в набор MongoDB.

Mongoose-операции над объектами person

В Mongoose используется некоторая интересная терминология для того, что фактически является двухэтапным процессом определения модели объектов JavaScript поверх API базы данных MongoDB. Сначала мы определяем «схему», которая выглядит как традиционный класс из более традиционного языка на основе классов (C#, C++, Java или Visual Basic). В этой схеме будут поля; определите типы этих полей и (не обязательно) включите какие-то правила для проверки этих полей при присваивании им значений. Кроме того, вы можете добавить некоторые методы (статические или экземпляра), о которых мы поговорим позже. Определив объект схемы, вы «компилируете» ее в модель, которая будет использоваться для конструирования экземпляров этих объектов.

Здесь будьте осторожны: это все тот же JavaScript, и, что важнее, это версия JavaScript, которая более точно описывается как ECMAScript 5, в которой вообще нет никакой концепции «класса». Поэтому, хотя подход с Mongoose создает иллюзию определения «класса», вы по-прежнему имеете дело с языком на основе прототипов, который вы знаете и любите (или ненавидите). Отчасти это является причиной наличия этого двухэтапного процесса: сначала нужно определить объект, который будет служить классом, а потом определить объект, который будет неявно работать в качестве «конструктора» или «фабрики» с учетом правил JavaScript/ECMAScript 5 вокруг оператора «new».

Если транслировать это в код, то после загрузки библиотеки Mongoose с помощью require() в обычную локальную переменную mongoose поверх JavaScript-кода вы можете использовать ее для определения нового объекта Schema:

// Определяем нашу Mongoose Schema
var personSchema = mongoose.Schema({
  firstName: String,
  lastName: String,
  status: String
});
var Person = mongoose.model('Person', personSchema);

Вы можете выполнять самые разнообразные операции с полями в personSchema, но для новичков мы не будем ничего усложнять; это не только поможет быстрее получить работающий код, но и высветит попутно ряд тонкостей в Mongoose. И вновь обратите внимание на двухэтапный процесс определения модели: сначала вы определяете схему, используя функцию/конструктор Schema, а затем передаете объект схемы функции/конструктору модели вместе с именем этой модели. Соглашение между пользователями Mongoose заключается в том, что объект, возвращаемый вызовом модели, будет иметь тот же тип, что и определенный ранее, поскольку именно это поможет создать иллюзию наличия класса.

Mongoose-программирование в действии

После определения модели остается лишь переписать различные методы-маршруты (route methods), которые будут использовать модель. Проще всего начать с getAllPersons (рис. 4), так как он возвращает полный набор из базы данных безо всякой фильтрации.

Рис. 4. Переписываем 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, поскольку он используется почти во всех остальных методах, как показано на рис. 5.

Рис. 5. Переписываем 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 поддерживает синтаксис/стиль так называемых обещаний (promises), которые станут важной деталью будущих версий JavaScript; findById возвращает объект Promise, для которого вы можете вызвать then и передать его в обратном вызове для выполнения, когда завершится конфигурирование запроса.

Затем этот двухэтапный процесс позволяет делать самые разнообразные вещи с данным запросом до его выполнения, например сортировать результаты запроса в определенном порядке на основе полей в результатах или выбирать только подмножество полей (чтобы скрывать любую конфиденциальную информацию, которую клиент не должен получать). Это должны быть вызовы методов между find и then в текучем стиле, например:

Person.find({ })
  .sort({ 'firstName':'asc', 'lastName':'desc' })
  .select('firstName lastName status')
  .then(function(persons) {
    // Что-то делаем с возвращенными persons
  });

В данном случае результаты были бы отсортированы по firstName в порядке возрастания, по lastName в порядке убывания (особого смысла это не имеет), а затем было бы отброшено все, кроме полей firstName, lastName и status. Полный список модификаторов запросов весьма впечатляет и включает ряд полезных методов, таких как limit (отсекает определенное количество результатов), skip (пропускает первые n полученных результатов), count (сообщает общее количество возвращенных документов) и т. д.

Однако, пока вы еще не слишком отвлеклись, я закончу с остальными методами маршрута (route methods).

Располагая промежуточным уровнем для получения объекта Person для обновления и удаления, становится совершенно понятным применение методов save и delete, предоставляемых Mongoose в самих объектах. Как показано на рис. 6, вставка нового объекта Person требует лишь создать экземпляр модели нового Person и использовать ее метод save.

Рис. 6. Создание экземпляра модели нового Person

var updatePerson = function(req, res) {
  debug("Updating",req.person,"with",req.body);
  _.merge(req.person, req.body);
  // The req.person уже является Person, поэтому просто вызываем 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 не могут быть пустыми, а status может иметь лишь одно из нескольких возможных значений (а-ля перечислимый тип). И здесь возможности Mongoose в создании модели объектов предметной области в серверном коде могут быть особенно полезны, поскольку, согласно классическим представлениям, правила такого рода должны быть инкапсулированы в сам тип объекта, а не в окружающий код, который использует эти объекты.

В случае Mongoose это делается весьма тривиально. В объекте схемы поля идут от простых пар «имя-тип» к более сложным парам «имя-дескриптор объекта», и правила проверки можно указывать в этих дескрипторах объектов. Помимо серии обычных числовых проверок (на минимальное и максимальное значения) и строковых (на минимальную и максимальную длину), вы можете указать массив допустимых значений для поля status и определить значение по умолчанию для каждого поля, если оно не задается явно, которое точно соответствует новым требованиям (рис. 7).

Рис. 7. Проверка с помощью Mongoose

// Определяем наш 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);

И нигде ничего в кодовой базе изменять не нужно — все эти правила захватываются именно там, где они должны: в определении объекта схемы.

Если вы попытаетесь теперь вставить JSON, который не подчиняется списку правил для поля status, например:

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

то метод save, вызванный в insertPerson, выдаст ответ 500 и вернет тело JSON, как показано на рис. 8.

Рис. 8. Ошибка из-за не пройденной проверки при записи

{
  "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 method (либо метод для традиционного метода экземпляра, либо статический для метода класса), который будет определять ваш метод, и передаете в функцию для использования в качестве его тела, например:

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

Это отличается от написания класса на C#, но довольно близко к нему. Заметьте, что, как и все другие изменения в объектах Schema, эти изменения должны осуществляться до компиляции Schema в объект model, так что этот вызов должен появляться до вызова mongoose.model.

Контроль версий в Mongoose

Кстати, если вы снова выполните GET to /persons, конечный вывод будет содержать нечто слегка неожиданное:

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

Поле __v, которое Mongoose «молча» вставляет в смесь (и сохраняет на всем пути до базы данных), — это поле для контроля версий, известное в Mongoose как versionKey, и оно помогает Mongoose распознавать изменения в документе; считайте его своего рода номером версии файла исходного кода, способом обнаружения одновременных изменений. Обычно это просто внутренняя деталь Mongoose, но можно немного удивиться, впервые увидев ее появление.

Тем не менее, это поднимает более интересный вопрос: в системах, которым нужно отслеживать, когда документ был создан или когда в него были внесены изменения, нередко требуется и определенно может стать проблемой заполнение полей при каждом изменении или сохранении любой частью окружающего кода одного из документов.

Mongoose может помочь в этом, позволяя вам «подключать» некоторые методы жизненного цикла для обновления полей непосредственно перед записью документа в MongoDB независимо от вызова, который сохраняет этот документ (рис. 9).

Рис. 9. Подключение методов жизненного цикла с помощью Mongoose

// Определяем наш Mongoose Schema
var personSchema = mongoose.Schema({
  created: {
    type: Date,
    default: Date.now
  },
  updated: {
    type: Date,
  },
  // ...как и раньше
});
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 в поле created будет по умолчанию содержать текущую дату/время, а поле updated будет получать текущую дату/время перед самой отправкой в MongoDB для хранения. В Mongoose эти методы называются промежуточным уровнем, потому что они соответствуют по духу функциям промежуточного уровня, определяемым Express, но не заблуждайтесь: они специфичны и содержатся исключительно в объекте, определенном Mongoose.

После этого всякий раз, когда создается или обновляется объект Person, он будет иметь соответствующие поля, заполненные нужными данными, которые по мере необходимости будут обновляться. Более того, если вам понадобится нечто более сложное (вроде полноценного журнала аудита), то сравнительно легко понять, как это можно было бы добавить. Вместо полей created/updated нужно поле auditLog, которое будет массивом, и подключаемый save, который будет просто дописывать в этот массив информацию о том, что делалось каждый раз и/или кто делал это — в зависимости от требований.

Заключение

Уф, эта часть была посложнее, чем остальные, но к этому моменту у меня есть код, работающий исключительно с базой данных MongoDB, а не с массивом в памяти. Но в первой половине он был очень и очень несовершенен. Любая опечатка в коде вокруг предикатов запросов вызывала бы непредвиденные ошибки в период выполнения. Поэтому во второй половине статьи мы исследовали и задействовали Mongoose, которая, конечно же, заслуживает дальнейшего изучения. Главное — понимать, что Mongoose в паре с MongoDB является мощной комбинацией: «бессхемная» природа базы данных MongoDB сочетается с вводимыми языковыми средствами проверками базовых типов; кроме того, проверка значений данных в период выполнения дает нам кое-что лучшее из двух миров — статической и динамической типизации. Это реализуется не без проблем и ухабов, но в целом мне трудно вообразить какое-то серьезное кодирование в MongoDB без использования какого-то средства вроде Mongoose, не дающего мне совершать глупые ошибки.

Я почти закончил с тем, что касается серверной стороны, ну а тем временем, увы, нам пора прощаться, так что на сегодня все и… удачи в кодировании!


Тэд Ньюард (Ted Neward) — глава фирмы Neward & Associates, предоставляющей консалтинговые услуги по самым разнообразным технологиям. Автор и соавтор многочисленных книг, в том числе «Professional F# 2.0» (Wrox, 2010), более сотни статей, часто выступает на многих конференциях по всему миру; кроме того, имеет звание Microsoft MVP в области F#. С ним можно связаться по адресу ted@tedneward.com или почитать его блог blogs.tedneward.com.

Выражаю благодарность за рецензирование статьи эксперту Шону Уайлдермуту (Shawn Wildermuth).