Как показывает практика, огромная часть проблем возникает не из-за решений самих по себе, а из-за того каким образом происходит общение между компонентами системы. Если в коммуникации между компонентами системы бардак, то, как не старайся хорошо писать отдельные компоненты, система в целом будет сбоить.
Осторожно! Внутри велосипед.
Проблематика или постановка задачи
Какое-то время назад случилось работать над проектом для компании, несущей в массы такие прелести как системы 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
Добра и света.
