Cетевое взаимодействие посредством TCP в C# — свой велосипед

  • Tutorial


Приветствую!

Продолжу серию постов посвященных программированию, на этот раз я хочу поговорить на тему сетевого взаимодействие посредством TCP соединения между .Net приложениями. Статья может быть полезна новичкам или тем кто еще не сталкивался с сетью по отношению к .Net. Полностью работоспособный пример прилагается: http://yadi.sk/d/1OxmAFuCN3kmc.

Подробности под катом.

Зачем нужна эта статья

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

Немного теории

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

Моя реализация

В общем виде команда представляет собой объект с двумя методами «ToBytes» и «FromBytes», а также набором свойств которые мы хотим передать принимающей стороне.
SingleCommand
public class SingleCommand: BaseCommand
{
    public int IntField { get; set; }
    public decimal DecimalField { get; set; }

    //преобразует объект в массив байт
    public override byte[] ToBytes()
    {
        //вычисляем длину команды
        const int messageLenght = sizeof(int) + sizeof(decimal);

        //инициализируем массив байт в который будут сохраняться данные
        var messageData = new byte[messageLenght];
        using (var stream = new MemoryStream(messageData))
        {
            //записываем по очереди наши свойства
            var writer = new BinaryWriter(stream);
            writer.Write(IntField);
            writer.Write(DecimalField);
            return messageData;
        }
    }

    //возвращает объект из массива байт, критически важно считывать данные в том же порядке что и были записаны
    public static SingleCommand FromBytes(byte[] bytes)
    {
        using (var ms = new MemoryStream(bytes))
        {
            var br = new BinaryReader(ms);
            var command = new SingleCommand();

            command.IntField = br.ReadInt32();
            command.DecimalField = br.ReadDecimal();

            return command;
        }
    }
}


При необходимости отправки команды содержащий свойство переменной длины, например строка, в свойствах необходимо указывать длину этой строки:
StringCommand
public class StringCommand : BaseCommand
{
    //длина передаваемой строки
    private int StringFieldLenght { get; set; }
    
    //строка
    public string StringField { get; set; }

    public override byte[] ToBytes()
    {
        //преобразуем строку к массиву байт
        byte[] stringFieldBytes = CommandUtils.GetBytes(StringField);

        //задаем ко-во байт строки
        StringFieldLenght = stringFieldBytes.Length;

        //вычисляем длину команды в байтах
        int messageLenght = sizeof(int) + StringFieldLenght;

        var messageData = new byte[messageLenght];
        using (var stream = new MemoryStream(messageData))
        {
            var writer = new BinaryWriter(stream);
            
            //первым делом записываем длину строки
            writer.Write(StringFieldLenght);

            //записываем саму строки
            writer.Write(stringFieldBytes);
            return messageData;
        }
    }

    public static StringCommand FromBytes(byte[] bytes)
    {
        using (var ms = new MemoryStream(bytes))
        {
            var br = new BinaryReader(ms);
            var command = new StringCommand();

            //считываем из потока длину строки
            command.StringFieldLenght = br.ReadInt32();
            
            //считываем из потока указанное количества байт и преобразуем в строку             
            command.StringField = CommandUtils.GetString(br.ReadBytes(command.StringFieldLenght));

            return command;
        }
    }
}


Чтобы принимающая сторона узнала что за команда пришла, необходимо перед отправкой команды отослать заголовок, который указывает количество ( в примере этот момент упущен, прием идет только по одной команде) и тип команды:
CommandHeader
public struct CommandHeader
{
    // тип команды, соответствует перечислению CommandTypeEnum
    public int Type { get; set; }

    // количество команд
    public int Count { get; set; }

    public static int GetLenght()
    {
        return sizeof(int) * 2;
    }

    public static CommandHeader FromBytes(byte[] bytes)
    {
        using (var ms = new MemoryStream(bytes))
        {
            var br = new BinaryReader(ms);
            var currentObject = new CommandHeader();

            currentObject.Type = br.ReadInt32();
            currentObject.Count = br.ReadInt32();

            return currentObject;
        }
    }

    public byte[] ToBytes()
    {
        var data = new byte[GetLenght()];

        using (var stream = new MemoryStream(data))
        {
            var writer = new BinaryWriter(stream);
            writer.Write(Type);
            writer.Write(Count);
            return data;
        }
    }
}


