Как стать автором
Обновить

Логика в протоколе. Генерируем сетевое решение

Время на прочтение20 мин
Количество просмотров3.4K

Ниже разговор пойдет о проектировании и реализации сетевых приложений; о том как скрестить ужа с ежом rust с typescript; о том как тяжело жить без протокола и как же легко и хорошо с ним; о том как написать работающий чат за 10 минут на typescript/rust или не за 10, но всё равно быстро… В общем под катом долгий, местами нудный рассказ, а в конце даже небольшой интерактив. Всем кому нравятся слова: порядок, предсказуемость и протокол… прошу.


Проектирование любого сетевого приложения (или приложений, общающихся пусть и не через сеть, но по каким-либо иным каналам), начинается с обдумывания протокола. Ну должно начинаться… Даже если решение было принято за доли секунды и им стал банальный JSON - это уже протокол (как минимум его часть).

На самом деле - это важный этап разработки. Когда у нас есть клиент и есть сервер, ещё до их реализации следует определиться «как?» (транспорт) и «чем?» (протокол) они будут обмениваться. За обманчивой простотой кроется бездна багов и ошибок: неверный запрос может проскочить через валидатор и породить неверный ответ, а вы ищите где и от чего возник очередной жучок, проявляющий себе уже где-то на уровне UI.

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

Давайте попробуем спроектировать и написать банальное сетевое приложение - чат. Посмотрим какие проблемы могут возникнуть (очевидные и не очень) и что можно сделать, чтобы о них забыть и не вспоминать.

А использовать будет «генератор» протокола clibri, собственно ему (или ей) и посвящена данная статья. Суть проста — описываем данные, описываем логику и получаем кодовую базу - «почти» готовое к работе решение.

Что может быть проще?

Посмотрим на самый простой вариант: использование в качестве формата сообщений (коими будут обмениваться клиент и сервер) банального JSON. Ничего плохого не будет, просто часть проблем мы берём на себя, а часть перекладываем на трафик:

  • если мы говорим о web, то скорее всего говорим о typescript/javascript, а значит мы должны самостоятельно следить за типами данных, писать валидаторы и не допускать (ни в запросах, ни в ответах) неверных типов данных.

  • с одной стороны JSON остаётся human-readable, что безусловно удобно для отладки; с другой - это текст, а значит размер. Когда в системе «бегает» несколько сотен сообщений - это не проблема, но если сообщений миллионы, то каждый лишний байт, порождает избыточную нагрузку на трафик. Вы можете минимизировать длину имён полей, скажем использовать что-то вроде { «a»: …, «b»: …, … } вместо { «name»: …, «email»: …, … }, но от самих имён полей и всех скобок, запятых вы не избавитесь, а это всё байты, составляющие JSON строку. Конечно выручает компрессия, но опять же - мы лишь переводим проблему с уровня протокола, на уровень производительности.

  • не очевидное ограничение - это возможность передавать такие сообщения в потоке. Тут всё просто, чтобы корректно пропарсить JSON строку, вам нужна вся строка: не символом меньше, не символом больше. Таким образом, каждый приходящий пакет должен содержать только одно сообщение. Вы не можете создать скажем буфер, куда можно было бы кидать входящие байты и получать прочитанные полностью сообщения. Не выйдет, без добавления header, который как минимум содержал бы длину ожидаемого сообщения.

Именно поэтому существует множество протоколов, которые используют собственную логику по «упаковке» данных, оставаясь при этом строго типизированными. Грубо говоря, пакет с данными может иметь подобную структуру:

[u16            ][u8        ][u32         ]   
[message length ][type field][field value ]

Мы сразу видим сколько байт нам ждать, что бы спокойно и без нервов пропарсить сообщение; собственно это и есть header с одним единственным полем - длина сообщения. Дальше кодируем тип поля. Например 0 - это u8, 1 - u16, 2 - u32 и так далее. Зная тип данных, мы знаем сколько он «весит» и чтобы получить u32 мы прочитаем 4 следующих байта.

Давайте представим, как может выглядеть протокол нашего чата в формате clibri

Краткое описание синтаксиса протокола clibri

Clibri оперирует лишь тремя сущностями:

  • struct - структура (объект если хотите). Имеет строго типизированные поля.

  • enum - перечисление. Каждый элемент перечисления может включать в себя и значение. То есть enum в clibri практически эквивалентен enum в rust.

  • group - это как namespace в typescript или же mod в rust. Предназначено исключительно для группировки структур и перечислений, что делает протокол прозрачнее и яснее.

Clibri поддерживает следующие типы данных

Type

Array type

Length

Description

i8

i8[]

8-bit

Signed integer

i16

i16[]

16-bit

Signed integer

i32

i32[]

