Python 新一代 HTTP 请求库:httpx

0aa354a5304ac8c91b6815d3aa498f75.png

楔子

f697fe288f5d8eb1f80629f1f55174fe.png

来源:古明地觉的编程教室

本次我们来聊一聊 httpx,它是一个 HTTP 请求库。不过说到发送 HTTP 请求,我们首先想到的应该是 requests,但 requests 是一个同步库,目前只能同步发请求。而 httpx 不仅可以同步发请求,还可以异步发请求,并且支持 HTTP/1.1 和 HTTP/2。

另外 httpx 在设计上也模仿了 requests,两者的 API 是兼容的,如果你会 requests,那么 httpx 很容易上手。

安装方式:直接 pip install httpx 即可

下面就来看一下相关用法。

fc8ceebb338b9c5645f2e5620176dca2.png

使用 httpx 发请求

51eabd3dd172da493dc3593b4417f067.png

使用 httpx 发送请求非常简单,首先请求有以下几种:

fa665baf8e646684518c319d2d59c12b.png

API 和 requests 是相似的,我们以 GET 请求为例,测试一下:

import httpx

# 发送请求,会返回一个 httpx.Response 对象
response = httpx.get("http://www.baidu.com")
print(response)
print(response.__class__)
"""
<Response [200 OK]>
<class 'httpx.Response'>
"""

当然其它请求也是类似的,我们一会儿会说。不过虽然请求种类有很多,但不管发送的是哪一种请求,背后调用的都是 httpx.request。

import httpx

response = httpx.get("http://www.baidu.com")
# 等价于如下:
response = httpx.request("GET", "http://www.baidu.com")

# 同理:
"""
httpx.post(url) 等价于 httpx.request("POST", url)
httpx.put(url) 等价于 httpx.request("PUT", url)
httpx.delete(url) 等价于 httpx.request("DELETE", url)
"""

因此我们调用 httpx.request 即可发送所有类型的请求,但为了方便使用,httpx 又专门针对不同的请求,封装了相应的函数。比如我们要发送 get 请求,那么直接调用 httpx.get 就好。

7dcf62df3feab341577015367ac471da.png

服务端响应(httpx.Response)

9e934d5fceceb86214759defde6ff051.png

当服务端收到请求并处理完毕之后,会给客户端返回响应,而这里的客户端显然就是 httpx。httpx 收到响应之后,会将其包装成  Response 对象。

import httpx

response = httpx.get("http://www.baidu.com")
print(response)
print(response.__class__)
"""
<Response [200 OK]>
<class 'httpx.Response'>
"""

那么这个 Response 对象内部都包含了哪些属性呢?我们来总结一下。

url:客户端请求的 URL

print(response.url)
"""
http://www.baidu.com
"""

注意:通过 response.url 拿到的不是字符串,而是一个 httpx.URL 对象,我们可以很方便地获取 URL 的每一个组成部分。

response = httpx.get("http://www.baidu.com")
url = response.url
print(
    url.scheme,
    url.host,
    url.port,
    url.username,
    url.password,
    url.netloc,
    url.path
)

status_code:状态码,比如请求成功返回 200

print(response.status_code)
"""
200
"""

Response 对象还有一个 raise_for_status() 方法,如果状态码不在 200 ~ 299 之间,那么调用的时候会根据状态码的值,抛出相应的异常来提示开发者。

reason_phrase:状态码的文字描述

# 一般跟在状态码后面,比如 200 OK,404 NOT FOUND
print(response.reason_phrase)
"""
OK
"""

headers:响应头,返回的是 httpx.Headers 对象。我们将它当成字典来用即可,但会忽略 key 的大小写

print(response.headers["Content-Type"])
print(response.headers["CONTENT-TYPE"])
print(response.headers.get("content-TYPE"))
"""
text/html
text/html
text/html
"""
# 也可以调用 dict 转成字典,但转成字典之后,key 一律全部小写
print(dict(response.headers)["content-type"])
"""
text/html
"""

content:响应体,一个原始的字节流

print("百度一下".encode("utf-8") in response.content)
"""
True
"""

text:对响应体进行解码所得到的字符串

# 相当于对 response.content 进行 decode
print("百度一下" in response.text)
"""
True
"""

json:对响应体进行 JSON 解析所得到的字典

# 相当于 json.loads(response.content)
# 因此服务端返回的响应体数据必须满足 JSON 格式,否则报错
try:
    print(response.json())
except Exception:
    print("返回的数据不符合 JSON 格式")
"""
返回的数据不符合 JSON 格式
"""
# httpx 在解析 JSON 的时候,使用的是内置的 json 库
# 但这个库的性能不好,因此推荐一个第三方库叫 orjson,是基于 Rust 编写的
# 个人觉得它是目前性能最好、使用最方便的 JSON 解析库
# 所以我个人习惯先获取 content,然后手动调用 orjson.loads 进行解析

cookies:服务端返回的 cookie,一个 httpx.Cookies 对象

# 可以调用 dict,将其转成字典,也可以直接当成字典来操作
# 注意,对于 Cookie 而言,大小写是敏感的
print(response.cookies["PSTM"])
print(response.cookies.get("pstm"))
"""
1676011925
None
"""

encoding:返回网站的编码,没有的话则使用 utf-8

# response.text 就等价于
# response.content.decode(response.encoding)
print(response.encoding)
"""
utf-8
"""

基于以上这些字段,我们可以获取服务端响应的全部信息。既然服务端的响应可以拿到,那客户端请求该如何获取呢?

815e61687fe59d6d5025b988ada72e92.png

客户端请求(httpx.Request)

8b031c0a1ffbb7985049a9895e5a4772.png

调用 httpx.get 的时候,内部会构建一个请求,然后直接发给服务端,而请求我们也可以通过 response 来获取。

import httpx

response = httpx.get("http://www.baidu.com")
# 客户端请求是一个 httpx.Request 对象
# 我们可以通过 response.request 获取
request = response.request
print(request)
print(request.__class__)
"""
<Request('GET', 'http://www.baidu.com')>
<class 'httpx.Request'>
"""

# 客户端请求的 URL,和 response.url 是一样的
print(request.url)
"""
http://www.baidu.com
"""

# 请求方式
print(request.method)
"""
GET
"""

# 客户端发送请求时的请求头,一个 httpx.Headers 对象
# 而 response.header 是服务端返回响应时的响应头
print(request.headers["User-Agent"])
"""
python-httpx/0.23.3
"""

# 请求体,这里是 GET 请求,所以请求体为空
print(request.content)
"""
b''
"""

所以 HTTP 的核心就是:客户端发送请求,服务端返回响应。请求包含请求头、请求体,响应包含响应头、响应体。

怎么发请求我们已经知道了,然后再来看看发请求时的一些更具体的细节。

8257edcca8b669a31bd4e78c73bbb046.png

向服务端传递数据

136844457c035e933c841b5b8e609d16.png

在发送请求时,客户端还可以携带指定的数据给服务端。对于 GET 请求而言,数据是以查询参数的方式,拼接在 URL 的尾部。

import httpx

response = httpx.get("http://www.baidu.com/s?wd=python")
print(response.url)

# 但上面的做法有些麻烦,我们可以让 httpx 帮我们拼接
response = httpx.get("http://www.baidu.com/s",
                     params={"wd": "python"})
print(response.url)
"""
http://www.baidu.com/s?wd=python
http://www.baidu.com/s?wd=python
"""

# 当然也可以传递多个参数
response = httpx.get("http://httpbin.org/get",
                     params={"k1": "v1", "k2": ["v2", "v3"]})
print(response.url)  
"""
http://httpbin.org/get?k1=v1&k2=v2&k2=v3
"""

如果是 post 请求,则可以将数据放在请求体中,通过 data 或者 json 参数传递。至于参数选择哪一个,则看服务端要求是使用表单数据提交、还是使用 JSON 数据提交。

import httpx

# 传递表单数据,通过 data 参数
response = httpx.post(
    "https://httpbin.org/post",
    data={"name": "satori", "age": 16}
)
# 查看请求体
print(response.request.content)
"""
b'name=satori&age=16'
"""

# 传递 JSON 数据,通过 json 参数
response = httpx.post(
    "https://httpbin.org/post",
    json={"name": "satori", "age": 16}
)
print(response.request.content)
"""
b'{"name": "satori", "age": 16}'
"""

现在你一定明白表单数据和 JSON 数据之间的差异了,当然不管什么数据,无论是请求体还是响应体,都是一坨字节流。所谓的文本、字典,都是拿到字节流之后再进行解析所得到的,如果无法解析则返回错误。

比如上面的 POST 请求,如果通过 data 参数传递,那么服务端拿到的字节流就是下面这样:

b'name=satori&age=16'

显然服务端应该通过表单的方式去解析,如果使用 JSON 库则解析失败。

如果客户端是通过 json 参数传递,那么服务端拿到的字节流就是下面这样:

b'{"name": "satori", "age": 16}'

此时服务端可以放心地使用 json.loads。

对于客户端来说也是如此,比如这里的 httpx。如果明确服务端会返回 JSON,那么可以直接调用 response.json() 拿到字典;但如果返回的不是 JSON,那么就不能这么做。比如服务端返回的是图片、视频,我们只能以二进制的方式保存下来。

再补充一点,查询参数对于所有请求都是适用的,比如 POST 请求,我们也可以通过 params 指定查询参数。

import httpx

response = httpx.post(
    "https://httpbin.org/post",
    params={"ping": "pong"},
    json={"name": "satori", "age": 16}
)
print(response.url)
print(response.request.content)
"""
https://httpbin.org/post?ping=pong
b'{"name": "satori", "age": 16}'
"""

结果没有问题。

885933afc33f2b270e988db2de5161b4.png

自定制请求头

b60b64b46880242f2dbbf9e9668bc110.png

很多网站,都设置了反爬虫机制。最常用的就是判断请求头里的 User-Agent 字段,如果不是浏览器的,会直接将你屏蔽掉。

import httpx, requests

response = httpx.get("http://www.baidu.com",)
print(response.request.headers["User-Agent"])
"""
python-httpx/0.23.3
"""

response = requests.get("http://www.baidu.com",)
print(response.request.headers["User-Agent"])
"""
python-requests/2.28.0
"""

无论是 httpx 还是 requests,都会设置一个默认的 User-Agent。但很明显,很容易被服务端检测出来,因此我们需要自定制请求头。

import httpx

response = httpx.get("http://www.baidu.com",
                     headers={"User-Agent": "Chrome user agent"})
print(response.request.headers["User-Agent"])
"""
Chrome user agent
"""

