В предыдущей статье я рассказал как создать сервер и клиент на Python 3, используя встроенные сокеты. Но у этого приложения было много недостатков, которые я попытаюсь исправить в этой и последующих статьях.
Так какими же недостатками обладает наше приложение?
Сегодня я расскажу как решить первую проблему, а заодно и немного о TCP.
Мы использовали «голый» протокол TCP для передачи данных между сервером и клиентом. TCP — это потоковый протокол, он передаёт данные последовательным набором байт. Передавая команду с аргументами по сети в первой версии нашего приложения мы читали только 1024 байт данных из принятого пакета. Но что делать, если данные не помещаются в 1024 байт? Выход только один — разбить данные на несколько пакетов на одном хосте и «склеить» их в один кусок при получении на другом хосте. Но как узнать когда заканчивается одна команда (с её аргументами) и начинается другая? Для этого нам нужно нужно знать, какова длина всего передаваемого сообщения.
Так как заранее узнать длину сообщения у нас не получиться, придётся передавать её в одном из пакетов. Разумеется, делать это лучше в самом начале первого пакета. Выделив под хранение длины сообщения всего 4 байта, мы сможем передать сообщение длиной свыше 4 млрд символов! Длина сообщения — это информация о нём, то есть, часть заголовка, заголовка нашего протокола. Какого протокола спросите вы? Если верить Википедии, то
Мы договорились, что будем передавать данные в нескольких пакетах по TCP, а в начале данных первого пакета будет храниться длина всего сообщения в байтах. Таким образом мы разработали наш простой протокол! Нужно помнить, что наш протокол основан на TCP, а значит, обладает теми же особенностями, что и последний.
Мы описали наш протокол, настало время его разработать!
Метод sendall сокета в Питоне самостоятельно разбивает данные на пакеты и отправляет их на сервер. Здесь можно не париться. Для передачи длины переведём её в C struct типа unsigned int (4 байта) используя встроенную библиотеку struct.
Параметр '>I', переданный функции struct.pack просит перевести второй параметр в тип unsigned int (I) в обратном порядке байт (>). Рассмотрим это чуть позже.
В функции recv_packets мы читаем уже полученные данные до тех пор, пока не получим часть сообщения необходимой нам длины. Если метод recv сокета ничего не вернёт, значит мы не смогли получить сообщение полностью. В этом случае тоже нечего не возвращаем, а тогда ничего не вернёт и функция recv нашего протокола.
Теперь мы можем воспользоваться нашим протоколом, написанным поверх другого протокола — TCP. Заодно, я буду рассказывать о том, что происходит в этот момент под капотом.
Для начала зайдите в каталог с только что созданным файлом protocol.py и запустите интерпретатор Python 3 (обычно это команда python3) в двух терминалах, интерпретаторах командных строк, или среде разработки. В обоих поочерёдно введите следующие команды.
Первая строка импортирует библиотеку сокетов и наш мини-протокол, вторая — создаёт сокет с протоколом TCP/IPv4. AF_INET — пара (домен/IPv4, порт), SOCK_STREAM — потоковый тип подключения, на котором основан TCP протокол.
Следующие две строки вводятся в первый терминал.
В этот момент операционная система система занимает для нашего приложения адрес localhost:65043 и наше приложение начинает его слушать. Если вы ещё не поняли, сокет — это программный интерфейс, который создаётся операционной системой.
Присоединяемся к серверу через второй терминал.
В этот момент происходит следующее. Клиент отправляем небольшой пакет. Первый пакет у меня имеет длину 74 байта. (длина не всегда такая, я привожу её, чтобы было примерно понятно, что же отправляется в момент установления соединения.) Их можно разложить так: 8 байт — два Ethernet адреса, 20 байт — IP-заголовок, и 46 байт — TCP-заголовок. В пакете есть два бита из TCP-заголовка — syn и ack. В первом пакете syn = 1, ack = 0. После этого сервер отправляет нам пакет такой же длины (74 байта), подтверждая получение и разрешая подключиться, при этом syn = 1, ack = 1. Затем клиент отправляет уже третий пакет, но длиной не 74, а 66 байт. В третьем пакете syn = 0, ack = 1. Этот пакет окончательно устанавливает соединение и теперь мы можем принимать и получать пакеты. Так выглядит благополучное соединение. Если вам хочется более подробно изучить TCP и другие возможные случаи, можете прочитать об этом, например, в книге Танентбаума «Компьютерные сети».
Читаем информацию о клиенте. В этот момент никаких пакетов не передаётся, мы просто берём уже записанные данные.
Во втором терминале вводим
Этой строчкой мы отправляем сообщение серверу, используя наш мини-протокол.
В этот момент мы отправляем серверу пакет со следующим содержанием (исключая заголовки):
00:00:00:11 — 0x11, или 17 в десятичной системе счисления, — длина переданного сообщения (не данных, а именно сообщения, так как данные, в данном случае, — это 4 байта длины + сообщение).
48:65:6c:6c:6f:2c:20:6c:6f:63:61:6c:68:6f:73:74:21 — переданная строка Hello, localhost!
Сервер отвечает нам другим пакетом, подтверждая получение.
И наконец читаем и декодируем сообщение в первом терминале, клиенте:
Можно разорвать соединение, введя во втором терминале
Клиент посылает серверу пакет с установленным битом fin, говоря о том, что у него больше нет данных для передачи и он хочет разорвать соединение. В ответ сервер так же отправляет пакет с битом fin = 1.
Эта же команда в первом терминале остановит работу сервера и он перестанет слушать.
После всего проделанного мы можем легко передавать сообщения между клиентским и серверным приложениями. Изменения не сильно отличаются, так что я не стал приводить листинги кода в статье, а выложил проект на GitHub. В следующей статье я расскажу про безопасность. Если вам интересно что-то ещё, о чём вы хотели бы услышать в следующих статьях, то пишите об этом в комментариях.
Так какими же недостатками обладает наше приложение?
- Отсылается один единственный пакет, длина которого не может превышать заранее заданной границы в 1 Кбайт.
- Приложение без проверки передаёт аргумент, принятый из сети, в оболочку (URL).
- Недостаток функциональности. Мы не можем, например, скачать все изображения с Хабра, или скачать отдельный хаб.
Сегодня я расскажу как решить первую проблему, а заодно и немного о TCP.
Описание протокола
Мы использовали «голый» протокол TCP для передачи данных между сервером и клиентом. TCP — это потоковый протокол, он передаёт данные последовательным набором байт. Передавая команду с аргументами по сети в первой версии нашего приложения мы читали только 1024 байт данных из принятого пакета. Но что делать, если данные не помещаются в 1024 байт? Выход только один — разбить данные на несколько пакетов на одном хосте и «склеить» их в один кусок при получении на другом хосте. Но как узнать когда заканчивается одна команда (с её аргументами) и начинается другая? Для этого нам нужно нужно знать, какова длина всего передаваемого сообщения.
Так как заранее узнать длину сообщения у нас не получиться, придётся передавать её в одном из пакетов. Разумеется, делать это лучше в самом начале первого пакета. Выделив под хранение длины сообщения всего 4 байта, мы сможем передать сообщение длиной свыше 4 млрд символов! Длина сообщения — это информация о нём, то есть, часть заголовка, заголовка нашего протокола. Какого протокола спросите вы? Если верить Википедии, то
Протокол передачи данных — набор соглашений интерфейса логического уровня, которые определяют обмен данными между различными программами.
Мы договорились, что будем передавать данные в нескольких пакетах по TCP, а в начале данных первого пакета будет храниться длина всего сообщения в байтах. Таким образом мы разработали наш простой протокол! Нужно помнить, что наш протокол основан на TCP, а значит, обладает теми же особенностями, что и последний.
Разработка протокола
Мы описали наш протокол, настало время его разработать!
Метод sendall сокета в Питоне самостоятельно разбивает данные на пакеты и отправляет их на сервер. Здесь можно не париться. Для передачи длины переведём её в C struct типа unsigned int (4 байта) используя встроенную библиотеку struct.
import struct
# Функция отправки данных
def send(connection, data):
# Трансформируем строку в массив байт
data = bytes(data, "utf8")
# Отправляем C struct типа unsigned int размером 4 байта, хранящие длину сообщения. Данные содержат 4 байта, хранящие длину сообщения, плюс само сообщение.
connection.sendall(struct.pack('>I', len(data)) + data)
Параметр '>I', переданный функции struct.pack просит перевести второй параметр в тип unsigned int (I) в обратном порядке байт (>). Рассмотрим это чуть позже.
# Вспомогательная функция
def recv_packets(connection, n):
piece = b''
# Пока не получим кусок данных необходимой длины <b>n</b>
while len(piece) < n:
# Читаем участок длиной не более, чем нам недостаёт до длины <b>n</b>
packet = connection.recv(n - len(piece))
# Если пакет получить не удалось, то ничего не возвращаем
if not packet:
return None
piece += packet
return piece
# Функция чтения принятых данных
def recv(connection):
# Читаем участок, содержащий длину пакета
length_data = recv_packets(connection, 4)
# Если получить не удалось, то ничего не возвращаем
if not length_data:
return None
# Переводим в питоновский тип
data_len = struct.unpack('>I', length_data)[0]
# Декодируем, используя кодировку UTF-8. Можно использовать аргументы "utf8", "utf-8", "UTF8", "UTF-8", это одно и то же.
return recv_packets(connection, data_len).decode("utf8")
В функции recv_packets мы читаем уже полученные данные до тех пор, пока не получим часть сообщения необходимой нам длины. Если метод recv сокета ничего не вернёт, значит мы не смогли получить сообщение полностью. В этом случае тоже нечего не возвращаем, а тогда ничего не вернёт и функция recv нашего протокола.
Использование протокола
Теперь мы можем воспользоваться нашим протоколом, написанным поверх другого протокола — TCP. Заодно, я буду рассказывать о том, что происходит в этот момент под капотом.
Для начала зайдите в каталог с только что созданным файлом protocol.py и запустите интерпретатор Python 3 (обычно это команда python3) в двух терминалах, интерпретаторах командных строк, или среде разработки. В обоих поочерёдно введите следующие команды.
>>> import socket, protocol
>>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
Первая строка импортирует библиотеку сокетов и наш мини-протокол, вторая — создаёт сокет с протоколом TCP/IPv4. AF_INET — пара (домен/IPv4, порт), SOCK_STREAM — потоковый тип подключения, на котором основан TCP протокол.
Следующие две строки вводятся в первый терминал.
>>> sock.bind(("localhost", 65043))
>>> sock.listen(True)
В этот момент операционная система система занимает для нашего приложения адрес localhost:65043 и наше приложение начинает его слушать. Если вы ещё не поняли, сокет — это программный интерфейс, который создаётся операционной системой.
Присоединяемся к серверу через второй терминал.
>>> sock.connect(("localhost", 65043))
В этот момент происходит следующее. Клиент отправляем небольшой пакет. Первый пакет у меня имеет длину 74 байта. (длина не всегда такая, я привожу её, чтобы было примерно понятно, что же отправляется в момент установления соединения.) Их можно разложить так: 8 байт — два Ethernet адреса, 20 байт — IP-заголовок, и 46 байт — TCP-заголовок. В пакете есть два бита из TCP-заголовка — syn и ack. В первом пакете syn = 1, ack = 0. После этого сервер отправляет нам пакет такой же длины (74 байта), подтверждая получение и разрешая подключиться, при этом syn = 1, ack = 1. Затем клиент отправляет уже третий пакет, но длиной не 74, а 66 байт. В третьем пакете syn = 0, ack = 1. Этот пакет окончательно устанавливает соединение и теперь мы можем принимать и получать пакеты. Так выглядит благополучное соединение. Если вам хочется более подробно изучить TCP и другие возможные случаи, можете прочитать об этом, например, в книге Танентбаума «Компьютерные сети».
>>> conn, addr = sock.accept()
Читаем информацию о клиенте. В этот момент никаких пакетов не передаётся, мы просто берём уже записанные данные.
Во втором терминале вводим
>>> protocol.send(sock, "Hello, localhost!")
Этой строчкой мы отправляем сообщение серверу, используя наш мини-протокол.
В этот момент мы отправляем серверу пакет со следующим содержанием (исключая заголовки):
00:00:00:11:48:65:6c:6c:6f:2c:20:6c:6f:63:61:6c:68:6f:73:74:21
00:00:00:11 — 0x11, или 17 в десятичной системе счисления, — длина переданного сообщения (не данных, а именно сообщения, так как данные, в данном случае, — это 4 байта длины + сообщение).
48:65:6c:6c:6f:2c:20:6c:6f:63:61:6c:68:6f:73:74:21 — переданная строка Hello, localhost!
Сервер отвечает нам другим пакетом, подтверждая получение.
И наконец читаем и декодируем сообщение в первом терминале, клиенте:
>>> protocol.recv(conn)
Можно разорвать соединение, введя во втором терминале
>>> sock.close()
Клиент посылает серверу пакет с установленным битом fin, говоря о том, что у него больше нет данных для передачи и он хочет разорвать соединение. В ответ сервер так же отправляет пакет с битом fin = 1.
Эта же команда в первом терминале остановит работу сервера и он перестанет слушать.
Заключение
После всего проделанного мы можем легко передавать сообщения между клиентским и серверным приложениями. Изменения не сильно отличаются, так что я не стал приводить листинги кода в статье, а выложил проект на GitHub. В следующей статье я расскажу про безопасность. Если вам интересно что-то ещё, о чём вы хотели бы услышать в следующих статьях, то пишите об этом в комментариях.
Комментарии (8)
Scratch
19.10.2015 21:58Protocol Buffers же, зачем велосипед?
SemperPeritus
20.10.2015 05:40Чтобы понять, как работают с сетями в Python и вообще с сетями в программировании.
Первая часть начинается с этого предложения:
Проект был написан скорее в учебных целях (научиться сетевому программированию в Python), чем в практических.
stavinsky
Вопрос: Почему не сделать через датаграммы, концом которых будет например двойной возврат каретки. Так мы можем делить полученные данные на команды. И не надо считать сколько данных мы передаем. Мне почему-то показался такой вариант красивее.
stavinsky
А вообще интрересно как будет реализована многопоточность или асинхронность. Сейчас, если я правильно понимаю, сервер может принять лишь одно подключение.
SemperPeritus
Да, всё верно, сервер может работать только с одним соединением одновременно. Если мы, например, отошлём две команды, разделённые на несколько пакетов, примерно в одно и то же время, то сервер может часть их них прочитать как одну команду, а остальное как другую. Первую «некорректную» команду он попытается исполнить, что у него не получиться, а вторую, скорее всего, принять не сможет, а если сможет, то разделит на ещё большее количество команд.
stavinsky
насколько я понимаю мы второй раз просто не подключимся.
Мне кажется в tcp с UDP немного путаете.
SemperPeritus
Такой вариант тоже имеет право на существование. Но обычно в «сообщении» имеется заголовок, только после передачи которого передаются сами данные. Эта информация может содержать длину сообщения, контрольную сумму и другие параметры. В плане надёжности оба варианта примерно одинаковы. Если мы прочитаем пакеты не в том порядке, то вряд ли сможем это понять (если не перепутаны первый и последний пакеты в первом и втором вариантах соответственно) и будем использовать принятые данные как корректные. Для избежания подобного можно было бы передавать не только длину сообщения, но и контрольную сумму. А если мы потеряем какой-то пакет, то не сможем возобновить передачу, но это не столь критично, так как данные очень небольшие (по крайней мере пока).
stavinsky
tcp гарантирует очередность доставки сообщений. Это дает очень интересный эффект на IP телефонии например. Но тут на этот эффект можно смело положиться