32-bit

Signed integer

i64

i64[]

64-bit

Signed integer

u8

u8[]

8-bit

Unsigned integer

u16

u16[]

16-bit

Unsigned integer

u32

u32[]

32-bit

Unsigned integer

u64

u64[]

64-bit

Unsigned integer

f32

f32[]

32-bit

Floating-point number

f64

f64[]

64-bit

Floating-point number

str

str[]

unlimited

String value

bool

bool[]

8-bit

Boolean value

Таким образом можно определить структуру и перечисление

# Простое перечисление (элементы не имеют значений)
enum UserRole {
    Admin;
    User;
}

struct Address {
    str email;
    # Используя знак ? можно указывать опциональные поля
    str address?;
    str phone?;
}

struct User {
    str nickname;
    # Ссылка на перечисление
    UserRole role;
    # Ссылка на структуру
    Address addr; 
}

struct Message {
		# Ссылка на структуру
    User author; 
    str field_str?;          
    u8 field_u8;          
    # Используя [] мы говорим о том, что поле является массивом
    u8[] field_array_u8;  
}

Как уже говорилось, перечисление может иметь и значение

enum IncomeMessage {
    # Опция Text будет содержать строку
    str Text;            
    # Опция Bytes будет содержать массив байт
    u8[] Bytes;           
}

С группами все просто - открываем группу и «кладем» туда все что хотим, включая другие (вложенные) группы.

group Messages {
    enum Content {
        str Text;
        u8[] Bytes;
    }

    struct Anonymous {
        Content message;
    }

    struct Authorized {
        str uuid;
        Content message;
    }
}

struct AllMessages {
    # Для указания пути к структуре, находящейся в группе, используем "." (точку)
    Messages.Anonymous[] anonymous;    
    Messages.Authorized[] authorized;

}

# Возможные роли наших пользователей
enum UserRole {
    Admin;
    User;
    Manager;
}

# В эту группу поместим события в системе
group Events {
    # Новый пользователь подключился
    struct UserConnected {
        str username;
        str uuid;
    }
    # Пользователь отключился
    struct UserDisconnected {
        str username;
        str uuid;
    }
    # Новое сообщение пришло в чат
    struct Message {
        u64 timestamp;
        str user;
        str message;
        str uuid;
    }
}

# Так выглядит запрос на добавление сообщения в чат
group Message {
    struct Request {
        str user;
        str message;
    }
    struct Accepted {
        str uuid;
    }
    struct Denied {
        str reason;
    }
    struct Err {
        str error;
    }
}

# Запрашиваем все сообщения в чате (например, для вновь подключенных пользователей)
group Messages {
    struct Message {
        u64 timestamp;
        str user;
        str uuid;
        str message;
    }
    struct Request { }
    struct Response {
        Message[] messages;
    }
    struct Err {
        str error;
    }
}

# Запрос на вход (пример простой, поэтому без паролей и хешей)
group UserLogin {
    struct Request {
        str username;
    }
    struct Accepted {
        str uuid;
    }
    struct Denied {
        str reason;
    }
    struct Err {
        str error;
    }
}

# Запрос списка всех пользователей в чате.
group Users {
    struct User {
        str name;
        str uuid;
    }
    struct Request { }
    struct Response {
        User[] users;
    }
    struct Err {
        str error;
    }
}

Наверное, вас уже немного смутило, наличие в протоколе структур Accepted и Denied. Мы привыкли, видеть отказ в авторизации в качестве ошибки. Но ошибка ли это в чистом виде? Если посмотреть с точки зрения данных: логин / пароль предоставлены, но войти не получается так как пользователь, например, не зарегистрирован. С данными все - ок, а значит это не ошибка протокола, а один из возможных вариантов ответа на запрос.

Ниже вы увидите, clibri стремится называть ошибкой - ошибки данных или обработки, то есть те события, в результате которых произошел некий сбой. Сейчас, на уровне протокола, это не очевидно, но чуть ниже вы всё увидите.

Итак, у нас есть протокол; хотя это скорее описание тех сообщений, которыми собираются обмениваться наши клиенты и сервер.

И вроде, применяя нормальные имена, схема уже более ли менее читается. Мы примерно представляем, что будет происходить в системе. Но лишь примерно…

Прежде чем двигаться дальше, давайте остановимся немного на имплементации протокола. По существу clibri - это генератор кодовой базы. Вы даёте clibri схему, указываете язык (на данный момент поддерживается typescript и rust) и clibri генерирует для вас модуль, который вы вольны встраивать в свое приложение. Давайте попробуем. Нам будет нужен пустой проект npm для web-клиента чата и пустой rust проект - для сервера (можно сделать сервер и на typescript - не проблема, просто для целей данной статьи, я стараюсь осветить реализацию на два языка).

Дерево проекта
.
├── consumer
│   ├── package.json
│   ├── src
│   │   └── index.ts
│   ├── tsconfig.json
│   └── tslint.json
├── producer
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── protocol
    └── protocol.prot

Скачиваем clibri (это просто консольная утилита - никаких установок, настроек не требуется), сохраняем выше описанный протокол в файл и генерируем.

clibri --src ./protocol/protocol.prot -rs ./producer/src/protocol.rs -ts ./consumer/src/protocol.ts -o --em
# --src путь к файлу протокола
# -rs путь куда следует сохранить модуль для rust
# -ts путь куда следует сохранить модуль для typescript

Clibri сгенерирует имплементацию протокола для typescript и для rust, включая описание всех наших данных и, главное, буфер, который мы можем использовать для чтения наших сообщений.

Например, для typescript

import * as Protocol from "./protocol";

// Наполним буфер тестовыми данным. Для этого clibri добавляет
// метод defaults(), что весьма удобно для тестов.
// Метод pack() упаковывает сообщение (добавляет header)
// так что бы его можно было прочитать из буфера
const buffer = Buffer.concat([
    new Uint8Array(Protocol.Messages.Message.defaults().pack(1)),
    new Uint8Array(Protocol.Users.User.defaults().pack(2)),
]);

// Создаем reader
const reader: Protocol.BufferReaderMessages =
    new Protocol.BufferReaderMessages();

// Бросаем наши тестовые байты в reader
reader.chunk(buffer);

do {
    // Читаем сообщения, пока все не будут прочтены
    const received:
        | Protocol.IAvailableMessage
        | undefined = reader.next();
    if (received === undefined) {
        // Больше сообщений нет
        break;
    }
    if (received.msg.Messages.Message !== undefined) {
        console.log(`Message has been gotten`);
    } else if (received.msg.Users.User !== undefined) {
        console.log(`User has been gotten`);
    }
} while (true);

И для rust

pub mod protocol;

use protocol::{PackingStruct, StructDecode, StructEncode};

fn reading() -> Result<(), String> {
    // Создаем пару тестовых сообщений
    let mut message = protocol::Messages::Message::defaults();
    let mut user = protocol::Users::User::defaults();
  
    // Создаем reader
    let mut reader = protocol::Buffer::new();

    // Создаем временный буфер с тестовыми данными
    let buffer: Vec<u8> = [
        message.pack(1, None).map_err(|e| e.to_string())?,
        user.pack(2, None).map_err(|e| e.to_string())?,
    ]
    .concat();

    // Кидаем байты в ридер
    reader
        .chunk(&buffer, None)
        .map_err(|e| format!("Fail to add data: {:?}", e))?;

    // Читаем сообщения
    while let Some(msg) = reader.next() {
        match msg.msg {
            protocol::AvailableMessages::Messages(
                protocol::Messages::AvailableMessages::Message(msg),
            ) => {
                println!("Получено Message {:?}", msg);
            }
            protocol::AvailableMessages::Users(
                protocol::Users::AvailableMessages::User(msg),
            ) => {
                println!("Получено User {:?}", msg);
            }
            _ => {}
        }
    }
    Ok(())
}

fn main() {
    reading().expect("Oops!");
}

Помимо тела сообщения, буфер также предоставляет заголовок, который содержит некоторые полезные данные, например sequence, что может быть использовано для связки запрос - ответ (это та самая цифра, которая указывалась как аргумент в методе pack(sequence: number)).

Итак, здесь уже можно было бы и закончить. Увы, многие здесь и заканчивают, приступая к реализации. Протокол вроде есть, буфер есть - почему бы и нет.

Это всё так, но нет главного - логики. Да, мы видим какие сообщения будут передаваться; благодаря «говорящим» именам, мы предполагаем (не знаем) в каких случаях и какие сообщения будут отправлены. То есть если вы дадите свой протокол другому разработчику, вы никак не можете быть уверенным, что его представление о логике программы будет эквивалентно вашему.

Например, мы хотели бы чтобы в чат оправлялось сообщение «Пользователь ______ подключился к чату». То есть при событии UserConnected мы хотим отправлять Message в чат и оповещать всех участников об этом. Глядя на протокол, эту логику мы не увидим.

Как мы можем изменить нашу схему, что бы «покрыть» событие оправки сообщения в чат на подключение/отключение пользователя? На самом деле - никак. Более того - это даже «вредно». Приведенная выше схема описывает данные, а не логику. И кроме данных она описывать ничего и не должна.

Для описания логики clibri предоставляет механизм workflow. Давайте на примере.

# На запрос авторизации (UserLogin.Request) у нас может быть два ответа:
# одобрено (Accept) и отказано (Deny). А может случится и ошибка, в случае
# которой мы отправим клиенту UserLogin.Err.
#
# - В случае одобрения (Accept), мы отправляем клиенту ответ
#   UserLogin.Accepted, а также отправляем другим пользователям сообщения
#   Events.UserConnected и Events.Message
#
# - в случает отказа (Deny), отправляем UserLogin.Denied пользователю
#
UserLogin.Request !UserLogin.Err {
    (Accept    > UserLogin.Accepted) > Events.UserConnected;
                                     > Events.Message;
    (Deny      > UserLogin.Denied);
}

# На попытку получить спосок пользователей, отправляем его с Users.Response, либо
# в случае ошибки отвечаем  Users.Err
Users.Request !Users.Err {
    (Users.Response);
}

Message.Request !Message.Err {
    (Accept    > Message.Accepted) > Events.Message;
    (Deny      > Message.Denied);
}

Messages.Request !Messages.Err {
    (Messages.Response);
}

# Если кто-то отключился (событие disconnected) отправляем подключенным
# пользователям сообщения  Events.Message и Events.UserDisconnected.
# Знак ? означает, что отправка помеченного сообщения опциональная.  
@disconnected {
    > Events.Message?;
    > Events.UserDisconnected;
}

Итак, мы добавили совсем незначительное количество кода, но сумели исчерпывающе описать логику работы нашего чата. Теперь мы видим когда, кому и какие отправляются сообщения.

Кратоко о синтаксисе clibri workflow

Запрос/ответ

Если запрос может породить несколько вариантов ответа, то необходимо определить каждый из них. В рамках clibri это называется - «заключение» (conclusion). Каждое заключение должно определятся своим именем (ConclusionA, ConclusionB) и ответом для клиента (ResponseA, ResponseB). Кроме того, за ответом может следовать и необходимость отправки сообщений другим клиентам (broadcasting), для чего мы можем определить такие сообщения после описания заключения (BroadcastA, BroadcastB).

Request !Error {
    (ConclusionA    > ResponseA) > BroadcastA;
                                 > BroadcastB;
    (ConclusionB    > ResponseB);
}

Все ссылки (Request, ResponseA, ResponseB, BroadcastA, BroadcastB) являются ссылками на данные протокола. То есть схема workflow является расширением протокола, а не самостоятельной сущностью.

Самый простой запрос (где есть только запрос, ответ и ошибка) будет выглядеть так:

Request !Error {
    (Response);
}

События

На стороне сервера могут происходить и некоторые события, например connected (клиент подключился), disconnected (клиент отключился). На случай этих событий, мы так же можем указать, какие сообщения должны быть отправлены клиентам.

# Чтобы определить событие, используется символ @
@connected {
    > BroadcastStructureA;
    ...
    > BroadcastStructureB;
}

@disconnected {
    > BroadcastStructureA;
    ...
    > BroadcastStructureB;
}

@connected и @disconnected - это два события, относящиеся к системным, то есть всегда присутствующие в системе. Но вы также можете определить и собственные события. Для чего, конечно, вы должны создать объект в протоколе.

Добавляем в протокол объект (структуру) события:

group ServerEvents {
    struct UserKickOff {
        str reason?;
        str uuid;
    }
}

Добавляем в workflow описание данного события:

@ServerEvents.UserKickOff {
    > Events.Message;
    > Events.UserDisconnected;
}

Теперь на стороне сервера мы можем вызвать событие UserKickOff, которое приведет к отправке другим пользователям Message и UserDisconnected.

Маячки

С одной стороны для сервера мы можем определять события, то есть заставить сервер отправлять данные пользователям без входящего запроса. С другой стороны и клиент должен обладать такой возможностью - просто уведомить сервер о чем-либо без ожидания какого-либо ответа. Для этого мы можем определить @beacons.

Добавим в протокол пару новых структур

group Beacons {
    struct LikeUser {
        str uuid;
    }
    struct LikeMessage {
        str uuid;
    }
}

Теперь в workflow укажем их как маячки

@beacons {
    > Beacons.LikeUser;
    > Beacons.LikeMessage;
}

Таким образом мы сообщаем системе, что клиент помимо запросов может отправлять на сервер сообщения LikeUser, LikeMessage без ожидания какого-либо ответа.

Необходимо отметить, что клиент в любом случае получит подтверждение о доставке сообщения.

Теперь, когда мы располагаем информацией и о данных в системе, и о логике их обмена, мы можем это визуализировать.

clibri --src ./protocol/protocol.prot --workflow ./protocol/protocol.workflow --puml ./plantuml.puml
# --src путь к файлу протокола
# --workflow путь к схеме workflow
# --puml путь для файла диаграммы

