О TCP сокете для чайников

6 июля 2012

Очень упрощенный рассказ про TCP сокет для тех, кто не в теме :)

Сидел я, писал внутреннюю документацию по своему проекту. И нужно было, помимо прочего, описать реализацию сокета со стороны клиента. Я сам делал эту реализацию на Java для специфического клиента, выполняющего функциональное и стресс тестирование сервера. А нужна будет еще одна реализация на .NET для Unity приложения, которое и будет настоящим клиентом моего сервера. И эту реализацию будет писать другой разработчик.

И вот писал я о своем Java сокете, и понял, что неплохо было бы сперва рассказать, как вообще работает TCP сокет. И понял, что такой рассказ можно выложить публично, ибо это уже не есть специфическая внутренняя документация. Ну вот и выкладываю :)

Как работает сокет на низком уровне? Речь идет о TCP Full Duplex сокете, без всяких надстроек типа HTTP протокола. Full Duplex -- это две трубы. По одной трубе данные текут с клиента на сервер. По другой трубе текут с сервера на клиент. Текут они маленькими пакетами, например, по 1500 байт (в зависимости от настроек сети).

Трубы работают независимо друг от друга. То, что течет по одной, никак не мешает тому, что течет по другой.

И чтобы с этим работать, нужно решить две проблемы.

Проблема извлечения данных из сокета

Вот клиент что-то посылает на сервер, какой-то цельный кусок данных. Он может весь уместиться в один пакет. А может не уместиться, может быть разбит на несколько пакетов. TCP гарантирует, что все эти пакеты дойдут, и дойдут в нужном порядке. Но сервер как-то должен знать, как из этих пакетов опять собрать цельный кусок данных.

Давайте условно представим, что клиент посылает такой запрос:

{
action:"login",
name:"Bob",
password:"123"
}

Мы сейчас вообще не трогаем тему сериализации данных, в каком формате они передаются. Предположим, у нас есть такой объект, как-то описанный на том языке программирования, на котором мы пишем клиентскую часть. И этот объект каким-то образом сериализован в массив байтов. Допустим, в сериализованом виде он будет выглядеть так:

{action:"login",name:"Bob",password:"123"}

Допустим, объект большой, и массив байтов получился большой. В один пакет он не влез, был разделен и пошел по трубе в виде 3х пакетов:

{action:"login",n
ame:"Bob",passwor
d:"123"}

Стало быть, сервер читает из трубы первый кусок {action:"login",n. И что ему с этим делать? Можно попробовать десериализовать. Если получим ошибку, то будет ясно, что данные не полные, и нужно получить больше. И так каждый раз, когда что-то приходит из трубы, мы будем пытаться это десериализовать. Получилось -- хорошо, интерпретируем и отправляем дальше на обработку. Не получилось -- ждем больше данных.

Но тут плохо то, что лишние попытки десериализации будут создавать лишнюю нагрузку на CPU. Нужен другой вариант.

В Erlang модуль gen_tcp предлагает разные варианты решения этой задачи. Давайте воспользуемся тем, что уже есть. Например, есть вариант, в котором сервер предполагает, что каждый клиентский запрос имеет заголовок. И в заголовке указана длинна данных, составляющих этот запрос.

То есть, целый запрос выглядит так:

42{action:"login",name:"Bob",password:"123"}

А разбитый на пакеты так:

42{action:"login"
,name:"Bob",passw
ord:"123"}

И когда на сервер приходит 42{action:"login", то сервер читает заголовок, видит в нем длину запроса -- 42 байта, и понимает, что нужно дождаться, пока придут эти 42 байта. И после этого данные можно десериализовать и интерпретировать. Например, интерпретация может заключаться в том, что сервер вызовет у себя метод login с аргументами "Bob" и "123". Точно также будет извлекать данные и клиент, когда он будет получать их с сервера.

Размер заголовка может быть 1 или 2 или 4 байта. Такие варианты предлагает gen_tcp, когда используется в активном режиме. (А в пассивном режиме мы сами извлекаем и интерпретируем этот заголовок, так что вольны делать как угодно).

Какой размер заголовка лучше? В 1 байт влезет число 2 ^ 8 = 256. Значит запрос не может быть больше 256 байт. Это слишком мало. В 2 байта влезет число 2 ^ 16 = 65536. Значит запрос может быть до 65536 байт. Этого вполне достаточно для большинства случаев.

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

Взять-то взял, но меня душит жаба :) Таких больших запросов будет немного. В основном все запросы будут маленькими, но все равно все они будут использовать 4-х байтный заголовок. Тут есть почва для оптимизации. Например, можно использовать два заголовка. Первый, однобайтный, будет указывать длину второго. А второй, 1-4 байтный, будет указывать длину пакета :) Или можно использовать безразмерный int, занимающий 1-4 байта, как это сделано в AMF сериализации. При желании можно сэкономить трафик.

