Как показывает практика, огромная часть проблем возникает не из-за решений самих по себе, а из-за того каким образом происходит общение между компонентами системы. Если в коммуникации между компонентами системы бардак, то, как не старайся хорошо писать отдельные компоненты, система в целом будет сбоить.


Осторожно! Внутри велосипед.


Проблематика или постановка задачи


Какое-то время назад случилось работать над проектом для компании, несущей в массы такие прелести как системы 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 протокол. Подключаем и используем:


image

Итак, протокол уже кое-что дает разработчику:


  • 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 доступны следующие пакеты



Добра и света.

Комментарии (0)