Как я писал свой чат

в 19:03, , рубрики: .net, .net 3.5, open source, p2p, метки: , , , ,

Привет!
В статье я написал, о том как разрабатывал чат. О его архитектуре и о технических решениях принятых в ходе его разработки.

Чат представляет собой клиент-серверное приложение с элементами p2p.
С поддеркжой:

  • Личных сообщений.
  • Комнат.
  • Передачи файлов.
  • Голосового чата.

Как я писал свой чат

Исходный код проекта: GitHub

Итак, понеслась.


Модель

  1. Данные и их синхронизация.
  2. API.
  3. Запись и воспроизведение звука.
Данные и их синхронизация.

В начале когда я писал первую версию, я сразу же написал асинхронную версию клиента и сервера. Но, почему-то, напрочь забыл про то, что данные нужно синхронизировать. И так-как серьезной нагрузки чат никогда не испытывал, то я понял это только после введения в чат передачи файлов. После этого сразу все вспомнилось и везде было вставлено куча локов. Что разумеется не было лучшим решением. Если сказать точнее, то это было всего лишь чуть лучше, чем программа без синхронизации.

Сейчас же на клиенте и на сервере используется единый механизм доступа к данным. Блокируется полностью модель. Должен сказать что для сервера это не самое удачное решение.

Идея достаточно простая: есть контекст, который приватным статическим полем содержит модель. В конструкторе он, вызывает Monitor.Enter. На саму модель, либо на отдельный объект синхронизации. Так же контекст реализует интерфейс IDisposable и в методе Dispose он эту эту модель освобождает, вызывая метод Monitor.Exit.

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

Код

  public abstract class ModelContext<TModel> :
    IDisposable
  {
    #region consts
    private const int TimeOut = 10000;
    #endregion

    #region fields
    private static object syncObject = new object();
    protected TModel model;
    #endregion

    #region initialization
    protected ModelContext(TModel initialModel)
    {
      if (!Monitor.TryEnter(syncObject, TimeOut))
        throw new InvalidOperationException("model lock timeout");

      model = initialModel;
    }

    public void Dispose()
    {
      model = default(TModel);

      Monitor.Exit(syncObject);
    }
    #endregion
  }
}

В результате, для доступа к данным хочешь-не хочешь их нужно блокировать, и уже не задумываешься о синхронизации. Главное не забывать использовать конструкцию using. Для сервера это не является лучшим решением т.к. половина команд работают с 2умя пользователями максимум, а блокируются в результате — все.

Контекст в программе может создавать только одна сущность (ServerModel — (неожиданно) для сервера, и ClientModel — для клиента). Она представляет собой класс содержащий статическую приватную модель (саму себя), API а также клиентское соединение и пир — для клиентской модели или сервер для серверной. (API, клиент и т.д. содержатся как статические поля). Также клиентская модель, в отличии от серверной, содержит еще и события. На которые будет подписан пользовательский интерфейс. В общем эти классы выступают как основные для доступа к чему либо.
В качестве примера приведу серверную модель (она поменьше). Обратить внимание следует на метод Get() создающий контекст.

Код

  public class ServerModel
  {
    #region static model
    private static ServerModel model;

    /// <summary>
    /// Серверный API
    /// </summary>
    public static IServerAPI API { get; private set; }

    /// <summary>
    /// Сервер
    /// </summary>
    public static AsyncServer Server { get; private set; }

    /// <summary>
    /// Исользовать только с конструкцией using
    /// </summary>
    /// <example>using (var server = SeeverModel.Get()) { ... }</example>
    /// <returns>Возвращает и блокирует модель.</returns>
    public static ServerContext Get()
    {
      if (Interlocked.CompareExchange(ref model, null, null) == null)
        throw new ArgumentException("model do not inited yet");

      return new ServerContext(model);
    }
    #endregion

    #region consts
    public const string MainRoomName = "Main room";
    #endregion

    #region properties
    public Dictionary<string, Room> Rooms { get; private set; }
    public Dictionary<string, User> Users { get; private set; }
    #endregion

    #region constructor
    public ServerModel()
    {
      Users = new Dictionary<string, User>();
      Rooms = new Dictionary<string, Room>();

      Rooms.Add(MainRoomName, new Room(null, MainRoomName));
    }
    #endregion

    #region static methods
    public static bool IsInited
    {
      get { return Interlocked.CompareExchange(ref model, null, null) != null; }
    }

    public static void Init(IServerAPI api)
    {
      if (Interlocked.CompareExchange(ref model, new ServerModel(), null) != null)
        throw new InvalidOperationException("model already inited");

      Server = new AsyncServer("ServerErrors.log");
      API = api;
    }

    public static void Reset()
    {
      if (Interlocked.Exchange(ref model, null) == null)
        throw new InvalidOperationException("model not yet inited");

      if (Server != null)
      {
        Server.Dispose();
        Server = null;
      }

      API = null;
    }
    #endregion
  }