В моем случае, взаимодействие сервера с клиентом, происходит по следующему алгоритму:
1. Клиент создает подключение.
2. Отправляет команду
3. Получает ответ.
4. Закрывает соединение.
5. Если ответ от сервера не пришел, отключается по таймауту.

Отправка команды серверу:
Вызов метода отправки команды на сервер
//создаем команду содержащую строку текста
var stringCommand = new StringCommand
{
    StringField = stringCommandTextBox.Text
};

//отправляем на локальный сервер
CommandSender.SendCommandToServer("127.0.0.1", stringCommand, CommandTypeEnum.StringCommand);


Тело метода команды отправки на сервер
public static void SendCommandToServer(string serverIp, BaseCommand command, CommandTypeEnum typeEnum)
{
    //создаем заголовок команды, которые указывает тип и количество
    var commandHeader = new CommandHeader
    {
        Count = 1,
        Type = (int)typeEnum
    };

    //соединяем заголовок и саму команду
    byte[] commandBytes = CommandUtils.ConcatByteArrays(commandHeader.ToBytes(), command.ToBytes());

    //отправляем на сервер
    SendCommandToServer(serverIp, Settings.Port, commandBytes);
}

private static void SendCommandToServer(string ipAddress, int port, byte[] messageBytes)
{
    var client = new TcpClient();
    try
    {
        client.Connect(ipAddress, port);

        //добавляем 4 байта указывающие на длину команды
        byte[] messageBytesWithEof = CommandUtils.AddCommandLength(messageBytes);
        NetworkStream networkStream = client.GetStream();
        networkStream.Write(messageBytesWithEof, 0, messageBytesWithEof.Length);

        //получаем и парсим от сервера ответ
        MessageHandler.HandleClientMessage(client);
    }
    catch (SocketException exception)
    {
        Trace.WriteLine(exception.Message + " " + exception.InnerException);
    }
}


Получение команд от клиентов на стороне сервера
public class CommandListener
{
    private readonly TcpListener _tcpListener;
    private Thread _listenThread;
    private bool _continueListen = true;

    public CommandListener()
    {
        //слушаем любой интерфейс на указанном порту
        _tcpListener = new TcpListener(IPAddress.Any, Settings.Port);
    }

    public void Start()
    {
        //прием команд ведется в отдельном потоке
        _listenThread = new Thread(ListenForClients);
        _listenThread.Start();
    }

    private void ListenForClients()
    {
        _tcpListener.Start();

        while (_continueListen)
        {
            TcpClient client = _tcpListener.AcceptTcpClient();

            //обработка каждой отдельной команды ведется в отдельном потоке
            var clientThread = new Thread(HandleClientCommand);
            clientThread.Start(client);
        }
        _tcpListener.Stop();
    }

    private void HandleClientCommand(object client)
    {
        //обработка команд
        MessageHandler.HandleClientMessage(client);
    }

    public void Stop()
    {
        _continueListen = false;
        _tcpListener.Stop();
        _listenThread.Abort();
    }
}


Обработка полученных команд:
public static void HandleClientMessage(object client)
{
    var tcpClient = (TcpClient)client;

    //задаем таймаут в три секунды
    tcpClient.ReceiveTimeout = 3;

    //получаем поток
    NetworkStream clientStream = tcpClient.GetStream();

    var ms = new MemoryStream();
    var binaryWriter = new BinaryWriter(ms);

    var message = new byte[tcpClient.ReceiveBufferSize];
    var messageLenght = new byte[4];
    int readCount;
    int totalReadMessageBytes = 0;

    //получаем общую длину сообщения
    clientStream.Read(messageLenght, 0, 4);

    //преобразуем к целому числу
    int messageLength = CommandUtils.BytesToInt(messageLenght);

    //считываем данные из потока пока не дошли до конца сообщения
    while ((readCount = clientStream.Read(message, 0, tcpClient.ReceiveBufferSize)) != 0)
    {
        binaryWriter.Write(message, 0, readCount);
        totalReadMessageBytes += readCount;
        if (totalReadMessageBytes >= messageLength)
            break;
    }

    if (ms.Length > 0)
    {
        //парсим полученные байты
        Parse(ms.ToArray(), tcpClient);
    }
}

