Введение
В данной статье хотел бы поделиться своими эмоциями и приобретенными навыками в разработке первого REST API на Node.js с использованием TypeScript, как говорится, с нуля. История достаточно банальная: «Закончил университет, получил диплом. Куда же пойти работать?» Как можно было догадаться меня проблема не обошла стороной, пусть думать особо и не пришлось. Позвал к себе на стажировку разработчик (выпускник той же специальности). Полагаю, что это достаточно распространенная практика и существует множество подобных историй. Я, недолго думая, решил попробовать свои силы и пошел…
День первый. Знакомство с Node.js
Пришёл я на back-end разработку. В данной IT-компании используют платформу Node.js, с которой я абсолютно не был знаком. Я немного убежал вперед, забыв рассказать читателю, что никогда и ничего не разрабатывал на JavaScript (за исключением пары скриптов с копированным кодом). Алгоритм работы и архитектуру веб-приложений в целом я понимал, так как разрабатывал CRUD на Java, Python и Clojure, но этого было недостаточно. Поэтому первый день я полностью посвятил изучению Node.js, очень помог этот скринкаст.
Параллельно изучая веб-фреймворк Express, менеджер пакетов npm, а также такие файлы как package.json и tsconfig.json, голова просто шла кругом от количества информации. Очередной урок, что усвоение всего материала одновременно задача близкая к невозможной. К концу дня я все же справился с настройкой окружения и смог запустить express веб-сервер! Но радоваться было рано, потому что уходил домой с полным ощущением непонимания. Чувство, что я утопал в огромном мире JS не покидало меня ни на минуту, поэтому необходима была перезагрузка.
День второй. Знакомство с TypeScript
Та самая перезагрузка последовала именно в этот день. К этому моменту я полностью узнал свою задачу, к ней мы перейдем чуть ниже. Зная, что предстоит писать не на чистом JavaScipt, обучение от Node.js плавно перетекло к языку TypeScript, а именно, его особенностям и синтаксису. Здесь я увидел долгожданные типы, без которых не представлял программирование буквально 2 дня тому назад не в функциональных языках программирования. Это и было моим самым большим заблуждением, которое мешало мне понять и усвоить код, написанный на JavaScript в первый день.
Ранее писал по большей части на объектно-ориентированных языках программирования, таких как Java, C++, C#. Осознав возможности TypeScript, я почувствовал себя в своей тарелке. Этот язык программирования буквально вдохнул в меня жизнь этой сложной среды, как мне казалось на тот период. Под занавес дня я полностью настроил окружение, запустил сервер (уже на TypeScript), подключил необходимые библиотеки, о которых ниже расскажу. Итог: готов к разработке API. Переходим непосредственно к разработке…
Разработка API
Объяснение принципа работы и прочие разъяснение о том, что же такое REST API мы оставим, так как на форуме очень много статей об этом с примерами и разработкой на различных языках программирования.
Задача стояла следующая:
Сделать сервис с REST API. Авторизация по bearer токену (/info, /latency, /logout). Настроенный CORS для доступа с любого домена. DB — MongoDB. Токен создавать при каждом заходе.
Описание API:
- /signin [POST] — запрос bearer токена по id и паролю // данные принимает в json
- /signup [POST] — регистрация нового пользователя: // данные принимает в json
- /info [GET] — возвращает id пользователя и тип id, требует выданный bearer токен в аутентификации
- /latency [GET] — возвращает задержку (ping), требует выданный bearer токен в аутентификации
- /logout [GET] — с паметром all: true — удаляет все bearer токены пользователя или false — удаляет только текущий bearer токен
Отмечу сразу, задача выглядит невероятно простой для разработчика веб-приложений. Но задачу нужно реализовать на языке программирования, о котором 3 дня назад совсем ничего не знал! Даже для меня она на бумаге выглядит совсем прозрачной и на Python реализация потребовала немного времени, но такой опции у меня не было. Стек разработки предвещал беды.
Средства реализации
Итак, я упоминал, что уже изучил во второй день несколько библиотек (фреймворков), с этого и начнем. Для роутинга я выбрал routing-controllers, руководствовался большим сходством с декораторами из Spring Framework (Java). В качестве ORM я выбрал typeorm, хоть и работа с MongoDB в экспериментальном режиме, но для такой задачи вполне достаточно. Для генерации токенов использовал uuid, переменные загружаются с помощью dotenv.
Начальный запуск веб-сервера
Обычно, используется express в чистом виде, но я упоминал выше фреймворк Routing Controllers, который позволяет нам создать express сервер следующим образом:
//Создаем приложение Express
const app = createExpressServer({
//Префикс
routePrefix: process.env.SERVER_PREFIX,
//Инициализируем ошибки
defaults: {
nullResultCode: Number(process.env.ERROR_NULL_RESULT_CODE),
undefinedResultCode: Number(process.env.ERROR_NULL_UNDEFINED_RESULT_CODE),
paramOptions: {
required: true
}
},
//Проверка авторизации пользователя
authorizationChecker: authorizationChecker,
//Контроллер
controllers: [UserController]
});
//Запуск приложения
app.listen(process.env.SERVER_PORT, () => {
console.log(process.env.SERVER_MASSAGE);
});
Как вы можете заметить — ничего сложного нет. На самом деле фреймворк имеет намного больше возможностей, но в них не было никакой необходимости.
- routePrefix — это просто префикс в вашем url после адреса сервера, например: localhost:3000/prefix
- defaults — ничего интересного, просто инициализируем коды ошибок
- authorizationChecker — прекрасная возможность фреймворка проверять авторизацию пользователя, далее рассмотрим более подробно
- controllers — одно из основных полей, где мы указываем контроллеры, используемые в нашем приложении
Подключение к БД
Ранее, мы уже запустили веб-сервер, поэтому продолжим подключением к базе данных MongoDB, предварительно развернув на локальном сервере. Установка и настройка подробно описаны в официальной документации. Мы же непосредственно рассмотрим подключение с помощью typeorm:
//Подключение БД
createConnection({
type: 'mongodb',
host: process.env.DB_HOST,
database: process.env.DB_NAME_DATABASE,
entities: [
User
],
synchronize: true,
logging: false
}).catch(error => console.log(error));
Все достаточно просто, необходимо указать несколько параметров:
- type — БД
- host — ip адрес, на котором вы развернули базу данных
- database — название непосредственно базы, которую предварительно создали в mongodb
- synchronize — автоматическая синхронизация с БД (Примечание: миграции на тот момент было тяжеловато освоить)
- entities — здесь мы указываем сущности, c помощью которых производится синхронизация
Теперь соединяем запуск сервера и подключение к БД. Отмечу, что импорт ресурсов отличается от классического, используемого в Node.js. В итоге получаем следующий запускаемый файл, в моем случае main.ts:
import 'reflect-metadata';
import * as dotenv from 'dotenv';
import { createExpressServer } from 'routing-controllers';
import { createConnection } from 'typeorm';
import { authorizationChecker } from './auth/authorizationChecker';
import { UserController } from './controllers/UserController';
import { User } from './models/User';
dotenv.config();
//Подключение БД
createConnection({
type: 'mongodb',
host: process.env.DB_HOST,
database: process.env.DB_NAME_DATABASE,
entities: [
User
],
synchronize: true,
logging: false
}).catch(error => console.log(error));
//Создаем приложение Express
const app = createExpressServer({
//Префикс
routePrefix: process.env.SERVER_PREFIX,
//Инициализируем ошибки
defaults: {
nullResultCode: Number(process.env.ERROR_NULL_RESULT_CODE),
undefinedResultCode: Number(process.env.ERROR_NULL_UNDEFINED_RESULT_CODE),
paramOptions: {
required: true
}
},
//Проверка авторизации пользователя
authorizationChecker: authorizationChecker,
//Контроллер
controllers: [UserController]
});
//Запуск приложения
app.listen(process.env.SERVER_PORT, () => {
console.log(process.env.SERVER_MASSAGE);
});
Сущности
Напомню, что задача состоит в аутентификации и авторизации пользователей, соответственно нам необходима сущность: Пользователь (User). Но это еще не все, так как каждый пользователь имеет токе и не один! Поэтому необходимо создать сущность Токен (Token).
User
import { ObjectID } from 'bson';
import { IsEmail, MinLength } from 'class-validator';
import { Column, Entity, ObjectIdColumn } from 'typeorm';
import { Token } from './Token';
//Сущность пользователь
@Entity()
export class User {
//Уникальный идентификатор
@ObjectIdColumn()
id: ObjectID;
//Email для аутентификации пользователя
@Column()
@IsEmail()
email: string;
//Пароль пользователя
@Column({
length: 100
})
@MinLength(2)
password: string;
//Токены пользователя
@Column()
token: Token;
}
В таблице User мы создаем поле — массив тех самых токенов для пользователя. Также мы подключаем calss-validator, так как необходимо, чтобы пользователь осуществлял вход через email.
Token
import { Column, Entity } from 'typeorm';
//Сущность для токенов
@Entity()
export class Token {
@Column()
accessToken: string;
@Column()
refreshToken: string;
@Column()
timeKill: number;
}
База выглядит следующим образом:
Авторизация пользователя
Для авторизации мы используем authorizationChecker(один из параметров при создании сервера см. выше), для удобства вынесем в отдельный файл:
import { Action, UnauthorizedError } from 'routing-controllers';
import { getMongoRepository } from 'typeorm';
import { User } from '../models/User';
export async function authorizationChecker(action: Action): Promise<boolean> {
let token: string;
if (action.request.headers.authorization) {
//Получаем текущий токен
token = action.request.headers.authorization.split(" ", 2);
const repository = getMongoRepository(User);
const allUsers = await repository.find();
for (let i = 0; i < allUsers.length; i++) {
if (allUsers[i].token.accessToken.toString() === token[1]) {
return true;
}
}
}
else {
throw new UnauthorizedError('This user has not token.');
}
return false;
}
После аутентификации у каждого пользователя появляется свой токен, поэтому мы можем из заголовков (headers) ответа вытащить необходимый токен, выглядит он примерно так: Bearer 046a5f60-c55e-11e9-af71-c75526de439e. Теперь мы можем проверить, существует ли данный токен, после чего функция возвращает информацию об авторизации: true — пользователь авторизован, false — пользователь не авторизован. В приложении мы можем использовать очень удобный декоратор в контроллере: @Authorized(). В этот момент и будет вызвана функция authorizationChecker, которая вернет ответ.
Логика
Для начала я хотел бы описать бизнес логику, так как контроллер — это одна строчка вызова методов ниже представленного класса. Также, в контроллере мы будем принимать все данные, в нашем случае это будет JSON и Query. Рассмотрим по отдельным задачам методы, а в конце сформируем итоговый файл, который назван UserService.ts. Отмечу, что на тот момент знаний для устранения зависимостей попросту не хватало. Если вы не встречались с термином инъекция зависимостей, очень советую прочитать об этом. На данный момент пользуюсь DI-фреймворком, т. е. использую контейнеры, а именно инъекцию через конструкторы. Вот, я считаю, хорошая статья для ознакомления. Возвращаемся к задаче.
- /signin [POST] — аутентификация зарегистрированного пользователя. Все очень просто и прозрачно. Нам всего лишь нужно найти данного пользователя в базе данных и выдать новый токен. Для чтения и записи используется MongoRepository.
async userSignin(user: User): Promise<string> { //Создаем Mongo repository const repo = getMongoRepository(User); //Ищем введенный логин и пароль в БД let userEmail = await repo.findOne({ email: user.email, password: user.password }); if (userEmail) { //Создаем токен userEmail = await this.setToken(userEmail); //Обновляем токены в базе repo.save(userEmail); return userEmail.token.accessToken; } return process.env.USER_SERVICE_RESPONSE; }
- /signup [POST] — регистрация нового пользователя. Очень похожий метод, так как сначала мы тоже ищем пользователя, дабы у нас не было зарегистрированных пользователей с одним email. Далее мы записываем нового пользователя в базу, предварительно выдав токен.
async userSignup(newUser: User): Promise<string> { //Создаем Mongo repository const repo = getMongoRepository(User); //Проверяем на совпадение email (Чтобы не было 2 пользователя с одним email) const userRepeat = await repo.findOne({ email: newUser.email }); if (!userRepeat) { //Создаем токен newUser = await this.setToken(newUser); //Добавляем в базу const addUser = getMongoManager(); await addUser.save(newUser); return newUser.token.accessToken; } else { return process.env.USER_SERVICE_RESPONSE; } }
- /info [GET] — возвращает id пользователя и тип id, требует выданный bearer токен в аутентификации. Картина также прозрачна: сначала мы получаем из заголовков запроса (header) текущий токен пользователя, затем ищем его в базе данных и определяем кому он пренад лежит, и возвращаем найденного пользователя.
async getUserInfo(req: express.Request): Promise<User> { //Создаем Mongo repository const repository = getMongoRepository(User); //Поиск по текущему токену const user = await this.findUser(req, repository); return user; } private async findUser(req: express.Request, repository: MongoRepository<User>): Promise<User> { if (req.get(process.env.HEADER_AUTH)) { //Получаем токен const token = req.get(process.env.HEADER_AUTH).split(' ', 2); //Получаем пользователей из базы const usersAll = await repository.find(); //Ищем пользователя for (let i = 0; i < usersAll.length; i++) { if (usersAll[i].token.accessToken.toString() === token[1]) { return usersAll[i]; } } } }
- /latency [GET] — возвращает задержку (ping), требует выданный bearer токен в аутентификации. Совсем неинтересный пункт статьи, тем не менее. Здесь использовал просто готовую библиотеку для проверки задержки tcp-ping.
getLatency(): Promise<IPingResult> { function update(progress: number, total: number): void { console.log(progress, '/', total); } const latency = ping({ address: process.env.PING_ADRESS, attempts: Number(process.env.PING_ATTEMPTS), port: Number(process.env.PING_PORT), timeout: Number(process.env.PING_TIMEOUT) }, update).then(result => { console.log('ping result:', result); return result; }); return latency; }
- /logout [GET] — с параметром all: true — удаляет все bearer токены пользователя или false — удаляет только текущий bearer токен. Нам всего лишь достаточно найти пользователя, проверить query параметр и удалить токены. Думаю, все должно быть понятно.
async userLogout(all: boolean, req: express.Request): Promise<void> { //Создаем Mongo repository const repository = getMongoRepository(User); //Поиск по текущему токену const user = await this.findUser(req, repository); if (all) { //Если true удаляем все токены user.token.accessToken = process.env.GET_LOGOUT_TOKEN; user.token.refreshToken = process.env.GET_LOGOUT_TOKEN; //Сохраняем изменения repository.save(user); } else { //Если false удаляем только текущий user.token.accessToken = process.env.GET_LOGOUT_TOKEN; //Сохраняем изменения repository.save(user); } }
Контроллер
Многим не нужно объяснять для чего нужен и как используется контроллер в паттерне MVC, но два слова я все же скажу. В кратце, контроллер является связующим звеном между пользователем и приложением, который переправляет данные между ними. Выше полностью была описана логика, методы которой вызываются в соответствии с роутами, состоящий из URI и ip сервера (пример: localhost:3000/signin). О декораторах в контроллере я уже упоминал ранее: Get, POST, @Authorized и самый важный из них это @JsonController. Еще одна очень важная фишка данного фреймворка состоит в том, что если мы хотим отправлять и получать JSON, то используем именно данный декоратор вместо Controller.
import * as express from 'express';
import {
Authorized, Body, Get, Header, JsonController, NotFoundError, Post, QueryParam, Req,
UnauthorizedError
} from 'routing-controllers';
import { IPingResult } from '@network-utils/tcp-ping';
import { User } from '../models/User';
import { UserService } from '../services/UserService';
//Декоратор для работы с JSON
@JsonController()
export class UserController {
userService: UserService
//Конструткор контроллера
constructor() {
this.userService = new UserService();
}
//Вход пользователя
@Post('/signin')
async login(@Body() user: User): Promise<string> {
const responseSignin = await this.userService.userSignin(user);
if (responseSignin !== process.env.USER_SERVICE_RESPONSE) {
return responseSignin;
}
else {
throw new NotFoundError(process.env.POST_SIGNIN_MASSAGE);
}
}
//Регистрация пользователя
@Post('/signup')
async registrateUser(@Body() newUser: User): Promise<string> {
const responseSignup = await this.userService.userSignup(newUser);
if (responseSignup !== process.env.USER_SERVICE_RESPONSE) {
return responseSignup;
}
else {
throw new UnauthorizedError(process.env.POST_SIGNUP_MASSAGE);
}
}
//Возвращает авторизированного пользователя
@Get('/info')
@Authorized()
async getId(@Req() req: express.Request): Promise<User> {
return this.userService.getUserInfo(req);
}
//Время задержки сервера
@Authorized()
@Get('/latency')
getPing(): Promise<IPingResult> {
return this.userService.getLatency();
}
@Get('/logout')
async deleteToken(@QueryParam("all") all: boolean, @Req() req: express.Request): Promise<void> {
this.userService.userLogout(all, req);
}
}
Заключение
В данной статье я хотел отразить больше не техническую составляющую правильного кода или чего-то такого, а просто поделиться тем, что человек может с абсолютного нуля за пять дней собрать веб-приложение, использующее базу данных и содержащее хоть какую-то, но логику. Только вдумайтесь ни один инструмент не был знаком, вспомните себя или просто поставьте на мое место. Ни в коем случае это не случай, который говорит: «я самый лучший, вы так никогда не сможете». Наоборот, это крик души человека, который в данный момент находится в полном восторге от мира Node.js и делится с Вами этим. А также тем, что ничего нет невозможного, нужно просто брать и делать!
Конечно, нельзя отрицать, что автор ничего не знал и первый раз сел писать код. Нет, знания ООП, принципы работы REST API, ORM, база данных присутствовали в достаточном объеме. И это может говорить только о том, что средство достижения результата абсолютно не играет никакой роли и высказывания в стиле: «Не пойду на эту работу, там язык программирования, который я не учил», для меня теперь просто проявление человеком не слабости, а скорее защиты от незнакомой внешней среды. Да что там скрывать, страх присутствовал и у меня.
Подведем итоги. Хочу посоветовать студентам и людям, которые еще не начали свою карьеру в IT, не боятся средств разработки и неизвестных технологий. Вам обязательно помогут старшие товарищи (если повезет также как и мне), подробно разъяснят и ответят на вопросы, потому что каждый из них оказывался в таком положении. Но не забывайте, что Ваше желание — это самый важный аспект!
Ссылка на проект
Автор: Chessman97