Конечно, такая мелочная оптимизация только рассмешит тех, кто использует HTTP :) Ибо HTTP не мелочится, и в каждом запросе посылает нехилую пачку метаданных, совершенно не нужных серверу, и потому транжирит трафик в масштабах не сравнимых с моим аккуратным TCP сокетом :)

Проблема сопоставления запросов и ответов

Вот клиент сделал запрос, и чуть позже из другой трубы к нему что-то пришло. Что это, ответ на последний запрос? Или ответ на какой-то более ранний запрос? Или вообще не ответ, а активный пуш данных по инициативе сервера? Клиент должен как-то знать, что с этим делать.

Хороший вариант -- каждый запрос клиента должен иметь уникальный идентификатор. Ответ с сервера будет иметь такой же идентификатор. Так что можно будет определить, на какой именно запрос пришел ответ.

Вообще нам нужны три варианта взаимодействия клиента и сервера:

(На самом деле есть и 4й вариант, когда сервер активно посылает запрос на клиент и хочет получить ответ. Но мне такой вариант никогда не был нужен и я его не реализовывал).

В первом случае мы добавляем в запрос идентификатор:

{qid:15,action:"login",name:"Bob",password:"123"}

И получаем ответ:

{qid:15,success:true}

Во втором случае мы не добавляем в запрос идентификатор:

{action:"logout"}

И тогда сервер знает, что ответ не требуется, и не отвечает.

В третьем случае данные с сервера приходят без идентификатора:

{event:"invitation",fromUser:"Bill",msg:"Hello, wanna chat?"}

И тогда клиент знает, что это не ответ на какой-то запрос, а активный пуш с сервера.

Некоторые детали реализации

Отправляем данные с клиента с 4х байтным заголовком:

protected void send(byte[] data) {
    try {
        byte[] header = new byte[]{
            (byte) (data.length >>> 24),
            (byte) (data.length >>> 16),
            (byte) (data.length >>> 8),
            (byte) (data.length)
        };
        out.write(header);
        out.write(data);
        out.flush();
    } catch (IOException ioException) {
        ioException.printStackTrace();
    }
}

Читаем данные на сервере

handle_info(read_data, #state{socket = Socket, transport = Transport} = State) ->
    case Transport:recv(Socket, 4, 500) of
        {ok,  <<Size:32/integer>>} ->
            {ok, RawData} = Transport:recv(Socket, Size, infinity),
            do_something(RawData),
            {noreply, State};
        {error, timeout} -> self() ! read_data,
            {noreply, State};
        _ -> ok = Transport:close(Socket),
            {stop, normal, State}
    end;

Отправляем данные с сервера

Reply = some_data(),
RSize = byte_size(Reply),
Transport:send(Socket, <<RSize:32, Reply/binary>>).

Читаем данные на клиенте

protected byte[] receive() throws IOException {
    int b1 = in.read();
    int b2 = in.read();
    int b3 = in.read();
    int b4 = in.read();
    int len = ((b1 << 24) + (b2 << 16) + (b3 << 8) + b4);
    byte[] data = new byte[len];
    in.read(data);
    return data;
}

И последнее: запись и чтение данных на клиенте должны работать в разных потоках, ибо чтение -- блокирующая операция. Пока процесс висит на in.read(data), он больше ничего не может делать. А процесс висит там большую часть времени :)

comments powered by Disqus