Создание программной экосферы: Учебное пособие MyBooks: Шаг 4 — Веб-сервисы Node RESTful — Restify CRUD
Обзор
Если вы новичок в серии, вы можете увидеть все текущие шаги здесь. Как всегда, код доступен на github, основная ветка будет содержать все шаги на сегодняшний день, и я разветвил шаги, начиная с шага 003. Я не собираюсь подробно документировать каждый шаг, поэтому я настоятельно рекомендую вам либо использовать код ветки github напрямую, либо открывать его по ходу выполнения.
После шага 3 я сделал кучу очистки:
- Добавлен eslint-plugin-chai-expect для очистки моей ошибки/предупреждений lint.
- Изменена настройка orm.js для проверки веб-шторма и проверки ошибок/предупреждений.
- Изменены некоторые правила eslintrc
- Добавлены правила проверки длины строки в модели продолжения.
- Добавлено в остальные интеграционные тесты для слоя сохраняемости (sequelize).
- Добавлено в заголовки файлов и license.md
- Добавлена синяя птица для промисов
- Добавлен Стамбул для тестового покрытия
- Добавлен синон для тестовых заглушек, шпионов и моков.
- Созданы остальные интеграционные тесты для уровня сохраняемости.
- Разветвленное продолжение, поэтому валидация проверяет типы данных и улучшает работу с веб-штормом.
Самым большим изменением стал форк сиквела. Я использую webstorm, и процесс проверки использует js/esdoc вместо подписи функции, что приводит к большому количеству ложных предупреждений. Я добавляю ошибку для разработчиков сиквелов, но они не заинтересованы в изменении документации, так как она работает на них (и она создает хорошую документацию, но она играет в ад со всем, что использует объявления документации для расширения/проверки). Кроме того, функция валидации не выполняет проверку типов, и это важно для меня (проверяйте рано и часто!). Я бы предпочел избежать обращения к БД и обратно, чтобы узнать, есть ли несоответствующие значения типов. Мой форк можно найти на GitHub по адресу rpcarver/sequelize. Да, при попытке поддерживать ветку в актуальном состоянии возникают накладные расходы, но для меня это не имеет большого значения, так как это всего лишь учебник. Для моих собственных производственных приложений я, вероятно, в конечном итоге буду использовать mysql2 напрямую, чтобы избежать накладных расходов на сиквелизацию, но я сожгу этот мост после проведения некоторого бенчмаркинга. Чтобы установить мою вилку, удалите продолжение, установите мою ветку (ПРИМЕЧАНИЕ. Части тестов интеграции модели не пройдут, если вы используете продолжение или продолжение в основной ветке).
npm uninstall --save sequelize npm install --save rpcarver/sequelize
Если вы все догнали, то давайте об этом ...
Установите Restify и Loggers
Пока мы устанавливаем restify и restify-router, мы также собираемся установить ведение журнала. Я буду использовать pino и pino-http, потому что они чертовски быстрые. Существует также restify-pino-logger, но это всего лишь оболочка пакета вокруг pino-http, поэтому вместо того, чтобы загрязнять пространство нашего модуля, мы будем использовать pino-http. Очень просто…
npm install --save restify pino pino-http
Последовательное ведение журнала и ведение журнала приложений с помощью pino
Нам нужно настроить параметры ведения журнала. Это продолжит запись в стандартный вывод, но с помощью внешних процессов (чтобы мы не блокировали цикл событий) мы можем легко перенаправить его в файл или использовать пино-ти, чтобы разделить его на несколько файлов, или даже отправить его в такой сервис, как Elastisearch.
Тестирование логгера, захват stdout
Итак, мы собираемся написать тесты (используя TDD вместо BDD 😉). Прежде чем мы это сделаем, чтобы протестировать регистратор, нам нужно захватить стандартный вывод. Мы делаем это, заменяя функцию process.stdout.write нашей собственной функцией. Вот класс, который позволяет нам это сделать (mybooks-api/utils/capture-stdout.js). Кстати, если вы не крадете свои идеи, по крайней мере иногда, вы слишком много работаете. Я украл это у Престона Гиллори, но вместо того, чтобы копировать, я беру все это, чтобы наш тестовый вывод (который также использует стандартный вывод) не выглядел странным:
class CaptureStdout { constructor() { this._capturedText = []; } /** * Starts capturing the writes to process.stdout */ startCapture() { this._orig_stdout_write = process.stdout.write; process.stdout.write = this._writeCapture.bind(this); } /** * Stops capturing the writes to process.stdout. */ stopCapture() { process.stdout.write = this._orig_stdout_write; } /** * Private method that is used as the replacement write function for process.stdout * @param string * @private */ _writeCapture(string) { this._capturedText.push(string.replace(/\n/g, '')); } /** * Retrieve the text that has been captured since creation or since the last clear call * @returns {Array} of Strings */ getCapturedText() { return this._capturedText; } /** * Clears all of the captured text */ clearCaptureText() { this._capturedText = []; } } module.exports = CaptureStdout;
Logger TDD, тесты
В TDD мы определяем функциональность, сначала написав тесты. Если вы придерживаетесь точки зрения, согласно которой недельное кодирование экономит целый день планирования, изложение ваших требований сначала с помощью TDD или какого-либо другого метода, вероятно, не для вас. Но тогда мне, вероятно, тоже не понравилось бы поддерживать ваш код в продакшене ;). Мы отражаем нашу структуру каталогов реализации в тестовом каталоге, и я использую суффиксы модулей и интеграции, чтобы различать типы тестов. Этот тест будет в mybooks-api/tests/controllers/books-unit.js. Поэтому я стремлюсь к модульному тесту, а не к интеграционному. Отсюда и заглушки, шпионы и моки. Вот тесты для нашей оболочки регистратора:
... describe('util - logger', () => { const capture = new CaptureStdout(); const oldLevel = logger.level; beforeEach(() => { logger.level = 'debug'; }); after(() => { logger.level = oldLevel; }) it('should return a logger with the expected functionality', () => { expect(logger).is.an('Object'); expect(logger).has.property('level'); expect(logger).respondsTo('trace'); expect(logger).respondsTo('sql'); expect(logger).respondsTo('debug'); expect(logger).respondsTo('info'); expect(logger).respondsTo('warn'); expect(logger).respondsTo('error'); expect(logger).respondsTo('fatal'); }); it('should log a sql message at a level of 15', () => { logger.level = 'sql'; const msg = 'this is a message'; capture.startCapture(); logger.sql(msg); capture.stopCapture(); const json = capture.getCapturedText().map(JSON.parse); capture.clearCaptureText(); expect(json[0]).has.property('msg').which.equals(msg); expect(json[0]).has.property('level').which.equals(15); }); it('should log messages greater than the log level', () => { logger.level = 'debug'; const msg = 'this is a message'; capture.startCapture(); logger.fatal(msg); logger.error(msg); logger.warn(msg); logger.info(msg); logger.debug(msg); capture.stopCapture(); const json = capture.getCapturedText().map(JSON.parse); capture.clearCaptureText(); expect(json).has.lengthOf(5); expect(json[0]).has.property('msg').which.equals(msg); expect(json[0]).has.property('level').which.equals(60); expect(json[1]).has.property('msg').which.equals(msg); expect(json[1]).has.property('level').which.equals(50); expect(json[2]).has.property('msg').which.equals(msg); expect(json[2]).has.property('level').which.equals(40); expect(json[3]).has.property('msg').which.equals(msg); expect(json[3]).has.property('level').which.equals(30); expect(json[4]).has.property('msg').which.equals(msg); expect(json[4]).has.property('level').which.equals(20); }); it('should log nothing for log messages less than the log level', () => { logger.level = 'debug'; const msg = 'this is a message'; capture.startCapture(); logger.debug(msg); logger.trace(msg); capture.stopCapture(); const json = capture.getCapturedText().map(JSON.parse); capture.clearCaptureText(); expect(json).has.lengthOf(1); }); it('should log nothing when the log level is silent', () => { logger.level = 'silent'; const msg = 'this is a message'; capture.startCapture(); logger.fatal(msg); logger.error(msg); logger.warn(msg); logger.info(msg); logger.debug(msg); logger.sql(msg); logger.trace(msg); capture.stopCapture(); const json = capture.getCapturedText().map(JSON.parse); capture.clearCaptureText(); expect(json).has.lengthOf(0); }); });
Как вы можете видеть, наши требования требуют в значительной степени готового регистратора с пользовательским уровнем sql. То, на что вы смотрите, — это последнее испытание. И хотя это может быть не чистый TDD, так как реализация pino (далее) делает наш тест хрупким. Я готов с этим жить.
Обертка/расширение регистратора и привязка его к Sequelize
Сейчас реализуем. В директорию config добавим logger.js следующего содержания:
const pino = require('pino'); const env = require('./env'); const pjson = require('../package.json'); const logger = pino({ name: pjson.name, }); /* * 10 - trace * 20 - debug * 30 - info * 40 - warn * 50 - error * 60 - fatal */ logger.addLevel('sql', 15); logger.level = env.LOGGING_LEVEL; module.exports = logger;
Пользовательский уровень ведения журнала дает мне больше детализации и позволяет легко извлекать операторы sql, если мне нужно. Уровень находится между отладкой (20) и трассировкой (10). Это также позволит мне просто увидеть уровень отладки без операторов sql (что я обычно и делаю).
Теперь, когда у нас есть регистратор, мы можем использовать его где угодно (особенно для регистрации исключений!), используя стандартные вызовы регистратора. Итак, давайте свяжем его с сиквелом, чтобы у нас был единый регистратор. Во-первых, я удалил запись DATABASE_QUERY_LOGGING в env.js. Мы можем использовать уровень журнала sql, чтобы решить, регистрировать сообщения запроса sql или нет. Затем в orm.js мы меняем свойство logging на функцию logger.sql, чтобы сиквелизация вызывала logger.sql(msg) для всех вызовов журнала:
... const logger = require('../config/logger'); ... // connect to the database. const sequelize = new Sequelize(env.DATABASE_NAME, env.DATABASE_USERNAME, env.DATABASE_PASSWORD, { host: env.DATABASE_HOST, port: env.DATABASE_PORT, dialect: env.DATABASE_DIALECT, pool: { max: env.DATABASE_POOL_MAX, min: env.DATABASE_POOL_MIN, idle: env.DATABASE_POOL_IDLE, }, define: { timestamps: false, }, logging: logger.sql, }); ...
Первый контроллер — написание тестов
Опять же, тесты были написаны, затем запущены, чтобы убедиться, что они не работают (ничего хуже, чем ложное срабатывание!), а затем написан код, а затем я переделываю их оба, пока не буду доволен и все тесты не пройдены. Я не изменил функциональность, которую определил при первом тестировании. Я только что уточнил, как они работают с реализацией контроллера. Чтобы упростить тест, я добавил синон. Sinon позволит нам быстро писать заглушки, шпионы и макеты. Мы хотим начать с простого, поэтому начнем с реализации API:/books, которая будет возвращать все книги и ничего не требует в запросе. Для краткости, я не буду рассматривать мок-ответ, он находится в репозитории в разделе utils. Вот тест:
require('../../config/env'); const CaptureStdout = require('../../utils/capture-stdout'); const sinon = require('sinon'); const chai = require('chai'); const orm = require('../../models/orm'); const controller = require('../../controllers/books-controller')(orm); const MockResponse = require('../../utils/mock-response'); const expect = chai.expect; const allBooks = [ { bookID: 1, title: 'booger1' }, { bookID: 2, title: 'booger2' }, { bookID: 3, title: 'booger3' }, ]; describe('controllers - books - getAll', () => { let sandbox; beforeEach(() => { sandbox = sinon.sandbox.create(); }); afterEach(() => { sandbox.restore(); }); it('should have a getAll function', () => { expect(controller).has.property('getAll'); }); it('should return all books via the response and set the status code', async () => { // stub out the persistence call const findAll = sandbox.stub(orm.books, 'findAll'); const next = sandbox.stub(); // request is empty const req = {}; const res = new MockResponse(); // use spy to confirm the response was called as expected sandbox.spy(res, 'json'); sandbox.spy(res, 'status'); // stubbed persistence call is a promise, make sure we pretend we're one. findAll.resolves(allBooks); // use await to make sure the promise is fulfilled controller.getAll(req, res, next); // use await to make sure the promise is fulfilled await res; await next; expect(res.json.calledOnce).is.true; expect(res.json.getCall(0).args[0]).deep.equals(allBooks); expect(res.status.calledOnce).is.true; expect(res.status.getCall(0).args[0]).to.equal(200); }); it('should error and log an error message if the persistence call throws an error', async () => { const captureStdout = new CaptureStdout(); const msg = 'some error text here'; // stub out the persistence call const findAll = sandbox.stub(orm.books, 'findAll'); const next = sandbox.stub(); // request is empty const req = {}; const res = new MockResponse(); // use spy to confirm the response was called as expected sandbox.spy(res, 'json'); sandbox.spy(res, 'status'); // stubbed persistence call is a promise, make sure we pretend we're one. findAll.rejects(Error(msg)); captureStdout.startCapture(); controller.getAll(req, res, next); // use await to make sure the promise is fulfilled await res; await next; captureStdout.stopCapture(); const json = captureStdout.getCapturedText().map(JSON.parse); captureStdout.clearCaptureText(); expect(res.json.notCalled).is.true; expect(res.status.notCalled).is.true; expect(next.called).is.true; expect(json).has.lengthOf(1); expect(json[0]).has.property('msg').contains(msg); expect(json[0]).has.property('level').which.equals(50); }); });
Контроллер
Настройка контроллера пытается в полной мере использовать восстановление и продолжение. Это довольно просто, и мы рассмотрели требования в тесте. Restify предоставляет нам возможность вернуть ошибку в цепочке промисов и автоматически установить соответствующую информацию в заголовке. Мы проверим эту функциональность позже во время интеграционных тестов.
const orm = require('../models/orm'); const restify = require('restify'); const errors = require('restify-errors'); const logger = require('../utils/logger'); module.exports = { getAll: (req, res, next) => { orm.books.findAll({ include: [orm.publishers, orm.formats, orm.authors] }) .then((result) => { res.status(200); res.json(result); return next(); }) .catch((error) => { logger.error(`books.findAll error: ${error}`); return next(new errors.InternalError('Error retrieving all books')); }); }, };
Ну наконец то! Маршрут и цикл событий!
Сначала откройте app.js, мы вносим наши зависимости. Затем мы создаем сервер и добавляем маршрутизацию для книг, вызывая только что настроенную функцию контроллера. Затем мы создаем цикл прослушивания, который изначально будет:
const restify = require('restify'); const env = require('./config/env'); const orm = require('./models/orm'); const booksController = require('./controllers/books-controller'); const app = restify.createServer(); app.get('/books', booksController.getAll); app.listen(env.API_PORT, () => { orm.sequelize.authenticate() .then(() => { console.log('DB Connection Established'); }) .catch((err) => { console.error('DB Connection Failed: ', err); return; }); console.log(`app listening at port ${env.API_PORT} for ${env.NODE_ENV} environment.`); });
Я предлагаю вам выполнить синхронизацию с step004 в репозитории github (для краткости я опустил некоторые шаблоны). После того, как вы сделали это из командной строки (или по вашему выбору):
npm start
вы должны увидеть что-то вроде:
> [email protected] start /somepath/mybooks/mybook-api > NODE_ENV=development node app.js app listening at port 8080 for development environment. DB Connection Established
Откройте в браузере localhost:8080/books и…. чего ждать? 🙂 Просто пустой массив json. Ну, у нас нет никаких данных в БД, чего вы ожидали? Давайте быстро добавим некоторые данные в dev (вы указываете на базу данных dev в вашем .env.dev, не так ли?). Не забудьте остановить сервер.
Посеять базу данных разработки
В каталоге скриптов я создал файл init-dev-db.js и использовал bulkcreate для создания набора данных. Обратите внимание, что если бы я работал с командой без собственного экземпляра базы данных, я бы, вероятно, поступил НЕ так. Нет ничего хуже, чем исчезновение некоторых ваших данных разработки, когда вы меньше всего этого ожидаете. И да, нет никаких авторско-книжных отношений. Позже я вернусь назад и добавлю в книги некоторых авторов:
const orm = require('../models/orm'); async function createAllData() { // drop all tables and recreate await orm.sequelize.sync({ force: true }); await orm.locations.bulkCreate([ { locationName: 'location1', description: 'dev location 1' }, { locationName: 'location2', description: 'dev location 2' }, ]); await orm.formats.bulkCreate([ { formatName: 'format1', description: 'dev format 1' }, { formatName: 'format2', description: 'dev format 2' }, ]); await orm.authors.bulkCreate([ { firstName: 'John', lastName: 'Smith' }, { firstName: 'Ursala', lastName: 'LeGuin' }, { firstName: 'Anne', lastName: 'McCaffrey' }, { firstName: 'Spider', lastName: 'Robinson' }, ]); await orm.publishers.bulkCreate([ { publisherName: 'Baen Books' }, { publisherName: 'Ballantine Books' }, { publisherName: 'Ace Books' }, { publisherName: 'Gnome Press' }, ]); await orm.books.bulkCreate([ { title: 'Book One', publisherID: 1, ISBN: 1234567890, printingYear: 1976, printingNum: null, formatID: 1, rating: 10, locationName: 'location1', notes: 'This is a short note...', }, { title: 'Book Two', publisherID: 1, ISBN: 1234567890, printingYear: 1999, printingNum: null, formatID: 1, rating: 10, locationName: 'location1', notes: 'It was a dark and stormy night...', }, { title: 'Book Three', publisherID: 2, ISBN: 1234567890, printingYear: 2017, printingNum: null, formatID: 2, rating: 10, locationName: 'location2', notes: 'Meanwhile, our intrepid hero...', }, ]); } createAllData(); console.log('done loading data!'); process.exit(0);
Добавьте запись скрипта в package.json:
"init-dev-db": "NODE_ENV=development node ./scripts/init-dev-db.js",
Снова из командной строки:
npm run init-dev-db
перезапускаем сервер, подтягиваем браузер (если много работаете с json, установите хороший бьютификатор, я пользуюсь JSONview) и….
Заворачивать
У нас есть единственный метод и начало нашего RESTful API. Следующим шагом будет очистка нашей маршрутизации, добавление других конечных точек получения, тестирование и публикация API на Google Cloud Platform. Прежде чем мы опубликуем деструктивные сервисы, мы также добавим некоторые сервисы аутентификации и авторизации. Надеюсь, вам понравится эта попытка создать программную экосистему, и, как всегда, если у вас есть вопросы, не стесняйтесь их задавать!
Первоначально опубликовано на blueottersoftware.com 24 июля 2017 г.