Как показывает практика, огромная часть проблем возникает не из-за решений самих по себе, а из-за того каким образом происходит общение между компонентами системы. Если в коммуникации между компонентами системы бардак, то, как не старайся хорошо писать отдельные компоненты, система в целом будет сбоить.
Осторожно! Внутри велосипед.
Проблематика или постановка задачи
Какое-то время назад случилось работать над проектом для компании, несущей в массы такие прелести как системы CRM, ERM и производные. Причем продукт компания выдавала довольно комплексный от ПО для кассовых аппаратов до call-center с возможностью аренды операторов в количестве до 200 душ.
Сам же я трудился над front-end приложением для call-center.
Нетрудно представить, что именно в приложение оператора стекается информация со всех компонентов системы. А если учесть и тот факт, что не оператором единым, а еще и менеджер, и администратор, то можно представить какое количество коммуникаций и информации приложение должно «переваривать» и связывать между собой.
Когда проект уже был запущен и даже вполне себе стабильно работал, во весь рост встала проблема прозрачности системы.
Тут вот в чем суть. Компонентов много и все они работают со своими источниками данных. Но почти все эти компоненты в свое время писались как самостоятельные продукты. То есть, не как элемент общей системы, а как отдельные решения на продажу. Как следствие – никакого единого (системного) API и никаких общих стандартов коммуникации между ними.
Поясню. Какой-то компонент шлет JSON, «кто-то» шлет строки с key:value внутри, «кто-то» вообще присылает binary и делай с этим что хочешь. Но, а конечное приложение для call-center должно было вот это вот все получать и как-то обрабатывать. Ну и самое главное, в системе не было звена, которое могло бы распознать, что формат/структура данных изменилась. Если какой-то компонент вчера отправлял JSON, а сегодня решил слать binary – никто этого не увидит. Лишь конечное приложение начнет ожидаемо сбоить.
Очень скоро стало понятно (для окружающих, не для меня, так как о проблеме я говорил еще на этапе проектировки), что отсутствие «единого языка общения» между компонентами ведет к серьезным проблемам.
Самый простой кейс – это когда клиент попросил изменить какой-то dataset. Задачу отписывают молодцу, что «держит» компонент по работе с базами данных товаров/услуг к примеру. Он свою работу делает, новый dataset внедряет и у него, засранца, все работает. Но, на следующий день после апдейта… ой… приложение в call-center неожиданно начинает работать не так как от него этого ждут.
Вы уже наверняка догадались. Наш герой изменил не только dataset, но и структуру данных, что его компонент шлет в систему. Как следствие приложение для call-center просто не в состоянии работать более с этим компонентом, а там уж по цепочке летят и другие зависимости.
Стали думать над тем, что мы, собственно, хотим получить на выходе. Как результат, сформулировали следующие требования к потенциальному решению:
Первое и самое главное: любое изменение структуры данных должно немедленно «высвечиваться» в системе. Если кто-то, где-то внес изменения и эти изменения несовместимы с тем, что ожидает система – ошибка должна произойти еще на этапе тестов компонента, что был изменен.
Второе. Типы данных должны проверяться не только во время компиляции, но и run-time.
Третье. Поскольку над компонентами работает большое количество людей с совершенно разным уровнем квалификации, то «язык» описания должен быть проще.
Четвертое. Какое бы решение не было, с ним должно быть максимально удобно работать. По возможности IDE должна подсвечивать as much as possible.
Первая мысль была внедрить protobuf. Простой, читаемый и легкий. Строгая типизация данных. Вроде бы то, что доктор прописал. Но, увы, не всем синтаксис protobuf казался простым. Кроме того, даже скомпилированный протокол требовал наличия дополнительной библиотеки, ну а Javascript не поддерживался авторами protobuf и был результатом работы community. В общем, отказались.
Тогда же возникла идея описывать протокол в JSON. Ну куда уж проще?
Ну а потом я уволился. И на этом сей пост можно было бы и завершать, так как после моего ухода никто дальше проблемой особо плотно заниматься не стал.
Однако, учитывая пару личных проектов, где вопрос коммуникации между компонентами опять же встал в полный рост, я решил заняться реализацией задумки уже самостоятельно. О чем речь и пойдет ниже.
Итак, представляю вашему внимание проект ceres, что включает в себя:
- генератор протокола
- провайдер
- клиент
- реализацию транспортов
Протокол
Задачей было сделать так чтобы:
- можно было легко задавать структуру сообщений в системе.
- можно было легко определять тип данных всех полей сообщений.
- можно было определять вспомогательные сущности и ссылаться на них.
- ну и конечно, чтобы все это подсвечивалось IDE
Думаю, что совершенно естественным образом в качестве языка, в который конвертируется протокол был выбран не чистый Javascript, а Typescript. То есть все что делает генератор протокола — это превращает JSON в Typescript.
Для описания доступных в системе сообщений нужно лишь знать, что такое JSON. С чем, уверен, ни у кого проблем нет.
Вместо Hello World, предлагаю не менее избитый пример — чат.
{
"Events": {
"NewMessage": {
"message": "ChatMessage"
},
"UsersListUpdated": {
"users": "Array<User>"
}
},
"Requests": {
"GetUsers": {},
"AddUser": {
"user": "User"
}
},
"Responses": {
"UsersList": {
"users": "Array<User>"
},
"AddUserResult": {
"error?": "asciiString"
}
},
"ChatMessage": {
"nickname": "asciiString",
"message": "utf8String",
"created": "datetime"
},
"User": {
"nickname": "asciiString"
},
"version": "0.0.1"
}
Все до безобразия просто. У нас есть пара событий NewMessage и UsersListUpdated; а также пара запросов UsersList и AddUserResult. Еще есть две сущности: ChatMessage и User.
Как видите описание достаточно прозрачное и понятное. Немного про правила.
- Объект в JSON станет классом в сгенерированном протоколе
- В качестве значения свойства выступает определение типа данных или ссылка на класс (сущность)
- Вложенные объекты с точки зрения сгенерированного протокола станут "вложенными" классами, то есть вложенные будут наследовать все свойства своих родителей.
Теперь достаточно лишь сгенерировать протокол, чтобы начать его использовать.
npm install ceres.protocol -g
ceres.protocol -s chat.protocol.json -o chat.protocol.ts -r
В результате мы получим сгенерированный на Typescript протокол. Подключаем и используем:
Итак, протокол уже кое-что дает разработчику:
- IDE подсвечивает то, что у нас есть в протоколе. Также IDE подсвечивает все ожидаемые свойства
- Typescript, который непременно нам подскажет, если что-то не так с типами данных. Конечно делается это на этапе разработки, но и сам протокол уже в run-time будет проверять типы данных и выкинет исключение, если будет обнаружено нарушение
- Вообще о валидации можно забыть. Протокол будет делать все необходимые проверки.
- Сгенерированный протокол не требует никаких дополнительных библиотек. Все что нужно ему для работы он уже содержит. И это весьма удобно.
Да, размер сгенерированного протокола может вас, мягко говоря, удивить. Но, не забывайте о минификации, которой сгенерированный файл протокола хорошо поддается.
Теперь мы можем "упаковать" сообщение и отправить
import * as Protocol from '../../protocol/protocol.chat';
const message: Protocol.ChatMessage = new Protocol.ChatMessage({
nickname: 'noname',
message: 'Hello World!',
created: new Date()
});
const packet: Uint8Array = message.stringify();
// Send packet somewhere
Тут важно оговориться, packet будет массивом байт, что очень хорошо и правильно с точки зрения нагрузки на трафик, так как пересылка того же JSON "стоит", конечно, дороже. Однако у протокола есть одна фишка — в режиме отладки он будет генерировать читаемый JSON, дабы разработчик мог "глянуть" в трафик и посмотреть, что происходит.
Делается это непосредственно в run-time
import * as Protocol from '../../protocol/protocol.chat';
const message: Protocol.ChatMessage = new Protocol.ChatMessage({
nickname: 'noname',
message: 'Hello World!',
created: new Date()
});
// Switch to debug mode
Protocol.Protocol.state.debug(true);
// Now packet will be present as JSON string
const packet: string = message.stringify();
// Send packet somewhere
На сервере (или любом другом получателе), мы можем без труда сообщение распаковать:
import * as Protocol from '../../protocol/protocol.chat';
const smth = Protocol.parse(packet);
if (smth instanceof Error) {
// Oops. Something wrong with this packet.
}
if (Protocol.ChatMessage.instanceOf(smth) === true) {
// This is chat message
}
Протокол поддерживает все основные типы данных:
Тип | Значения | Описание | Размер, байт |
---|---|---|---|
utf8String | строка в UTF8 кодировке | x | |
asciiString | ascii строка | 1 символ — 1 байт | |
int8 | -128 to 127 | 1 | |
int16 | -32768 to 32767 | 2 | |
int32 | -2147483648 to 2147483647 | 4 | |
uint8 | 0 to 255 | 1 | |
uint16 | 0 to 65535 | 2 | |
uint32 | 0 to 4294967295 | 4 | |
float32 | 1.2x10-38 to 3.4x1038 | 4 | |
float64 | 5.0x10-324 to 1.8x10308 | 8 | |
boolean | 1 |
В рамках протокола эти типы данных называются примитивными. Однако еще одной фишкой протокола является то, что он позволяет добавлять свои собственные типы данных (что зовутся "дополнительные типы данных").
К примеру, вы уже, наверное, заметили, что ChatMessage имеет поле created с типом данных datetime. На уровне приложения — этот тип соответствует Date, а внутри протокола хранится (и пересылается) как uint32.
Добавить свой тип в протокол довольно просто. Например, если мы хотим иметь тип данных email, скажем для следующего сообщения в протоколе:
{
"User": {
"nickname": "asciiString",
"address": "email"
},
"version": "0.0.1"
}
Все что нужно — это написать определение для типа email.
export const AdvancedTypes: { [key:string]: any} = {
email: {
// Binary type or primitive type
binaryType : 'asciiString',
// Initialization value. This value is used as default value
init : '""',
// Parse value. We should not do any extra decode operations with it
parse : (value: string) => { return value; },
// Also we should not do any encoding operations with it
serialize : (value: string) => { return value; },
// Typescript type
tsType : 'string',
// Validation function to valid value
validate : (value: string) => {
if (typeof value !== 'string'){
return false;
}
if (value.trim() === '') {
// Initialization value is "''", so we allow use empty string.
return true;
}
const validationRegExp = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/gi;
return validationRegExp.test(value);
},
}
};
Вот и все. Сгенерировав протокол, мы получим поддержку нового типа данных email. При попытке создать сущность с неверным адресом мы получим ошибку
const user: Protocol.User = new Protocol.User({
nickname: 'Brad',
email: 'not_valid_email'
});
console.log(user);
Ой...
Error: Cannot create class of "User" due error(s):
- Property "email" has wrong value; validation was failed with value "not_valid_email".
Итак, протокол просто не допускает в систему "плохие" данные.
Обратите внимание, при определении нового типа данных, мы указали пару ключевых свойств:
- binaryType — ссылка на примитивный тип данных, который должен использоваться для хранения, кодирования/декодирования данных. В данном случае, мы указываем, что адрес — это ascii строка.
- tsType — ссылка на Javascript тип, то есть то, как должен быть представлен тип данных в среде Javascript. В данном случае мы говорим о string
- также стоит заметить, что определение нового типа данных нам нужно только в момент генерации протокола. На выходе мы получим сгенерированный протокол, уже содержащий новый тип данных.
Подробную информацию о всех возможностях протокола вы можете посмотреть здесь ceres.protocol.
Провайдер и клиент
По большому счету протокол уже сам по себе может использоваться для организации коммуникации. Однако, если речь идет о браузере и nodejs, то доступен провайдер и клиент.
Клиент
Создание
Для создания клиента, необходим сам клиент и транспорт.
Установка
# Install consumer (client)
npm install ceres.consumer --save
# Install transport
npm install ceres.consumer.browser.ws --save
Создание
import Transport, { ConnectionParameters } from 'ceres.consumer.browser.ws';
import Consumer from 'ceres.consumer';
// Create transport
const transport:Transport = new Transport(new ConnectionParameters({
host: 'http://localhost',
port: 3005,
wsHost: 'ws://localhost',
wsPort: 3005,
}));
// Create consumer
const consumer: Consumer = new Consumer(transport);
Клиент, равно как и провайдер, разработаны специально для протокола. То есть работать они будут только с протоколом (ceres.protocol).
События
После того как клиент создан, разработчик может подписаться на события
import * as Protocol from '../../protocol/protocol.chat';
import Transport, { ConnectionParameters } from 'ceres.consumer.browser.ws';
import Consumer from 'ceres.consumer';
// Create transport
const transport:Transport = new Transport(new ConnectionParameters({
host: 'http://localhost',
port: 3005,
wsHost: 'ws://localhost',
wsPort: 3005,
}));
// Create consumer
const consumer: Consumer = new Consumer(transport);
// Subscribe to event
consumer.subscribe(Protocol.Events.NewMessage, (message: Protocol.Events.NewMessage) => {
console.log(`New message came: ${message.message}`);
}).then(() => {
console.log('Subscription to "NewMessage" is done');
}).catch((error: Error) => {
console.log(`Fail to subscribe to "NewMessage" due error: ${error.message}`);
});
Обратите внимание, клиент вызовет обработчик события, только в том случае, если данные сообщения полностью корректны. Иными словами, наше приложение застраховано от некорректных данных и обработчик события NewMessage всегда будет вызван с экземпляром Protocol.Events.NewMessage в качестве аргумента.
Естественно, что клиент может события и генерировать.
consumer.emit(new Protocol.Events.NewMessage({ message: 'This is new message' })).then(() => {
console.log(`New message was sent`);
}).catch((error: Error) => {
console.log(`Fail to send message due error: ${error.message}`);
});
Заметьте, мы нигде не указываем названий событий, мы просто используем либо ссылку на класс из протокола, либо передаем его экземпляр.
Также мы можем послать сообщение ограниченной группе получателей, указав в качестве второго аргумента простой объект типа { [key: string]: string }
. В рамках ceres этот объект называется query.
consumer.emit(
new Protocol.Events.NewMessage({ message: 'This is new message' }),
{ location: "UK" }
).then(() => {
console.log(`New message was sent`);
}).catch((error: Error) => {
console.log(`Fail to send message due error: ${error.message}`);
});
Таким образом, дополнительно указав { location: "UK" }
, мы можем быть уверены, что это сообщение получат только те клиенты, которые определили свое положение, как UK.
Чтобы связать сам клиент с определенным query, нужно лишь вызвать метод ref:
consumer.ref({ id: '12345678', location: 'UK' }).then(() => {
console.log(`Client successfully bound with query`);
});
После того, как мы связали клиента с query, у него появляется возможность получать "персональные" или "групповые" сообщения.
Запросы
Так же мы можем делать запросы
consumer.request(
new Protocol.Requests.GetUsers(), // Request
Protocol.Responses.UsersList // Expected response
).then((response: Protocol.Responses.UsersList) => {
console.log(`Available users: ${response.users}`);
}).catch((error: Error) => {
console.log(`Fail to get users list due error: ${error.message}`);
});
Здесь стоит обратить внимание, что в качестве второго аргумента мы указываем ожидаемый результат (Protocol.Responses.UsersList), а значит наш запрос будет успешно завершен только в том случае, если ответом будет являться экземпляр UsersList, во всех прочих случаях мы "упадем" в catch. Опять же, это нас страхует от обработки некорректных данных.
Сам же клиент может выступать и тем, кто запросы может обрабатывать. Для этого нужно лишь "обозначить" себя, как "ответственного" за запрос.
function processRequestGetUsers(request: Protocol.Requests.GetUsers, callback: (error: Error | null, results : any ) => any) {
// Get user list somehow
const users: Protocol.User[] = [];
// Prepare response
const response = new Protocol.Responses.UsersList({
users: users
});
// Send response
callback(null, response);
// Or send error
// callback(new Error(`Something is wrong`))
};
consumer.listenRequest(Protocol.Requests.GetUsers, processRequestGetUsers, { location: "UK" }).then(() => {
console.log(`Consumer starts listen request "GetUsers"`);
});
Обратите внимание, опционально, в качестве третьего аргумента мы можем указать объект query, который может использоваться для идентификации клиента. Таким образом, если кто-то пришлет запрос с query, скажем, { location: "RU" }
, то наш клиент такой запрос не получит, так как его query { location: "UK" }
.
В query может быть включено неограниченное количество свойств. Например, можно указать следующее
{
location: "UK",
type: "managers"
}
Тогда, кроме полного совпадения query мы также успешно обработаем следующие запросы:
{ location: "UK" }
или
{ type: "managers" }
Провайдер
Создание
Для создания провайдера (равно как и для создания клиента), необходим сам провайдер и транспорт.
Установка
# Install provider
npm install ceres.provider --save
# Install transport
npm install ceres.provider.node.ws --save
Создание
import Transport, { ConnectionParameters } from 'ceres.provider.node.ws';
import Provider from 'ceres.provider';
// Create transport
const transport:Transport = new Transport(new ConnectionParameters({
port: 3005
}));
// Create provider
const provider: Provider = new Provider(transport);
С момента, как провайдер создан, он может принимать подключения от клиентов.
События
Равно как и клиент, провайдер может "слушать" сообщения и генерировать их.
Слушаем
// Subscribe to event
provider.subscribe(Protocol.Events.NewMessage, (message: Protocol.Events.NewMessage) => {
console.log(`New message came: ${message.message}`);
});
Генерируем
provider.emit(new Protocol.Events.NewMessage({ message: 'This message from provider' }));
Запросы
Естественно, что провайдер может (и должен) "слушать" запросы
function processRequestGetUsers(request: Protocol.Requests.GetUsers, clientID: string, callback: (error: Error | null, results : any ) => any) {
console.log(`Request from client ${clientId} was gotten.`);
// Get user list somehow
const users: Protocol.User[] = [];
// Prepare response
const response = new Protocol.Responses.UsersList({
users: users
});
// Send response
callback(null, response);
// Or send error
// callback(new Error(`Something is wrong`))
};
provider.listenRequest(Protocol.Requests.GetUsers, processRequestGetUsers).then(() => {
console.log(`Consumer starts listen request "GetUsers"`);
});
Здесь есть лишь одно отличие от клиента, провайдер в дополнение к телу запроса получит и уникальный clientId, что присваивается автоматически всем подключенным клиентам.
Пример
На самом деле мне жутко не хочется вас утомлять выдержками из документации, уверен будет проще и интереснее для вас просто посмотреть короткий фрагмент кода.
Пример чата вы можете легко установить, скачав исходники и сделав пару простых действий
Установка и запуск клиента
cd chat/client
npm install
npm start
Клиент будет доступен по адресу http://localhost:3000. Откройте сразу пару вкладок с клиентом, чтобы видеть "общение".
Установка и запуск провайдера (сервера)
cd chat/server
npm install
ts-node ./server.ts
Пакет ts-node, уверен, вам хорошо знаком, но если нет, то он позволяет запускать TS файлы. Если устанавливать не хочется, то просто скомпилируйте сервер, а затем запустите JS файл.
cd chat/server
npm run build
node ./build/server/server.js
Шо? Опять?!
Предвидя вопросы о том, на кой черт изобретать очередной велосипед, ведь вокруг столько уже отработанных решений, начиная от protobuf и заканчивая хардкорным joynr от BMW, я могу лишь сказать то, что мне это было интересно. Весь проект делался исключительно по личной инициативе без какой-либо поддержки, в свободное от работы время.
Именно поэтому ваши отзывы представляют для меня особую ценность. В попытке вас как-то замотивировать могу пообещать, что за каждую звездочку на github, я поглажу хомячка (которого мягко сказать недолюбливаю). За форк, уффф, почешу ему пузико… брррр.
Хомяк не мой, хомяк сына.
Кроме того, через пару недель проект пойдет на тестирование к моим бывшим коллегам (что я упоминал в начале поста и которых заинтересовало, то какой получилась alfa версия). Цель — отладка и обкатка на нескольких компонентах. Очень надеюсь, что заработает.
Ссылки и пакеты
Проект квартирует на двух репозитариях
- ceres исходники: ceres.provider, ceres.consumer и всех доступных на сегодня транспортов.
- ceres.protocol исходники генератора протокола
NPM доступны следующие пакеты
- ceres.protocol генератор протокола
- ceres.provider провайдер
- ceres.consumer клиент
- ceres.provider.node.longpoll транспорт для провайдера на базе long polling
- ceres.provider.node.ws транспорт для провайдера на базе Web Socket
- ceres.consumer.browser.longpoll транспорт для клиента на базе long polling
- ceres.consumer.browser.ws транспорт для клиента на базе Web Socket
Добра и света.