private static void Parse(byte[] bytes, TcpClient tcpClient)
{
    if (bytes.Length >= CommandHeader.GetLenght())
    {
        CommandHeader commandHeader = CommandHeader.FromBytes(bytes);
        IEnumerable<byte> nextCommandBytes = bytes.Skip(CommandHeader.GetLenght());

        var commandTypeEnum = (CommandTypeEnum)commandHeader.Type;

        if (commandTypeEnum == CommandTypeEnum.MessageAccepted)
        {
            if (OnMessageAccepted != null)
                OnMessageAccepted();
        }
        else
        {
            BaseCommand baseCommand = BytesToCommands[commandTypeEnum].Invoke(nextCommandBytes.ToArray());

            switch (commandTypeEnum)
            {
                case CommandTypeEnum.StringCommand:
                    if (OnStringCommand != null)
                        OnStringCommand((StringCommand)baseCommand, tcpClient);
                    break;
                case CommandTypeEnum.SingleCommand:
                    if (OnSingleCommand != null)
                        OnSingleCommand((SingleCommand)baseCommand, tcpClient);
                    break;
                case CommandTypeEnum.FileCommand:
                    if (OnSingleCommand != null)
                        OnFileCommand((FileCommand)baseCommand, tcpClient);
                    break;
                case CommandTypeEnum.SaveUserCommand:
                    if (OnSingleCommand != null)
                        OnSaveUserCommand((SaveUserCommand)baseCommand, tcpClient);
                    break;
            }
        }
    }
}



Взаимодействие с Java

Команда передает на сервер одно значение
Команда
package com.offviewclient.network.commands;

import java.io.*;

public class IntCommand implements Serializable {

    public int IntNumber;

    public static int GetLenght()
    {
        return 4 ;
    }

    public static IntCommand FromBytes(byte[] bytes) throws IOException {
        ByteArrayInputStream inputStream = new ByteArrayInputStream(bytes);
        DataInputStream ois = new DataInputStream(inputStream);

        IntCommand commandType = new IntCommand();
        commandType.IntNumber = ois.readInt();
        return commandType;
    }

    public byte[] ToBytes() throws IOException {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        DataOutputStream  oos = new DataOutputStream (bos);
        oos.writeInt(this.IntNumber);
        byte[] yourBytes = bos.toByteArray();
        oos.close();
        bos.close();
        return  yourBytes;
    }
}


Отправка команды и получение ответа от сервера (код из рабочего проекта) :
    private void SendPacket(byte[] packetBytes) throws IOException {

		byte[]  packetBytesWithEOF = CommandUtils.AddCommandLength(packetBytes);

		Socket socket = new Socket(serverIP, port);
		socket.setSoTimeout(5000);

		OutputStream socketOutputStream = socket.getOutputStream();
		socketOutputStream.write(packetBytesWithEOF);

		byte[] answerBytes = ReadAnswerBytes(socket);
		socket.close();
		Parse(answerBytes);
    }

    private byte[] ReadAnswerBytes(Socket socket) throws IOException {
        InputStream out = socket.getInputStream();
        DataInputStream dis = new DataInputStream(out);

        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        DataOutputStream  binaryWriter = new DataOutputStream (bos);

        int readCount;
        byte[] message = new byte[10000];
        byte[] messageLength  = new byte[4];

        dis.read(messageLength , 0, 4);
        int messageLength = CommandUtils.BytesToInt(messageLength);

        int totalReadMessageBytes = 0;

        while ((readCount = dis.read(message, 0, 10000)) != 0)
        {
            binaryWriter.write(message, 0, readCount);
            totalReadMessageBytes +=  readCount;
            if(totalReadMessageBytes >= messageLength)
                break;
        }
        return bos.toByteArray();
    }

    private void Parse(byte[] messageBytes) throws IOException {
        if (messageBytes.length >= CommandHeader.GetLenght())
        {
            CommandHeader commandType = CommandHeader.FromBytes(messageBytes);

            int skipBytes =  commandType.GetLenght();

            if(commandType.Type == CommandTypeEnum.MESSAGE_ACCEPTED)
            {
                RiseMessageAccepted();
            }

            if(commandType.Type == CommandTypeEnum.SLIDE_PAGE_BYTES)
            {
                List<byte[]> drawableList = new Vector<byte[]>();

                for(int i = 0; i< commandType.Count; i++)
                {
                    PresentationSlideCommand presentationSlideCommand = PresentationSlideCommand.FromBytes(messageBytes, skipBytes);
                    drawableList.add(presentationSlideCommand.FileBytes);
                    skipBytes += presentationSlideCommand.GetLenght();
                }

                RiseMessageAcceptSlideEvent(drawableList);
            }
        }
    }


