Пишем чат на web-сокетах в Symfony приложении

Первое, что нужно нам сделать это установить Docker, Docker-compose,Composer (если не установлены).

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

Composer:

После того, как всё было установлено, сделайте небольшую проверку:

docker -v
Docker version 18.09.7, build 2d0083d

docker-compose -v
docker-compose version 1.24.1, build 4667896b

composer -v
   ______
  / ____/___  ____ ___  ____  ____  ________  _____
 / /   / __ \/ __ `__ \/ __ \/ __ \/ ___/ _ \/ ___/
/ /___/ /_/ / / / / / / /_/ / /_/ (__  )  __/ /
\____/\____/_/ /_/ /_/ .___/\____/____/\___/_/
                    /_/
Composer version 1.9.0 2019-08-02 20:55:32

Вы должны увидеть плюс-минус (версии могут отличаться) тоже самое.

Ок-с, после того как необходимое ПО установлено, развернём пустой Symfony проект.

mkdir projects
cd projects
composer create-project symfony/skeleton chat

После этого, в консоли, вы увидите установку пустого приложения symfony. Как установка закончится, может смело открывать эту папку в своём IDE (в моём случае это PhpStorm).

Вы должны увидеть плюс-минус следующее:

После этого, в основной папке проекта создаём папку docker, содержимое которой берём отсюда: https://github.com/ko4a/dockerFile

КРОМЕ ФАЙЛА docker-compose.yml

После этого, в основной папке проекта создаём файл: docker-compose.yml, содержимое которого берём c файла docker-compose.yml из репозитория.

Ок, теперь разворачиваем наши контейнеры.

docker-compose build

Начнётся сборка, как закончится:
docker-compose up -d
Поздравляю! Вы развернули своё Symfony приложение из под docker.

Сделаем небольшую проверку

docker-compose ps
    Name                  Command              State                 Ports               
chat           docker-php-entrypoint php-fpm   Up      0.0.0.0:8000->10000/tcp, 9000/tcp 
chat_nginx_1   nginx                           Up      0.0.0.0:8080->80/tcp              
lesson-mysql   docker-entrypoint.sh mysqld     Up      0.0.0.0:33061->3306/tcp, 33060/tcp
php            docker-php-entrypoint php-fpm   Up      0.0.0.0:9001->9000/tcp    

Добавим алиасы.

alias sf="docker-compose exec --user www-data php php -d memory_limit=4096M bin/console"

alias ch="docker-compose exec --user www-data chat php -d memory_limit=4096M bin/console"

Этими алиасами мы будем пользоваться вместо стандартного php bin/console, по сути, они делают тоже самое, только из под контейнера.

Теперь добавим maker bundle и библиотеку Ratchet:

docker-compose exec php composer require symfony/maker-bundle --dev
docker-compose exec php composer require cboden/ratchet --dev

Окей, теперь наконец перейдём к написанию самого чата. Для этого, в папке src, создайте папку Sockets, а внутри класс Chat.

Этот класс должен реализовывать интерфейс MessageComponentInterface, который находится в пространстве имён Ratchet.

Вот моя реализация:

<?php

namespace App\Sockets;
use Ratchet\MessageComponentInterface;
use Ratchet\ConnectionInterface;

class Chat implements MessageComponentInterface {
    protected $clients;

    public function __construct() {
        $this->clients = new \SplObjectStorage;
    }

    public function onOpen(ConnectionInterface $conn) {
        $this->clients->attach($conn);
    }

    public function onMessage(ConnectionInterface $from, $msg) {
       foreach ($this->clients as $client) {
            if ($from !== $client) {
                $client->send($msg);
            }
        }
    }

    public function onClose(ConnectionInterface $conn) {
        $this->clients->detach($conn);
        echo "Connection {$conn->resourceId} has disconnected\n";
    }

    public function onError(ConnectionInterface $conn, \Exception $e) {
        echo "An error has occurred: {$e->getMessage()}\n";
        $conn->close();
    }
}

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

На открытие сокета на клиенте (метод onOpen) мы будем сохранять соединение в хранилище.

При получении сообщения(метод onMessage) мы будем отсылать сообщение на все соединения, кроме своего.

При закрытии сокета на клиенте (метод onClose) мы будем убирать соединение из хранилища.

При возникновении ошибки (метод onError) мы будем закрывать соответствующее соединение.

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

 sf make:command
 Choose a command name (e.g. app:tiny-chef):
 > ChatCommand
 created: src/Command/ChatCommand.php           
  Success! 

Чтобы сильно не тянуть, сразу покажу свою реализацию:

<?php

namespace App\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

use Ratchet\Server\IoServer;
use Ratchet\Http\HttpServer;
use Ratchet\WebSocket\WsServer;

use App\Sockets\Chat;


class ChatCommand extends Command
{
    protected function configure()
    {
        $this
            ->setName('sockets:start-chat')
            ->setDescription('Starts the socket chat demo')
        ;
    }

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $output->writeln([
            'Chat socket',
            '============',
            'Starting chat, open your browser.',
        ]);

        $server = IoServer::factory(
            new HttpServer(
                new WsServer(
                    new Chat()
                )
            ),
            10000
        );

        $server->run();

    }
}

Каждая команда состоит как минимум из двух методов.

Первый — configure, задаёт конфигурацию. В нашем случае — я задал имя команды, описание. Имя нужно для того, чтобы обращаться к команде из консоли, а описание — это то, что будет стоять справа от имени команды в списке всех команд ( ради интереса напишите в консоль sf и найдите свою команду).

Второй — execute, само действие, которое будет делать команда. В нашем случае оно создаёт и запускает наш сервер, на 10000 порту.

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

Перед тем, как создавать контроллер опишем нашу страничку. Для этого создадим папку templates в папке с проектом. В ней создадим файл base.html.twig

Это наша базовая страница (если вы знакомы с наследованием в ООП, то тут это работает плюс минус также, так что если вы вдруг соберётесь расширять это приложение, то оставьте на базовой странице только то, что нужно на ВСЕХ других страницах). Со следующим содержимым:

  <!DOCTYPE html>
    <html>
    <head>
        <meta charset="UTF-8">
        <title>
            {% block title %}Welcome!{% endblock %}
        </title>
        {% block stylesheets %}
            <link rel="stylesheet" href="{{ asset('css/chat.css') }}">
        {% endblock %}
    </head>
    <body>
        {% block body %}{% endblock %}
    {% block javascripts %}
        <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/2.1.4/jquery.min.js"></script>
        <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/3.0.3/handlebars.min.js"></script>
        <script src="http://cdnjs.cloudflare.com/ajax/libs/moment.js/2.10.2/moment.min.js"></script>
        <script src="{{ asset('js/chat.js') }}"></script>
    {% endblock %}
    </body>
    </html>

Здесь мы подключили наши (пока ещё не написанные стили), и js — скрипты. Первые три — это библиотеки. Jquery, handlebars,moment. О каждой из них вам лучше почитать отдельно, ведь я не js разработчик.

Если вкратце:

    • Jquery — позволяет нам довольно просто работать с DOM.
    • Handlebars— это клиентский шаблонизатор для JavaScript.
    • moment — позволяет легко работать со временем.

Теперь создадим нашу страничку с чатом. В той же папке (templates) создайте папку chat, и в ней файл index.html.twig со следующим содержимым:

{% extends'base.html.twig' %}
    {% block title %} chatapp {% endblock %}
{% block body %}
    <div id="wrapper">
        <div id="user-container">
            <label for="user">What's your name?</label>
            <input type="text" id="user" name="user">
            <button type="button" id="join-chat">Join Chat</button>
        </div>
        <div id="main-container" class="hidden">
            <button type="button" id="leave-room">Leave</button>
            <div id="messages">

            </div>

            <div id="msg-container">
                <input type="text" id="msg" name="msg">
                <button type="button" id="send-msg">Send</button>
            </div>
        </div>

    </div>
    {% verbatim %}
    <script id="messages-template" type="text/x-handlebars-template">
        {{#each messages}}
        <div class="msg">
            <div class="time">{{time}}</div>
            <div class="details">
                <span class="user">{{user}}</span>: <span class="text">{{text}}</span>
            </div>
        </div>
        {{/each}}
    </script>
    {% endverbatim %}
{% endblock %}

Здесь комментировать особо нечего, кроме twig — блока {% verbatim %}, это блок, который НЕ будет отображаться на странице.

Ок-с, теперь в папке проекта public создайте папку css, а в ней файл chat.css

Можете стилизовать по своему вкусу, я предложу это (довольно минималистичненько).

.hidden {
    display: none;
}

#wrapper {
    width: 800px;
    margin: 0 auto;
}

#leave-room {
    margin-bottom: 10px;
    float: right;
}

#user-container {
    width: 500px;
    margin: 0 auto;
    text-align: center;
}

#main-container {
    width: 500px;
    margin: 0 auto;
}

#messages {
    height: 300px;
    width: 500px;
    border: 1px solid #ccc;
    padding: 20px;
    text-align: left;
    overflow-y: scroll;
}

#msg-container {
    padding: 20px;
}

#msg {
    width: 400px;
}

.user {
    font-weight: bold;
}

.msg {
    margin-bottom: 10px;
    overflow: hidden;
}

.time {
    float: right;
    color: #939393;
    font-size: 13px;
}

.details {
    margin-top: 20px;
}

Отлично, теперь JS. В папке public создайте папку js и в ней файл chat.js

Наполните его следующим.

(function(){


    var user;
    var messages = [];
    var messages_template = Handlebars.compile($('#messages-template').html());

    function updateMessages(msg){
        messages.push(msg);
        var messages_html = messages_template({'messages': messages});
        $('#messages').html(messages_html);
        $("#messages").animate({ scrollTop: $('#messages')[0].scrollHeight}, 1);
    }

    var conn = new WebSocket('ws://localhost:8000');
    conn.onopen = function(e) {
        console.log("Connection established!");
    };

    conn.onmessage = function(e) {
        var msg = JSON.parse(e.data);
        updateMessages(msg);
    };


    $('#join-chat').click(function(){
        user = $('#user').val();
        $('#user-container').addClass('hidden');
        $('#main-container').removeClass('hidden');

        var msg = {
            'user': user,
            'text': user + ' entered the room',
            'time': moment().format('hh:mm a')
        };

        updateMessages(msg);
        conn.send(JSON.stringify(msg));

        $('#user').val('');
    });


    $('#send-msg').click(function(){
        var text = $('#msg').val();
        var msg = {
            'user': user,
            'text': text,
            'time': moment().format('hh:mm a')
        };
        updateMessages(msg);
        conn.send(JSON.stringify(msg));

        $('#msg').val('');
    });


    $('#leave-room').click(function(){

        var msg = {
            'user': user,
            'text': user + ' has left the room',
            'time': moment().format('hh:mm a')
        };
        updateMessages(msg);
        conn.send(JSON.stringify(msg));

        $('#messages').html('');
        messages = [];

        $('#main-container').addClass('hidden');
        $('#user-container').removeClass('hidden');

        conn.close();
    });

})();

ГЛАВНОЕ ЗДЕСЬ: создаём WebSocket с нашим url-ом и портом.

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

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

Для этого:

docker-compose exec php composer require annotations

После установки бандла:

sf make:controller 
Choose a name for your controller class (e.g. FierceGnomeController):
> ChatController 
created:  src/Controller/ChatController.php          
  Success! 

Переходим в наш контроллер, и чуть -чуть переделываем метод index, чтобы он просто рендерил нашу страничку.

    /**
     * @Route("/chat", name="chat")
     */
    public function index()
    {
        return $this->render('chat/index.html.twig');
    }

После этого устанавливаем ещё кое-какие компоненты symfony.

docker-compose exec php composer require symfony/twig-bundle
docker-compose exec php composer require symfony/asset

и, наконец, мы можем запускать наш чат.

ch sockets:start-chat

Переходим в браузер по следующему адресу: 0.0.0.0:8080/chat

и, переписываемся сколько нам влезет.

Важно заметить, что в принципе вебсокеты на PHP это плохая идея. Если вам нужны сокеты, то лучше использовать другой инструмент: Node.js / Python / C# что-то из этого.

Если у вас вдруг что-то не получилось, то можете посмотреть это приложение в гите:

https://github.com/ko4a/simpleChatSymfony

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *