Введение


В данной статье хотел бы поделиться своими эмоциями и приобретенными навыками в разработке первого REST API на Node.js с использованием TypeScript, как говорится, с нуля. История достаточно банальная: «Закончил университет, получил диплом. Куда же пойти работать?» Как можно было догадаться меня проблема не обошла стороной, пусть думать особо и не пришлось. Позвал к себе на стажировку разработчик (выпускник той же специальности). Полагаю, что это достаточно распространенная практика и существует множество подобных историй. Я, недолго думая, решил попробовать свои силы и пошел…

image

День первый. Знакомство с 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 мы оставим, так как на форуме очень много статей об этом с примерами и разработкой на различных языках программирования.
image

Задача стояла следующая:

Сделать сервис с REST API. Авторизация по bearer токену (/info, /latency, /logout). Настроенный CORS для доступа с любого домена. DB — MongoDB. Токен создавать при каждом заходе.

Описание API:

  1. /signin [POST] — запрос bearer токена по id и паролю // данные принимает в json
  2. /signup [POST] — регистрация нового пользователя: // данные принимает в json
  3. /info [GET] — возвращает id пользователя и тип id, требует выданный bearer токен в аутентификации
  4. /latency [GET] — возвращает задержку (ping), требует выданный bearer токен в аутентификации
  5. /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;
}

База выглядит следующим образом:

image

Авторизация пользователя


Для авторизации мы используем 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, не боятся средств разработки и неизвестных технологий. Вам обязательно помогут старшие товарищи (если повезет также как и мне), подробно разъяснят и ответят на вопросы, потому что каждый из них оказывался в таком положении. Но не забывайте, что Ваше желание — это самый важный аспект!

Ссылка на проект

Комментарии (8)


  1. makarik
    09.09.2019 17:00

    POST_SIGNUP_MASSAGE
    регистрация массаж?


    1. Chessman97 Автор
      09.09.2019 18:13
      -1

      Фейл:)


  1. Tantrido
    09.09.2019 17:32

    человек может с абсолютного нуля за пять дней собрать веб-приложение, использующее базу данных и содержащее хоть какую-то, но логику.
    Аналогичную задачу только без TypeScript пока: Node.JS + Express освоил за 1-2 дня.

    это крик души человека, который в данный момент находится в полном восторге от мира Node.js и делится с Вами этим.
    Находился и нахожусь в таком же восторге!

    А также тем, что ничего нет невозможного, нужно просто брать и делать!
    Совершенно согласен!

    Конечно, нельзя отрицать, что автор ничего не знал и первый раз сел писать код. Нет, знания ООП, принципы работы REST API, ORM, база данных присутствовали в достаточном объеме. И это может говорить только о том, что средство достижения результата абсолютно не играет никакой роли и высказывания в стиле: «Не пойду на эту работу, там язык программирования, который я не учил», для меня теперь просто проявление человеком не слабости, а скорее защиты от незнакомой внешней среды. Да что там скрывать, страх присутствовал и у меня.
    Страха такого совершенно нет, просто интересно, как человека взяли на работу без этого опыта?! Тоже есть большой опыт с REST API, ORM, базами данных. Однако частенько требуется опыт Typscript, Mongo, Angular иле React и т.п., если чего-то не знаешь, то даже, если имеешь представление об этом всём и как оно работает не факт, что возьмут, хотя всё это можно освоить за 2-3 дня максимум (кроме может Angular и React ;). Хотя конечно попадаются адекватные работодатели, которые при коротком собеседовании по уверенности ли в голосе или предыдущему похожему опыту понимают, что ты без труда с этим разберёшься при необходимости и без проблем сделаешь. Мне недавно такой попался. :) Может просто от кармы зависит? ;)


    1. Chessman97 Автор
      10.09.2019 06:17

      Спасибо за Ваш развернутый комментарий. Наверное так и бывает, что по общению с человеком становится понятно опытному работодателю.


  1. Mnwamnowich
    09.09.2019 22:43
    +1

    Автор, если тебе так зашёл ts и typeorm, посмотри в сторону фраемворка nest js. Это тот самый, настоящий, бэкенд на Node js + ts с крутейшими возможностями (работает поверх всё того же express). Где-то на хабре даже выходила статья про него, советую прочесть.


    1. Chessman97 Автор
      10.09.2019 06:18

      Спасибо, как раз данный фреймворк и обнаружил позднее. Обязательно изучу:)


  1. yarick123
    09.09.2019 22:50
    +1

    Chessman97, посмотрите ещё в сторону GraphQL. Мы были и до сих пор остаёмся весьма довольными, перейдя на него с REST API.


  1. AriesUa
    10.09.2019 06:19

    Нда… Сначала всегда восторг… А потом приходит проект на поддержку, а с ним приходит боль…