Простой websocket-чат на Dart

в 13:02, , рубрики: dart, websockets

Здравствуйте!

В этой статье я хочу описать создание простого 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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js