javascript

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

  • четверг, 8 февраля 2018 г. в 03:14:56
https://habrahabr.ru/post/348564/
  • Node.JS
  • JavaScript
  • API


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


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


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


Основная проблема серверной разработки на 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.