Упорядочить хаос

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


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


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


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



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

    Share post

    Comments 0

    Only users with full accounts can post comments. Log in, please.