Однажды передо мной встала довольно интересная задача: обеспечить взаимодействие стороннего веб-приложения - и набора сервисов, имеющих gRPC интерфейс.

Нужно было проводить обработку данных от веб-приложения через конвейеры AI-агентов, собирать конечный результат, и отдавать его приложению.

А также, что немаловажно, обеспечить наглядность мониторинга и управления всем хозяйством.

Мне потребовалось поддержать три типа интерфейсов. Во-первых,RestAPI для веб-приложения. Во-вторых, gRPC для взаимодействия с сервисами. В-третьих, HTTP + Websocket для интерактивного мониторинга и управления.

Конечно, при этом хотелось сэкономить на инфраструктуре.

TL;DR

Статья посвящена разработанному мной opensource-продукту OswServer, доступному всем желающим под свободной лицензией MIT.

Продукт был создан под конкретную, очень практическую задачу - получить универсальный gRPC + HTTP + Websocket сервер на базе Openswoole.

Здесь я рассказываю как OswServer устроен, и как им пользоваться.

Чтобы объяснить это максимально внятно, мне потребуется совсем кратко напомнить об азах: как работает PHP и что такое Openswoole.

Общая информация

Что такое Openswoole

Привычное применение PHP - это какой-нибудь веб-сервер (типа nginx) на фронте, который, при необходимости выполнить PHP-скрипт, делает запрос в сервис php-fpm, получает ответ,
и отдает его клиенту. Сам скрипт при этом внутри php-fpm запускается "с нуля", проводит все инициализации, особенно заметные при использовании "взрослых" фреймворков
типа Symfony или Laravel, делает необходимую работу, и "умирает". Конечно, после "смерти" скрипта его код обычно остается в байткод-кэше, и повторная компиляция
как правило уже не требуется - но это не снимает необходимости повторного выполнения всех инициализаций при новом вызове.

Приложение на Java, например, работает иначе: приложение запускается, инициализируется, и живет в памяти сервера долгое время, обрабатывая входящие запросы путем порождения
дочерних процессов-обработчиков.

Можно ли писать на PHP "долгоживущие" приложения по типу Java? Конечно можно. Это было можно делать еще в 2003 году, когда я перешел в веб-разработку, и занялся изучением PHP. Правда, в те времена это занятие было осложнено утечками памяти в PHP и его расширениях, а также блокировками, не позволявшими организовать многопоточную обработку. Нормальная параллельная обработка в PHP тех времен была возможна только на уровне процессов, но не потоков. И требовала от разработчика очень большой аккуратности.

Этот недостаток осознавался многими, и было несколько подходов к этому снаряду. На данный момент можно сказать, что на слуху две истории успеха: это Swoole/Openswoole и RoadRunner. Мне ближе проект Openswoole, поэтому мой рассказ о нем, и мой продукт OswServer - тоже базируется на нем.

Как работает Openswoole

Вы пишете php-скрипт. Называете его, например, "server.php". И запускаете его один раз обычном образом из командной строки: php server.php

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

Теперь ваш скрипт server.php живет "вечно", а обработка запросов к вашему скрипту производится объектом-сервером. Этот объект, в ответ на определенные события,
порождает отдельные потоки исполнения (корутины), передает им запрос, получает ответ, и возвращает его клиенту.

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

Зачем нужен дополнительный продукт

В Openswoole существует базовый продукт - TCP/UDP server, и несколько производных от него - HTTP server, Websocket server, GRPC server, MQTT server.

Каждый из этих специализированных серверов наследован от TCP/UDP server, и добавляет к нему свои "нашлепки", для облегчения обработки соответствующих протоколов.

Так что если вы не мечтаете вручную поддерживать HTTP-протокол поверх TCP/UDP - вы берете HTTP server. Если не мечтаете вручную разбирать upgrade-запросы и фреймы, а также поддерживать жизненный цикл клиентских соединений - берете Websocket server, и т.д.

