[Из песочницы] Простой websocket-чат на Dart

Здравствуйте! В этой статье я хочу описать создание простого websocket-чата на Dart с целью показать, как работать с вебсокетами в Dart. Код приложения доступен на github, а пример его работы можно посмотреть здесь: http://simplechat.rudart.in.

Приложение будет состоять из двух частей: сервера и клиента. Серверную часть мы разберем очень подробно, а из клиентской рассмотрим только то, что отвечает за работу с соединением.

Требования к приложению очень простые — отправка сообщений от пользователя всем или только выбранным участникам чата.

Настройки приложенияВсе настройки приложения и константы будут храниться в файле common/lib/common.dart. В этом файле находится определение библиотеки simplechat.common. library simplechat.common;

const String ADDRESS = 'simplechat.rudart.in'; const int PORT = 9224;

const String SYSTEM_CLIENT = 'Simple Chat'; Сам файл мы будем подключать как пакет, т.к. если будем использовать относительные пути, то при сборке приложения (pub build) мы можем получить ошибку от pub: Exception: Cannot read {file} because it is outside of the build environment.Для того, чтобы подключить пакет, находящийся где-то на нашей машине, мы будем использовать pub path dependency. Для этого мы просто допишем в секцию dependencies файла pubspec.yaml определение нашего пакета:

dependencies: simplechat.common: path: ./common Все содержимое файла pubspec.yaml я приводить не буду (но его можно посмотреть на github). Также нужно будет добавить файл pubspec.yaml в директорию common в котором просто укажем имя нашего пакета: name: simplechat.common Сервер Файлы сервера располагаются в папке bin. В файле main.dart находится точка входа в сервер, а в файле server.dart — класс нашего сервера. Начнем с рассмотрения содержимого файла main.dart.Общая схема работы сервера Давайте поговорим о том, как вообще будет работать наш сервер. Первое, что мы будем делать с сервером — это запускать его. Во время запуска он начнет слушать порт 9224.Когда новый пользователь отправит запрос на этот порт, то сервер откроет для него websocket-соединение, сгенерирует имя и сохранит имя и соединение в хэш с открытыми соединениями. После этого клиент сможет отправлять сообщения по этому соединению. Сервер сможет передавать эти сообщения другим пользователям, а также отправлять уведомления о подключении и отключении клиентов.

Если пользователь закроет соединение, то сервер удалит его из хэша с активными соединениями.

Точка входа в сервер В самом начале файла bin/main.dart мы определим, что это библиотека simplechat.bin. Для работы сервера нам понадобится подключить библиотеки dart: async, dart: convert, dart: io, пакет route (его ставим через pub) и файл с настройками приложения. Также в bin/main.dart мы подключаем файл bin/server.dart, который содержит основной код нашего сервера (рассмотрим его чуть позже).В функции main () мы создаем экземпляр сервера и запускаем его.

library simplechat.bin;

import 'dart: async'; import 'dart: convert'; import 'dart: io'; import 'package: route/server.dart' show Router; import 'package: simplechat.common/common.dart';

part 'server.dart';

/** * Entry point */ main () { Server server = new Server (ADDRESS, PORT); server.bind (); } Базовый класс сервера, прослушка порта Ниже приведен базовый код сервера, который будет просто привязываться на нужный порт. part of simplechat.bin;

/** * Class [Server] implement simple chat server */ class Server { /** * Server bing port */ int port;

/** * Server address */ var address;

/** * Current server */ HttpServer _server;

/** * Router */ Router _router;

/** * Active connections */ Map connections = new Map();

int generalCount = 1;

/** * Server constructor * param [address] * param [port] */ Server ([ this.address = '127.0.0.1', this.port = 9224 ]);

/** * Bind the server */ bind () { HttpServer.bind (address, port).then (connectServer); }

/** * Callback when server is ready */ connectServer (server) { print ('Chat server is running on »$address:$port»');

_server = server; bindRouter (); } } В конце функции connectServer () вызывается функция для настройки роутера — bindRouter (), которую мы рассмотрим ниже.Настройка роутера и создание websocket-соединения Для настройки роутера создадим функцию bindRouter (). Входящий поток на / мы будем изменять с помощью WebSocketTransformer и прослушивать в функции createWs (). /** * Bind routes */ bindRouter () { _router = new Router (_server);

_router.serve ('/') .transform (new WebSocketTransformer ()) .listen (this.createWs); }