Можно воспользоваться рендером puml здесь.

Теперь то ваш коллега точно будет понимать и логику, и данные, так как понимаете их вы, и так как это определено решением.

Визуальная схема - это хорошо, но куда важнее то, что мы можем сгенерировать кодовую базу для нашего решения, а не просто модуль протокола.

Прежде чем это сделать нам нужно закрыть ещё два момента. Во-первых, нужно определиться как клиент будет себя идентифицировать и как сервер будет идентифицировать клиентов (часто это не одно и тоже).

Добавим в наш протокол пару структур:

# Пример идентификации для чата
group Identification {
    # Эта структура будет использоваться клиентом для идентификации самого себя
    struct SelfKey {
        str uuid?;
        u64 id?;
        str location?;
    }
    
    # Эта структура будет использоваться сервером для идентификации клиента
    # Естественно на стороне сервера клиент будет ассоциирован и с AssignedKey
    # и c SelfKey
    struct AssignedKey {
        str uuid?;
        bool auth?;
    }
}

Тут важно оговориться, SelfKey может определять и изменять только сам клиент, равно как и AssignedKey может модифицировать только сервер. При этом сервер ассоциирует клиента и по SelfKey и AssignedKey.

Во-вторых добавим в схему workflow секцию настроек, чтобы сообщить clibri недостающие данные:

# Конфигурация схемы workflow.
# Без данной секции генерация кодовой базы просто невозможна
&config {
    # Ссылка на self-key клиента
    SelfKey: Identification.SelfKey;
    # Ссылка на assigned-key клиента
    AssignedKey: Identification.AssignedKey;
    # Целевой язк для сервера
    Producer: rust;
    # Целевой язык для клиента
    Consumer: typescript;
}

Теперь всё готово к генерации кодовой базы.

clibri --src ./protocol/protocol.prot -wf ./protocol/protocol.workflow -cd ./consumer/src/consumer/ -pd ./producer/src/producer/
# --src путь к файлу протокола
# -wf путь к схеме workflow
# -cd путь для кодовой базы клиента
# -pd путь для кодовой базы сервера

Итак до генерации кодовой базы наше решение выглядело так

├── consumer
│   ├── package.json
│   ├── src
│   │   └── index.ts
│   ├── tsconfig.json
│   └── tslint.json
├── producer
│   ├── Cargo.toml
│   └── src
│       └── main.rs
└── protocol
    ├── protocol.prot
    └── protocol.workflow

А после генерации кодовой базы уже так:

.
├── consumer
│   ├── package.json
│   ├── src
│   │   ├── consumer
│   │   │   ├── beacons # Отправители маичков
│   │   │   │   ├── beacons.likemessage.ts
│   │   │   │   └── beacons.likeuser.ts
│   │   │   ├── index.ts
│   │   │   ├── interfaces
│   │   │   │   └── request.ts
│   │   │   ├── options.ts
│   │   │   ├── protocol
│   │   │   │   └── protocol.ts
│   │   │   └── requests # Отправители запросов
│   │   │       ├── message.request.ts
│   │   │       ├── messages.request.ts
│   │   │       ├── userlogin.request.ts
│   │   │       └── users.request.ts
│   │   └── index.ts
│   ├── tsconfig.json
│   └── tslint.json
├── producer
│   ├── Cargo.toml
│   └── src
│       ├── main.rs
│       └── producer
│           ├── beacons # Обработчики маичков
│           │   ├── beacons_likemessage.rs
│           │   ├── beacons_likeuser.rs
│           │   └── mod.rs
│           ├── context.rs # Контекст нашего сервера
│           ├── events # Обработчики сообщений
│           │   ├── connected.rs
│           │   ├── disconnected.rs
│           │   ├── error.rs
│           │   ├── mod.rs
│           │   ├── ready.rs
│           │   ├── serverevents_useralert.rs
│           │   ├── serverevents_userkickoff.rs
│           │   └── shutdown.rs
│           ├── implementation
│           │   └── ... # Здесь располагается имплементация сервера, изменение которой в большинстве случаев не требуется
│           ├── mod.rs
│           └── responses # Обработчики запросов от клиента
│               ├── message_request.rs
│               ├── messages_request.rs
│               ├── mod.rs
│               ├── userlogin_request.rs
│               └── users_request.rs
└── protocol
    ├── protocol.prot
    └── protocol.workflow

Итак clibri сгенерировала довольно большую кодовую базу. При кажущейся сложности на самом деле наше приложение уже почти готово. Все что нам осталось - это добавить логику в обработчики.

Начнем с клиента.

Несмотря на фрагменты кода ниже, моей целью не является полное описание API выходного кода. Я лишь хочу обозначит направление в котором работает clibri и то каким образом облегчает жизнь разработчику. Полное и детальное описание API вы найдете здесь.

