Все мы пользуемся различными приложениями для разных целей. Банковские приложения для операций с денежными средствами, мессенджеры для общения. Они принимают внутрь себя команду от человека и возвращают ответ. Банальный запрос ответ, но это так кажется с первого взгляда. Эти операции называются Input Output и они является самой распространённой операцией в сети. Предлагаю сегодня разобраться как они работают.
Определение
Операции ввода-вывода (I/O) - это операции, которые связанны с получением или отправкой данных, они требуют взаимодействия с внешними источниками (например, файловой системой, сетью, базой данных, сервером и т.д.). В контексте сетевого I/O они не используют процессор напрямую.
В блокирующем/синхронном режиме они блокируют выполнение программы в ожидании ответа от удалённого сервера или устройства.
В неблокирующем/асинхронном режиме они не блокируют выполнения программы в ожидании ответа а занимаются отправкой нового запроса.
Классификация IO
Грубо можно разделить на несколько групп, так как официальной классификации мною не было найдено:
File IO - работает с файлами и каталогами на диске через файловую систему;
Device IO - работа с данными физических устройств: клавиатуры, мыши, диски, ...;
Console IO - работает с вводом/выводом в терминале;
Network IO - работает с данными по сети через сетевые сокеты;
Inter-Process IO - работает с данными между процессами в пределах одного устройства;
Инструменты IO
В Python существует множество библиотек для работы с различным IO. Каждая из них имеет свои особенности и области применения. Перечислять все библиотеки для каждого IO мы не будем, расскажем про самые популярные и чуть-чуть затронем Linux утилиты.
Инструмент | Прикладной протокол | Транспортный протокол | Тип | Применение |
HTTP | TCP | Network IO | Синхронная Python библиотека для работы с веб-ресурсами. | |
HTTP | TCP | Network IO | Асинхронная Python библ��отека для работы с веб-ресурсами. | |
HTTP | TCP | Network IO | Асинхронная/синхронная Python библиотека для работы с веб-ресурсами. | |
FTP | TCP | Network IO | Синхронная Python библиотека для работа с FTP-серверами. | |
SSH | TCP | Network IO | SSH Python клиент. | |
TCP | Network IO | Асинхронный Python драйвер для СУБД PostgreSQL. | ||
TCP | Network IO | Cинхронный Python драйверы для СУБД MySQL. | ||
- | - | File IO | Асинхронная Python библиотека для работы с файлами. | |
- | - | File IO | Синхронная Python функция для чтения файлов. | |
touch, cat, more | - | - | Console IO | Синхронные UNIX утилиты для работы с файлами. |
- | - | Inter-Process IO | Работа с общей памятью между процессами на одном устройстве. | |
- | - | Device IO | Работа с портами и интерфейсами. |
Все эти библиотеки предоставляют удобный интерфейс для работы с разными источниками: с файлами, веб-ресурсами, СУБД, серверами, портами, памятью.
В рамках данной статьи мы рассмотрим только Network IO.
Принцип работы Network IO
Рассмотрим как оно работает максимально подробно насколько нам это позволит Python. Для демонстрации будем использовать это API: http://jsonplaceholder.typicode.com/posts/1 Ниже представлен код, который будет разобран по шагам.
import socket class Fetch: def __init__(self): self._port = 80 self.path = "/posts/1" self.host = "jsonplaceholder.typicode.com" self.response = b"" self._family = socket.AF_INET self._type = socket.SOCK_STREAM def __call__(self) -> bytes: ip = self.request_to_dns() # 1 request = self.create_request() # 2 sock = self.create_socket() # 3 sock.connect((ip, self._port)) # 4 sock.sendall(request) # 5 self.read_response(sock) # 6 sock.close() print("Socket is closed\n") return self.response def request_to_dns(self) -> str: response = socket.getaddrinfo(self.host, self._port, self._family, self._type) print(f"Response from dns: {response}") first_tuple = response[0] print(f"First tuple in response from dns: {first_tuple}") ip = first_tuple[4][0] print(f"Resolved: {self.host} -> {ip}:{self._port}") return ip def create_socket(self): sock = socket.socket(self._family, self._type) print("Socket created") return sock def create_request(self) -> bytes: headers = [f"Host: {self.host}", "Connection: close"] http_request = f"GET {self.path} HTTP/1.1\r\n" + "\r\n".join(headers) + "\r\n\r\n" print(f"Create HTTP request") return http_request.encode() def read_response(self, sock): print("Wait for response") while True: chunk = sock.recv(4096) if not chunk: break self.response += chunk print(Fetch()().decode())
Шаг первый - запрос в DNS
Прежде чем получить информацию от Web ресурса нам сначала необходимо получить все доступные его адреса, которые хранятся в DNS. Это можно сделать с помощью функции socket.getaddrinfo которая является оберткой над libc, стандартной библиотекой языка Си. Для автоматизации процесса в функцию передаются все данные о будущем соединении в виде параметров. Таким образом можно получить сразу все подходящие адреса(IPv4 или IPv6) для запроса.
Параметры socket.getaddrinfo:
family - Семейство адресов(socket.AF_INET (IPv4), socket.AF_INET6 (IPv6), etc);
type - Тип сокета(socket.SOCK_STREAM (TCP), socket.SOCK_DGRAM (UDP), etc);
proto - Указания конкретного протокола(socket.IPPROTO_TCP, socket.IPPROTO_UDP);
flags - Флаги управления поведением запроса;
Шаг второй - создание запроса
Данный шаг создает специальное HTTP сообщение, потому что будет использован прикладной протокол HTTP (версии 1.1).
Ниже представлен абстрактный пример сообщения.
GET /articles/42 HTTP/1.1 // Request line. Хранит метод, путь и версию протокола. Host: example.com Host: example.com // Обязательный заголовок в HTTP/1.1. Содержит доменное имя сервера. Connection: close // Указывает серверу, что после ответа соединение можно закрыть. User-Agent: CustomClient/1.0 // Заголовок, сообщающий серверу, кто делает запрос(Iphone, Bot, Mac, etc). Accept: application/json // Указывает, что клиент готов принять любой тип содержимого в ответ. ... // Еще какие-то заголовки. // Пустая строка обязательный разделитель головы и тела запроса. name=John+Doe&age=30&city=NY // Тело запроса. Для GET запроса обычно не используется.
Сообщение необходимо серверу для понимания что мы хотим от него:
Закрывать ли сразу соединение?
В какой хост мы отправляем запрос?
В какое конкретно API мы обращаемся?
Какой формат данных мы можем обработать?
Какая версия протокола используется для соединения?
Что мы хотим сделать(GET - получить данные или POST - опубликовать данные)?
Когда сообщение готово, нам необходимо его преобразовать в байты.
Шаг третий - создание сокета
На этом этапе создаётся сокет, через который самописный клиент будет общаться с сервером по сети. Сокет работает на уровне транспортного протокола TCP для надёжной доставки данных.
При создании сокета мы передали два параметра:для надёжной
AF_INET - Число 2, означает что будет использовано адресное семейство IPv4.
SOCK_STREAM - Число 1, означает, что будет использован протокол TCP, а не UDP.
Шаг четвертый - установка соединения
Для установки соединения с сервером используется метод sock.connect в который необходимо передать адрес сервера и его порт. Далее на уровне TCP происходит трёхстороннее рукопожатие для установки соединение по которому далее будет передано сообщение.
Шаг пятый - отправка сообщения
Закодированное в байты сообщение отправляется по кускам серверу и чтобы всё сообщение было отправлено используется метод sock.sendall(request).
Шаг шестой - получения ответа
Так как не возможно предугадать сколько конкретно байтов придет от сервера, то есть длинна ответа неизвестна, необходимо с помощью цикла и socket.recv(size) получать ответ по кусочка. Примерный размеро кусочков сообщения будет 4 кб. Постепенно наполняя атрибут экземпляра класса self.response данными.
В данном клиенте, для получния данных, используется size=4096 и это распространённый размер партиции сообщения для чтения сетевых данных, а не абстрактная цифра.
Когда передача сообщения сервером закончено он передает пустой байтовый объект b'' что является сигналом о завершении передачи.
Возможные вопросы:
Как получить все IP адреса с помощью socket.getaddrinfo?
Это довольно просто, передайте в функцию всего два параметра: host и port. Как в примере ниже:
import socket from pprint import pprint response = socket.getaddrinfo(host="jsonplaceholder.typicode.com", port=80) ipv4, ipv6 = set(), set() for address_tuple in response: address = address_tuple[-1][0] if "." in address: ipv4.add(address) continue ipv6.add(address) print("IPv4") pprint(ipv4) print() print("IPv6") pprint(ipv6)
Заключение
В данной статье мы рассмотрели, что такое операции ввода-вывода (IO). Особое внимание было уделено сетевому вводу-выводу (Network IO) и тому, как он работает на низком уровне с использованием стандартной библиотеки socket.
Разобрав пример с ручной реализацией HTTP-запроса, мы на практике прошли все этапы использования сетевого соединения: от разрешения DNS-имени до получения ответа от сервера.
Такой подход позволяет лучше понять, как устроены высокоуровневые библиотеки (например, requests, aiohttp) и что происходит "под капотом" при работе с сетью.
Возможно некоторые скажут что статья не совсем полная, так как тут не затрагивается тема защищенного протокола HTTP, не совсем подробно рассказано про DNS, не затронуты темы как это работает в Linux. Но задача данной статьи дать самое базовое понимание как работает NetworkIO.
Если вам понравилась статья, вы можете посмотреть маленькие мини-рубрики в моем Telegram канале.