API

API в данном случае это логика чата. Она представляет собой класс хранящий в себе команды которые могут выполнятся на нашей стороне, и набор методов которые отправляют команды другой стороне (Клиенту, если рассматриваем себя со стороны сервера. Для клиента это сервер или другой пир). В методах содержатся наиболее сложные команды, либо просто часто используемые.

Работает вся эта система следующим образом: как только клиент или сервер принимает пакет данных, он передает его на анализ в API. (У сервера принимают сообщения его соединения, а они в свою очередь дергают один метод у сервера, о том что данные приняты). API просто считывает первые два байта сообщения и ищет у себя в словаре команду с нужным id, и возвращает ее. Или пустую команду, которая ничего не делает, если такого id нет. Дальше команде передается полученный пакет, и id приславшего его соединения и она выполняется.

Также API имеет свой интерфейс, изначально его не было. Появился после того как я решил написать другую его реализацию, предполагалось что это будет защищенное API. Но потом мне это просто стало не интересно, и я не на долго забросил проект. Месяца на два. После возвращения к нему мне уже не хотелось все это делать, и я занялся реализацией P2P.

Клиент, кстати, умеет сам выбирать API, который использует сервер, и если такового не имеется он отсоединяется от сервера и говорит что не поддерживает серверный API. Это реализовано достаточно просто — после того как сервер принял соединение он сразу же отправляет строку с названием своего API, а клиент собственно ожидает эту строку и устанавливает нужный интерфейс. Ну или не устанавливает, если такой не поддерживает. После этого действия уже идет апишный запрос регистрации пользователя на сервере.

Метод сервера обрабатывающего принятые пакеты:

Код

  public class DataReceivedEventArgs : EventArgs
  {
    public byte[] ReceivedData { get; set; }
    public Exception Error { get; set; }
  }

  public interface IServerAPICommand
  {
    void Run(ServerCommandArgs args);
  }

  public class ServerCommandArgs
  {
    public string ConnectionId { get; set; }
    public byte[] Message { get; set; }
  }

    private void DataReceivedCallBack(object sender, DataReceivedEventArgs e)
    {
      try
      {
        if (e.Error != null)
          throw e.Error;

        if (!isServerRunning)
          return;

        IServerAPICommand command = ServerModel.API.GetCommand(e.ReceivedData);
        ServerCommandArgs args = new ServerCommandArgs
        {
          Message = e.ReceivedData,
          ConnectionId = ((ServerConnection)sender).Id,
        };

        command.Run(args);
      }
      catch (Exception exc)
      {
        ServerModel.Logger.Write(exc);
      }
    }

Полный код класса API (в данном случае — серверного):