Важный момент при взаимодействии Java и .Net: java хранить байты элементарных типов по отношению к .Net наоборот, поэтому на стороне .Net все числовые значение надо разворачивать вызовом метода IPAddress.HostToNetworkOrder:

Использование

В качестве примера, научим сервер принимать команду на сохранение пользователя. Данные пользователя будут содержать имя и фамилию.
Для этого нужно:
1. Добавить новую команду в перечисление
public enum CommandTypeEnum : int
{
    StringCommand = 1,
    MessageAccepted = 2,
    SingleCommand = 3,
    FileCommand = 4,
    SaveUserCommand = 5 // команда на сохранение пользователя
}

2. Добавить данные передающиеся с командой:
public class SaveUserCommand : BaseCommand
{
    private int FirstNameLenght { get; set; }
    public string FirstName { get; set; } //имя пользователя

    private int SecondNameLenght { get; set; }
    public string SecondName { get; set; } //фамилия

    public override byte[] ToBytes()
    {
        byte[] firstNamebytes = CommandUtils.GetBytes(FirstName);
        FirstNameLenght = firstNamebytes.Length;

        byte[] secondNamebytes = CommandUtils.GetBytes(SecondName);
        SecondNameLenght = secondNamebytes.Length;

        int messageLenght = sizeof(int) * 2 + FirstNameLenght + SecondNameLenght; // длина сообщения

        var messageData = new byte[messageLenght];
        using (var stream = new MemoryStream(messageData))
        {
            var writer = new BinaryWriter(stream);
            writer.Write(FirstNameLenght);
            writer.Write(firstNamebytes);

            writer.Write(SecondNameLenght);
            writer.Write(secondNamebytes);

            return messageData;
        }
    }

    public static SaveUserCommand FromBytes(byte[] bytes)
    {
        using (var ms = new MemoryStream(bytes))
        {
            var br = new BinaryReader(ms);
            var command = new SaveUserCommand();

            command.FirstNameLenght = br.ReadInt32();
            command.FirstName = CommandUtils.GetString(br.ReadBytes(command.FirstNameLenght));

            command.SecondNameLenght = br.ReadInt32();
            command.SecondName = CommandUtils.GetString(br.ReadBytes(command.SecondNameLenght));

            return command;
        }
    }
}

3. Дополнить MessageHandler новым кейсом
case CommandTypeEnum.SaveUserCommand:
    if (OnSingleCommand != null)
        OnSaveUserCommand((SaveUserCommand)baseCommand, tcpClient);
    break;

4. Дополнить MessageHandler новым маппингом на делегат десериализации
private static void FillBytesToCommandsDictionary()
{
    BytesToCommands.Add(CommandTypeEnum.StringCommand, StringCommand.FromBytes);
    BytesToCommands.Add(CommandTypeEnum.SingleCommand, SingleCommand.FromBytes);
    BytesToCommands.Add(CommandTypeEnum.FileCommand, FileCommand.FromBytes);
    BytesToCommands.Add(CommandTypeEnum.SaveUserCommand, SaveUserCommand.FromBytes);
}

5. Отправляем новую команду на стороне клиента:
var saveUserCommand = new SaveUserCommand
{
    FirstName = firstNameTextBox.Text,
    SecondName = secondNameTextBox.Text
};

CommandSender.SendCommandToServer(serverIpTextBox.Text, saveUserCommand, CommandTypeEnum.SaveUserCommand);

6. Принимаем на стороне сервера:
MessageHandler.OnSaveUserCommand += CommandListener_OnSaveUserCommand;

private static void CommandListener_OnSaveUserCommand(SaveUserCommand saveUserCommand, TcpClient tcpClient)
{
    Console.WriteLine("SaveUserCommand accepted, FirstName:{0}, SecondName:{1}", saveUserCommand.FirstName, saveUserCommand.SecondName);
    CommandSender.SendMessageAcceptedToClient(tcpClient);
}


Надеюсь, что это все будет кому-то полезным. Демо проект на яндекс диске: http://yadi.sk/d/1OxmAFuCN3kmc.
Всем спасибо!
Метки:
  • +2
  • 27,4k
  • 9