此时 User-Agent 就被我们替换掉了,这里是我随便指定的,在真正发请求的时候,从浏览器里面拷贝一下即可。另外不仅是 User-Agent,请求头里的其它字段也是可以设置的。

headers 参数不仅可以接收字典,还可以接收一个 httpx.Headers 对象

33de3ea2d1805a658e4dcc0d51909dbc.png

自定制 cookie

cdead4395ee07323f3eaece28fb70a98.png

这个在模拟登录的时候非常有用,一般在输入用户名和密码登录成功之后,服务端会返回一个 cookie,这个 cookie 里面存储了 Session ID。后续浏览器发请求的时候,会带上这个 cookie,服务端检测到之后就知道该用户已经登录了。

那么 httpx 在发请求的时候,如何带上 cookie 呢?

from pprint import pprint
import httpx

cookies = httpx.Cookies({"Session ID1": "0000001"})
# 也可以单独设置
cookies["Session ID2"] = "0000001"
cookies.set("Session ID3", "0000003", domain="", path="/")

response = httpx.get("http://httpbin.org/cookies",
                     # 这里也可以直接传一个字典
                     cookies=cookies)
pprint(response.json())
"""
{'cookies': {'Session ID1': '0000001',
             'Session ID2': '0000001',
             'Session ID3': '0000003'}}
"""

至于服务端返回的 cookie,可以通过 response.cookies 获取。比如模拟登录成功之后,将服务端返回的 cookie 保存下来,然后下一次发请求的时候带上它。

3915bb617c81db0d361fe9d67032ccea.png

重定向与请求历史

9355f9b0a8e5e303e8b8003186962d49.png

重定向分为暂时性重定向和永久性重定向。

  • 暂时性重定向:状态码为 302,比如我们要发表评论,但是没有登录,那么此时就会被重定向到登录页面;

  • 永久性重定向:状态码为 301,比如我们访问一个已经废弃的域名,会被永久性重定向到新的域名。

那我们怎么判断在访问的时候有没有重定向呢?如果被重定向了,那么如何获取重定向之前的页面呢?

import httpx, requests

# 如果是 requests,那么会自动重定向
# 会被重定向到 https://www.taobao.com
response = requests.get("http://www.taobao.com")
# 而 response 也是重定向之后返回的响应
print(response.status_code)
"""
200
"""
# 但通过 response.history 可以获取重定向之前的响应
# 因为可能会被重定向多次,因此返回的是列表
print(response.history)
"""
[<Response [301]>]
"""
print(response.history[0].text)
"""
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<h1>301 Moved Permanently</h1>
<p>The requested resource has been assigned a new permanent URI.</p>
<hr/>Powered by Tengine</body>
</html>
"""

# 但 httpx 不会自动重定向
response = httpx.get("http://www.taobao.com")
print(response.status_code)
print(response.history)
"""
301
[]
"""
# 如果希望重定向,那么需要指定一个参数
response = httpx.get("http://www.taobao.com",
                     follow_redirects=True)
print(response.status_code)
print(response.history)
"""
200
[<Response [301 Moved Permanently]>]
"""

个人觉得 httpx 的这个设计不是很好,首先它和 requests 一样,都有一个参数用来控制是否重定向。

  • requests 里面的参数叫 allow_redirects,默认为 True,表示允许重定向。如果不希望重定向,那么将其设置为 False;

  • httpx 里面的参数叫 follow_redirects,默认为 False,表示不进行重定向。如果希望重定向,那么将其设置为 True;

但个人觉得,应该自动重定向会好一些。

99c6d812ddebe59e07b2e7825c54cdeb.png

上传文件

789398936adf4fb785a6f184bcb37a34.png

如果服务端需要接收一个文件,那么我们应该怎么上传呢?比如我们有一个 1.txt,里面写着一句 Hello World,那这个 1.txt 要如何上传呢?

from pprint import pprint
import httpx

response = httpx.post("http://httpbin.org/post",
                      files={"file": open("1.txt", "rb")})

pprint(response.json())
"""
{'args': {},
 'data': '',
 'files': {'file': 'Hello World'},
 'form': {},
 'headers': {...},
 'json': None,
 'origin': '120.244.40.157',
 'url': 'http://httpbin.org/post'}
"""

http://httpbin.org/post 是一个专门用来测试 HTTP 请求的网站,根据返回结果我们知道文件上传成功了。

当然,在上传文件的时候,也可以显示地指定文件名和文件类型。

from pprint import pprint
import httpx

response = httpx.post(
    "http://httpbin.org/post",
    files={"file": ("1.html", open("1.html", "rb"), "text/html")}
)

pprint(response.json())
"""
{'args': {},
 'data': '',
 'files': {'file': '<h1>我是 HTML 文件</h1>'},
 'form': {},
 'headers': {...},
 'json': None,
 'origin': '120.244.40.157',
 'url': 'http://httpbin.org/post'}
"""

我们也可以同时上传多个文件,并且上传文件的同时,还可以传递表单数据。

from pprint import pprint
import httpx

response = httpx.post(
    "http://httpbin.org/post",
    files={"file1": open("1.html", "rb"),
           "file2": open("1.txt", "rb")},
    data={"ping": "pong"},
)

pprint(response.json())
"""
{'args': {},
 'data': '',
 'files': {'file1': '<h1>我是 HTML 文件</h1>', 
           'file2': 'Hello World'},
 'form': {'ping': 'pong'},
 'headers': {...},
 'json': None,
 'origin': '120.244.40.157',
 'url': 'http://httpbin.org/post'}
"""

然后上传文件还有一种方式,就是我们可以将文件以二进制的方式读出来,然后将字节流传过去。

import httpx

with open("1.txt", "rb") as f:
    content = f.read()

response = httpx.post(
    "http://httpbin.org/post",
    content=content
)

前面我们说传递表单数据使用参数 data,传递 JSON 数据使用参数 json,如果是普通的字节流,那么应该使用参数 content。

当然这种方式的话,服务端只能拿到文件的字节流,但是类型并不知道。因此可以在 headers 参数里面,通过 Content-Type 告诉服务端字节流对应文件的类型。

63482baed4bc25c03b69e14364dc3150.png

流式响应

db495b85be235ac16e743c8e571a1556.png

到目前为止,我们都是调用 httpx 的请求函数发送 HTTP 请求(比如 GET、POST),服务端返回响应,然后通过 response.content 获取响应体。但如果响应体非常大,该怎么办?显然这会带来两个问题:

  • 1)程序在调用 httpx.get 或 httpx.post 的时候,会一直处于阻塞状态,直到服务端将数据一次性全部返回。而响应体如果比较大,那么阻塞的时间就会比较长。

  • 2)如果服务端返回的数据过大,可能会导致内存不够用。

所以需要有一种机制,能够不让数据一次性全部返回,而是分批返回。在 httpx 里面是支持的。

import httpx

# httpx.stream 和 httpx.request 的参数是一样的
# 可以传递 headers、cookies、data、json 等等
with httpx.stream("GET", "http://www.baidu.com") as r:
    # 分块返回,每块 100KB
    for chunk in r.iter_bytes(chunk_size=1024 * 100):
        print(len(chunk))
"""
102400
102400
102400
62890
"""

通过分块读取,可以避免因响应体过大,而导致内存溢出。

a7c51efe33e022be8c3836e364760e1a.png

超时控制

6a9051c51adb855db1b73bcddab8991c.png

httpx 有很多优秀的特性,其中一个就是超时控制。httpx 为所有的网络操作都提供了一个合理的超时时间,如果连接没有正确地建立,那么 httpx 会及时地引发错误,而不会让开发者陷入长时间的等待。

import httpx

# 默认的超时时间是 5 秒,我们可以将其设置的更严格一些
response = httpx.get("https://www.google.com",
                     timeout=1)

# 如果传递一个 None,那么表示不设置超时时间

非常简单,但 httpx 还支持更细粒度地控制超时时间。因为如果发生超时,无非以下几种情况:

  • 连接超时:如果没有在规定时间内,和请求的主机建立套接字连接,我们就说连接超时了,会引发 ConnectTimeout 异常;

  • 读超时:如果没有在规定时间内,接收到服务端返回的数据块(响应体的一个块),我们就说读超时了,会引发 ReadTimeout 异常。

  • 写超时:如果没有在规定时间内,将数据块(请求体的一个块)发送给服务端,我们就说写超时了,会引发 WriteTimeout 异常。

而不同种类的超时,可以设置不同的超时时间,如果只写一个整数或浮点数,那么表示所有的超时时间都是相同的。

import httpx

# 连接超时时间设置为 10 秒,其它超时时间设置为 3 秒
timeout1 = httpx.Timeout(3, connect=10)

# 连接超时时间设置为 10 秒,读超时时间设置为 5 秒
# 其它超时时间设置为 3 秒
timeout2 = httpx.Timeout(3, connect=10, read=5)

# 连接超时时间设置为 10 秒,读超时时间设置为 5 秒
# 写超时时间设置为 6 秒,其它超时时间设置为 3 秒
timeout3 = httpx.Timeout(3, connect=10, read=5, write=6)

# 如果 connect、read、write 都不传,比如 Timeout(1)
# 那么 timeout=Timeout(1) 和 timeout=1 是等价的

response = httpx.get(
    "https://www.google.com",
    timeout=timeout1  # timeout2、timeout3
)

读超时时间适用于 get、head 等请求,写超时时间适用于 post、put 等请求,连接超时时间适用于所有请求(因为不管什么请求都需要建立连接)。

1b18019401f6907de09dad5996058b42.png

身份验证

f2aa951de8b9a7c8f1e5df33d4803bcf.png

有的时候发起 HTTP 请求的时候,会让你输入用户名和密码,也就是所谓的 Basic 认证。我们用 FastAPI 编写一个服务,举例说明:

d96146facb3e2074eeb94e4efd756840.png

在浏览器中输入 URL 之后,会让我们提供用户名和密码,用户名密码正确才会执行请求,否则直接返回认证失败。那么面对这种情况,我们如何在发起请求的同时指定用户名和密码呢?

import httpx

response = httpx.get("http://localhost:5555/index")
# 如果没有认证的话,FastAPI 会默认返回一个 JSON
print(response.status_code)
"""
401
"""
print(response.json())
"""
{'detail': 'Not authenticated'}
"""

# 如何输入用户名和密码呢,通过 auth 参数指定即可
response = httpx.get("http://localhost:5555/index",
                     auth=("satori", "123456"))
# 这里的 FastAPI 服务会将输入的用户名和密码返回
print(response.json())
"""
{'username': 'satori', 'password': '123456'}
"""

# 或者下面这种做法也行
response = httpx.get("http://satori:123456@localhost:5555/index")
print(response.json())
"""
{'username': 'satori', 'password': '123456'}
"""

以上就是 Basic 认证,但除了 Basic 认证之外还有 Digest 认证,要更安全一些。如果是 Digest 认证的话,我们实例化一个 httpx.DigestAuth 对象(输入用户名和密码),然后传给 auth 参数即可。

a5d32dc166b03edb908c79041e2beafe.png

异常处理

6183e5b5277ce19f9cafe22fb48e76cb.png

当请求出现错误时,httpx 会引发相应的异常。在 httpx 里面有两个关键的异常:

1)RequestError

这是一个超类,发送 HTTP 请求后产生的任何异常都可以用它来捕获。

import httpx

try:
    httpx.get("https://www.google.com",
              timeout=1)
except httpx.RequestError as e:
    # 内部有一个 request 属性,值为 httpx.Request 对象
    # 通过该属性可以拿到请求相关的信息
    print(f"访问 {e.request.url} 失败")
"""
访问 https://www.google.com 失败
"""

2)HTTPStatusError

Response 对象有一个 raise_for_status 方法,如果状态码不是 200 ~ 299,那么调用的时候会抛异常,而 HTTPStatusError 专门用来捕获该异常。

import httpx

response = httpx.get("http://localhost:5555/index")
print(response.status_code)
"""
401
"""

try:
    # 状态码不在 200 ~ 299,调用会抛异常
    response.raise_for_status()
except httpx.HTTPStatusError as e:
    print(e)
    """
    Client error '401 Unauthorized' for url 'http://localhost:5555/index'
    For more information check: https://httpstatuses.com/401
    """
    # 然后内部还有两个属性,分别是 response 和 request
    print(e.response is response)
    print(e.request is response.request)
    """
    True
    True
    """

比较简单,并且也不是很常用。

31e36e45be189d4fe530a08805580f09.png

Client 对象

18a0205b4a754f772a7e2b1c8875a466.png

我们知道 httpx 内部的 get、post 等函数,背后都调用了 request 函数,那么 request 函数的逻辑是怎么样的呢?我们看一下源代码。

85e2f5b721b996b657306d4b59cc9ad1.png

发送请求的逻辑都在类 Client 里面,我们可以实例化一个 Client 对象,然后调用它的 get、post、put 等方法,当然这些方法背后都调用了 client.request。

如果是通过 httpx 调用的话,比如 httpx.get,那么内部会先帮我们实例化一个 Client 对象,然后调用对象的 request 方法。

httpx.Client 和 requests.Session 的作用是类似的。

所以当我们要多次向某个网址发请求(比如 get 请求)时,那么先实例化一个 Client 对象,然后再调用它的 get 方法会更好一些。因为底层的 TCP 连接会复用,从而带来性能提升。如果使用 httpx.get,那么每次访问都要新创建一个 TCP 连接。

import httpx

# 内部会创建一个 Client 对象,然后调用它的 request 方法
# 调用结束之后,再将对象销毁,因此底层的 TCP 连接无法复用
httpx.get("http://www.baidu.com")
httpx.get("http://www.baidu.com")
httpx.get("http://www.baidu.com")

# 实例化一个 Client 对象,它内部使用了 HTTP 连接池
client = httpx.Client()
# 向同一主机发出多个请求时,客户端将重用底层的 TCP 连接
# 而不是为每个请求重新创建一个
client.get("http://www.baidu.com")
client.get("http://www.baidu.com")
client.get("http://www.baidu.com")

使用 Client 对象除了能带来性能上的提升,还有一个重要的地方就是,它可以将请求参数保存起来,并让它们跨请求传递。举个例子:

import httpx

response = httpx.get("http://www.baidu.com",
                     headers={"ping": "pong"})
print("ping" in response.request.headers)
"""
True
"""
# httpx 内部每次都会创建一个新的 Client 对象
# 因此在上一个请求当中设置的请求头,与后续请求无关
response = httpx.get("http://www.baidu.com")
print("ping" in response.request.headers)
"""
False
"""

# 先实例化一个 Client 对象,在里面设置请求头
# 那么每一次请求的时候,都会带上,因为用的是同一个对象
client = httpx.Client(headers={"ping": "pong"})
response = client.get("http://www.baidu.com")
print("ping" in response.request.headers)
"""
True
"""
response = client.get("http://www.baidu.com")
print("ping" in response.request.headers)
"""
True
"""

除了请求头,像 cookie、超时时间、auth、代理等等都是支持的,一旦设置了,那么后续的每次请求都会带上。

并且除了在实例化的时候设置之外,也可以实例化之后单独设置,举个例子:

import httpx

client = httpx.Client()
client.headers["ping"] = "pong"
response = client.get("http://www.baidu.com")
print("ping" in response.request.headers)
"""
True
"""

总的来说,和 requests 模块是一致的。

还有一点,如果我们在请求的方法中又传了相应的参数,那么请求方法中的参数,会覆盖 client 当中的参数。

import httpx

client = httpx.Client(headers={"ping": "pong"})
response = client.get("http://www.baidu.com",
                      headers={"X-MAN": "TX",
                               "ping": "pong pong pong"})