Нам стал доступен класс Consumer, класс с помощью которого мы можем создать нашего клиента. Взглянем поближе.

class Consumer {
    // Subject для события connected. Срабатывает при подключении нашего клиента к серверу
    // Важно, подключен - не значит готов, так как клиент и сервер после подключения
    // «представляются» друг другу.  
    public readonly connected: Subject<void>;
    // Subject для события ready. Срабатывает после connected тогда когда клиентам
    // был зарегистрирована сервером и полностью готов к работе.
    public readonly ready: Subject<string>;
    // Subject для события disconnected. Срабатывает при обрыве связи с сервером (
    // независимо от причин)
    public readonly disconnected: Subject<void>;
    // Subject для события ошибки. Срабатывает при любой ошибке на уровне клиента.
    public readonly error: Subject<ExtError.TError>;
    // Subjects событий, определённых протоколом. Это события broadcast
    public readonly broadcast: {         
        EventsUserConnected: Subject<Protocol.Events.UserConnected>,
        EventsMessage: Subject<Protocol.Events.Message>,
        EventsUserDisconnected: Subject<Protocol.Events.UserDisconnected>,
    };
    // Полная остановка клиента с его отключением от сервера
    public destroy(): Promise<void>;
}

Для отправки запросов мы можем использовать специально назначенные классы. Например, запрос авторизации:

const login: UserLoginRequest = new UserLoginRequest({
    username: username,
});

login
    .accept((response: Protocol.UserLogin.Accepted) => {
        // Обработка заключения Accept
    })
    .deny((response: Protocol.UserLogin.Denied) => {
        // Обработка заключения Deny
    })
    .err((response: Protocol.UserLogin.Err) => {
        // Обработка ошибки
    });

login.send().catch((err: Error) => {
    // Здесь мы будем если что-то пошло не так с отправкой
});

Как вы видите, заключения (conclusions) определенные в нашей схеме, здесь представляют собой именованные обработчики. Хотя если такая форма вам не привычна, то можно и по старинке:

const login: UserLoginRequest = new UserLoginRequest({
    username: username,
});

login.send()
    .then((
        response:  
            Protocol.UserLogin.Accepted |  
            Protocol.UserLogin.Denied |  
            Protocol.UserLogin.Err) => {
        // Обработка ответа
    })
    .catch((err: Error) => {
        // Здесь мы будем если что-то пошло не так с отправкой
    });

Аналогичным образом мы можем отправлять и маячки

const like = new BeaconsLikeMessage(
    Protocol.Beacons.LikeMessage.defaults()
);

like.send().then(() => {
    // Мы здесь, если маячок был успешно получен сервером
}).catch((err: Error) => {
    // Здесь мы будем если что-то пошло не так с отправкой
});

Ну и давайте посмотрим как запустить клиент:

// Создаем транспорт для нашего клиента
const connection = new Connection(`ws://127.0.0.1:8080`);

// Создаем и запускаем клиент
const consumer = new Consumer(connection, {
    id: BigInt(123),
    uuid: "Some UUID",
    location: "London",
});

// Подписываемся на входящие broadcast сообщения
const subscriptions: { [key: string]: Subscription } = {};
subscriptions.onConnected = consumer.connected.subscribe(onConnected);
subscriptions.onDisconnected = consumer.disconnected.subscribe(onDisconnected);
subscriptions.onReady = consumer.ready.subscribe(onReady);

// Где-то ниже наши обработчики
function onConnected(…) { … }
function onDisconnected(…) { … }
function onReady(…) { … }

Теперь сервер

Для каждого запроса clibri создаст специальный обработчик, который будет отличаться типом возвращаемых данных. Так, например, если заключение (conclusion) подразумевает ещё и broadcast, то будет требоваться и соответствующий возврат из обработчика. Ниже обработчик запроса на добавление нового сообщения.

use super::{identification, producer::Control, protocol, scope::Scope, Context};
use clibri::server;

type BroadcastEventsMessage = (Vec<Uuid>, protocol::Events::Message);

// Response has a couple of possible responses
pub enum Response {
    // Response with broadcasting
    Accept((protocol::Message::Accepted, BroadcastEventsMessage)),
    // Response without broadcasting
    Deny(protocol::Message::Denied),
}

#[allow(unused_variables)]
pub async fn response<E: server::Error, C: server::Control<E>>(
    request: &protocol::Message::Request,
    scope: &mut Scope<'_, E, C>,
) -> Result<Response, protocol::Message::Err> {
    Err(protocol::Message::Err { error: String::from("Handler isn't implemented yet") })
}