createWs (WebSocket webSocket) { String connectionName = 'user_$generalCount'; ++generalCount;

connections.putIfAbsent (connectionName, () => webSocket); } В функции createWs () мы генерируем имя для соединения по схеме user_{counter} и сохраняем это соединение в connections.Структура сообщения от сервера и функция создания сообщения Сервер отправляет сообщения в виде объекта Map (а точнее его представления в json) со следующими ключами: from — от кого сообщение; message — текст сообщения; online — количество пользователей онлайн. Вот функция, которая строит такое сообщение: /** * Build message */ String buildMessage (String from, String message) { Map data = { 'from': from, 'message': message, 'online': connections.length };

return JSON.encode (data); } Отправка сообщений с сервера Для того, чтобы отправить сообщение клиенту, нужно воспользоваться методом add () класса WebSocket. Ниже приведена функция, которая будет отправлять сообщения пользователю: /** * Sending message */ void send (String to, String message) { connections[to].add (message); } Наш сервер может отправлять уведомления всем активным клиентам о подключении или отключении пользователя. Давайте рассмотрим функцию для этого. Функция notifyAbout (String connectionName, String message) принимает имя соединения и сообщение (о подключении или отключении). Эта функция уведомляет всех активных клиентов кроме того, о ком делается это уведомление. Т.е. если к нам присоединился пользователь user_3, то уведомление получат все пользователи, кроме него. Для того, чтобы отфильтровать клиентов по определенному условию (в нашем случае нам нужно получить имена всех клиентов, которые не совпадают с текущим) мы воспользуемся методом where () абстрактного класса Iterable. /** * Notify users */ notifyAbout (String connectionName, String message) { String jdata = buildMessage (SYSTEM_CLIENT, message);

connections.keys .where ((String name) => name!= connectionName) .forEach ((String name) { send (name, jdata); }); } Также, после присоединения нового пользователя мы поприветствуем его: /** * Sending welcome message to new client */ void sendWelcome (String connectionName) { String jdata = buildMessage (SYSTEM_CLIENT, 'Welcome to chat!');

send (connectionName, jdata); } Давайте теперь посмотрим функцию, которая обрабатывает входящие сообщения от пользователя и отправляет их всем (или только указанным) участникам чата. Функция sendMessage (String from, String message) принимает имя отправителя и его сообщение. Если теле сообщения (message) указать имена получателей по маске @{user_name}, то сообщение будет доставлено только им. Давайте посмотрим на код функции sendMessage: /** * Sending message to clients */ sendMessage (String from, String message) { String jdata = buildMessage (from, message);

// search users that the message is intended RegExp usersReg = new RegExp (r»@([\w|\d]+)»); Iterable users = usersReg.allMatches (message);

// if users found — send message only them if (users.isNotEmpty) { users.forEach ((Match match) { String user = match.group (0).replaceFirst ('@', ''); if (connections.containsKey (user)) { send (user, jdata); } }); send (from, jdata); } else { connections.forEach ((username, conn) { conn.add (jdata); }); } } Когда пользователь закроет соединение, то мы должны удалить его из списка активных соединений. Функция closeConnection (String connectionName) принимает имя соединения, которое было закрыто и удаляет его из списка соединений: /** * Close user connections */ closeConnection (String connectionName) { if (connections.containsKey (connectionName)) { connections.remove (connectionName); } } Добавляем возможности к слушателю соединения Подытожим все, что мы сейчас имеем. Функция createWs занимается прослушкой соединения пользователя. send — отправляет сообщение указанному пользователю. sendWelcome — отправляет сообщение с приветствием новому пользователю. notifyAbout — уведомляет участников чата (кроме инициатора) о каких-либо действиях инициатора (подключение/отключение). sendMessage — отправляет сообщение всем или только указанным пользователям.Давайте теперь изменим функцию createWs так, чтобы мы могли использовать все это. В предыдущий раз мы остановились на том, что добавили соединение в список. После этого нам необходимо уведомить всех остальных участников чата о новом пользователе, а новому пользователю отправить сообщение с приветствием.

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

createWs (WebSocket webSocket) { String connectionName = 'user_$generalCount'; ++generalCount;

connections.putIfAbsent (connectionName, () => webSocket); // Уведомим всех о новом подключении notifyAbout (connectionName, '$connectionName joined the chat'); // Отправим новому пользователю приветствие sendWelcome (connectionName);

webSocket .map ((string) => JSON.decode (string)) .listen ((json) { sendMessage (connectionName, json['message']); }).onDone (() { closeConnection (connectionName); notifyAbout (connectionName, '$connectionName logs out chat'); }); } Вот и все, простой сервер готов. Теперь перейдем к клиентской части.Клиент Здесь я не стану рассказывать о верстке клиентской части и об отображении сообщений. В этой части мы поговорим только о том, как мы открываем websocket-соединение с сервером, посылаем и принимаем сообщения.Точка входа в клиентское приложение Точка входа в клиентское приложение находится в файле web/dart/index.dart. Давайте посмотрим на его содержимое: library simplechat.client;

import 'dart: html'; import 'dart: convert'; import 'package: simplechat.common/common.dart';

part './views/message_view.dart'; part './controllers/web_socket_controller.dart';

main () { WebSocketController wsc = new WebSocketController ('ws://$ADDRESS:$PORT', '#messages', '#userText .text', '#online'); } В первой строке мы объявляем библиотеку. Затем подключаем необходимые файлы и части библиотек. В файле ./views/message_view.dart находится определение класса MessageView, который занимается отображением сообщений. Его мы рассматривать не будем (код можно посмотреть на github). В файле ./controllers/web_socket_controller.dart находится определение класса WebSocketController, на котором мы остановимся более подробно.В функции main () посто создается экземпляр этого контроллера.

WebSocketController — конструктор класса и создание соединения Давайте взглянем на свойства и конструктор класса WebSocketController: class WebSocketController { WebSocket ws; HtmlElement output; TextAreaElement userInput; DivElement online;

WebSocketController (String connectTo, String outputSelector, String inputSelector, String onlineSelector) { output = querySelector (outputSelector); userInput = querySelector (inputSelector); online = querySelector (onlineSelector);

ws = new WebSocket (connectTo);

ws.onOpen.listen ((e){ showMessage ('Сonnection is established', SYSTEM_CLIENT); bindSending (); });

ws.onClose.listen ((e) { showMessage ('Connection closed', SYSTEM_CLIENT); });

ws.onMessage.listen ((MessageEvent e) { processMessage (e.data); });

ws.onError.listen ((e) { showMessage ('Connection error', SYSTEM_CLIENT); }); }

// … } Из кода видно, что WebSocketController имеет следующие свойства: WebSocket ws — здесь мы храним наше websocket-соединение; HtmlElement output — элемент, в который будем выводить сообщения; TextAreaElement userInput — текстовая область, в которую пользователь вводит сообщения; DivElement online — элемент, в который выводится количество активных пользователей. Конструктор класса принимает адрес, по которому можно открыть websocket-соединение, селекторы для элементов output, userInput и online. В самом начале он находит элементы в дереве. Затем создается websocket-соединение с сервером с помощью конструктора WebSocket: ws = new WebSocket (connectTo); Затем мы назначаем обработчики событий для нашего соединения.Событие onOpen срабатывает тогда, когда соединение успешно установлено. Его обработчик показывает сообщение о том, что соединение установлено и ставит слушателя событий нажатия клавиш на элементе ввода сообщений так, чтобы при нажатии на Enter происходила отправка сообщения. Вот код функции bindSending ():

bindSending () { userInput.onKeyUp.listen ((KeyboardEvent key) { if (key.keyCode == 13) { key.stopPropagation (); sendMessage (userInput.value); userInput.value = ''; } }); } В теле обработчика события keyUp можно заметить вызов функции sendMessage (String message), которая занимается отправкой сообщения. Отправка сообщения по websocket-соединению просходит с помощью метода send () класса WebSocket. Вот код этой функции: sendMessage (String message) { Map data = { 'message': message }; String jdata = JSON.encode (data); ws.send (jdata); } Событие onClose срабатывает тогда, когда соединение закрывается. Обработчик этого события просто отображает сообщение о том, что соединение сброшено.Событие onMessage срабатывает при получении сообщения от сервера. Слушателю передается объект MessageEvent. Обработчик этого события передает данные, поступившие от сервера в функцию processMessage, которая просто отображает сообщение. Вот ее код:

processMessage (String message) { var data = JSON.decode (message);

showOnline (data['online']); showMessage (data['message'], data['from']); } Я не стану приводить код функций showOnline и showMessage, т.к. в них ничего особо интересного не происходит. Но если вам интересно их содержание, то вы всегда можете найти полный код контроллера на github.Вот и все. Это весь основной функционал клиентской части.

Вы можете посмотреть работающее приложение здесь: http://simplechat.rudart.in.

Если я допустил какие-нибудь ошибки и неточности, то сообщайте, а я постараюсь все быстро поправить.

© Habrahabr.ru