Введение в HTTP в Python3

В этой статье мы погрузимся в мир веб-протоколов, в частности HTTP, а также рассмотрим связь HTTP с TCP и UDP. Попутно, в качестве примеров, мы приведем примеры реализации на Python клиентов и серверов, использующих эти протоколы.

HTTP (Hypertext Transfer Protocol) является основой мира Интернета. Когда вы посещаете какой-нибудь сайт, ваш браузер посылает HTTP-запросы на сервер, который в ответ выдает веб-страницы. Это похоже на разговор между браузером и сервером. Например, если послать запрос с текстом “Joe”, то сервер может ответить “Hi there, Joe”.

Ниже приведен базовый пример работы такого HTTP-сервера в Python3. Мы будем использовать встроенные модули http.client и http.server. Это простой пример, поэтому он не строго соответствует стандартам HTTP и не рекомендуется для использования в производстве, поскольку реализует только базовые проверки безопасности. Но для наших целей это подойдет.

import http.server
import http.client

PORT = 8001

class Store:
    def __init__(self):
        self.requestBody = ''
        self.responseBody = ''

store = Store()

class MyHTTPRequestHandler(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        content_length = int(self.headers['Content-Length'])
        content = self.rfile.read(content_length).decode('utf-8')
        store.requestBody = content

        response_content = f'Hi there, {content}'.encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/plain')
        self.send_header('Content-Length', len(response_content))
        self.end_headers()
        self.wfile.write(response_content)

def server_listen():
    with http.server.HTTPServer(('localhost', PORT), MyHTTPRequestHandler) as server:
        print(f'HTTP server listening on {PORT}')
        http_request()

def http_request():
    conn = http.client.HTTPConnection('localhost', PORT)
    content = 'Joe'
    headers = {
        'Content-Type': 'text/plain',
        'Content-Length': str(len(content))
    }
    conn.request('POST', '/greet', body=content, headers=headers)
    response = conn.getresponse()
    data = response.read().decode('utf-8')
    store.responseBody = data
    close_connections()

def close_connections():
    server.server_close()

    print(store.requestBody)  # Joe
    print(store.responseBody)  # Hi there, Joe

server_listen()

TCP-соединение

Теперь познакомимся с TCP  (Transmission Control Protocol). TCP является базовым протоколом, на котором построен HTTP, как видно из официальных спецификаций последнего. И хотя я уже об этом сказал, я попрошу вас сделать вид, что вы этого еще не знаете. Давайте докажем, что HTTP базируется на TCP!

В Python имеются встроенные модули threading и socket, которые помогают нам создавать TCP-клиенты и серверы.

Следует знать, что TCP отличается от HTTP по нескольким параметрам:

  • Запросы не могут отправляться спонтанно. Сначала должно быть установлено соединение.
  • После установки соединения сообщения могут передаваться в обоих направлениях.
  • Установленное соединение должно быть закрыто вручную.

Ниже приведена простая реализация TCP-клиента, который желает получить приветствие от сервера:

import socket
import threading

PORT = 8001
MAXIMUM_BYTES_RECEIVABLE = 1024

class Store:
    def __init__(self):
        self.requestBody = ''
        self.responseBody = ''

store = Store()

def handle_client(client_socket):
    request_data = client_socket.recv(MAXIMUM_BYTES_RECEIVABLE).decode('utf-8')
    store.requestBody = request_data

    response_data = f'Hi there, {request_data}'.encode('utf-8')
    client_socket.send(response_data)

    response = client_socket.recv(MAXIMUM_BYTES_RECEIVABLE).decode('utf-8')
    store.responseBody = response

    client_socket.close()

def server_listen():
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # When the socket type is socket.SOCK_STREAM the protocol being used is TCP by default.
    server.bind(('0.0.0.0', PORT))
    server.listen(5)
    print(f'TCP server listening on {PORT}')

    while True:
        client_socket, addr = server.accept() # Blocks execution and waits for an incoming connection.
        client_handler = threading.Thread(target=handle_client, args=(client_socket,))
        client_handler.start()

def http_request():
    client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client.connect(('localhost', PORT))
    content = 'Joe'
    client.send(content.encode('utf-8'))
    client.shutdown(socket.SHUT_WR)
    response = client.recv(MAXIMUM_BYTES_RECEIVABLE).decode('utf-8')
    store.responseBody = response
    client.close()
    close_connections()

def close_connections():
    server.close()

    print(store.requestBody)  # Joe
    print(store.responseBody)  # Hi there, Joe

if __name__ == '__main__':
    server_listen()
    http_request()

Теперь представьте, что у вас есть TCP-прокси, который может передавать сообщения между HTTP-клиентами и серверами. Даже если этот прокси не понимает HTTP, он все равно может передавать запросы и ответы.

Вот как будет выглядеть его реализация:

import socket
import http.client
import threading

HTTP_PORT = 8001
PROXY_TCP_PORT = 8002
MAXIMUM_BYTES_RECEIVABLE = 1024

class Store:
    def __init__(self):
        self.requestBody = ''
        self.responseBody = ''

store = Store()

def proxy_handler(local_socket):
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as remote_socket:
        remote_socket.connect(('localhost', HTTP_PORT))
        
        def forward(src, dst):
            while True:
                data = src.recv(MAXIMUM_BYTES_RECEIVABLE)
                if not data:
                    break
                dst.send(data)
        
        threading.Thread(target=forward, args=(local_socket, remote_socket)).start()
        threading.Thread(target=forward, args=(remote_socket, local_socket)).start()

def http_server_handler(client_socket):
    data = client_socket.recv(MAXIMUM_BYTES_RECEIVABLE).decode('utf-8')
    store.requestBody = data

    response_data = f'Hi there, {data}'.encode('utf-8')
    client_socket.send(response_data)

    response = client_socket.recv(MAXIMUM_BYTES_RECEIVABLE).decode('utf-8')
    store.responseBody = response

def http_server_listen():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
        server.bind(('0.0.0.0', HTTP_PORT)
        server.listen(5)
        print(f'HTTP server listening on {HTTP_PORT}')

        while True:
            client_socket, addr = server.accept()
            threading.Thread(target=http_server_handler, args=(client_socket,)).start()

def proxy_listen():
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as proxy_server:
        proxy_server.bind(('0.0.0.0', PROXY_TCP_PORT))
        proxy_server.listen(5)
        print(f'TCP proxy listening on {PROXY_TCP_PORT}')

        while True:
            local_socket, addr = proxy_server.accept()
            threading.Thread(target=proxy_handler, args=(local_socket,)).start()

def http_request():
    conn = http.client.HTTPConnection('localhost', PROXY_TCP_PORT)
    content = 'Joe'
    headers = {
        'Content-Type': 'text/plain',
        'Content-Length': str(len(content))
    }
    conn.request('POST', '/greet', body=content, headers=headers)
    response = conn.getresponse()
    data = response.read().decode('utf-8')
    close_connections()

def close_connections():
    http_server_listen_thread.join()
    proxy_listen_thread.join()

    print(store.requestBody)  # Joe
    print(store.responseBody)  # Hi there, Joe

if __name__ == '__main__':
    http_server_listen_thread = threading.Thread(target=http_server_listen)
    proxy_listen_thread = threading.Thread(target=proxy_listen)
    http_server_listen_thread.start()
    http_server_listen_thread.join()
    proxy_listen_thread.start()
    http_request()

Как уже говорилось, хотя TCP-прокси-сервер не знает, что такое HTTP, запросы и ответы полностью проходят через него.

Понимание особенностей TCP

Прежде чем мы продолжим, несколько фактов о TCP:

  • Он надежен, т.е. обеспечивает подтверждение сообщений, их повторную передачу и тайминг при необходимости.
  • Он гарантирует, что данные поступают в правильном порядке.

Неудивительно, что TCP так распространен, но… Вы же знали, что будет какое-то “но”, верно?

TCP может быть несколько тяжеловат. Чтобы сокетное соединение могло разрешить отправку данных, требуется установить три пакета. В мире HTTP это означает, что для выполнения параллельных запросов HTTP/1.1 требуется несколько TCP-соединений, что может потребовать значительных ресурсов.

HTTP/2 пытается улучшить эту ситуацию, обрабатывая параллельные запросы через одно соединение. Однако при этом возникают проблемы. Когда один пакет задерживается или приходит не по порядку, это приводит к остановке всех запросов.

А теперь представьте, что есть альтернатива TCP, позволяющая параллельные HTTP-сообщения без этих последствий. Звучит неплохо, не так ли? Эта альтернатива – UDP (User Datagram Protocol).

UDP-соединение

Начнем с того, чем UDP отличается от TCP:

  • Здесь нет понятия соединения. Вы отправляете данные и надеетесь, что кто-то их получит.
  • Вы можете передавать только небольшие фрагменты данных, которые не обязательно представляют собой целое сообщение (подробнее об этом можно почитать в статье Википедии), а встроенные разделители отсутствуют, если они не включены в явном виде.
  • В результате создание даже базового механизма запрос/ответ становится более сложным (но все же возможным).

Давайте рассмотрим пример UDP-клиента, который хочет взаимодействовать с сервером. На этот раз мы определим наш сокет как SOCK_DGRAM:

import socket

PORT = 8001
EOS = b'\0'  # End of stream
MAXIMUM_BYTES_RECEIVABLE = 1024

class Store:
    def __init__(self):
        self.requestBody = ''
        self.responseBody = ''

store = Store()

def slice_but_last(data, encoding='utf-8'):
    return data[:-1].decode(encoding)

def server_listen():
    sender = None

    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server:
        server.bind(('0.0.0.0', PORT))
        print(f'UDP server listening on {PORT}')

        while True:
            chunk, addr = server.recvfrom(MAXIMUM_BYTES_RECEIVABLE)
            sender = addr if sender is None else sender
            store.requestBody += slice_but_last(chunk)

            if chunk[-1:] == EOS:
                response_data = f'Hi there, {store.requestBody}'.encode('utf-8') + EOS
                server.sendto(response_data, sender)

                # Note: You can choose to close the server here if needed
                break

def http_request():
    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as client:
        content = 'Joe'.encode('utf-8') + EOS
        client.sendto(content, ('localhost', PORT))
        response_data, _ = client.recvfrom(MAXIMUM_BYTES_RECEIVABLE)
        store.responseBody = slice_but_last(response_data)

        close_connections()

def close_connections():
    print(store.requestBody)  # Joe
    print(store.responseBody)  # Hi there, Joe

if __name__ == '__main__':
    server_listen()
    http_request()

Итак, учитывая, что у нас есть парсер HTTP (http-parser, например), вот как можно реализовать HTTP-решение через UDP:

import socket
from http_parser.parser import HttpParser

PORT = 8001
CRLF = '\r\n'
MAXIMUM_BYTES_RECEIVABLE = 1024

class Store:
    def __init__(self):
        self.requestBody = ''
        self.responseBody = ''

store = Store()

def server_listen():
    parser = HttpParser()

    def on_body(data):
        store.requestBody += data

    def on_message_complete():
        content = f'Hi there, {store.requestBody}'
        response = f'HTTP/1.1 200 OK{CRLF}' \
                   f'Content-Type: text/plain{CRLF}' \
                   f'Content-Length: {len(content)}{CRLF}' \
                   f'{CRLF}' \
                   f'{content}'
        server.sendto(response.encode('utf-8'), sender)

    parser.on_body = on_body
    parser.on_message_complete = on_message_complete

    sender = None

    with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as server:
        server.bind(('0.0.0.0', PORT))
        print(f'UDP server listening on {PORT}')

        while True:
            chunk, sender = server.recvfrom(MAXIMUM_BYTES_RECEIVABLE)
            parser.execute(chunk)

def http_request():
    parser = HttpParser()

    def on_body(data):
        store.responseBody += data

    def on_message_complete():
        close_connections()

    parser.on_body = on_body
    parser.on_message_complete = on_message_complete

    content = 'Joe'
    request = f'POST /greet HTTP/1.1{CRLF}' \
              f'Content-Type: text/plain{CRLF}' \
              f'Content-Length: {len(content)}{CRLF}' \
              f'{CRLF}' \
              f'{content}'

    client.sendto(request.encode('utf-8'), ('localhost', PORT))

def close_connections():
    server.close()
    client.close()

    print(store.requestBody)  # Joe
    print(store.responseBody)  # Hi there, Joe

if __name__ == '__main__':
    client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    server_listen()
    http_request()

Выглядит неплохо. У нас есть полноценная реализация с использованием UDP. Но пока не стоит слишком радоваться. UDP имеет ряд существенных недостатков:

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

Возникновение QUIC и HTTP/3

Для устранения недостатков UDP был создан новый протокол – QUIC . Он построен на основе UDP и использует “умные” алгоритмы для его реализации. Отличительные особенности QUIC:

  • Надежность
  • Обеспечение упорядоченной доставки пакетов
  • Легкость

Это приводит нас прямо к HTTP/3, который все еще является относительно новым и экспериментальным. В нем используется QUIC для устранения проблем, возникших в HTTP/2. В HTTP/3 нет соединений, поэтому сессии не влияют друг на друга.

Таблица HTTP-семантики. Три столбца с версиями HTTP и соответствующими им протоколами.

HTTP/3 – перспективное направление развития веб-протоколов, использующее сильные стороны QUIC и UDP.

Хотя встроенная поддержка протокола QUIC отсутствует, можно воспользоваться модулем aioquic, который поддерживает реализацию как QUIC, так и HTTP/3.

Пример с использованием протокола QUIC

Рассмотрим простой пример сервера, использующего QUIC:

import asyncio
import ssl
from aioquic.asyncio import connect, connect_udp, Connection, serve
from aioquic.asyncio.protocol import BaseProtocol, DatagramProtocol
from aioquic.asyncio.protocol.stream import DataReceived

class HTTPServerProtocol(BaseProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    async def data_received(self, data):
        await super().data_received(data)
        if isinstance(self._quic, Connection):
            for stream_id, buffer in self._quic._events[DataReceived]:
                data = buffer.read()
                response = f'HTTP/1.1 200 OK\r\nContent-Length: {len(data)}\r\n\r\n{data.decode("utf-8")}'
                self._quic.send_stream_data(stream_id, response.encode('utf-8'))

async def main():
    loop = asyncio.get_event_loop()

    # Create QUIC server context
    quic_server = await loop.create_server(HTTPServerProtocol, 'localhost', 8001)

    async with quic_server:
        await quic_server.serve_forever()

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

А это – клиент:

import asyncio
import ssl
from aioquic.asyncio import connect, connect_udp, Connection
from aioquic.asyncio.protocol import BaseProtocol

class HTTPClientProtocol(BaseProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.connected_event = asyncio.Event()

    def quic_event_received(self, event):
        super().quic_event_received(event)
        if event.matches('connected'):
            self.connected_event.set()

    async def request(self, path, data=None):
        stream_id = self._quic.get_next_available_stream_id()
        self._quic.send_stream_data(stream_id, data)
        await self.connected_event.wait()
        response = await self._quic.receive_data(stream_id)
        return response

async def main():
    loop = asyncio.get_event_loop()

    # Create QUIC client context
    quic = connect('localhost', 8001)

    async with quic as protocol:
        client_protocol = HTTPClientProtocol(quic, protocol._session_id, None)
        await client_protocol.connected_event.wait()
        
        data = 'Hello, Joe!'
        response = await client_protocol.request('/greet', data.encode('utf-8'))
        print(response.decode('utf-8'))

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

Пример с использованием протокола HTTP/3

А чтобы вы получили полное представление, приведем пример с использованием протокола HTTP/3 (с помощью модуля aioquic).

Сервер:

import asyncio
from aioquic.asyncio.protocol import connect, connect_udp, serve, QuicProtocol
from aioquic.asyncio.protocol.stream import DataReceived
from h11 import Response, Connection
from h11._events import Data

class HTTP3ServerProtocol(QuicProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.conn = Connection()

    def quic_event_received(self, event):
        super().quic_event_received(event)
        if event.matches('handshake_completed'):
            self.conn.initiate_upgrade_for_http2()

    async def data_received(self, data):
        await super().data_received(data)
        if isinstance(self._quic, QuicProtocol):
            for stream_id, buffer in self._quic._events[DataReceived]:
                data = buffer.read()
                response = Response(status_code=200, headers=[('content-length', str(len(data)))], content=data)
                data = self.conn.send(response)
                self._quic.transmit_data(stream_id, data)

async def main():
    loop = asyncio.get_event_loop()

    # Create QUIC server context
    quic_server = await loop.create_server(HTTP3ServerProtocol, 'localhost', 8001)

    async with quic_server:
        await quic_server.serve_forever()

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

И клиент:

import asyncio
from aioquic.asyncio.protocol import connect, connect_udp, QuicProtocol
from h11 import Request, Response, Connection
from h11._events import Data

class HTTP3ClientProtocol(QuicProtocol):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.conn = Connection()

    async def request(self, path, data=None):
        stream_id = self._quic.get_next_available_stream_id()
        request = Request(method='POST', target=path, headers=[('content-length', str(len(data)))]
            if data else [])
        data = self.conn.send(request)
        self._quic.transmit_data(stream_id, data)

        while True:
            event = self.conn.next_event()
            if isinstance(event, Data):
                self._quic.transmit_data(stream_id, event.data)
            elif event == h11.EndOfMessage():
                break

        response = await self._quic.receive_data(stream_id)
        return response

async def main():
    loop = asyncio.get_event_loop()

    # Create QUIC client context
    quic = connect('localhost', 8001)

    async with quic as protocol:
        client_protocol = HTTP3ClientProtocol(quic, protocol._session_id, None)
        
        data = 'Hello, Joe!'
        response = await client_protocol.request('/greet', data.encode('utf-8'))
        print(response.content.decode('utf-8'))

if __name__ == '__main__':
    loop = asyncio.get_event_loop()
    loop.run_until_complete(main())

Итоги

Такая эволюция ставит вопрос о том, сможем ли мы в будущем распрощаться с TCP, но это уже тема для другой статьи и, возможно, для будущего.

На этом мы завершаем наше путешествие по HTTP, TCP и UDP в Python3! Тема может показаться сложной, но под поверхностью каждого посещаемого вами сайта скрывается увлекательный мир веб-коммуникаций, с которым стоит познакомиться.

Перевод статьи «Introduction to HTTP in Python3».

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *