Создание Node.js/TypeScript REST API, часть 1: Express.js
Опубликовано: 2022-03-11Как написать REST API в Node.js?
При создании серверной части для REST API Express.js часто является первым выбором среди сред Node.js. Хотя он также поддерживает создание статического HTML и шаблонов, в этой серии мы сосредоточимся на внутренней разработке с использованием TypeScript. Результирующий REST API будет таким, который сможет запрашивать любая интерфейсная платформа или внешняя серверная служба.
Тебе понадобиться:
- Базовые знания JavaScript и TypeScript
- Базовые знания Node.js
- Базовые знания архитектуры REST (см. этот раздел моей предыдущей статьи REST API, если необходимо)
- Готовая установка Node.js (желательно версии 14+)
В терминале (или командной строке) мы создадим папку для проекта. Из этой папки запустите npm init
. Это создаст некоторые из основных файлов проекта Node.js, которые нам нужны.
Далее мы добавим фреймворк Express.js и несколько полезных библиотек:
npm i express debug winston express-winston cors
Есть веские причины, по которым эти библиотеки являются фаворитами разработчиков Node.js:
-
debug
— это модуль, который мы будем использовать, чтобы избежать вызоваconsole.log()
при разработке нашего приложения. Таким образом, мы можем легко фильтровать операторы отладки во время устранения неполадок. Их также можно полностью отключить в процессе производства вместо того, чтобы удалять их вручную. -
winston
отвечает за регистрацию запросов к нашему API и возврат ответов (и ошибок).express-winston
напрямую интегрируется с Express.js, поэтому весь стандартный код ведения журналаwinston
, связанный с API, уже выполнен. -
cors
— это компонент промежуточного программного обеспечения Express.js, который позволяет нам обеспечивать совместное использование ресурсов из разных источников. Без этого наш API можно было бы использовать только из внешних интерфейсов, обслуживаемых из того же поддомена, что и наш серверный.
Наш сервер использует эти пакеты во время работы. Но нам также необходимо установить некоторые зависимости разработки для нашей конфигурации TypeScript. Для этого мы запустим:
npm i --save-dev @types/cors @types/express @types/debug source-map-support tslint typescript
Эти зависимости необходимы для включения TypeScript для собственного кода нашего приложения, наряду с типами, используемыми Express.js и другими зависимостями. Это может сэкономить много времени, когда мы используем IDE, такую как WebStorm или VSCode, позволяя нам автоматически выполнять некоторые методы функций во время кодирования.
Окончательные зависимости в package.json
должны быть такими:
"dependencies": { "debug": "^4.2.0", "express": "^4.17.1", "express-winston": "^4.0.5", "winston": "^3.3.3", "cors": "^2.8.5" }, "devDependencies": { "@types/cors": "^2.8.7", "@types/debug": "^4.1.5", "@types/express": "^4.17.2", "source-map-support": "^0.5.16", "tslint": "^6.0.0", "typescript": "^3.7.5" }
Теперь, когда у нас установлены все необходимые зависимости, давайте начнем создавать собственный код!
Структура проекта TypeScript REST API
Для этого урока мы создадим всего три файла:
-
./app.ts
-
./common/common.routes.config.ts
-
./users/users.routes.config.ts
Идея, лежащая в основе двух папок структуры проекта ( common
и users
), состоит в том, чтобы иметь отдельные модули со своими собственными обязанностями. В этом смысле мы в конечном итоге будем иметь некоторые или все из следующего для каждого модуля:
- Конфигурация маршрута для определения запросов, которые может обрабатывать наш API.
- Услуги для таких задач, как подключение к нашим моделям баз данных, выполнение запросов или подключение к внешним службам, которые требуются для конкретного запроса.
- Промежуточное ПО для выполнения определенных проверок запросов до того, как последний контроллер маршрута обработает его особенности.
- Модели для определения моделей данных, соответствующих заданной схеме базы данных, для облегчения хранения и поиска данных.
- Контроллеры для отделения конфигурации маршрута от кода, который окончательно (после любого промежуточного ПО) обрабатывает запрос маршрута, вызывает при необходимости вышеперечисленные сервисные функции и отдает ответ клиенту
Эта структура папок обеспечивает базовый дизайн REST API, начальную отправную точку для остальной части этой серии руководств и достаточна для начала практики.
Файл общих маршрутов в TypeScript
В common
папке давайте создадим файл common.routes.config.ts
, который будет выглядеть следующим образом:
import express from 'express'; export class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; } getName() { return this.name; } }
Способ, которым мы создаем маршруты здесь, является необязательным. Но поскольку мы работаем с TypeScript, наш сценарий маршрутов — это возможность попрактиковаться в использовании наследования с ключевым словом extends
, как мы вскоре увидим. В этом проекте все файлы маршрутов имеют одинаковое поведение: у них есть имя (которое мы будем использовать для целей отладки) и доступ к основному объекту Application
Express.js.
Теперь мы можем начать создавать файл маршрута пользователя. В папке users
давайте создадим users.routes.config.ts
и начнем кодировать его следующим образом:
import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } }
Здесь мы импортируем класс CommonRoutesConfig
и расширяем его до нашего нового класса с именем UsersRoutes
. С помощью конструктора мы отправляем приложение (основной объект express.Application
) и имя UsersRoutes в CommonRoutesConfig
.
Этот пример достаточно прост, но при масштабировании для создания нескольких файлов маршрутов это поможет нам избежать дублирования кода.
Предположим, мы хотим добавить в этот файл новые функции, например ведение журнала. Мы могли бы добавить необходимое поле в класс CommonRoutesConfig
, и тогда все маршруты, расширяющие CommonRoutesConfig
, будут иметь к нему доступ.
Использование абстрактных функций TypeScript для аналогичной функциональности в разных классах
Что, если мы хотели бы иметь некоторые функции, схожие между этими классами (например, настройку конечных точек API), но требующие разных реализаций для каждого класса? Один из вариантов — использовать функцию TypeScript, называемую абстракцией .
Давайте создадим очень простую абстрактную функцию, которую класс UsersRoutes
(и будущие классы маршрутизации) унаследует от CommonRoutesConfig
. Допустим, мы хотим, чтобы все маршруты имели функцию (чтобы мы могли вызывать ее из нашего общего конструктора) с именем configureRoutes()
. Здесь мы объявим конечные точки ресурса каждого класса маршрутизации.
Для этого мы добавим в common.routes.config.ts
три быстрых вещи:
- Ключевое слово
abstract
для нашей строкиclass
, чтобы включить абстракцию для этого класса. - Объявление новой функции в конце нашего класса,
abstract configureRoutes(): express.Application;
. Это заставляет любой класс, расширяющийCommonRoutesConfig
, предоставлять реализацию, соответствующую этой сигнатуре — если это не так, компилятор TypeScript выдаст ошибку. - Вызов
this.configureRoutes();
в конце конструктора, так как теперь мы можем быть уверены, что эта функция будет существовать.
Результат:
import express from 'express'; export abstract class CommonRoutesConfig { app: express.Application; name: string; constructor(app: express.Application, name: string) { this.app = app; this.name = name; this.configureRoutes(); } getName() { return this.name; } abstract configureRoutes(): express.Application; }
При этом любой класс, расширяющий CommonRoutesConfig
, должен иметь функцию с именем configureRoutes()
, которая возвращает объект express.Application
. Это означает, что users.routes.config.ts
нуждается в обновлении:
import {CommonRoutesConfig} from '../common/common.routes.config'; import express from 'express'; export class UsersRoutes extends CommonRoutesConfig { constructor(app: express.Application) { super(app, 'UsersRoutes'); } configureRoutes() { // (we'll add the actual route configuration here next) return this.app; } }
В качестве резюме того, что мы сделали:
Сначала мы импортируем файл common.routes.config
, затем express
-модуль. Затем мы определяем класс UserRoutes
, говоря, что мы хотим, чтобы он расширял базовый класс CommonRoutesConfig
, что подразумевает, что мы обещаем, что он будет реализовывать configureRoutes()
.
Чтобы отправить информацию классу CommonRoutesConfig
, мы используем constructor
класса. Он ожидает получить объект express.Application
, который мы опишем более подробно на следующем шаге. С помощью super()
мы передаем конструктору CommonRoutesConfig
приложение и имя наших маршрутов, в данном случае это UsersRoutes. ( super()
, в свою очередь, вызовет нашу реализацию configureRoutes()
.)
Настройка маршрутов Express.js конечных точек пользователей
В функции configureRoutes()
мы создадим конечные точки для пользователей нашего REST API. Там мы будем использовать приложение и его функции маршрутизации из Express.js.
Идея использования функции app.route()
состоит в том, чтобы избежать дублирования кода, что легко, поскольку мы создаем REST API с четко определенными ресурсами. Основным ресурсом для этого руководства являются пользователи . У нас есть два случая в этом сценарии:
- Когда вызывающая сторона API хочет создать нового пользователя или перечислить всех существующих пользователей, конечная точка должна изначально просто иметь
users
в конце запрошенного пути. (В этой статье мы не будем касаться фильтрации запросов, разбиения на страницы или других подобных запросов.) - Когда вызывающая сторона хочет сделать что-то конкретное с определенной записью пользователя, путь к ресурсу запроса будет следовать шаблону
users/:userId
.
То, как .route()
работает в Express.js, позволяет нам обрабатывать HTTP-глаголы с некоторой элегантной цепочкой. Это связано с тем, что .get()
, .post()
и т. д. возвращают тот же экземпляр IRoute
, что и первый .route()
. Окончательная конфигурация будет такой:
configureRoutes() { this.app.route(`/users`) .get((req: express.Request, res: express.Response) => { res.status(200).send(`List of users`); }) .post((req: express.Request, res: express.Response) => { res.status(200).send(`Post to users`); }); this.app.route(`/users/:userId`) .all((req: express.Request, res: express.Response, next: express.NextFunction) => { // this middleware function runs before any request to /users/:userId // but it doesn't accomplish anything just yet--- // it simply passes control to the next applicable function below using next() next(); }) .get((req: express.Request, res: express.Response) => { res.status(200).send(`GET requested for id ${req.params.userId}`); }) .put((req: express.Request, res: express.Response) => { res.status(200).send(`PUT requested for id ${req.params.userId}`); }) .patch((req: express.Request, res: express.Response) => { res.status(200).send(`PATCH requested for id ${req.params.userId}`); }) .delete((req: express.Request, res: express.Response) => { res.status(200).send(`DELETE requested for id ${req.params.userId}`); }); return this.app; }
Приведенный выше код позволяет любому клиенту REST API вызывать конечную точку наших users
с помощью запроса POST
или GET
. Точно так же он позволяет клиенту вызывать нашу конечную точку /users/:userId
с запросом GET
, PUT
, PATCH
или DELETE
.

Но для /users/:userId
мы также добавили универсальное промежуточное ПО с использованием функции all()
, которая будет запускаться перед любой из функций get()
, put()
, patch()
или delete()
. Эта функция будет полезной, когда (позже в этой серии) мы создадим маршруты, которые предназначены для доступа только аутентифицированных пользователей.
Вы могли заметить, что в нашей .all()
— как и в любом промежуточном программном обеспечении — у нас есть три типа полей: Request
, Response
и NextFunction
.
- Запрос — это то, как Express.js представляет обрабатываемый HTTP-запрос. Этот тип обновляет и расширяет собственный тип запроса Node.js.
- Ответ аналогичен тому, как Express.js представляет ответ HTTP, снова расширяя собственный тип ответа Node.js.
- Не менее важно то, что
NextFunction
служит функцией обратного вызова, позволяя передавать управление через любые другие функции промежуточного программного обеспечения. Попутно все промежуточное ПО будет совместно использовать одни и те же объекты запроса и ответа, прежде чем контроллер, наконец, отправит ответ обратно запрашивающей стороне.
Наш файл точки входа Node.js, app.ts
Теперь, когда мы настроили несколько базовых скелетов маршрутов, мы приступим к настройке точки входа приложения. Давайте создадим файл app.ts
в корне папки нашего проекта и начнем его с этого кода:
import express from 'express'; import * as http from 'http'; import * as winston from 'winston'; import * as expressWinston from 'express-winston'; import cors from 'cors'; import {CommonRoutesConfig} from './common/common.routes.config'; import {UsersRoutes} from './users/users.routes.config'; import debug from 'debug';
Только два из этих импортов являются новыми на данный момент в статье:
-
http
— это собственный модуль Node.js. Это необходимо для запуска нашего приложения Express.js. -
body-parser
— это промежуточное ПО, поставляемое с Express.js. Он анализирует запрос (в нашем случае в формате JSON) до того, как управление перейдет к нашим собственным обработчикам запросов.
Теперь, когда мы импортировали файлы, мы начнем объявлять переменные, которые хотим использовать:
const app: express.Application = express(); const server: http.Server = http.createServer(app); const port = 3000; const routes: Array<CommonRoutesConfig> = []; const debugLog: debug.IDebugger = debug('app');
Функция express()
возвращает основной объект приложения Express.js, который мы будем передавать по всему коду, начиная с его добавления к объекту http.Server
. (Нам нужно будет запустить http.Server
после настройки нашего express.Application
.)
Мы будем прослушивать порт 3000, который TypeScript автоматически определит как Number
, вместо стандартных портов 80 (HTTP) или 443 (HTTPS), потому что они обычно используются для внешнего интерфейса приложения.
Почему порт 3000?
Нет правила, согласно которому порт должен быть 3000 — если он не указан, будет назначен произвольный порт — но 3000 используется во всех примерах документации как для Node.js, так и для Express.js, поэтому мы продолжаем традицию здесь.
Может ли Node.js совместно использовать порты с внешним интерфейсом?
Мы по-прежнему можем работать локально на пользовательском порту, даже если мы хотим, чтобы наша серверная часть отвечала на запросы на стандартных портах. Для этого потребуется обратный прокси-сервер для приема запросов на порт 80 или 443 с определенным доменом или субдоменом. Затем он перенаправит их на наш внутренний порт 3000.
Массив routes
будет отслеживать наши файлы маршрутов в целях отладки, как мы увидим ниже.
Наконец, debugLog
станет функцией, похожей на console.log
, но лучше: ее легче настроить, потому что она автоматически привязана к тому, что мы хотим назвать контекстом нашего файла/модуля. (В данном случае мы назвали его «приложение», когда передали его в строке конструктору debug()
.)
Теперь мы готовы настроить все наши промежуточные модули Express.js и маршруты нашего API:
// here we are adding middleware to parse all incoming requests as JSON app.use(express.json()); // here we are adding middleware to allow cross-origin requests app.use(cors()); // here we are preparing the expressWinston logging middleware configuration, // which will automatically log all HTTP requests handled by Express.js const loggerOptions: expressWinston.LoggerOptions = { transports: [new winston.transports.Console()], format: winston.format.combine( winston.format.json(), winston.format.prettyPrint(), winston.format.colorize({ all: true }) ), }; if (!process.env.DEBUG) { loggerOptions.meta = false; // when not debugging, log requests as one-liners } // initialize the logger with the above configuration app.use(expressWinston.logger(loggerOptions)); // here we are adding the UserRoutes to our array, // after sending the Express.js application object to have the routes added to our app! routes.push(new UsersRoutes(app)); // this is a simple route to make sure everything is working properly const runningMessage = `Server running at http://localhost:${port}`; app.get('/', (req: express.Request, res: express.Response) => { res.status(200).send(runningMessage) });
expressWinston.logger
к Express.js, автоматически регистрируя детали — через ту же инфраструктуру, что и debug
— для каждого выполненного запроса. Параметры, которые мы ему передали, будут аккуратно форматировать и раскрашивать соответствующий вывод терминала с более подробным ведением журнала (по умолчанию), когда мы находимся в режиме отладки.
Обратите внимание, что мы должны определить наши маршруты после того, как expressWinston.logger
.
Наконец и самое главное:
server.listen(port, () => { routes.forEach((route: CommonRoutesConfig) => { debugLog(`Routes configured for ${route.getName()}`); }); // our only exception to avoiding console.log(), because we // always want to know when the server is done starting up console.log(runningMessage); });
Это фактически запускает наш сервер. После запуска Node.js запустит нашу функцию обратного вызова, которая в режиме отладки сообщает имена всех настроенных нами маршрутов — пока только UsersRoutes
. После этого наш обратный вызов уведомляет нас о том, что наш сервер готов принимать запросы, даже если он работает в рабочем режиме.
Обновление package.json
для преобразования TypeScript в JavaScript и запуска приложения
Теперь, когда у нас есть готовый к запуску скелет, нам сначала потребуется некоторая стандартная конфигурация, чтобы включить транспиляцию TypeScript. Добавим файл tsconfig.json
в корень проекта:
{ "compilerOptions": { "target": "es2016", "module": "commonjs", "outDir": "./dist", "strict": true, "esModuleInterop": true, "inlineSourceMap": true } }
Затем нам остается только добавить последние штрихи в package.json
в виде следующих скриптов:
"scripts": { "start": "tsc && node --unhandled-rejections=strict ./dist/app.js", "debug": "export DEBUG=* && npm run start", "test": "echo \"Error: no test specified\" && exit 1" },
test
сценарий — это заполнитель, который мы заменим позже в этой серии.
tsc в start
скрипте принадлежит TypeScript. Он отвечает за преобразование нашего кода TypeScript в JavaScript, который будет выводиться в папку dist
. Затем мы просто запускаем собранную версию с node ./dist/app.js
.
Мы передаем --unhandled-rejections=strict
в Node.js (даже с Node.js v16+), потому что на практике отладка с использованием прямого подхода «сбой и отображение стека» более проста, чем более сложная регистрация с помощью объекта expressWinston.errorLogger
. Это чаще всего верно даже в производственной среде, где продолжение работы Node.js, несмотря на необработанный отказ, может привести к тому, что сервер окажется в неожиданном состоянии, что приведет к возникновению дополнительных (и более сложных) ошибок.
Сценарий debug
вызывает сценарий start
, но сначала определяет переменную среды DEBUG
. Это приводит к тому, что все наши debugLog()
(плюс аналогичные из самого Express.js, который использует тот же модуль debug
, что и мы) для вывода полезных сведений на терминал — сведений, которые в противном случае (удобно) скрыты при запуске. сервер в рабочем режиме со стандартным npm start
.
Попробуйте запустить npm run debug
самостоятельно, а затем сравните это с npm start
, чтобы увидеть, как изменится вывод консоли.
Совет: Вы можете ограничить вывод отладки собственными debugLog()
нашего файла app.ts
, используя DEBUG=app
вместо DEBUG=*
. Модуль debug
в целом достаточно гибкий, и эта функция не исключение.
Пользователям Windows, вероятно, потребуется изменить export
на SET
, так как export
работает на Mac и Linux. Если вашему проекту необходимо поддерживать несколько сред разработки, пакет cross-env предлагает здесь простое решение.
Тестирование серверной части Live Express.js
Когда npm run debug
или npm start
все еще продолжаются, наш REST API будет готов обслуживать запросы на порту 3000. На этом этапе мы можем использовать cURL, Postman, Insomnia и т. д. для тестирования серверной части.
Поскольку мы создали только скелет для ресурса пользователей, мы можем просто отправлять запросы без тела, чтобы убедиться, что все работает как положено. Например:
curl --request GET 'localhost:3000/users/12345'
Наш сервер должен отправить ответ GET requested for id 12345
.
Что касается POST
:
curl --request POST 'localhost:3000/users' \ --data-raw ''
Этот и все другие типы запросов, для которых мы построили скелеты, будут выглядеть очень похоже.
Готов к быстрой разработке REST API Node.js с помощью TypeScript
В этой статье мы начали создавать REST API, настроив проект с нуля и углубившись в основы фреймворка Express.js. Затем мы сделали первый шаг к освоению TypeScript, создав шаблон с UsersRoutesConfig
, расширяющим CommonRoutesConfig
, шаблон, который мы будем повторно использовать в следующей статье этой серии. Мы закончили настройку точки входа app.ts
для использования наших новых маршрутов и package.json
со сценариями для сборки и запуска нашего приложения.
Но даже основы REST API, созданные с помощью Express.js и TypeScript, достаточно сложны. В следующей части этой серии мы сосредоточимся на создании правильных контроллеров для ресурсов пользователей и рассмотрим некоторые полезные шаблоны для сервисов, промежуточного ПО, контроллеров и моделей.
Полный проект доступен на GitHub, а код на конец этой статьи находится в toptal-article-01
.