Erlang russian про tcp сокеті для чайників

Про TCP сокеті для чайників

Дуже спрощений розповідь про TCP сокет для тих, хто не в темі :)

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

І ось писав я про своє Java сокеті, і зрозумів, що непогано було б спершу розповісти, як взагалі працює TCP сокет. І зрозумів, що така розповідь можна викласти публічно, бо це вже не є специфічна внутрішня документація. Ну ось і викладаю :)

Як працює сокет на низькому рівні? Йдеться про TCP Full Duplex сокеті, без всяких надбудов типу HTTP протоколу. Full Duplex - це дві труби. По одній трубі дані течуть з клієнта на сервер. За іншою трубі течуть з сервера на клієнт. Течуть вони маленькими пакетами, наприклад, по 1500 байт (в залежності від налаштувань мережі).

Труби працюють незалежно один від одного. Те, що тече по одній, ніяк не заважає тому, що тече по інший.

І щоб з цим працювати, потрібно вирішити дві проблеми.

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

Ось клієнт щось посилає на сервер, якийсь цілісний шматок даних. Він може весь вміститися в один пакет. А може не вміститися, може бути розбитий на кілька пакетів. TCP гарантує, що всі ці пакети дійдуть, і дійдуть в потрібному порядку. Але сервер якось повинен знати, як з цих пакетів знову зібрати цілісний шматок даних.

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

Ми зараз взагалі не чіпаємо тему сериализации даних, в якому форматі вони передаються. Припустимо, у нас є такий об'єкт, як-то описаний на тій мові програмування, на якому ми пишемо клієнтську частину. І цей об'єкт якимось чином серіалізовані в масив байтів. Припустимо, в серіалізованом вигляді він буде виглядати так:

Припустимо, об'єкт великий, і масив байтів вийшов великий. В один пакет він не вліз, був розділений і пішов по трубі у вигляді 3-х пакетів:

Стало бути, сервер Новомосковскет з труби перший шматок. І що йому з цим робити? Можна спробувати десеріалізовать. Якщо отримаємо помилку, то буде ясно, що дані не повні, і потрібно отримати більше. І так кожен раз, коли щось приходить з труби, ми будемо намагатися це десеріалізовать. Вийшло - добре, інтерпретуємо і відправляємо далі на обробку. Не вийшло - чекаємо більше даних.

Але тут погано те, що зайві спроби десеріалізациі створюватимуть зайве навантаження на CPU. Потрібен інший варіант.

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

Тобто, цілий запит виглядає так:

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

І коли на сервер приходить 42. то сервер Новомосковскет заголовок, бачить в ньому довжину запиту - 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й варіант, коли сервер активно надсилає запит на клієнт і хоче отримати відповідь. Але мені такий варіант ніколи не був потрібен і я його не реалізовував).

У першому випадку ми додаємо в запит ідентифікатор:

І отримуємо відповідь:

У другому випадку ми не додаємо в запит ідентифікатор:

Схожі статті