Веб-браузер на Python через сокеты: пишем HTTP-клиент с нуля

Мой первый "веб-браузер" уместился в 12 строк Python-кода. Он подключался к серверу, отправлял текстовый запрос и получал текстовый ответ. Никакой магии: тот же принцип, по которому работают Chrome и Firefox, только без рендеринга HTML. Сокет (socket) это программная конструкция, через которую два компьютера обмениваются данными по сети. Веб-браузер это программа, которая отправляет HTTP-запрос через сокет и отображает полученный ответ.
В этой статье собираю веб-браузер по частям: от подключения к серверу до скачивания изображений. Каждый шаг показан с кодом, выводом и объяснением того, что происходит "под капотом".
Что такое сокет и зачем он нужен
Сокет это конечная точка двустороннего соединения между двумя программами в сети. Когда вы открываете страницу в браузере, происходит следующее:
- Браузер создаёт сокет и подключается к серверу по IP-адресу и порту.
- Через сокет браузер отправляет HTTP-запрос (текстовую команду).
- Сервер отправляет ответ через тот же сокет.
- Браузер закрывает соединение.
Веб-серверы обычно слушают порт 80 для HTTP и порт 443 для HTTPS. Порт это число, которое определяет, какому приложению на сервере предназначены данные.
Протокол HTTP за 5 минут
HTTP (Hypertext Transfer Protocol) это набор правил, по которым браузер и сервер обмениваются данными. Правила описаны в RFC 2616 и уместились бы на 176 страницах, но для простейшего браузера достаточно знать формат GET-запроса.
Формат GET-запроса
GET /romeo.txt HTTP/1.0\r\n
Host: data.pr4e.org\r\n
\r\n
Три элемента первой строки: метод (GET), путь к ресурсу (/romeo.txt), версия протокола (HTTP/1.0). После заголовков идёт пустая строка \r\n, которая сигнализирует серверу: запрос закончен, жду ответ.
Формат HTTP-ответа
HTTP/1.1 200 OK
Date: Wed, 11 Apr 2018 18:52:55 GMT
Content-Type: text/plain
Content-Length: 167
Connection: close
But soft what light through yonder window breaks
It is the east and Juliet is the sun
Arise fair sun and kill the envious moon
Who is already sick and pale with grief
Ответ состоит из строки статуса (200 OK), заголовков (метаданные) и тела (собственно контент). Заголовок Content-Type сообщает тип данных: text/plain, text/html, image/jpeg.
HTTP/1.0 vs HTTP/1.1
Характеристика
HTTP/1.0
HTTP/1.1
Заголовок Host
Необязателен
Обязателен
Соединение
Закрывается после ответа
Может оставаться открытым
Сложность реализации
Проще для учебного кода
Сложнее (chunked encoding и др.)
Для учебного браузера используем HTTP/1.0: сервер сам закрывает соединение после отправки данных, и нам не нужно разбирать chunked transfer encoding.
Шаг 1: подключаемся к серверу
import socket
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mysock.connect(('data.pr4e.org', 80))
print('Подключено!')
mysock.close()
socket.AF_INET означает IPv4, socket.SOCK_STREAM означает TCP. TCP гарантирует доставку данных в правильном порядке. Метод connect() принимает кортеж (хост, порт) и устанавливает соединение с сервером.
Если сервер недоступен, connect() выбросит ConnectionRefusedError или зависнет до таймаута. В боевом коде стоит оборачивать подключение в try/except.
Шаг 2: отправляем GET-запрос
import socket
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mysock.connect(('data.pr4e.org', 80))
cmd = 'GET http://data.pr4e.org/romeo.txt HTTP/1.0\r\nHost: data.pr4e.org\r\n\r\n'
mysock.send(cmd.encode())
print('Запрос отправлен')
mysock.close()
Метод send() принимает байты, не строку. Метод encode() преобразует строку Python в байты (по умолчанию UTF-8). Последовательность \r\n\r\n означает конец заголовков: после неё сервер начинает обрабатывать запрос.
Почему encode() обязателен
Сокет работает на уровне байтов. Он не знает про строки, кодировки и символы. Всё, что проходит через сокет, это последовательности байтов.
# Строка Python (Unicode)
text = 'Hello'
print(type(text)) # <class 'str'>
# Байты
raw = text.encode()
print(type(raw)) # <class 'bytes'>
print(raw) # b'Hello'
# Обратно в строку
print(raw.decode()) # Hello
encode() переводит строку в байты для отправки. decode() переводит полученные байты обратно в строку для чтения.
Шаг 3: получаем ответ
import socket
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mysock.connect(('data.pr4e.org', 80))
cmd = 'GET http://data.pr4e.org/romeo.txt HTTP/1.0\r\nHost: data.pr4e.org\r\n\r\n'
mysock.send(cmd.encode())
while True:
data = mysock.recv(512)
if len(data) < 1:
break
print(data.decode(), end='')
mysock.close()
Метод recv(512) читает до 512 байт из сокета. Слово "до" важно: recv может вернуть меньше байт, чем запрошено, если данные ещё не пришли полностью. Когда сервер закрывает соединение, recv возвращает пустую строку байтов, и цикл завершается.
Вывод программы
HTTP/1.1 200 OK
Date: Wed, 11 Apr 2018 18:52:55 GMT
Server: Apache/2.4.7 (Ubuntu)
Last-Modified: Sat, 13 May 2017 11:22:22 GMT
Content-Length: 167
Content-Type: text/plain
Connection: close
But soft what light through yonder window breaks
It is the east and Juliet is the sun
Arise fair sun and kill the envious moon
Who is already sick and pale with grief
Первая часть это заголовки HTTP. Пустая строка разделяет заголовки и тело. Тело содержит текст файла romeo.txt. Наш браузер показывает и заголовки, и тело. Настоящий браузер скрывает заголовки и рендерит только тело.
Шаг 4: разделяем заголовки и тело
Реальному браузеру нужно отделить заголовки от контента. Заголовки заканчиваются двойным переносом строки \r\n\r\n.
import socket
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mysock.connect(('data.pr4e.org', 80))
cmd = 'GET http://data.pr4e.org/romeo.txt HTTP/1.0\r\nHost: data.pr4e.org\r\n\r\n'
mysock.send(cmd.encode())
# Собираем весь ответ
response = b''
while True:
data = mysock.recv(4096)
if len(data) < 1:
break
response += data
mysock.close()
# Разделяем заголовки и тело
header_end = response.find(b'\r\n\r\n')
headers_raw = response[:header_end].decode()
body = response[header_end + 4 :]
print('=== ЗАГОЛОВКИ ===')
for line in headers_raw.split('\r\n'):
print(line)
print('\n=== ТЕЛО ===')
print(body.decode())
Сначала собираем весь ответ в переменную response. Затем find(b'\r\n\r\n') находит границу между заголовками и телом. Срез response[:header_end] это заголовки, response[header_end + 4:] это тело (4 байта это длина \r\n\r\n).
Парсинг заголовков в словарь
def parse_headers(raw):
headers = {}
lines = raw.split('\r\n')
status_line = lines[0] # 'HTTP/1.1 200 OK'
for line in lines[1:]:
if ': ' in line:
key, value = line.split(': ', 1)
headers[key.lower()] = value
return status_line, headers
status, headers = parse_headers(headers_raw)
print(status) # HTTP/1.1 200 OK
print(headers['content-type']) # text/plain
print(headers.get('content-length')) # 167
Каждый заголовок это пара "ключ: значение". split(': ', 1) разбивает по первому двоеточию с пробелом. Приведение ключа к нижнему регистру упрощает поиск, потому что регистр заголовков в HTTP не стандартизирован.
Шаг 5: скачиваем изображение
Сокетный браузер может скачивать не только текст, но и бинарные файлы: изображения, PDF, архивы.
import socket
HOST = 'data.pr4e.org'
PORT = 80
mysock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
mysock.connect((HOST, PORT))
mysock.sendall(
b'GET http://data.pr4e.org/cover3.jpg HTTP/1.0\r\n' b'Host: data.pr4e.org\r\n\r\n'
)
picture = b''
while True:
data = mysock.recv(5120)
if len(data) < 1:
break
picture += data
mysock.close()
# Отделяем заголовки от изображения
pos = picture.find(b'\r\n\r\n')
print(picture[:pos].decode())
# Сохраняем тело в файл
image_data = picture[pos + 4 :]
fhand = open('cover.jpg', 'wb')
fhand.write(image_data)
fhand.close()
print(f'Сохранено {len(image_data)} байт в cover.jpg')
Ключевое отличие от текстового запроса: тело ответа не декодируется в строку, а записывается как байты (wb в open). Декодирование decode() для бинарных данных вызвало бы UnicodeDecodeError.
Буфер recv и скорость сети
Метод recv(5120) не гарантирует, что вернёт ровно 5120 байт. Он возвращает столько, сколько пришло к моменту вызова. На быстрой сети recv может вернуть полный буфер, на медленной существенно меньше.
count = 0
while True:
data = mysock.recv(5120)
if len(data) < 1:
break
count += len(data)
print(f'Получено: {len(data)}, всего: {count}')
Вывод на медленном соединении может выглядеть так:
Получено: 5120, всего: 5120
Получено: 3200, всего: 8320
Получено: 5120, всего: 13440
...
Получено: 3167, всего: 230607
Последний вызов recv вернул 3167 байт, потому что именно столько оставалось. Цикл while с проверкой len(data) < 1 корректно обрабатывает эту ситуацию.
Шаг 6: собираем всё в функцию
import socket
def http_get(host, path, port=80):
'''Отправляет GET-запрос и возвращает (заголовки, тело).'''
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
sock.connect((host, port))
request = f'GET {path} HTTP/1.0\r\nHost: {host}\r\n\r\n'
sock.send(request.encode())
response = b''
while True:
chunk = sock.recv(4096)
if len(chunk) < 1:
break
response += chunk
sock.close()
separator = response.find(b'\r\n\r\n')
headers = response[:separator].decode()
body = response[separator + 4 :]
return headers, body
# Скачиваем текстовую страницу
headers, body = http_get('data.pr4e.org', 'http://data.pr4e.org/romeo.txt')
print(body.decode())
# Скачиваем изображение
headers, body = http_get('data.pr4e.org', 'http://data.pr4e.org/cover3.jpg')
with open('cover.jpg', 'wb') as f:
f.write(body)
print(f'Сохранено {len(body)} байт')
settimeout(10) устанавливает таймаут в 10 секунд. Если сервер не ответит за это время, Python выбросит socket.timeout.
Шаг 7: добавляем ввод URL от пользователя
import socket
def fetch_url(url):
# Разбираем URL
url = url.replace('http://', '')
if '/' in url:
host = url[: url.find('/')]
path = url[url.find('/') :]
else:
host = url
path = '/'
# Подключаемся и отправляем запрос
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.settimeout(10)
try:
sock.connect((host, 80))
except Exception as e:
print(f'Не удалось подключиться к {host}: {e}')
return None, None
request = f'GET {path} HTTP/1.0\r\nHost: {host}\r\n\r\n'
sock.send(request.encode())
response = b''
while True:
try:
chunk = sock.recv(4096)
except socket.timeout:
print('Таймаут при получении данных')
break
if len(chunk) < 1:
break
response += chunk
sock.close()
separator = response.find(b'\r\n\r\n')
if separator == -1:
return None, None
headers = response[:separator].decode()
body = response[separator + 4 :]
return headers, body
# Интерактивный режим
while True:
url = input('Введите URL (или quit): ')
if url == 'quit':
break
headers, body = fetch_url(url)
if headers is None:
continue
# Показываем первые 500 символов тела
print('\n--- Заголовки ---')
print(headers)
print('\n--- Тело (первые 500 символов) ---')
try:
print(body[:500].decode())
except UnicodeDecodeError:
print(f'[Бинарные данные, {len(body)} байт]')
print()
Парсинг URL через find('/') разбивает адрес на хост и путь. try/except вокруг connect() и recv() ловит ошибки сети.
Пример работы
Введите URL (или quit): data.pr4e.org/romeo.txt
--- Заголовки ---
HTTP/1.1 200 OK
Content-Type: text/plain
Content-Length: 167
--- Тело (первые 500 символов) ---
But soft what light through yonder window breaks
It is the east and Juliet is the sun
Arise fair sun and kill the envious moon
Who is already sick and pale with grief
Сравнение: сокет vs urllib
Python включает библиотеку urllib, которая делает то же самое в три строки:
import urllib.request
fhand = urllib.request.urlopen('http://data.pr4e.org/romeo.txt')
for line in fhand:
print(line.decode().strip())
urllib скрывает работу с сокетами, формирование запроса и разбор ответа.
Характеристика
socket
urllib
Код для GET-запроса
15+ строк
3 строки
Контроль над соединением
Полный
Минимальный
Обработка редиректов
Вручную
Автоматически
HTTPS
Нужен ssl-модуль
Встроено
Учебная ценность
Высокая
Низкая
Сокеты нужны для обучения: они показывают, что HTTP это текстовый протокол поверх TCP. Для боевого кода используйте urllib или библиотеку requests.
Неочевидные детали
Первый факт: recv(4096) может вернуть от 1 до 4096 байт. Число 4096 это верхний предел, а не гарантированный размер. Рекомендуемые значения буфера: степени двойки от 2048 до 65536.
Второй факт: send() не гарантирует отправку всех данных за один вызов. Для больших запросов используйте sendall(), который повторяет отправку, пока все байты не будут переданы.
# send() может отправить не всё
sent = sock.send(large_data) # sent может быть меньше len(large_data)
# sendall() отправляет всё
sock.sendall(large_data) # гарантирует полную отправку
Третий факт: разделитель заголовков и тела в HTTP это \r\n\r\n (CR LF CR LF), а не \n\n. Символ \r (carriage return) обязателен по спецификации HTTP. Код с \n\n может работать с некоторыми серверами, но не по стандарту.
Четвёртый факт: при использовании HTTP/1.0 сервер закрывает соединение после ответа, и recv возвращает пустые байты. При HTTP/1.1 соединение по умолчанию остаётся открытым (keep-alive), и recv будет ждать новых данных бесконечно. Для учебного кода HTTP/1.0 проще.
Пятый факт: сборка ответа через response += data в цикле неэффективна для больших файлов, потому что при каждом += Python создаёт новый объект bytes. Для больших загрузок лучше собирать куски в список, а потом соединить:
chunks = []
while True:
data = sock.recv(4096)
if len(data) < 1:
break
chunks.append(data)
response = b''.join(chunks)
FAQ
Почему наш браузер не работает с HTTPS-сайтами?
HTTPS требует TLS-шифрования. Для работы с HTTPS нужно обернуть сокет в ssl.SSLContext. Это добавляет порядка 10 строк кода. Модуль urllib делает это автоматически.
Как обработать редирект (301, 302)?
Нужно прочитать заголовок Location из ответа и отправить новый запрос на указанный URL. Наш простейший браузер этого не делает.
if '301' in status_line or '302' in status_line:
new_url = headers.get('location')
print(f'Редирект на: {new_url}')
Зачем указывать заголовок Host?
На одном IP-адресе может работать несколько сайтов (virtual hosting). Заголовок Host сообщает серверу, какой сайт запрашивается. В HTTP/1.1 он обязателен.
Какой размер буфера recv выбрать?
Рекомендуемые значения от 4096 до 65536 байт. Для текстовых страниц 4096 достаточно. Для изображений и файлов лучше 8192 или 16384.
Можно ли написать веб-сервер на сокетах?
Да. Сервер вызывает bind(), listen() и accept() вместо connect(). Он принимает входящие соединения и отправляет HTTP-ответы. Принцип зеркальный: клиент шлёт запрос и получает ответ, сервер получает запрос и шлёт ответ.
Мой совет: напишите этот браузер руками, запустите, посмотрите сырой HTTP-ответ. После этого вы будете понимать, что делают urllib, requests и даже Chrome на нижнем уровне. А для рабочих проектов переключайтесь на urllib или requests, потому что они обрабатывают редиректы, куки, HTTPS и сжатие за вас.
