Пишем масштабируемые и поддерживаемые сервера на Node.js и TypeScript

Последние три года я занимаюсь разработкой серверов на Node.js и в процессе работы у меня накопилась некоторая кодовая база, которую я решил оформить в виде фреймворка и выложил в open-source.

Основными особенностями фреймворка можно назвать:

  • простую архитектуру, без всякой JS – магии
  • автоматическую сериализацию/десериализацию моделей (например, не нужно проверять пришло ли поле с клиента, все проверяется автоматически)
  • возможность генерации схемы API
  • генерацию документации на основе схемы
  • генерацию полностью типизированного SDK для клиента (на данный момент речь про JS frontend)

В данной статье мы поговорим немного о Node.js и рассмотрим данный фреймворк

Всем кому интересно – прошу под кат

В чем же проблема и зачем писать очередной велосипед?

Основная проблема серверной разработки на Node.js заключается в двух вещах:

  • отсутствие развитого сообщества и инфраструктуры
  • достаточно низкий уровень вхождения

С отсутствием инфраструктуры все просто: Node.js достаточно молодая платформа, поэтому вещи, которые можно удобно и быстро написать на более взрослых платформах (например тот же PHP или .NET) в Node.js вызывают сложности.

Что же с низким уровнем вхождения? Тут основная проблема в том, что с каждым оборотом колеса хайпа и приходом новых библиотек/фреймворков, все пытаются упростить. Казалось бы, проще — лучше, но т.к. пользуются этими решениями в итоге не всегда опытные разработчики — возникает культ поклонения библиотекам и фреймворкам. Наверное самый очевидный пример — это express.
Что не так с express? Да ничего! Нет, безусловно в нем можно найти недостатки, но express — это инструмент, инструмент которым нужно уметь пользоваться, проблемы начинаются когда express, или любой другой фреймворк, занимает главную роль в вашем проекте, когда вы слишком завязаны на нем.

Исходя из вышеперечисленного, начиная разрабатывать новый проект, я начал с написания некоторого «ядра» сервера, позже оно переносилось в другие проекты, дорабатывалось и в конечно итоге стало фреймврком Airship.

Основная концепция

Начиная продумывать архитектуру я задал себе вопрос: «Что делает сервер?». На самом высоком уровне абстракции сервер делает три вещи:

  • получает запросы
  • обрабатывает запросы
  • отдает ответы

Все же просто, зачем усложнять себе жизнь? Я решил, что архитектура должна быть примерно следующей: у каждого запроса, поддерживаемого нашим севером, есть модель, модели запросов поступают в обработчики запросов, обработчики в свою очередь отдают модели ответов.
Важно заметить, что наш код ничего не знает про сеть, он работает только с инстансами обычных классов запросов и ответов. Этот факт позволяет нам не только доиться более гибкой архитектуры, но и даже сменить транспортный слой. Например мы можем пересесть с HTTP на TCP без изменения нашего кода. Безусловно это очень редкий случай, но такая возможность показывает нам гибкость архитектуры.

Модели

Начнем с моделей, что от них нужно? В первую очередь нам нужен простой способ сериализации и десериализации моделей, еще не помешает валидация типов при десериализации, т.к. пользовательские запросы — это тоже модели.

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

Вот как это работает:

class Point {     @serializable()     readonly x: number      @serializable()     readonly y: number      constructor(x: number, y: number) {         this.x = x         this.y = y     } }

Как видим, модель — это обычный класс, единственное отличие — это использование декоратора serializable. С помощью этого декоратора мы указываем поля сериализатору.
Теперь мы можем сариализировать и десериализировать нашу модель:

JSONSerializer.serialize(new Point(1,2)) // { "x": 1, "y": 2 }  JSONSerializer.deserialize(Point, { x: 1, y: 2 })  // Point { x: 1, y: 2 }

Если мы передадим данные неверного типа, то сериализатор выбросит исключение:

JSONSerializer.deserialize(Point, { x: 1, y: "2" })  // Error: y must be number instead of string

Модели запросов

Запросы — это те же модели, разница в том, что все запросы наследуются от ASRequest и используют декоратор @queryPath для указания пути запроса:

@queryPath('/getUser') class GetUserRequest extends ASRequest {     @serializable()     private userId: number      constructor(         userId: number     ) {         super()          this.userId = userId     } }

Модели ответов

Модели ответов тоже пишутся как обычно, но наследуются от ASResponse:

 class GetUserResponse extends ASResponse {     @serializable()     private user: User      constructor(user: User) {         super()          this.user = user     } }

Обработчики запросов

