Хабрахабр

Пишем масштабируемые и поддерживаемые сервера на 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.

Теги
Показать больше

Похожие статьи

Кнопка «Наверх»
Закрыть