Здравствуйте!
В этой статье я хочу описать создание простого 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<String, WebSocket> connections = new Map<String, WebSocket>();
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<String, String> 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<Match> 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.
Если я допустил какие-нибудь ошибки и неточности, то сообщайте, а я постараюсь все быстро поправить.
Автор: cartrege