Вопрос обслуживания запросов на несколько портов решается в Openswoole Server путем добавления портов в список прослушивания на одном объекте-сервере. Для этого служит метод addlistener().

Проблема же заключается в том, что вы не можете комбинировать эти сервера в рамках одного php-скрипта. Когда вы создаете объект-сервер любого типа - вы больше не можете
создать другой объект-сервер. Ни того же, ни другого типа. Это связано с базовой реализацией модели событий (Event Loop API) в Openswoole.

Как же быть, если нам нужно, как описано в начале статьи, обслуживать сразу HTTP, Websocket, и gRPC протоколы? И при этом нам не хочется плодить лишние процессы Openswoole? Вот для этого мне и пришлось по-быстрому создать новый продукт: OswServer.

Как устроен OswServer

Поскольку Websocket протокол опирается на HTTP, в реализации Websocket server поддерживаются все методы HTTP server. Поэтому если вам нужен только HTTP + Websocket,
вам совсем необязательно брать именно OswServer, берите классический Openswoole Websocket server.

Однако, если вам ко всему прочему вам нужен еще и gRPC (который тоже базируется на HTTP, как и Websocket) - то OswServer обеспечит вам эту универсальность.

Сам OswServer базируется на коде Openswoole GRPC server, в коде которого пришлось сделать ряд доработок.

Дело в том, что Openswoole GRPC server наследован от базового TCP/UDP server, так что просто не содержит методов, позволяющих обрабатывать HTTP-запросы и Websocket-протоколы. Потребовалось не только наследовать OswServer от Websocket server, но и реализовать разделение обработки HTTP и GRPC запросов, а также добавить ряд методов, которые повысили удобство разработки лично для меня. Надеюсь, эти нововведения будут полезны и другим. А если нет - код открыт, всегда можно сделать мерж-реквест или форк.

Базовое использование OswServer

Модельная задача - чат между пользователями разных серверов

Часто, когда хотят проиллюстрировать применение Websockets, приводят пример реализации чата между двумя пользователями одного сервера.

Следуя этой традиции, я покажу применение OswServer для реализации чата между пользователями разных серверов, где взаимодействие между серверами будет опираться на gRPC.

Итак, пусть, например, у нас есть пользователи Алиса и Боб. У каждого из них есть собственный выделенный сервер. Пользователь обращается к корневому URL сервера,
и получает клиентскую HTML-страницу, которая содержит все необходимое для обмена сообщениями с другим пользователем.

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

При обрыве соединения с сервером Боба, Алиса должна получать соответствующие уведомления при попытке отправить что-нибудь Бобу.

При обрыве соединения между сервером Боба и его клиентской страницей - Алиса также должна получать уведомление о том, что ее сообщение не доставлено до адресата.

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

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

Описание демо-стенда

Демо-стенд будем разворачивать в докере. Создадим файл docker-compose.yml, в нем опишем проект oswserver, и нужный нам набор сервисов.

Нам понадобятся два сервера, для Алисы и Боба (назовем их osw1 и osw2). Эти сервера будут иметь общую приватную сеть 172.27.1.0/24, в которой каждому из них будет в явном виде присвоен IP.

Адрес корреспондента (peer) будет для каждого из этих серверов указан в его переменных среды.

Каждый из серверов будет принимать HTTP и Websocket-запросы на порт 8080, а gRPC-запросы - на порт 9501. Валидность портов для запросов будет контролироваться, HTTP-запросы на порт, отличный от 8080 будут отклоняться, как и gRPC-запросы на порт, отличный от 9501. С запросами Websocket это cделать не так просто, поэтому здесь я не буду контролировать порт, чтобы чрезмерно не усложнять пример.

docker-compose.yml

Я добавил в проект сервис protoc, представляющий собой gRPC-компилятор - он делает набор классов PHP из .proto-файлов, описывающих протокол gRPC.