Код

  /// <summary>
  /// Класс реазиующий стандартное серверное API.
  /// </summary>
  public class StandardServerAPI : IServerAPI
  {
    /// <summary>
    /// Версия и имя данного API.
    /// </summary>
    public const string API = "StandartAPI v2.0";

    private Dictionary<ushort, IServerAPICommand> commandDictionary = new Dictionary<ushort, IServerAPICommand>();

    /// <summary>
    /// Создает экземпляр API.
    /// </summary>
    /// <param name="host">Сервер которому будет принадлежать данный API.</param>
    public StandardServerAPI()
    {
      commandDictionary.Add(ServerRegisterCommand.Id, new ServerRegisterCommand());
      commandDictionary.Add(ServerUnregisterCommand.Id, new ServerUnregisterCommand());
      commandDictionary.Add(ServerSendRoomMessageCommand.Id, new ServerSendRoomMessageCommand());
      commandDictionary.Add(ServerSendPrivateMessageCommand.Id, new ServerSendPrivateMessageCommand());
      commandDictionary.Add(ServerGetUserOpenKeyCommand.Id, new ServerGetUserOpenKeyCommand());
      commandDictionary.Add(ServerCreateRoomCommand.Id, new ServerCreateRoomCommand());
      commandDictionary.Add(ServerDeleteRoomCommand.Id, new ServerDeleteRoomCommand());
      commandDictionary.Add(ServerInviteUsersCommand.Id, new ServerInviteUsersCommand());
      commandDictionary.Add(ServerKickUsersCommand.Id, new ServerKickUsersCommand());
      commandDictionary.Add(ServerExitFormRoomCommand.Id, new ServerExitFormRoomCommand());
      commandDictionary.Add(ServerRefreshRoomCommand.Id, new ServerRefreshRoomCommand());
      commandDictionary.Add(ServerSetRoomAdminCommand.Id, new ServerSetRoomAdminCommand());
      commandDictionary.Add(ServerAddFileToRoomCommand.Id, new ServerAddFileToRoomCommand());
      commandDictionary.Add(ServerRemoveFileFormRoomCommand.Id, new ServerRemoveFileFormRoomCommand());
      commandDictionary.Add(ServerP2PConnectRequestCommand.Id, new ServerP2PConnectRequestCommand());
      commandDictionary.Add(ServerP2PReadyAcceptCommand.Id, new ServerP2PReadyAcceptCommand());
      commandDictionary.Add(ServerPingRequestCommand.Id, new ServerPingRequestCommand());
    }

    /// <summary>
    /// Версия и имя данного API.
    /// </summary>
    public string Name
    {
      get { return API; }
    }

    /// <summary>
    /// Извлекает команду.
    /// </summary>
    /// <param name="message">Пришедшее сообщение, по которому будет определена необходимая для извлекания команда.</param>
    /// <returns>Команда для выполнения.</returns>
    public IServerAPICommand GetCommand(byte[] message)
    {
      ushort id = BitConverter.ToUInt16(message, 0);

      IServerAPICommand command;
      if (commandDictionary.TryGetValue(id, out command))
        return command;

      return ServerEmptyCommand.Empty;
    }

    /// <summary>
    /// Напрямую соединяет пользователей.
    /// </summary>
    /// <param name="container"></param>
    public void IntroduceConnections(string senderId, IPEndPoint senderPoint, string requestId, IPEndPoint requestPoint)
    {
      using (var context = ServerModel.Get())
      {
        var content = new ClientWaitPeerConnectionCommand.MessageContent
        {
          RequestPoint = requestPoint,
          SenderPoint = senderPoint,
          RemoteInfo = context.Users[senderId],
        };

        ServerModel.Server.SendMessage(requestId, ClientWaitPeerConnectionCommand.Id, content);
      }
    }

    /// <summary>
    /// Посылает системное сообщение клиенту.
    /// </summary>
    /// <param name="nick">Пользователь получащий сообщение.</param>
    /// <param name="message">Сообщение.</param>
    public void SendSystemMessage(string nick, string message)
    {
      var sendingContent = new ClientOutSystemMessageCommand.MessageContent { Message = message };
      ServerModel.Server.SendMessage(nick, ClientOutSystemMessageCommand.Id, sendingContent);
    }

    /// <summary>
    /// Закрывает соединение.
    /// </summary>
    /// <param name="nick">Ник пользователя, соединение котрого будет закрыто.</param>
    public void CloseConnection(string nick)
    {
      ServerModel.Server.CloseConnection(nick);

      using (var server = ServerModel.Get())
      {
        foreach (string roomName in server.Rooms.Keys)
        {
          Room room = server.Rooms[roomName];

          if (!room.Users.Contains(nick))
            continue;

          room.Remove(nick);
          server.Users.Remove(nick);

          var sendingContent = new ClientRoomRefreshedCommand.MessageContent
          {
            Room = room,
            Users = room.Users.Select(n => server.Users[n]).ToList()
          };

          foreach (string user in room.Users)
          {
            if (user == null)
              continue;

            ServerModel.Server.SendMessage(user, ClientRoomRefreshedCommand.Id, sendingContent);
          }
        }
      }
    }
  }