# 调用 get 方法时,也设置了 headers,那么以具体的方法为准
# 并且在请求方法中设置的参数,不会影响下一个请求
print(response.request.headers["ping"])
print(response.request.headers["X-MAN"])
"""
pong pong pong
TX
"""

# 重新调用,两个请求互不影响
response = client.get("http://www.baidu.com")
print(response.request.headers["ping"])
print("X-MAN" in response.request.headers)
"""
pong
False
"""

非常简单,比如我们要多次访问一个比较私密的接口,而接口要求我们在访问时,必须在请求头中带上指定的 Token,而 Token 需要访问另一个接口才能获取。那么便可以实例化一个 Client 对象,获取完 Token 之后通过 client.headers 设置进去,这样后续在请求的时候就会自动带上。

Client 对象和 requests 的 Session 对象一样,不用了应该调用 close 方法进行关闭。或者使用 with 语句,会自动关闭。

2fab47c96f8d844411b0430382b5287c.png

指定代理

2248f386e8bee0f8a73b32655555ef1c.png

如果要使用代理,那么需要通过 proxies 参数指定。

import httpx

proxies = {
  "http": "http://10.10.1.10:3128",
  "https": "http://10.10.1.10:1080",
}

httpx.get("...",  proxies=proxies)
# 或者手动实例化 Client 对象
# 后续每次请求都会带上
with httpx.Client(proxies=proxies) as client:
    client.get("...")
    client.get("...")

若你的代理需要使用HTTP Basic Auth,可以使用 http://user:pass@host:port 语法:

proxies = {
    "http": "http://user:pass@10.10.1.10:3128",
}

还可以为某个特定的连接方式或者主机设置代理,使用 scheme://host:port 作为 key,它会针对指定的主机和连接方式进行匹配。

proxies = {
    'http://10.20.1.128': 'http://10.10.1.10:5323'
}

以上是 HTTP 代理,除了它之外 httpx 还支持 SOCKS 代理。如果要使用的话,需要安装第三方库,pip install requests[socks]。

import httpx

httpx.Client(
    proxies='socks5://user:pass@host:port'
)

195790c716aa73e130cce25777029e7a.png

SSL 证书

6ffeb0b39f3540077e859627e1d05698.png

HTTP 在传输数据的时候是不安全的,所以引入了 HTTPS。在发送 HTTPS 请求时,httpx 会对服务端主机的 SSL 证书进行验证(默认行为),如果验证失败,或者不信任服务端的 SSL 证书,那么 httpx 会抛出异常。

对于大部分网站来说,它们的 SSL 证书都是由受信任的 CA 机构颁发,所以能够直接正常访问,验证通过。

但有些网站比较特殊,它会单独提供证书,你需要先把证书下载下来,然后发请求的时候带过去。

import httpx

response = httpx.get("https://xxx.org",
                     verify="证书.pem")

with httpx.Client(verify="证书.pem") as client:
    client.get("https://xxx.org")

或者你还可以使用标准库 ssl,传递一个 SSLContext 对象。

import ssl
import httpx

ctx = ssl.create_default_context()
ctx.load_verify_locations("证书.pem")
# 或者直接 ctx = httpx.create_ssl_context("证书.pem")
response = httpx.get("https://xxx.org",
                     verify=ctx)

SSL 证书是为了保证客户端和服务端之间的数据传输安全,如果你不需要考虑安全性的话,那么也可以指定 verify 为 False,表示禁用 SSL 验证。

既然服务端有证书,那么客户端也可以有。

import httpx

cert1 = "客户端证书.pem"
cert2 = ("客户端证书.pem", "秘钥文件.key")
cert3 = ("客户端证书.pem", "秘钥文件.key", "密码")
httpx.get(
    "https://example.org",
    cert=cert1  # cert2、cert3
)

不是太常用,了解一下就好。

e24152cee0838ff79ff044dbe3812e43.png

手动构造 Request 对象

6f8cba157d98aa985991d5dd8813a53b.png

调用 httpx 里面的函数发送请求时,httpx 内部会帮我们构造 Resquest 对象;服务端返回响应之后,httpx 会帮我们构造 Response 对象。

但为了最大限度地控制发送的内容,HTTPX 还支持我们手动构建 Request 对象。

import httpx

request = httpx.Request("GET", "http://www.baidu.com")
# 通过 client.send 方法将请求发送给服务端
with httpx.Client(headers={"ping": "pong"}) as client:
    response = client.send(request)
    print("ping" in response.request.headers)  # False

# 但上面这种方式,Client() 里面的参数无法作用在请求上
# 因此还可以通过 Client 对象来构造请求
with httpx.Client(headers={"ping": "pong"}) as client:
    request = client.build_request("GET", "http://www.baidu.com")
    response = client.send(request)
    print("ping" in response.request.headers)  # True

这种方式用的不多,我们直接调用 client 下面的 get、post 等方法发请求即可。

b2e3b9f25f2804fe0395a9570468e5c0.png

钩子函数

7bfe10f39b2175250cd8ca39f1b8e756.png

httpx 还允许我们向 Client 实例注册一些钩子函数,当指定事件发生时会调用,而事件有两种:

  • request:请求完全准备好之后,发给服务端之前调用;

  • response:从服务端获取响应之后,返回之前调用;

通过钩子函数,我们可以跟踪请求的整个过程,并进行记录。

import httpx

def before_request1(request):
    print(f"1)向 {request.url} 发送了请求")

def before_request2(request):
    print(f"2)向 {request.url} 发送了请求")

