Пишем масштабируемые и поддерживаемые сервера на Node.js и TypeScript
- четверг, 8 февраля 2018 г. в 03:14:56
Последние три года я занимаюсь разработкой серверов на 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 — это такой специальный 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 для фронтенда на TypeScript. SDK состоит из четырех файлов:
Приведу кусок 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.