Обработчики запросов наследуются от BaseRequestHandler и реализуют два метода:

 export class GetUserHandler extends BaseRequestHandler {     // в этом методе мы обрабатываем запрос и возвращаем ответ     public async handle(request: GetUserRequest): Promise<GetUserResponse> {         return new GetUserResponse(new User(....))     }      // этот метод показывает какой запрос поддерживает обработчик     public supports(request: Request): boolean {         return request instanceof GetUserRequest     } }

Т.к. при таком подходе не очень удобно реализовывать обработку нескольких запросов в одном обработчике – существует потомок BaseRequestHandler, который называется MultiRequestHandler и позволяет обрабатывать несколько запросов:

class UsersHandler extends MultiRequestHandler {     // указываем какой запрос обрабатывает этот метод     @handles(GetUserRequest)     // теперь все запросы GetUserRequest будут попадать в этот метод     public async handleGetUser(request: GetUserRequest): Promise<ASResponse> {      }      @handles(SaveUserRequest)     public async handleSaveUser(request: SaveUserRequest): Promise<ASResponse> {      } }

Получение запросов

Существует базовый класс RequestsProvider, который описывает поставщика запросов в систему:

abstract class RequestsProvider {     public abstract getRequests(         callback: (             request: ASRequest,             answerRequest: (response: ASResponse) => void         ) => void     ): void }

Система вызывает метод getRequests, ждет запросов, обрабатывает их и передает ответ в answerRequest.

Для получения запросов по HTTP реализован HttpRequestsProvider, он работает очень просто: все запросы приходят через POST, а данные приходят в json. Использовать его тоже просто, достаточно передать порт и список поддерживаемых запросов:

new HttpRequestsProvider(     logger,     7000,      // поддерживаемые запросы     GetUserRequest,     SaveUserRequest )

Соединяем все вместе

Основной класс сервера — это AirshipAPIServer, в него мы передаем обработчик запросов и поставщика запросов. Т.к. AirshipAPIServer принимает только один обработчик — был реализован менеджер обработчиков, который принимает список обработчиков и вызывает нужный. В итоге наш сервер будет выглядеть так:

let logger = new ConsoleLogger()  const server = new AirshipAPIServer({     requestsProvider: new HttpRequestsProvider(         logger,         7000,          GetUserRequest,         SaveUserRequest     ),      requestsHandler: new RequestHandlersManager([         new GetUserHandler(),         new SaveUserRequest()     ]) })  server.start() 

Генерация схемы API

Схема API — это такой специальный JSON, который описывает все модели, запросы и ответы нашего сервера, его можно сгенерировать с помощью специальной утилиты aschemegen.

В первую очередь нужно создать конфиг, который укажет все наши запросы и ответы:

import {AirshipAPIServerConfig} from "airship-server"  const config: ApiServerConfig = {     endpoints: [         [TestRequest, TestResponse],         [GetUserRequest, GetUserResponse]     ] }  export default config

После этого мы можем запустить утилиту, указав путь до конфига и до папки, в которую будет записана схема:

node_modules/.bin/aschemegen  --o=/Users/altox/Desktop/test-server/scheme  --c=/Users/altox/Desktop/test-server/build/config.js

Генерация клиентского SDK

Зачем же нам нужна схема? Например мы можем сгенерировать полностью типизированное SDK для фронтенда на TypeScript. SDK состоит из четырех файлов:

  • API.ts — основной файл со всеми методами и работой с сетью
  • Models.ts — тут находятся все модели
  • Responses.ts — тут все модели ответов
  • MethodsProps.ts — тут интерфейсы, описывающие запросы

Приведу кусок API.ts из рабочего проекта:

/**  *  This is an automatically generated code (and probably compiled with TSC)  *  Generated at Sat Aug 19 2017 16:30:55 GMT+0300 (MSK)  *  Scheme version: 1  */ const API_PATH = '/api/' import * as Responses from './Responses' import * as MethodsProps from './MethodsProps'  export default class AirshipApi {     public async call(method: string, params: Object, responseType?: Function): Promise<any> {...}      /**      *       *      * @param {{      *   appParams: (string),      *   groupId: (number),      *   name: (string),      *   description: (string),      *   startDate: (number),      *   endDate: (number),      *   type: (number),      *   postId: (number),      *   enableNotifications: (boolean),      *   notificationCustomMessage: (string),      *   prizes: (Prize[])      * }} params      *      * @returns {Promise<SuccessResponse>}      */     public async addContest(params: MethodsProps.AddContestParams): Promise<Responses.SuccessResponse> {         return this.call(             'addContest',             {                 appParams: params.appParams,                 groupId: params.groupId,                 name: params.name,                 description: params.description,                 startDate: params.startDate,                 endDate: params.endDate,                 type: params.type,                 postId: params.postId,                 enableNotifications: params.enableNotifications,                 notificationCustomMessage: params.notificationCustomMessage,                 prizes: params.prizes ? params.prizes.map((v: any) => v ? v.serialize() : undefined) : undefined             },             Responses.SuccessResponse         )     }     ...

Весь этот код написан автоматически, это позволяет не отвлекаться на написание клиента и бесплатно получить подсказки названий полей и их типов, если ваша IDE это умеет.

Сгенерировать SDK тоже просто, нужно запустить утилиту asdkgen и передать ей путь до схем и путь, где будут лежать SDK:

node_modules/.bin/asdkgen --s=/Users/altox/Desktop/test-server/scheme --o=/Users/altox/Desktop/test-server/sdk

Генерация документации

На генерации SDK я не остановился и написал генерацию документации. Документация достаточно простая, это обычный HTML с описанием запросов, моделей, ответов. Из интересного: для каждой модели есть сгенерированный код для JS, TS и Swift:

Заключение

Данное решение уже долгое время используется в production, помогает поддерживать старый код, писать новый и не писать код для клиента.
Многим и статья и сам фреймворк может показаться очень очевидным, я это понимаю, другие могут сказать, что такие решения уже есть и даже ткнуть меня носом в ссылку на такой проект. В свою защиту я могу сказать только две вещи:

  • я таких решений в свое время не нашел, либо они мне не подошли
  • писать свои велосипеды — это весело!

Если кому-то все вышеперечисленное понравилось — добро пожаловать в GitHub.

FavoriteLoadingДобавить в избранное

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *