詳解新一代 HTTP 請求庫:httpx

楔子

本次我們來聊一聊 httpx,它是一個 HTTP 請求庫。不過說到發送 HTTP 請求,我們首先想到的應該是 requests,但 requests 是一個同步庫,目前只能同步發請求。而 httpx 不僅可以同步發請求,還可以異步發請求,並且支持 HTTP/1.1 和 HTTP/2。

另外 httpx 在設計上也模仿了 requests,兩者的 API 是兼容的,如果你會 requests,那麼 httpx 很容易上手。

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

下面就來看一下相關用法。

使用 httpx 發請求

使用 httpx 發送請求非常簡單,首先請求有以下幾種:

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 就好。

服務端響應(httpx.Response)

當服務端收到請求並處理完畢之後,會給客戶端返回響應,而這裏的客戶端顯然就是 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
"""

基於以上這些字段,我們可以獲取服務端響應的全部信息。既然服務端的響應可以拿到,那客戶端請求該如何獲取呢?

客戶端請求(httpx.Request)

調用 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 的核心就是:客戶端發送請求,服務端返回響應。請求包含請求頭、請求體,響應包含響應頭、響應體。

怎麼發請求我們已經知道了,然後再來看看發請求時的一些更具體的細節。

向服務端傳遞數據

在發送請求時,客戶端還可以攜帶指定的數據給服務端。對於 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}'
"""

結果沒有問題。

自定製請求頭

很多網站,都設置了反爬蟲機制。最常用的就是判斷請求頭裏的 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 對象

自定製 cookie

這個在模擬登錄的時候非常有用,一般在輸入用戶名和密碼登錄成功之後,服務端會返回一個 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 保存下來,然後下一次發請求的時候帶上它。

重定向與請求歷史

重定向分爲暫時性重定向和永久性重定向。

那我們怎麼判斷在訪問的時候有沒有重定向呢?如果被重定向了,那麼如何獲取重定向之前的頁面呢?

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 一樣,都有一個參數用來控制是否重定向。

但個人覺得,應該自動重定向會好一些。

上傳文件

如果服務端需要接收一個文件,那麼我們應該怎麼上傳呢?比如我們有一個 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 告訴服務端字節流對應文件的類型。

流式響應

到目前爲止,我們都是調用 httpx 的請求函數發送 HTTP 請求(比如 GET、POST),服務端返回響應,然後通過 response.content 獲取響應體。但如果響應體非常大,該怎麼辦?顯然這會帶來兩個問題:

所以需要有一種機制,能夠不讓數據一次性全部返回,而是分批返回。在 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
"""

通過分塊讀取,可以避免因響應體過大,而導致內存溢出。

超時控制

httpx 有很多優秀的特性,其中一個就是超時控制。httpx 爲所有的網絡操作都提供了一個合理的超時時間,如果連接沒有正確地建立,那麼 httpx 會及時地引發錯誤,而不會讓開發者陷入長時間的等待。

import httpx

# 默認的超時時間是 5 秒,我們可以將其設置的更嚴格一些
response = httpx.get("https://www.google.com",
                     timeout=1)

# 如果傳遞一個 None,那麼表示不設置超時時間

非常簡單,但 httpx 還支持更細粒度地控制超時時間。因爲如果發生超時,無非以下幾種情況:

而不同種類的超時,可以設置不同的超時時間,如果只寫一個整數或浮點數,那麼表示所有的超時時間都是相同的。

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 等請求,連接超時時間適用於所有請求(因爲不管什麼請求都需要建立連接)。

身份驗證

有的時候發起 HTTP 請求的時候,會讓你輸入用戶名和密碼,也就是所謂的 Basic 認證。我們用 FastAPI 編寫一個服務,舉例說明:

在瀏覽器中輸入 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 參數即可。

異常處理

當請求出現錯誤時,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
    """

比較簡單,並且也不是很常用。

Client 對象

我們知道 httpx 內部的 get、post 等函數,背後都調用了 request 函數,那麼 request 函數的邏輯是怎麼樣的呢?我們看一下源代碼。

發送請求的邏輯都在類 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 語句,會自動關閉。

指定代理

如果要使用代理,那麼需要通過 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'
)

SSL 證書

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
)

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

手動構造 Request 對象

調用 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 等方法發請求即可。

鉤子函數

httpx 還允許我們向 Client 實例註冊一些鉤子函數,當指定事件發生時會調用,而事件有兩種:

通過鉤子函數,我們可以跟蹤請求的整個過程,並進行記錄。

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 請求而言,用的不多。

開啓 HTTP/2

先來說一說爲什麼會有 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 幀存放實體數據。

這種做法有點像是 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 幀,否則消息就亂掉了。然後組裝的時候,直接按照順序進行組裝即可。

另外爲了更好地利用連接,加大吞吐量,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 對象,然後指定相關參數。

發送異步請求

前面介紹的所有內容都是基於同步模式,但 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 定義。也就是說,我們要傳協程函數,而不是普通的函數。

小結

關於 httpx 的內容就說到這裏,總的來說它的功能還是很強大的,在設計上和 requests 保持了高度的一致性。熟悉 requests 的話,那麼學習 httpx 基本上沒有任何壓力。

但 httpx 在 requests 的基礎上提供了很多新功能,比如嚴格的超時控制,精確的類型註解,HTTP/2 和協程的支持等等。以後在發送 HTTP 請求的時候,不妨使用 httpx 吧。

本文參考自:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Cvk0jo8pN9zjjhQzbsqdvA