name: oswserver
services:
    protoc:
        container_name: protoc_osw
        image: openswoole/protoc
        volumes:
            - ./grpc:/app
    osw1:
        container_name: osw1
        image: openswoole/swoole:latest
        entrypoint:
            - php
            - /app/server.php
        ports:
            - "12080:8080"
        volumes:
            - ./host:/app
        environment:
            PEER : "172.27.1.11"
        networks:
            world:
            backend:
                ipv4_address: 172.27.1.12
    osw2:
        container_name: osw2
        image: openswoole/swoole:latest
        entrypoint:
            - php
            - /app/server.php
        ports:
            - "11080:8080"
        volumes:
            - ./host:/app
        environment:
            PEER : "172.27.1.12"
        networks:
            world:
            backend:
                ipv4_address: 172.27.1.11
networks:
    world:
        driver: bridge
        driver_opts:
            com.docker.network.bridge.host_binding_ipv4: "127.0.0.1"
    backend:
        internal: true
        ipam:
            driver: default
            config:
                - subnet: "172.27.1.0/24"

Общий план демонстрации

  1. Поддерживаем gRPC протокол между серверами.

    1. Описываем протокол.

    2. Пишем реализацию gRPC.

  2. Поддерживаем Websocket-обмен между клиентом и сервером.

    1. Описываем протокол.

    2. Создаем клиентскую часть.

  3. Создаем и инициализируем сервер

    1. Создаем объект-сервер на базе класса OswServer.

    2. Реализуем методы для обработки HTTP и websocket-запросов.

    3. Реализуем метод для обработки gRPC-запросов.

    4. Реализуем код отправки сообщений с сервера в клиентский Websocket.

  4. Запускаем и играем.

    1. Инсталляция и запуск.

    2. Нормальный обмен сообщениями.

    3. Два одновременных клиентских подключения.

    4. Остановка и перезапуск сервера.

    5. Обрыв и восстановление клиентского соединения.

Поддерживаем gRPC протокол между серверами

Описываем протокол

Делаем простейший симметричный протокол передачи сообщений, с возвратом признака успеха или неудачи для вызывающей стороны.

message.proto

Нужно сказать, что в репозитории этот файл разбит на три, в соответствии с рекомендациями .proto best practices. Да и мне самому так удобнее, честно говоря.

syntax = "proto3";

package grpc.interconnect;

message MessageRequest {
    string name = 1;
    string message = 2;
}

message MessageResponse {
    bool success = 1;
    string message = 2;
}

service Host {
    rpc Message(MessageRequest) returns (MessageResponse);
}

Наш gRPC-сервис будет получать вызов Message, в качестве параметра обработчик вызова Message будет получать объект типа MessageRequest,
с полями name (никнейм пользователя) и message (текст сообщения).

В ответ обработчик будет возвращать объект типа MessageResponse, с полями success (true - сообщение успешно доставлено получателю), и message - чтобы в случае success == false
мы могли понять что именно пошло не так.

Пишем реализацию gRPC

В результате компиляции .proto-файла (в примере для этого используется компилятор protoc), мы получаем набор .php-файлов.

Нам нужен класс \Grpc\Interconnect\HostService. В него мы дописываем реализацию обработки метода Message.

Grpc\Interconnect\HostService
namespace Grpc\Interconnect;

use OpenSwoole\GRPC;

class HostService implements HostInterface
{
    public function Message(GRPC\ContextInterface $ctx, MessageRequest $request): MessageResponse
    {
        // Log the request
        $ip = $ctx->getValue( \OpenSwoole\Http\Request::class )->server["remote_addr"];
        \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Received GRPC Message from $ip, contains ".var_export($request->getMessage(), true) );

        // Get instance of main server class
        $serv = $ctx->getValue( 'WORKER_CONTEXT' )->getValue( \MyServer::class );

        // Send message to client via websocket connection
        [$success, $msg] = $serv->sendMsgToClient( "msg", $request->getName(), $request->getMessage(), $ip, null );

        // Reply to gRPC peer
        $message = new \Grpc\Interconnect\MessageResponse();
        $message->setMessage( $msg );
        $message->setSuccess( $success );
        return $message;
    }
}

