Создание программной экосферы: Учебное пособие 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 г.