def after_response1(response):
    print(f"1)服务端返回了响应,状态码 {response.status_code}")

def after_response2(response):
    print(f"2)服务端返回了响应,状态码 {response.status_code}")

client = httpx.Client(
    event_hooks={"request": [before_request1, before_request2],
                 "response": [after_response1, after_response2]}
)
client.get("http://www.baidu.com")
"""
1)向 http://www.baidu.com 发送了请求
2)向 http://www.baidu.com 发送了请求
1)服务端返回了响应,状态码 200
2)服务端返回了响应,状态码 200
"""

总的来说,钩子函数很有用,但对于我们简单地发送 HTTP 请求而言,用的不多。

4ee3a79618e7827a920ab45f3472afbb.png

开启 HTTP/2

89aeba6a313b5eaf0f6777f3352954b4.png

先来说一说为什么会有 HTTP/2,存在即合理,既然 HTTP/2 会出现,那么说明 HTTP/1.1 一定存在一些缺点。那么缺点都有哪些呢?

队头阻塞

HTTP/1.1 是基于「请求-响应」模式,如果一个请求阻塞,那么在后面排队的所有请求也会一同阻塞,会导致客户端一直请求不到数据,就类似堵车。

延迟高

HTTP/1.1 处理响应的顺序和请求顺序是一致的,只有第一个响应处理完毕之后才能处理第二个响应。就类似于打卡,第一个人因为某些原因怎么也打不上卡,但他如果打不上,后面的人也没法打。

总的来说,对于 HTTP/1.1 而言,没有轻重缓急的优先级,只有先后入队的顺序。

HTTP 头部过大

无论是请求报文还是响应报文,都由 Header + Body 组成,因为 HTTP/1.1 是无状态的,所以就要求 Header 携带很多的头字段,有时多达几百字节甚至上千字节。但 Body 却经常只有几十字节、甚至几字节(比如 GET 请求、204/301/304 响应),等于说变成了一个不折不扣的大头儿子。

更要命的是,成千上万的请求响应报文里有很多字段值都是重复的,非常浪费,长尾效应导致大量带宽消耗在了这些冗余度极高的数据上。

以上就是 HTTP/1.1 面临的一些问题,尽管也通过一些额外的手段来曲线救国,但仍然不能很好的解决问题。所以业界就开始改革了,发明了 HTTP/2 协议,这个协议很好地解决了 HTTP/1.1 所面临的问题。

首先 HTTP/2 它是安全的,和 HTTPS 一样,也是基于 SSL/TLS 之上。但将 HTTP 分解成了语义和语法两个部分,语义层不做改动,与 HTTP/1 完全一致。比如请求方法、URI、状态码、头字段等概念都保留不变,这样就消除了再学习的成本,基于 HTTP 的上层应用也不需要做任何修改,可以无缝转换到 HTTP/2,如果代理服务器不支持 HTTP/2,那么会自动降级到 HTTP/1.1(HTTPS)。

特别要说的是,与 HTTPS 不同,HTTP/2 没有在 URI 里引入新的协议名,仍然用 http 表示明文协议,用 https 表示加密协议。这是一个非常了不起的决定,可以让浏览器或者服务器去自动升级或降级协议,免去了选择的麻烦,让用户在上网的时候都意识不到协议的切换,实现平滑过渡。

在语义保持稳定之后,HTTP/2 在语法层做了天翻地覆的改造,完全变更了 HTTP 报文的传输格式。

头部压缩

HTTP/1.1 对 Body 进行了压缩,并且还提供了 Content-Encoding 指定压缩方式,但 Header 却没有进行优化。

于是 HTTP/2 把头部压缩作为性能改进的一个重点,优化的方式自然还是压缩。但 HTTP/2 并没有使用传统的压缩算法,而是开发了专门的 HPACK 算法,在客户端和服务器两端建立字典,用索引号表示重复的字符串,还釆用哈夫曼编码来压缩整数和字符串,可以达到 50%~90% 的高压缩率。

二级制格式

HTTP/1.1 的报文是明文格式,但这样容易出现多义性,比如大小写、空白字符、回车换行、多字少字等等,程序在处理时必须用复杂的状态机,效率低,还麻烦。

于是 HTTP/2 将报文换成了二进制格式,这样虽然对人不友好,但却大大方便了计算机的解析。具体做法是把原来的 Header+Body 的消息打散为数个小片的二进制帧(Frame),其中 HEADERS 帧存放头数据、DATA 帧存放实体数据。

807ca8d3edd64ccea4aa8af6ba6a8e93.png

这种做法有点像是 Chunked 分块编码的方式,也是化整为零的思路,但 HTTP/2 数据分帧后 Header+Body 的报文结构就完全消失了,协议看到的只是一个个的碎片。

虚拟的流

在连接的层面上看,多个消息就是一堆乱序收发的帧,比如可以先接收消息 1 的帧、再接收消息 2 的帧,然后再接收消息 1 的帧。这个过程不要求顺序(比如先将消息 1 的帧全部接收完毕之后才能接收消息 2 的帧),否则和 HTTP/1.1 就没有区别了。

那么问题来了,这些消息碎片(二进制帧)到达目的地之后应该怎么组装起来呢?HTTP/2 为此定义了一个流(Stream)的概念,它是虚拟的,可以想象成二进制帧的双向传输序列。