Здесь представляет интерес вызов метода $serv->sendMsgToClient(). Этот метод мы реализуем позже в нашем классе-сервере.

Задача метода sendMsgToClient() - отправить сообщение, полученное от peer-сервера, всем клиентским подключениям на текущем сервере.

Мы отдаем этому методу поля name и message из поступившего нам в обработку объекта MessageRequest, а также IP-адрес вызвавшей нас стороны, который мы извлекаем из параметров запроса. Кроме этого, мы передаем ему указание на тип сообщения ("msg"), которое надо доставить всем клиентам данного сервера.

Метод вернет нам пару success/message, которые мы и отдадим в ответе MessageResponse вызывающей стороне.

Поддерживаем Websocket-обмен между клиентом и сервером.

Описываем протокол

Взаимодействие между сервером и клиентской страницей устроено так:

  • со стороны клиента на сервер поступает json-объект с двумя полями: name (никнейм) и text (сообщение);

  • со стороны сервера на клиент поступает json-объект с тремя полями: type (тип сообщения), name (никнейм) и text (сообщение);

  • всего имеется четыре типа сообщений от сервера:

    • type = "msg" - текст сообщения от другого пользователя;

    • type = "echo" - текст сообщения от этого же пользователя, но отправленного с другой клиентской страницы;

    • type = "info" - подтверждение получения отправленного ранее сообщения;

    • type = "error" - уведомление об ошибке доставки предыдущего сообщения.

Создаем клиентскую часть

Здесь мы используем классический javascript-объект Websocket. Код страницы в целом тривиален (полностью можно посмотреть его в репозитории OswServer).

Единственное что тут может быть интересно - оборачиваем открытие сокета в функцию renewSocket(), и, в случае разрыва соединения с сервером, поллим вызов этой функции
чтобы восстановить соединение как только сервер вновь выйдет на связь. Попутно отображаем индикатор наличия или отсутствия соединения.

renewSocket
function renewSocket() {
    conn = new WebSocket( "ws://" + window.location.host );
    if( conn ) {
        conn.onopen = (event) => {
            document.getElementById( 'indicator' ).innerHTML = '<div class = "connected">Connected</div>';
        }
        conn.onclose = (event) => {
            document.getElementById( 'indicator' ).innerHTML = '<div class = "disconnected">Disconnected</div>';
            setTimeout( renewSocket, 1000 );
        }
        conn.onmessage = (event) => {
            var log = document.getElementById( 'log' );
            var msg = JSON.parse(event.data);
            var txt = '';
            switch( msg.type ) {
                case "echo" : txt = "<br><br>Message from me:<br>" + esc(msg.text);
                    break;
                case "msg" : txt = '<br><br>Message from ' + esc(msg.name) + '<br>' + esc(msg.text);
                    break;
                default : txt = '<br><span class = "' + msg.type + '">' + esc(msg.text) + '</span>';
            }
            log.innerHTML += txt;
            log.scrollTop = log.scrollHeight;
        };
    }
}

Создаем и инициализируем сервер

Создаем объект-сервер на базе класса OswServer

Определяем свой класс-сервер, наследуя его от OswServer.

Попутно переопределяем метод onStart, чтобы прочитать из переменной окружения IP-адрес своего пира - куда слать сообщения от клиента.

И сразу пишем код, инициализирующий и запускающий экземпляр этого класса.

MyServer
class MyServer extends \Naivic\OswServer {

    const PORT_GRPC = 9501;
    const PORT_HTTP = 8080;

    public $peer = null;

    public function onStart( \OpenSwoole\HTTP\Server $server ) {
        // Get gRPC peer IP from environment variable
        $this->peer = $_ENV["PEER"];
        \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Server starts with peer {$this->peer}" );

        // Call parent class to provide standard initialization (mandatory)
        parent::onStart( $server );
    }

}

$serv = (new MyServer( '0.0.0.0', MyServer::PORT_GRPC )) // GRPC
    ->register( \Grpc\Interconnect\HostService::class )
    ->addlistener( "0.0.0.0", MyServer::PORT_HTTP, OpenSwoole\Constant::SOCK_TCP ) // HTTP+WebSocket
    ->start()
;

Реализуем методы для обработки HTTP и websocket-запросов

Реализуем в классе MyServer два метода:

  • processRequestHttp() - для обработки HTTP-запросов;

  • processRequestWs() - для обработки WebSocket-запросов.

В методе processRequestHttp() иы будем контролировать порт, на который был принят запрос, и обсуживать только запросы на порт 8080. Смысл метода - отдать клиентскую страницу при обращении к корню сайта, и отдать 404 Not found для любых других URL.

MyServer::processRequestHttp()
public function processRequestHttp( \OpenSwoole\GRPC\Context $context, \OpenSwoole\HTTP\Request $rawRequest, \OpenSwoole\HTTP\Response $rawResponse ) {
    $info = $context->getValue( 'info' );
    $path = $context->getValue( 'path' );
    if( $info["server_port"] != static::PORT_HTTP ) {
        \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Have got HTTP Request $path on invalid port {$info["server_port"]}" );
        $rawResponse->status( 403, "Forbidden" );
        $rawResponse->end();
        return;
    }
    \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Have got HTTP Request $path on valid port {$info["server_port"]}" );
    switch( $path ) {
        case "/" :
            $fname = dirname(__FILE__).'/root.html';
            $rawResponse->write( file_get_contents( $fname ) );
            $rawResponse->status(200, "OK");
            break;
        default  : $rawResponse->status(404, "Not Found");
    }
    $rawResponse->end();
}

Логика обработки Websocket-запросов такова: берем из запроса поля name и text (подставляем дефолтные пустые значения, если таких полей нет), отправляем сообщение MessageRequest peer-серверу. Помимо этого, отправляем текст сообщения всем остальным клиентским соединениям нашего сервера, с типом "echo", чтобы сообщение появилось на всех страницах - помимо той, с которой это сообщение было отправлено.

Если в процессе отправки получили исключение - считаем, что сообщение не доставлено, и возвращаем всем клиентским страницам соответствующее сообщение об ошибке.

Если получили ответ типа MessageResponse, то проверяем его поле success. Если оно true, то считаем что сообщение успешно доставлено до клиента. Если false - показываем своему клиенту сообщение об ошибке доставки.

Для рассылки сообщений по своим клиентским соединениям - используем тот же метод sendMsgToClient(), который использовали в обработчике gRPC-метода Message().

MyServer::processRequestWs()
public function processRequestWs( \OpenSwoole\Server $server, \OpenSwoole\WebSocket\Frame $frame ) {
    \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Received message from client: '{$frame->data}'" );
    $json = json_decode( $frame->data, true );
    $message = new \Grpc\Interconnect\MessageRequest();
    $message->setMessage( $json['text']??'' );
    $message->setName( $json['name']??'' );
    $this->sendMsgToClient( "echo", "", $message->getMessage(), 'localhost', $frame->fd );
    try {
        $conn = (new \OpenSwoole\GRPC\Client( $this->peer, static::PORT_GRPC ))->connect();
        $out = (new \Grpc\Interconnect\HostClient( $conn ))->Message( $message );
        $conn->close();
        if( $out->getSuccess() ) {
            \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Client's message '{$frame->data}' was sent to peer {$this->peer}, peer response: '{$out->getMessage()}'" );
            $this->sendMsgToClient( "info", "", $out->getMessage(), 'localhost', null );
        } else {
            \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Client's message '{$frame->data}' was not accepted by peer {$this->peer}, peer reason: '{$out->getMessage()}'" );
            $this->sendMsgToClient( "error", "", "message not delivered, user is currently disconnected", 'localhost', null );
        }
    } catch ( \Throwable $e ) {
        \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Client's message '{$frame->data}' was not sent to peer {$this->peer} because of gRPC exception: ".$e->getMessage() );
        $this->sendMsgToClient( "error", "", "message not delivered, server is currently offline", 'localhost', null );
    }
}