Тут следует упомянуть об аргументе Scope. Через данный объект вы получаете доступ к довольно важным данным:

Field / Method

Access

Description

context

scope.context

Доступ к контексту сервера. Контекст - это объект, который будет доступен в рамках каждого запроса и события. Пожалуй, это самое удобное место для реализации доступа к базам данных и прочим интерфейсам.

control

scope.control

API сервера.

identification

scope.identification

Идентификация текущего клиента. Включает его UUID, self-key и assigned-key

filter

scope.filter

Объект фильтра. С помощью фильтра вы можете получить UUID других подключенных клиентов. Это важно, например для отправки broadcast сообщений.

deferred

scope.deferred(&mut self, cb: Pin<Box<dyn Future<Output = ()>>>)

Фактически это отложенное задание. Дело в том, что после возврата из обработчика сервер отправит клиенту ответ и другим клиентам broadcast сообщения (если это было предусмотрено). Однако иногда нам нужно сделать ещё что-то, но только после того, как сервер полностью закончил свою работу по текущему запросу. Именно для этого мы и можем использовать deferred - чтобы выполнить что-то по окончании обработки запроса.

Обработчик простого запроса (без broadcast) выглядит и того проще - просто возвращаем ответ, который следует отправить клиенту. Обработчик входящего запроса на получение списка пользователей.

use super::{identification, producer::Control, protocol, scope::Scope, Context};
use clibri::server;

#[allow(unused_variables)]
pub async fn response<E: server::Error, C: server::Control<E>>(
    request: &protocol::Users::Request,
    scope: &mut Scope<'_, E, C>,
) -> Result<protocol::Users::Response, protocol::Users::Err> {
    Err(protocol::Users::Err { error: String::from("Handler isn't implemented yet") })
}

Обработчики маячков имеют схожую структуру, разве что не возвращают данных. Обработчик маячка LikeMessage

use super::{identification, producer::Control, protocol, Context, scope::Scope};
use clibri::server;

#[allow(unused_variables)]
pub async fn emit<E: server::Error, C: server::Control<E>>(
    beacon: &protocol::Beacons::LikeMessage,
    scope: &mut Scope<'_, E, C>,
) -> Result<(), String> {
    println!("Handler for protocol::Beacons::LikeMessage isn't implemented");
    Ok(())
}

В том же ключе реализованы и обработчики событий. Обработчик события UserKickOff

use super::{identification, producer::Control, protocol, Context, scope::AnonymousScope};
use clibri::server;
use uuid::Uuid;

type BroadcastEventsMessage = (Vec<Uuid>, protocol::Events::Message);
type BroadcastEventsUserDisconnected = Option<(Vec<Uuid>, protocol::Events::UserDisconnected)>;

#[allow(unused_variables)]
pub async fn emit<E: server::Error, C: server::Control<E>>(
    event: protocol::ServerEvents::UserKickOff,
    scope: &mut AnonymousScope<'_, E, C>,
) -> Result<(BroadcastEventsMessage, BroadcastEventsUserDisconnected), String> {
    panic!("Handler for protocol::ServerEvents:: UserKickOff isn't implemented");
}

Обработчики системных событий не имеют объекта события, например, обработчик события connected

use super::{identification, producer::Control, scope::Scope, Context};
use clibri::server;

#[allow(unused_variables)]
pub async fn emit<E: server::Error, C: server::Control<E>>(
    scope: &mut Scope<'_, E, C>,
) -> Result<(), String> {
    Ok(())
}

Что ж, запускаем сервер

mod producer;

use clibri_transport_server::{
    options::{Listener, Options},
    server::Server,
};
use std::net::SocketAddr;

#[tokio::main]
async fn main() -> Result<(), String> {

    // Создаем транспорт
    let socket_addr = "127.0.0.1:8080".parse::<SocketAddr>().unwrap();
    let server = Server::new(Options {
        listener: Listener::Direct(socket_addr),
    });

    // Создаем контекст (будет доступен во всех обработчиках)
    let context = producer::Context::new();

    // Запускаем сервер
    producer::run(server, producer::Options::new(), context)
        .await
        .map_err(|e| e.to_string())?;

    // Game is over
    Ok(())
}

Итак, clibri подготовила кодовую базу и для клиента и для сервера. Нам как разработчикам остается добавить даже не сами обработчики, а реализовать их функционал. То есть clibri берет на себя:

  1. Контроль над валидностью данных, передаваемых между сервером и клиентами

  2. Получение и отправку данных

  3. Кодирование/декодирование данных

  4. Соблюдение логики, предусмотренной в схеме workflow

  5. Создание черновика приложения

Например в рамках рассматриваемого примера (чат) нам не придется отправлять никаких сообщений напрямую («сырыми» байтами) ни со стороны клиента, ни со стороны сервера. Мы оперируем возвратами из обработчиков, либо приготовленными для нас абстракциями (например, мы создаем класс запроса на сервер и вызываем «на нём» метод send).

Транспорт

Понятное дело, что данные нужно как-то отправлять. Решение которое генерирует clibri опирается на транспорт, соответсвующий интерфейсам, описанным в библиотеке clibri. Иными словами, вы сами можете разработать любой транспорт на свой вкус и цвет.

Однако «из коробки» clibri даёт транспорт на базе websocket. Имеются соответсвующие библиотеки: клиент и сервер на rust (crates), клиент (для node и для браузера) и сервер для typescript (npm библиотеки).

Повторюсь, я не ставлю своей задачей дать полное описание API в рамках данной статьи, а хочу лишь поделится вами идеями, которые стоят за clibri. Полную документацию вы можете найти здесь, а примеры здесь.

Кстати о примерах, вот как запустить чат локально:

git clone git@github.com:DmitryAstafyev/clibri.git
cd clibri/examples
# Генерируем сервер на rust и клиента на typescript
sh ./gen-rs-ts.sh
# запускаем сервер
./producer/rust/target/release/clibri_producer_rs

Для запуска клиента, просто открываем в браузере examples/consumer/typescript/src/index.html

Если вы уже заглянули на сайт документации, то наверняка заметили плашку «alpha». Это, конечно же не случайно. Такого рода проект не может сразу «прыгнуть» в production и наверняка есть где-то затаившееся баги, несмотря на неплохое покрытие тестами (смотрите gitactions).

Кстати о тестах, можете побаловаться и сами если хотите (например сравнив производительность сервера на node и rust).

git clone git@github.com:DmitryAstafyev/clibri.git
# Запускаем «тяжелый» тест - сервер
cd tests/workflow
sh ./run-producer-rs-heavy.sh
# В другой консоле запускаем «тяжелый» тест - клиент
cd tests/workflow
sh ./run-consumer-rs-heavy.sh

Можете выставить параметры самостоятельно

# Для сервера
exec ./producer/rust/target/release/clibri_producer_rs --connections=120000 --multiple=1000
  • --connections=number - число ожидаемых подключений

  • --multiple=number - число подключений на один порт

# Для клиента
exec ./consumer/rust/target/release/clibri_client_rs --connections=10000 --timeout=600000 --multiple -threads=12
  • --connections=number - число клиентов на один процесс

  • --threads=number - число процессов; при 10000 подключений на 1 процесс и 6 процессах, будет создано 60000 клиентов

  • --multiple - запрашивать порт подключения на сервере перед подключением

  • --timeout=number - таймаут на каждый отдельный запрос/ответ; лучше выставить побольше, так как сами клиенты запускаются на той же машине что и тест; указывается в ms

Одна ремарка. Как вы понимаете на один порт особо много клиентов не подключить. Поэтому rust сервер умеет принимать подключения на разные порты, чтобы можно было выйти за лимиты. Клиент до подключения спрашивает свободный порт и уже к нему подключается.

На весьма скромной машине (i7, 16Gb RAM, ArchLinux) мне удалось запустить тест на 200 000 клиентов, где одновременно подключёнными было около 110 тыс. клиентов. Но это конечно необъективная оценка, так как и сервер, и клиенты запускаются на одной и той же машине (и кстати клиенты «жрут» ресурсов даже больше).

Суть теста

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

Между тем, как уже было отмеченно - это alpha. И я бы, конечно, хотел бы выйти из alpha на production. Но сделать это без вашей обратной связи решительно невозможно. Посему, если высказанные здесь идеи вам интересны, или же вы просто хотите дать проекту шанс на развитие - кликните на звездочку на github. При этом у меня уже есть определенные планы, которые как раз связанны с вашей обратной связью.

Поясню. С каждой новой звездой или форком, deadline`ы пересчитываются и даты улучшений становятся ближе. Можете проверить и сами ;) Кстати, это и есть обещанный интерактив.

Благодарю всех, кто осилил столь длинную тягомотину и дотянул до конца - спасибо!

Добра и света вам в новом году, несмотря на то что мир, кажется, немного съехал )

Ссылки
Теги:
Хабы:
Всего голосов 7: ↑6 и ↓1+5
Комментарии11

Публикации

Истории

Работа

Ближайшие события

Конференция «Я.Железо»
Дата18 мая
Время14:00 – 23:59
Место
МоскваОнлайн
Антиконференция X5 Future Night
Дата30 мая
Время11:00 – 23:00
Место
Онлайн
Конференция «IT IS CONF 2024»
Дата20 июня
Время09:00 – 19:00
Место
Екатеринбург