本文用python在TCP的基础上实现一个HTTP客户端, 该客户端能够复用TCP连接, 使用HTTP1.1协议.
HTTP是基于TCP连接的, 它的请求报文格式如下:
因此, 我们只需要创建一个到服务器的TCP连接, 然后按照上面的格式写好报文并发给服务器, 就实现了一个HTTP请求.
基于以上的分析, 我们首先定义一个HTTPConnection类来管理连接和请求内容:
class HTTPConnection:
default\_port = 80
\_http\_vsn = 11
\_http\_vsn\_str = 'HTTP/1.1'
def \_\_init\_\_(self, host: str, port: int = None) -> None:
self.sock = None
self.\_buffer = \[\]
self.host = host
self.port = port if port is not None else self.default\_port
self.\_state = \_CS\_IDLE
self.\_response = None
self.\_method = None
self.block\_size = 8192
def \_output(self, s: Union\[str, bytes\]) -> None:
if hasattr(s, 'encode'):
s = s.encode('latin-1')
self.\_buffer.append(s)
def connect(self) -> None:
self.sock = socket.create\_connection((self.host, self.port))
对于这个HTTPConnection对象, 我们只需要创建TCP连接, 然后按照HTTP协议的格式把请求数据写入buffer中, 最后把buffer中的数据发送出去就行了.
请求行的内容比较简单, 就是说明请求方法, 请求路径和HTTP协议. 使用下面的方法来编写一个请求行:
def put\_request(self, method: str, url: str) -> None:
self.\_method = method
url = url or '/'
request = f'{method} {url} {self.\_http\_vsn\_str}'
self.\_output(request)
HTTP请求头和python的字典类似, 每行都是一个字段名与值的映射关系. HTTP协议并不要求设置所有合法的请求头的值, 我们只需要按照需要, 设置特定的请求头即可. 使用如下代码添加请求头:
def put\_header(self, header: Union\[bytes, str\], value: Union\[bytes, str, int\]) -> None:
if hasattr(header, 'encode'):
header = header.encode('ascii')
if hasattr(value, 'encode'):
value = value.encode('latin-1')
elif isinstance(value, int):
value = str(value).encode('ascii')
header = header + b': ' + value
self.\_output(header)
此外, 在HTTP请求中, Host请求头字段是必须的, 否则网站可能会拒绝响应. 因此, 如果用户没有设置这个字段, 这里就应该主动把它加上去:
def \_add\_host(self, url: str) -> None:
# 所有HTTP / 1.1请求报文中必须包含一个Host头字段
# 如果用户没给,就调用这个函数来生成
netloc = ''
if url.startswith('http'):
nil, netloc, nil, nil, nil = urllib.parse.urlsplit(url)
if netloc:
try:
netloc\_enc = netloc.encode('ascii')
except UnicodeEncodeError:
netloc\_enc = netloc.encode('idna')
self.put\_header('Host', netloc\_enc)
else:
host = self.host
port = self.port
try:
host\_enc = host.encode('ascii')
except UnicodeEncodeError:
host\_enc = host.encode('idna')
# 对IPv6的地址进行额外处理
if host.find(':') >= 0:
host\_enc = b'\[' + host\_enc + b'\]'
if port == self.default\_port:
self.put\_header('Host', host\_enc)
else:
host\_enc = host\_enc.decode('ascii')
self.put\_header('Host', f'{host\_enc}:{port}')
我们接受两种形式的body数据: 一个基于io.IOBase的可读文件对象, 或者是一个能通过迭代得到数据的对象. 在传输数据之前, 我们首先要确定数据是否采用分块传输:
def request(self, method: str, url: str, headers: dict = None, body: Union\[io.IOBase, Iterable\] = None,
encode\_chunked: bool = False) -> None:
...
if 'content-length' not in header\_names:
if 'transfer-encoding' not in header\_names:
encode\_chunked = False
content\_length = self.\_get\_content\_length(body, method)
if content\_length is None:
if body is not None:
# 在这种情况下, body一般是个生成器或者可读文件之类的东西,应该分块传输
encode\_chunked = True
self.put\_header('Transfer-Encoding', 'chunked')
else:
self.put\_header('Content-Length', str(content\_length))
else:
# 如果设置了transfer-encoding,则根据用户给的encode\_chunked参数决定是否分块
pass
else:
# 只要给了content-length,那么一定不是分块传输
encode\_chunked = False
...
@staticmethod
def \_get\_content\_length(body: Union\[str, bytes, bytearray, Iterable, io.IOBase\], method: str) -> Optional\[int\]:
if body is None:
# PUT,POST,PATCH三个方法默认是有body的
if method.upper() in \_METHODS\_EXPECTING\_BODY:
return 0
else:
return None
if hasattr(body, 'read'):
return None
try:
# 对于bytes或者bytearray格式的数据,通过memoryview获取它的长度
return memoryview(body).nbytes
except TypeError:
pass
if isinstance(body, str):
return len(body)
return None
在确定了是否分块之后, 就可以把正文发出去了. 如果body是一个可读文件的话, 就调用_read_readable方法把它封装为一个生成器:
def \_send\_body(self, message\_body: Union\[str, bytes, bytearray, Iterable, io.IOBase\], encode\_chunked: bool) -> None:
if hasattr(message\_body, 'read'):
chunks = self.\_read\_readable(message\_body)
else:
try:
memoryview(message\_body)
except TypeError:
try:
chunks = iter(message\_body)
except TypeError:
raise TypeError(
f'message\_body should be a bytes-like object or an iterable, got {repr(type(message\_body))}')
else:
# 如果是字节类型的,通过一次迭代把它发出去
chunks = (message\_body,)
for chunk in chunks:
if not chunk:
continue
if encode\_chunked:
chunk = f'{len(chunk):X}\\r\\n'.encode('ascii') + chunk + b'\\r\\n'
self.send(chunk)
if encode\_chunked:
self.send(b'0\\r\\n\\r\\n')
def \_read\_readable(self, readable: io.IOBase) -> Generator\[bytes, None, None\]:
need\_encode = False
if isinstance(readable, io.TextIOBase):
need\_encode = True
while True:
data\_block = readable.read(self.block\_size)
if not data\_block:
break
if need\_encode:
data\_block = data\_block.encode('utf-8')
yield data\_block
HTTP响应报文的格式与请求报文大同小异, 它大致是这样的:
因此, 我们只要用HTTPConnection的socket对象读取服务器发送的数据, 然后按照上面的格式对数据进行解析就行了.
我们首先定义一个简单的HTTPResponse类. 它的属性大致上就是socket的文件对象以及一些请求的信息等等, 调用它的begin方法来解析响应行和响应头的数据, 然后调用read方法读取响应正文:
class HTTPResponse:
def \_\_init\_\_(self, sock: socket.socket, method: str = None) -> None:
self.fp = sock.makefile('rb')
self.\_method = method
self.headers = None
self.version = \_UNKNOWN
self.status = \_UNKNOWN
self.reason = \_UNKNOWN
self.chunked = \_UNKNOWN
self.chunk\_left = \_UNKNOWN
self.length = \_UNKNOWN
self.will\_close = \_UNKNOWN
def begin(self) -> None:
...
def read(self, amount: int = None) -> bytes:
...
状态行的解析比较简单, 我们只需要读取响应的第一行数据, 然后把它解析为HTTP协议版本,状态码和原因短语三部分就行了:
def \_read\_status(self) -> Tuple\[str, int, str\]:
line = str(self.\_read\_line(), 'latin-1')
if not line:
raise RemoteDisconnected('Remote end closed connection without response')
try:
version, status, reason = line.split(None, 2)
except ValueError:
# reason只是给人看的, 一般和status对应, 所以它有可能不存在
try:
version, status = line.split(None, 1)
reason = ''
except ValueError:
version, status, reason = '', '', ''
if not version.startswith('HTTP/'):
self.\_close\_conn()
raise BadStatusLine(line)
try:
status = int(status)
if status < 100 or status > 999:
raise BadStatusLine(line)
except ValueError:
raise BadStatusLine(line)
return version, status, reason.strip()
如果状态码为100, 则客户端需要解析多个响应状态行. 它的原理是这样的: 在请求数据过大的时候, 有的客户端会先不发送请求数据, 而是先在header中添加一个Expect: 100-continue, 如果服务器愿意接收数据, 会返回100的状态码, 这时候客户端再把数据发过去. 因此, 如果读取到100的状态码, 那么后面往往还会收到一个正式的响应数据, 应该继续读取响应头. 这部分的代码如下:
def begin(self) -> None:
while True:
version, status, reason = self.\_read\_status()
if status != HTTPStatus.CONTINUE:
break
# 跳过100状态码部分的响应头
while True:
skip = self.\_read\_line().strip()
if not skip:
breakself.status = status
self.reason = reason
if version in ('HTTP/1.0', 'HTTP/0.9'):
self.version = 10
elif version.startswith('HTTP/1.'):
self.version = 11
else:
# HTTP2还没研究, 这里就不写了
raise UnknownProtocol(version)
...
解析响应头比响应行还要简单. 因为每个header字段占一行, 我们只需要一直调用read_line方法读取字段, 直到读完header为止就行了.
def \_parse\_header(self) -> None:
headers = {}
while True:
line = self.\_read\_line()
if len(headers) > \_MAX\_HEADERS:
raise HTTPException('got more than %d headers' % \_MAX\_HEADERS)
if line in \_EMPTY\_LINE:
break
line = line.decode('latin-1')
i = line.find(':')
if i == -1:
raise BadHeaderLine(line)
# 这里默认没有重名的情况
key, value = line\[:i\].lower(), line\[i + 1:\].strip()
headers\[key\] = value
self.headers = headers