Реализуем метод для обработки gRPC-запросов

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

Если у вас не предполагается никакой кастомной обработки - этот метод можно не реализовывать.

MyServer::processRequestGrpc()
public function processRequestGrpc( \OpenSwoole\GRPC\Context $context, \OpenSwoole\HTTP\Request $rawRequest, \OpenSwoole\HTTP\Response $rawResponse ) {
    $info = $context->getValue( 'info' );
    $path = $context->getValue( 'path' );
    if( $info["server_port"] != static::PORT_GRPC ) {
        \OpenSwoole\Util::LOG(\OpenSwoole\Constant::LOG_INFO, "Have got GRPC Request $path on invalid port {$info["server_port"]}" );
        $rawResponse->status( 403, "Forbidden" );
        $rawResponse->end();
        return;
    }
    \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Have got GRPC Request $path on valid port {$info["server_port"]}" );

    // Call parent class to provide standard request processing
    parent::processRequestGrpc( $context, $rawRequest, $rawResponse );
}

Реализуем код отправки сообщений с сервера в клиентский Websocket

Описанные ранее методы - onStart(), processRequestHttp(), processRequestWs(), processRequestGrpc() - все они должны дыть реализованы в класе MyServer,
унаследованном от OswServer.

В отличе от них, метод sendMsgToClient() может быть размещен где угодно. Я разместил его в классе MyServer исключительно с целью упрощения примера.

Этот метод принимает на вход поля type, name и text, образующие сообщение клиенту, а также IP источника сообщения - исключительно для отображения в логе. Последний параметр - skip - используется для того, чтобы не отправлять echo-сообшение в то же клиентское соединение, откуда поступило сообщение исходное.

MyServer::sendMsgToClient()
public function sendMsgToClient( $type, $name, $text, $ip, $skip ) {
    $sent = 0;
    foreach( $this->server->connections as $conn ) {
        if( $conn !== $skip ) {
            if( $this->server->isEstablished($conn) ) {
                if( $this->server->push( $conn, json_encode([ "type" => $type, "name" => $name, "text" => $text]) ) ) {
                    $sent++;
                    $log = "Message type '$type' with text '$text' from {$ip} was sent to client connection {$conn}";
                    \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, $log );
                } else {
                    \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, "Cannot push message to client connection {$conn}" );
                }
            }
        }
    }
    if( $sent == 0 ) {
        $log = "Message type '$type' with text '$text' from {$ip} was not sent to client, because connection was closed";
        \OpenSwoole\Util::LOG( \OpenSwoole\Constant::LOG_INFO, $log );
        return [false, $msg];
    }
    return [true, "The message has been sent to {$sent} client connection".($sent>1?'s':'')];
}

Запускаем и играем

Инсталляция и запуск

У вас должен быть установлен докер и composer.

  1. Вытаскиваем код примера из репозитория - git clone git@github.com:Naivic/OswServer.git

  2. cd example/host

  3. composer update

  4. cd ..

  5. docker-compose up -d --build

  6. В браузере откраваете две вкладки:

Если на каждой вкладке вы видите в левом верхнем углу зеленый индикатор "Connected" - все готово к обмену сообщениями.

Нормальный обмен сообщениями

Во вкладке Алисы пишете никнейм "Алиса" в поле Your name в верней части страницы, пишете "привет" в текстовом поле внизу, и нажимете Enter (или кнопку Send).

Вы увидите в логе сообщений у Алисы "Message from me: привет". И на вкладке Боба вы увидите в логе "Message from Алиса привет". В логе у Алисы помимо этого еще добавится статус доставки: "The message has been sent to 1 client connection".

Точно так же работает отправка сообщений и в обратную сторону.

Два одновременных клиентских подключения.

Откройте в браузере еще одну вкладку для Боба: http://127.0.0.1:12080/

Отправьте сообщение Бобу от Алисы. Вы увидите это сообщение на обеих вкладках Боба, причем у Алисы в логе будет приписка: "The message has been sent to 2 client connections".

Обратите внимание, что сообщения, отправленные Бобом из одной вкладки - дублируются и на другой вкладке Боба.

Остановка и перезапуск сервера

Давайте посмотрим что будет, если остановить сервер Алисы - контейнер osw2.

Выполните:

docker stop osw2

Зайдите на вкладку Алисы, и вы увидите, что индикатор в левом верхнем углу стал красным, с надписью Disconnected.

Попробуйте отправить сообщение от Боба Алисе. Вы сначала увидите свое сообщение в привычном стиле "Message from me:", но затем, через пару секунд, под ним появится красное уведомление: "message not delivered, server is currently offline".

При попытке отправить сообщение со вкладки Алисы, вы получите javascript alert "cannot connect server".

Давайте вернем Алисе ее сервер:

docker start osw2

Индикатор на странице Алисы вновь зеленый. Но сообщение от Боба, которое он отправлял раньше, не придет - мы не делали такой функции в нашем примере. Зато новое сообщение от Боба - Алиса получит, а также сможет отправить ему свое сообщение.

Обрыв и восстановление клиентского соединения.

Давайте посмотрим что будет, если сервер остался в строю, но клиентское соединение оборвано.

Закройте вкладку Алисы, и попробуйте отправить сообщение со вкладки Боба. Вы мгновенно получите уведомление "message not delivered, user is currently disconnected" под своим сообщением.

Вновь откройте вкладку Алисы. Она будет чистой, все сообщения, отправленные Алисе ранее - потеряны. Но зато новые сообщения от Боба будут поступать.

Что дальше?

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

Также вы можете поиграться с приведенным кодом. Я, признаться, с трудом удержался от добавления peer-to-peer шифрования, mesh-архитектуры с динамическим роутингом,
и эмотикона маленькой зеленой собачки. Но вам совсем не обязательно проявлять подобную сдержанность. Используйте OswServer на полную катушку.

Удачи!

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


  1. mantyr
    05.07.2025 07:12

    Старание это конечно хорошо... но... https://go.dev/


    1. ForsakenROX
      05.07.2025 07:12

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


      1. mantyr
        05.07.2025 07:12

        Наверное потому что на дворе 21 век и пора обновить стек.


  1. Gugic
    05.07.2025 07:12

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

    Ну и с точки зрения интеграции rest, вебсокетов и grpc, имхо, гораздо интереснее штуки вроде grpc-gateway, которые при помощи уже практически стандартных http аннотаций могут мапить grpc в рест запросы в автоматическом режиме и даже при большом желании при помощи сторонних плагинов заворачивать честные bi-di grpc стримы, невозможные в текущих браузерах в http, в вебсокеты. И конечно их всего этого можно поднимать сваггер, генерировать документацию, open API спеки и клиентские библиотеки (что для grpc, что для рест)


    1. Naivic Автор
      05.07.2025 07:12

      С тремя инстансами была бы отдельная задача их синхронизировать. Это ведь связанные вещи: пришел запрос на RestAPI, в рамках его обработки нужно вызвать определенных агентов по gRPC, дождаться результата от каждого, собрать ответ, отдать его внешней системе. Все это в асинхронном режиме.
      Конечно, удобнее это делать, когда у тебя один инстанс, и состояние обработки каждой задачи, состояние каждого агента - все лежит в общей памяти. И мониторить это дело удобнее.

      Альтернативой было бы использование БД, и тогда инстансов стало бы уже не три - а четыре.

      Так что у меня встречный вопрос: а зачем усложнять, и вводить четыре инстанса - там, где абсолютно достаточно одного?