Поделиться публикацией
Комментарии 9
  • 0
    Буду благодарен за конструктивную критику, просто я немного сконфужен, с одной стороны в избранное добавляют, соответственно статья имеет право на жизнь, с другой, большое количество минусов.
    Спасибо!
    • +2
      Половина статьи состоит из описания сериализатора. Далее вполне обычная работа с TcpClient/TcpListener, которая в msdn довольно подробно описана.

      //считываем из потока длину строки
      command.StringFieldLenght = br.ReadInt32();
      
      и верим ей…

      Пула коннектов (с пересоединением для балансировки) не хватает.

      Из конструктивного — WCF же.
      • 0
        >Из конструктивного — WCF же.
        WCF не всегда возможен / удобен, особенно если речь идет о разных платформах / языках программирования.

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

        >Половина статьи состоит из описания сериализатора. Далее вполне обычная работа с TcpClient/TcpListener, которая в msdn довольно подробно описана.
        В свое время я потратил довольно прилично времени, чтобы это все написать и исправить все найденные ошибки, поэтому и решил написать статью с работающим примером, чтобы кто-то воспользовался результатами моей работы и сэкономил себе время.
        • 0
          Предполагаю, что именно за примеры и добавляют в закладки.
          Лично мне нравится, когда я вбиваю в поисковик ".NET TcpClient пример" и попадаю на аналогичные статьи. Позже, за деталями, иду в документацию (которая будет на английском), но для первичного осмотра — вполне.
  • 0
    Замечание 1:
    Заголовок статьи не соответствует содержимому. Я, посмотрев на заголовок и преамбулу, подумал, что речь пойдёт о своём сокетном велосипеде. Даже подумать не мог, что здесь свой RPC изобретается.

    Замечание 2: В статье был изобретён свой RPC-велосипед, но при этом протокол не был описан, и его пришлось выуживать из исходников.
    Кстати, я вот что-то не припомню, инты BinaryReader читает/пишет в big endian или little endian? Такие вещи нужно обязательно описывать.

    Замечание 3: Не понятно, почему вместо сериализации, нужно было руками байты вытаскивать. Команд может быть очень много, и в процессе их ручной сериализации вы очень быстро начнёте делать ошибки.

    Замечание 4: Допустим, я — хакер. В процессе поиска дырочек, я перебираю все найденные порты, отправляя на них стандартный HTTP Get. Что произойдёт?
    Вот эта строка на нехорошие мысли наводит:
    CommandHeader commandHeader = CommandHeader.FromBytes(bytes);
    
    • +1
      Спасибо за комментарий, отвечу по пунктам:
      >Заголовок статьи не соответствует содержимому.
      На мой взгляд вполне соответствует.

      >Я, посмотрев на заголовок и преамбулу, подумал, что речь пойдёт о своём сокетном велосипеде
      Это и есть свой велосипед.

      > В статье был изобретён свой RPC-велосипед
      RPC — Remote Procudure Call, ничего такого у меня нет, просто отправка и прием команд с подробным описанием, хотя, возможно все таки описание не достаточно подробное.

      >Кстати, я вот что-то не припомню, инты BinaryReader читает/пишет в big endian или little endian
      Если я правильно помню, .net работает с little endian порядком. Если же вы хотите взаимодействовать с Java, то необходимо разворачивать порядок байт, я об этом упомянул в конце статьи.

      >Замечание 3: Не понятно, почему вместо сериализации, нужно было руками байты вытаскивать. Команд может быть очень много, и в процессе их ручной сериализации вы очень быстро начнёте делать ошибки.
      Вы правы, логичнее было бы сделать автоматический сериализатор, но я сделал именно так как сделал.

      >Замечание 4: Допустим, я хакер. В процессе поиска дырочек, я перебираю все найденные порты, отправляя на них стандартный HTTP Get. Что произойдёт?
      Это не понял, причем тут хакер и http get?

      >Вот эта строка на нехорошие мысли наводит: CommandHeader commandHeader = CommandHeader.FromBytes(bytes);
      Поясните, что именно смутило?
  • 0
    Раз статья о довольно низкоуровневой работе с сокетами, то, возможно, есть смысл детальнее описать сам протокол и как вы решили сами проблемы буферизации и полинга данных?
    • 0
      Спасибо за комментарий. Дополнил статью примером использования и обновил прикрепленный пример.
      Проблема буферизации и полинга данных в данном конкретном примере не актуальна за отсутствием таковой:
      1.Клиент создает подключение,
      2. Передает данные,
      3. Получает ответ и отключается.
      • 0
        Жаль. В первый раз именно эти задачи вызывают наибольшие проблемы.

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