為什么爬蟲(chóng)工程師應(yīng)該有一些基本的后端常識(shí)?

劇照:眷思量
作者:kingname
來(lái)源:未聞Code
今天在粉絲交流群里面,有個(gè)同學(xué)說(shuō)他發(fā)現(xiàn)了Requests的一個(gè) bug,并修復(fù)了它:

聊天記錄中對(duì)應(yīng)的圖片為:

看到這個(gè)同學(xué)的截圖,我大概知道他遇到了什么問(wèn)題,以及為什么會(huì)誤認(rèn)為這是 Requests 的 bug。
要解釋這個(gè)問(wèn)題,我們需要首先明白一個(gè)問(wèn)題,那就是 JSON 字符串的兩種顯示形式和json.dumps的ensure_ascii參數(shù)。
假設(shè)我們?cè)?Python 里面有一個(gè)字典:
info = {'name': '青南', 'age': 20}
當(dāng)我們想把它轉(zhuǎn)成 JSON 字符串的時(shí)候,我們可能會(huì)這樣寫(xiě)代碼:
import json
info = {'name': '青南', 'age': 20}
info_str = json.dumps(info)
print(info_str)
運(yùn)行效果如下圖所示,中文變成了 Unicode 碼:

我們也可以增加一個(gè)參數(shù)ensure_ascii=False,讓中文正常顯示出來(lái):
info_str = json.dumps(info, ensure_ascii=False)
運(yùn)行效果如下圖所示:

這位同學(xué)認(rèn)為,由于{"name": "\u9752\u5357", "age": 20}和{"name": "青南", "age": 20}從字符串角度看,顯然不相等。而 Requests 在 POST 發(fā)送數(shù)據(jù)的時(shí)候,默認(rèn)是沒(méi)有這個(gè)參數(shù),而對(duì)json.dumps來(lái)說(shuō),省略這個(gè)參數(shù)等價(jià)于ensure_ascii=True:

所以實(shí)際上Requests在 POST 含有中文的數(shù)據(jù)時(shí),會(huì)把中文轉(zhuǎn)成 Unicode 碼發(fā)給服務(wù)器,于是服務(wù)器根本就拿不到原始的中文信息了。所以就會(huì)導(dǎo)致報(bào)錯(cuò)。
但實(shí)際上,并不是這樣的。我常常跟群里的同學(xué)說(shuō),做爬蟲(chóng)的同學(xué),應(yīng)該要有一些基本的后端常識(shí),才不至于被這種現(xiàn)象誤導(dǎo)。為了說(shuō)明為什么上面這個(gè)同學(xué)的理解是錯(cuò)誤的,為什么這不是 Requests 的 bug,我們自己來(lái)寫(xiě)一個(gè)含有 POST 的服務(wù),來(lái)看看我們POST 兩種情況的數(shù)據(jù)有沒(méi)有區(qū)別。為了證明這個(gè)特性與網(wǎng)絡(luò)框架無(wú)關(guān),我這里分別使用Flask、Fastapi 、Gin 來(lái)進(jìn)行演示。
首先,我們來(lái)看看Requests 測(cè)試代碼。這里用3種方式發(fā)送了 JSON 格式的數(shù)據(jù):
import requests
import json
body = {
'name': '青南',
'age': 20
}
url = 'http://127.0.0.1:5000/test_json'
# 直接使用 json=的方式發(fā)送
resp = requests.post(url, json=body).json()
print(resp)
headers = {
'Content-Type': 'application/json'
}
# 提前把字典序列化成 JSON 字符串,中文轉(zhuǎn)成 Unicode,跟第一種方式等價(jià)
resp = requests.post(url,
headers=headers,
data=json.dumps(body)).json()
print(resp)
# 提前把字典序列化成 JSON 字符串,中文保留
resp = requests.post(url,
headers=headers,
data=json.dumps(body, ensure_ascii=False).encode()).json()
print(resp)
這段測(cè)試代碼使用3種方式發(fā)送 POST 請(qǐng)求,其中,第一種方法就是 Requests 自帶的json=參數(shù),參數(shù)值是一個(gè)字典。Requests 會(huì)自動(dòng)把它轉(zhuǎn)成 JSON 字符串。后兩種方式,是我們手動(dòng)提前把字典轉(zhuǎn)成 JSON 字符串,然后使用data=參數(shù)發(fā)送給服務(wù)器。這兩種方式需要在 Headers 里面指明'Content-Type': 'application/json',服務(wù)器才知道發(fā)上來(lái)的是 JSON 字符串。
我們?cè)賮?lái)看看 Flask 寫(xiě)的后端代碼:
from flask import Flask, request
app = Flask(__name__)
@app.route('/')
def index():
return {'success': True}
@app.route('/test_json', methods=["POST"])
def test_json():
body = request.json
msg = f'收到 POST 數(shù)據(jù),{body["name"]=}, {body["age"]=}'
print(msg)
return {'success': True, 'msg': msg}
運(yùn)行效果如下圖所示:

可以看到,無(wú)論使用哪種 POST 方式,后端都能接收到正確的信息。
我們?cè)賮?lái)看 Fastapi 版本:
from fastapi import FastAPI
from pydantic import BaseModel
class Body(BaseModel):
name: str
age: int
app = FastAPI()
@app.get('/')
def index():
return {'success': True}
@app.post('/test_json')
def test_json(body: Body):
msg = f'收到 POST 數(shù)據(jù),{body.name=}, {body.age=}'
print(msg)
return {'success': True, 'msg': msg}
運(yùn)行效果如下圖所示,三種 POST 發(fā)送的數(shù)據(jù),都能被后端正確識(shí)別:

我們?cè)賮?lái)看看 Gin 版本的后端:
package main
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
)
type Body struct {
Name string `json:"name"`
Age int16 `json:"age"`
}
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"message": "running",
})
})
r.POST("/test_json", func(c *gin.Context) {
json := Body{}
c.BindJSON(&json)
msg := fmt.Sprintf("收到 POST 數(shù)據(jù),name=%s, age=%d", json.Name, json.Age)
fmt.Println(">>>", msg)
c.JSON(http.StatusOK, gin.H{
"msg": fmt.Sprintf("收到 POST 數(shù)據(jù),name=%s, age=%d", json.Name, json.Age),
})
})
r.Run()
}
運(yùn)行效果如下,三種請(qǐng)求方式的數(shù)據(jù)完全相同:

從這里可以知道,無(wú)論我們 POST 提交的 JSON 字符串中,中文是以 Unicode 碼的形式存在還是直接以漢字的形式存在,后端服務(wù)都可以正確解析。
為什么我說(shuō)中文在 JSON 字符串里面以哪種形式顯示并不重要呢?這是因?yàn)椋瑢?duì) JSON 字符串來(lái)說(shuō),編程語(yǔ)言把它重新轉(zhuǎn)換為對(duì)象的過(guò)程(叫做反序列化),本身就可以正確處理他們。我們來(lái)看下圖:

ensure_ascii參數(shù)的作用,僅僅控制的是 JSON 的顯示樣式,當(dāng)ensure_ascii為True的時(shí)候,確保 JSON 字符串里面只有 ASCII 字符,所以不在 ASCII 128個(gè)字符內(nèi)的字符,都會(huì)被轉(zhuǎn)換。而當(dāng)ensure_ascii為False的時(shí)候,這些非 ASCII 字符依然以原樣顯示。這就像是一個(gè)人化妝和不化妝一樣,本質(zhì)并不會(huì)改變。現(xiàn)代化的編程語(yǔ)言在對(duì)他們進(jìn)行反序列化的時(shí)候,兩種形式都能正確識(shí)別。
所以,如果你是用現(xiàn)代化的 Web 框架來(lái)寫(xiě)后端,那么這兩種 JSON 形式應(yīng)該是沒(méi)有任何區(qū)別的。Request 默認(rèn)的json=參數(shù),相當(dāng)于ensure_ascii=True,任何現(xiàn)代化的 Web 框架都能正確識(shí)別 POST 提交上來(lái)的內(nèi)容。
當(dāng)然,如果你使用的是 C 語(yǔ)言、匯編或者其他語(yǔ)言來(lái)裸寫(xiě)后端接口,那確實(shí)可能有所差別??芍巧陶5娜?,誰(shuí)會(huì)這樣做?
綜上所述,這位同學(xué)遇到的問(wèn)題,并不是 Requests 的 bug,而是他的后端接口本身有問(wèn)題??赡苣莻€(gè)后端使用了某種弱智 Web 框架,它接收到的被 POST 發(fā)上來(lái)的信息,沒(méi)有經(jīng)過(guò)反序列化,就是一段 JSON 字符串,而那個(gè)后端程序員使用正則表達(dá)式從 JSON 字符串里面提取數(shù)據(jù),所以當(dāng)發(fā)現(xiàn) JSON 字符串里面沒(méi)有中文的時(shí)候,就報(bào)錯(cuò)了。
除了這個(gè) POST 發(fā)送 JSON 的問(wèn)題,以前我有個(gè)下屬,在使用 Scrapy 發(fā)送 POST 信息的時(shí)候,由于不會(huì)寫(xiě)POST 的代碼,突發(fā)奇想,把 POST 發(fā)送的字段拼接到 URL 上,然后用 GET 方式請(qǐng)求,發(fā)現(xiàn)也能獲取數(shù)據(jù),類(lèi)似于:
body = {'name': '青南', 'age': 20}
url = 'http://www.xxx.com/api/yyy'
requests.post(url, json=body).text
requests.get('http://www.xxx.com/api/yyy?name=青南&age=20').text
于是,這個(gè)同學(xué)得出一個(gè)結(jié)論,他認(rèn)為這是一個(gè)普遍的規(guī)律,所有 POST 的請(qǐng)求都可以這樣轉(zhuǎn)到 GET 請(qǐng)求。
但顯然,這個(gè)結(jié)論也是不正確的。這只能說(shuō)明,這個(gè)網(wǎng)站的后端程序員,讓這個(gè)接口能同時(shí)兼容兩種提交數(shù)據(jù)的方式,這是需要后端程序員額外寫(xiě)代碼來(lái)實(shí)現(xiàn)的。在默認(rèn)情況下,GET 和 POST 是兩種完全不同的請(qǐng)求方式,也不能這樣轉(zhuǎn)換。
如果這位同學(xué)會(huì)一些簡(jiǎn)單的后端,那么他立刻就可以寫(xiě)一個(gè)后端程序來(lái)驗(yàn)證自己的猜想。
再來(lái)一個(gè)例子,有一些網(wǎng)站,他們?cè)?URL 中可能會(huì)包含另外一個(gè) URL,例如:
https://kingname.info/get_info?url=https://abc.com/def/xyz?id=123&db=admin
如果你沒(méi)有基本的后端知識(shí),那么你可能看不出上面的網(wǎng)址有什么問(wèn)題。但是如果你有一些基本的后端常識(shí),那么你可能會(huì)問(wèn)一個(gè)問(wèn)題:網(wǎng)址中的&db=admin,是屬于https://kingname.info/get_info的一個(gè)參數(shù),跟url=平級(jí);還是屬于https://abc.com/def/xyz?id=123&db=admin的參數(shù)?你會(huì)疑惑,后端也會(huì)疑惑,所以這就是為什么我們這個(gè)時(shí)候需要 urlencode 的原因,畢竟下面兩種寫(xiě)法,是完全不一樣的:
https://kingname.info/get_info?url=https%3A%2F%2Fabc.com%2Fdef%2Fxyz%3Fid%3D123%26db%3Dadmin
https://kingname.info/get_info?url=https%3A%2F%2Fabc.com%2Fdef%2Fxyz%3Fid%3D123&db=admin
最后,以我的爬蟲(chóng)書(shū)序言中的一句話來(lái)作為總結(jié):
爬蟲(chóng)是一門(mén)雜學(xué),如果你只會(huì)爬蟲(chóng),那么你是學(xué)不好爬蟲(chóng)的。

還不過(guò)癮?試試它們
▲為什么數(shù)據(jù)庫(kù)不應(yīng)該使用外鍵?
▲Python 算法模板庫(kù),Pythonista 找工作利器