隶属同一个消息的所有帧都有一个相同的流 ID,不同消息的流 ID 则不同。后续在对帧进行组装的时候,根据这个 ID 来将属于同一个消息的帧组装在一起,得到类似 HTTP/1.1 中的报文,也就是传输时无序,接收时组装。

所以,在 HTTP/2 中的多个请求与响应之间没有了顺序关系,不需要排队等待,也就不会再出现队头阻塞问题,降低了延迟,大幅度提高了连接的利用率。

说白了在 HTTP/2 中就是将二进制的报文数据切分成多个帧进行传输,而不同消息的帧可以混在一起发送(传输时无序),而在接收时再根据流 ID 将属于同一个消息的帧组装在一起(接收时组装)。

因为流是虚拟的,实际上并不存在,所以 HTTP/2 就可以在一个 TCP 连接上同时发送多个碎片化的消息,这就是常说的多路复用( Multiplexing),多个往返通信都复用一个连接来处理。

但需要注意的是,我们说传输时无序指的是多个消息之间的帧可以无序,但同一个消息的帧则必须是有序的,比如每条消息必须先传输其 HEADER 帧、再传输 DATA 帧,否则消息就乱掉了。然后组装的时候,直接按照顺序进行组装即可。

a83f8a663a16af89c2a18d0c0fb4ce7b.png

另外为了更好地利用连接,加大吞吐量,HTTP/2 还添加了一些控制帧来管理虚拟的流,实现了优先级和流量控制,这些特性也和 TCP 协议非常相似。

HTTP/2 还在一定程度上改变了传统的请求响应工作模式,服务器不再是完全被动地响应请求,也可以新建流主动向客户端发送消息。比如,在浏览器刚请求 HTML 的时候就提前把可能会用到的 JS、CSS 文件发给客户端,减少等待的延迟,这被称为服务器推送(Server Push)。

开启 HTTP2

好了,说回重点,虽然 HTTP/1.1 存在一些问题,但它非常成熟,所以默认没有开启 HTTP/2。如果想开启的话,那么只需要在实例化 Client 的时候加上一个参数即可。

但是要安装 HTTP/2 的依赖项,直接 pip install httpx[http2] 即可

import httpx

# 通过指定 http2=True,即可开启 HTTP/2
client = httpx.Client(http2=True)
response = client.get("http://www.baidu.com")

注:httpx.get、http.post 里面没有 http2 这个参数,如果想开启 HTTP/2,那么必须手动实例化 Client 对象,然后指定相关参数。

643d0d764b926d4f2e9f3385bef78bd5.png

发送异步请求

f66d45a22125b9a520eae538b51a29ec.png

前面介绍的所有内容都是基于同步模式,但 httpx 还可以发送异步请求,否则就没有必要学它了,直接用 requests 就行了。

那么如何发送异步请求呢?

import asyncio
import httpx

async def send():
    async with httpx.AsyncClient() as client:
        response = await client.get("http://www.baidu.com")
        print(response.status_code)
        print(response.url)

asyncio.run(send())
"""
200
http://www.baidu.com
"""

过程非常简单,使用 AsyncClient 实例化一个客户端,然后调用里面的方法发送请求即可。用法和同步的 Client 对象是一样的,只有几个方法名不一样,我们举例说明:

import asyncio
import httpx

async def send():
    client = httpx.AsyncClient()
    # 分块读取
    async with client.stream(
        'GET', 'http://www.baidu.com'
    ) as response:
        # 如果是 Client,那么方法名为 iter_bytes
        # 而 AsyncClient 的方法名则是 aiter_bytes
        # 然后遍历要用 async for,因为返回的是异步生成器
        async for chunk in response.aiter_bytes():
            pass

    # 关闭的时候要使用 aclose(),而不是 close
    await client.aclose()

对啦,还有钩子函数,如果使用的是异步客户端,钩子函数应该使用 async def 定义。也就是说,我们要传协程函数,而不是普通的函数。

d1d227acaff1e0e797505dc0c49ef3fd.png

小结

7eb1a9621e2e5cbef053ed7ad49e4a84.png

关于 httpx 的内容就说到这里,总的来说它的功能还是很强大的,在设计上和 requests 保持了高度的一致性。熟悉 requests 的话,那么学习 httpx 基本上没有任何压力。

但 httpx 在 requests 的基础上提供了很多新功能,比如严格的超时控制,精确的类型注解,HTTP/2 和协程的支持等等。以后在发送 HTTP 请求的时候,不妨使用 httpx 吧。


本文参考自:

  • httpx 官方文档

  • 罗剑锋《透视 HTTP 协议》

cd2268a9864652ad912c162d6d77c766.gif

Python猫技术交流群开放啦!群里既有国内一二线大厂在职员工,也有国内外高校在读学生,既有十多年码龄的编程老鸟,也有中小学刚刚入门的新人,学习氛围良好!想入群的同学,请在公号内回复『交流群』,获取猫哥的微信(谢绝广告党,非诚勿扰!)~

还不过瘾?试试它们

Python 最强大的任务调度框架 Celery!

为什么要有协程,协程是如何实现的?

终于懂了:协程思想的起源与发展

一个小破网站,居然比 Python 官网还牛逼

Python最佳代码实践:性能、内存和可用性!

Python 为什么不支持 switch 语句?

如果你觉得本文有帮助

请慷慨分享点赞,感谢啦

 

  • 7
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值