Каждая команда реализует интерфейс команды. Для сервера IServerAPICommand, для клиента IClientAPICommand, на данном этапе их можно было бы свести к 1 интерфейсу, но мне этого делать почему то не хочется. Также она содержит свой Id и данные необходимые для ее выполнения, описывающееся классом MessageContent. Впрочем команде могут быть и не нужны данные. И она сама ответственна за то, что бы десериализовать набор байт в экземпляр класса.

Пример команды. В данному случае это команда добавления файла в комнату:

Код


  public interface IServerAPICommand
  {
    void Run(ServerCommandArgs args);
  }

  public class ServerCommandArgs
  {
    public string ConnectionId { get; set; }
    public byte[] Message { get; set; }
  }

  abstract class BaseCommand
  {
    protected static T GetContentFormMessage<T>(byte[] message)
    {
      using (MemoryStream messageStream = new MemoryStream(message))
      {
        messageStream.Position = sizeof(ushort);
        BinaryFormatter formatter = new BinaryFormatter();
        T receivedContent = (T)formatter.Deserialize(messageStream);
        return receivedContent;
      }
    }
  }

  class ServerAddFileToRoomCommand :
      BaseServerCommand,
      IServerAPICommand
  {
    public void Run(ServerCommandArgs args)
    {
      MessageContent receivedContent = GetContentFormMessage<MessageContent>(args.Message); //Извлекаем контент.

      if (receivedContent.File == null)
        throw new ArgumentNullException("File");

      if (string.IsNullOrEmpty(receivedContent.RoomName))
        throw new ArgumentException("RoomName");

      if (!RoomExists(receivedContent.RoomName, args.ConnectionId))
        return;

      using (var context = ServerModel.Get()) //Получаем доступ к модели, и блокируем ее
      {
        Room room = context.Rooms[receivedContent.RoomName];

        if (!room.Users.Contains(args.ConnectionId))
        {
          ServerModel.API.SendSystemMessage(args.ConnectionId, "Вы не входите в состав этой комнаты.");
          return;
        }

        if (room.Files.FirstOrDefault(file => file.Equals(receivedContent.File)) == null)
          room.Files.Add(receivedContent.File);

        var sendingContent = new ClientFilePostedCommand.MessageContent
        {
          File = receivedContent.File,
          RoomName = receivedContent.RoomName
        };

        //отправляем сообщения всем пользователям в комнате
        //в данном случае это сообщение о том что в комнату добавлен файл, как вы уже догадались
        foreach (string user in room.Users)
          ServerModel.Server.SendMessage(user, ClientFilePostedCommand.Id, sendingContent); 
      }
    }

    [Serializable]
    public class MessageContent
    {
      string roomName;
      FileDescription file;

      public string RoomName { get { return roomName; } set { roomName = value; } }
      public FileDescription File { get { return file; } set { file = value; } }
    }

    public const ushort Id = (ushort)ServerCommand.AddFileToRoom;
  }

Запись и воспроизведение звука.

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

Первым вариантом были WinApi функции waveIn* waveOut*. Это был самый простой вариант, поэтому начал с него. Но с ними не сложилось, т.к. на версии framework'a 3.5 неадекватно работал маршалинг на платформе x64 и при запуске приложение просто падало без каких либо исключений. При сборке под х86 все было нормально.

Дальше была попытка подключить DirectSound, но у него был найден свой баг с способом оповещения о завершении проигрывания куска данных. После гугления на эту тему выяснилось, что Mircosoft давно забросили DirectSound и работают с XAudio2. К тому же его использование привело бы к необходимости компиляции 2ух версий х86 и х64.

Так как мне не хотелось самому писать обертку для XAudio2, то я вспомнил про OpenAL. Для которого к тому же есть обертка (OpenTK), еще и с открытым исходным кодом. Из OpenTK был аккуратно вырезан только сама аудио библиотека. Которая сейчас и работает в программе.

Так как мне приходилось работать OpenGL ES 2, то и с OpenAL я подружился сразу. Особенное если учесть что по нему на официальном сайте OpenTK есть примеры.

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

Код можно посмотреть в ветке TCPChatEngineAudioOpenAL.


Сеть

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

Это было сделано просто добавлением размера сообщения в его начало.
То есть пакет представляет из себя следующее:
Первые 4 байта — размер сообщения.
5-6 байт — идентификатор команды.
Остальные данные это сериализованный MessageContent.

Далее на одном форуме мне предложили ввести передачу файлов и голосовую связь. С файлами я справился почти сразу, правда в первой версии файлы передавались через сервер, что было вообще ужасно. После этого я задумался над тем, что хорошо было бы передавать их на прямую. Как вы знаете с проблемой NAT, это сделать не так то и просто.

Я долго возился и пытался реализовать обход NAT используя TCP, тогда бы не пришлось парится по поводу ненадежности UDP. С ним так ничего и не получилось. После чего было решено использовать UDP и технологию UDP hole punching.

А для начала надо было решить проблемы надежности. Как обычно бывает, я начал трудится над своим протоколом поверх UDP обеспечивающим мне надежную доставку сообщений. И он все таки у меня получился, но работал только локально. При его тестировании в реальных условиях он, видимо, настолько нагружал сеть, что у меня полностью зависал компьютер.

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

Обход NAT реализован на уровне API. Схема простая, нужно всего лишь, что бы пиры узнали реальные адреса, по которым их видит сервер. После этого один пир должен кинуть сообщение другому. Это сообщение возможно не дойдет, но создаст правило на роутере, что сообщения от того адреса, по которому оно было отправлено нужно доставлять именно этому компьютеру. После этого с помощью сервера другой пир узнает, что ему уже можно подключатся и, собственно, подключается.

И так, последовательность действий:

  1. Клиент 1 говорит серверу, что хочет подключится к Клиенту 2. (Команда ServerP2PConnectRequestCommand)
  2. Сервер делегирует свою задачу классу P2PService
  3. P2PService смотрит не подключались ли к нему уже такие клиенты и не знает ли он уже их адреса. Если нет — просит подключится тех кто подключен небыл (Команда ClientConnectToP2PServiceCommand)
  4. После их подключения, P2PService отправляет одному из них команду ожидания подключения. В данному случае это Клиент 2. (ClientWaitPeerConnectionCommand)
  5. Клиент получивший команду, также получает адрес который будет к нему подключатся, и отправляет на него сообщение. Начинает ожидать подключение и отправляет серверу команду о том, что готов принять соединение. (ServerP2PReadyAcceptCommand)
  6. После получения команды готовности, сервер говорит другому клиенту (Клиент 1), что тот может подключатся. (ClientConnectToPeerCommand)
  7. Связь между клиентами установлена.

Как я писал свой чат

Черными стрелками обозначены отправки команд. Красным инициализации Lidgren.Network соединений.

Весь этот алгоритм спрятан в AsyncPeer, и достаточно вызвать метод SendMessage, и если клиент не подключен, он сам подключится, и отправит сообщение. Либо сразу отправит, если уже подключен.

Команды голосовой связи инициируют соединение немного иначе. При создании голосовой комнаты сервер создает в комнате карту подключений. В которой записано куда должен подключится каждый пользователь в комнате. А команды воспроизведения голоса отправляются с помощью метода AsyncPeer.SendMessageIfConnected, который просто выкидывает сообщение, если соединения нет.


Пользовательский интерфейс.

Напоследок немного об интерфейсе программы.
Он разработан с помощью WPF и паттерна MVVM. Очень гибкая технология, правда в версии 3.5 немного сыровата, но тем не менее позволяет обойти сыроватые места. Как на пример некоторые свойства Command все еще не зависимые, и для них пришлось написать обертку CommandReference.

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

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

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

В прочем, об интерфейсах написать больше нечего, WPF как WPF.

Автор: Nirvano

Источник


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


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