接口的一般性問題
很多程序員開發(fā)接口的時(shí)候在岂,往往僅關(guān)注功能實(shí)現(xiàn)崖咨,但決定接口質(zhì)量的恰恰是非功能性方面——遺憾的是胸嘴,這一點(diǎn)在很多公司埠对,從項(xiàng)目到產(chǎn)品到研發(fā),甚至到測(cè)試汹碱,都未得到應(yīng)有的重視粘衬。
接口的非功能性要素主要體現(xiàn)在如下幾個(gè)方面:
- 冪等性;
- 魯棒性咳促;
- 安全性稚新;
冪等性
如果某一天你在超市消費(fèi)了 1000 元,而你的銀行卡被扣了 2000 元跪腹,你是什么感受褂删?
(當(dāng)然你我?guī)缀醪粫?huì)遇到這種問題,因?yàn)榻鹑诩?jí)別軟件出現(xiàn)這種低級(jí)錯(cuò)誤尺迂,估計(jì)是不想在市面上混了笤妙。)
重復(fù)扣款涉及到接口的冪等性問題。
冪等性是指寫型接口必須保證重復(fù)調(diào)用時(shí)的數(shù)據(jù)正確性噪裕,一般出現(xiàn)在添加數(shù)據(jù)的場(chǎng)景蹲盘,以及一些非冪等修改的場(chǎng)景(如扣減余額)。刪除場(chǎng)景一般具備冪等性膳音。
我們無法預(yù)期接口調(diào)用方如何調(diào)接口召衔,可能由于調(diào)用超時(shí),或者調(diào)用方實(shí)現(xiàn)問題(比如前端用戶可短時(shí)間內(nèi)高頻點(diǎn)擊)祭陷,接口設(shè)計(jì)必須將重復(fù)調(diào)用作為常態(tài)考慮——因接口被重復(fù)調(diào)用而導(dǎo)致數(shù)據(jù)問題苍凛,責(zé)任應(yīng)歸于接口實(shí)現(xiàn)者而不是調(diào)用者趣席。
處理冪等性的手段一般分業(yè)務(wù)邏輯層面和數(shù)據(jù)庫層面。
業(yè)務(wù)邏輯層面:select + insert:
這種方式應(yīng)用得很多醇蝴,實(shí)現(xiàn)方式是在添加或修改數(shù)據(jù)之前先根據(jù)請(qǐng)求參數(shù)(如用戶編號(hào)宣肚、訂單編號(hào))查一下相關(guān)數(shù)據(jù),以決定該請(qǐng)求是否已經(jīng)處理過了悠栓,防止重復(fù)處理(如重復(fù)加積分霉涨、重復(fù)扣款)。
這種處理方式的優(yōu)點(diǎn)是它本身屬于業(yè)務(wù)邏輯的一部分惭适,產(chǎn)品和開發(fā)人員畫流程圖時(shí)往往會(huì)自然而然地包括這些邏輯笙瑟,因而也是最容易想到的實(shí)現(xiàn)方式——容易想到就意味著現(xiàn)實(shí)中大部分的系統(tǒng)已經(jīng)實(shí)現(xiàn)了這種基本的冪等性處理。
但這種 select + insert 解決不了并發(fā)問題:在極短的時(shí)間內(nèi)發(fā)生的重復(fù)請(qǐng)求癞志,比如用戶瘋狂地點(diǎn)擊按鈕(假如按鈕沒做任何限制)往枷、羊毛黨薅羊毛等。
在高并發(fā)時(shí)凄杯,同一個(gè)用戶的兩個(gè)請(qǐng)求幾乎同時(shí)到達(dá)错洁,此時(shí)兩個(gè)請(qǐng)求幾乎同時(shí) select,都發(fā)現(xiàn)數(shù)據(jù)庫沒有相關(guān)記錄盾舌,于是都能執(zhí)行后續(xù)業(yè)務(wù)邏輯墓臭。
所以對(duì)于重要場(chǎng)景(如發(fā)券、積分等)妖谴,請(qǐng)求必須在用戶級(jí)別具有排他性:同一時(shí)間同一個(gè)用戶只能有一個(gè)請(qǐng)求在處理窿锉,多個(gè)同樣的請(qǐng)求必須串行處理。
我們可以借助 Redis 來實(shí)現(xiàn)分布式請(qǐng)求鎖膝舅。根據(jù)相關(guān)請(qǐng)求參數(shù)生成 redis key嗡载,比如在增加積分場(chǎng)景,可以根據(jù)“用戶 id + 場(chǎng)景 id” 生成 key 作為鎖仍稀,請(qǐng)求到來時(shí)先檢查鎖是否存在洼滚,如果存在則直接拒絕處理,不存在的話才進(jìn)入下一步技潘。這樣就保證了請(qǐng)求的排它性遥巴。流程圖如下:
然而,當(dāng)你的數(shù)據(jù)庫使用讀寫分離時(shí)享幽,你會(huì)發(fā)現(xiàn)請(qǐng)求鎖方案有時(shí)還是會(huì)出現(xiàn)漏網(wǎng)之魚铲掐。業(yè)務(wù)系統(tǒng)處理完成后會(huì)解除請(qǐng)求鎖,此時(shí)同一個(gè)用戶的重復(fù)請(qǐng)求就可以進(jìn)來值桩,但此時(shí)新數(shù)據(jù)可能還沒有同步到從庫摆霉,因而 select 仍然查不到,于是業(yè)務(wù)邏輯又被執(zhí)行了一遍(如加了兩次積分)。你可能覺得這種延遲在毫秒級(jí)携栋,問題不大搭盾,但如果對(duì)方是腳本薅羊毛,這可能就是不容忽視的問題婉支。
這種情況必須結(jié)合數(shù)據(jù)庫層面的約束來解決鸯隅。
Redis 分布式鎖:
Redis 的高性能、高并發(fā)和單線程處理(命令的原子性)很適合做分布式鎖向挖。有些細(xì)節(jié)值得注意滋迈。
我們一般使用 Redis 的 set 帶 nx 選項(xiàng)實(shí)現(xiàn)分布式鎖:
set lock_key private_val ex 20 nx
(其中 lock_key 和 private_val 是程序生成的。)
上面設(shè)置鎖 lock_key户誓,過期時(shí)間是 20 秒。其中關(guān)鍵在 nx 選項(xiàng)幕侠,它表示當(dāng) lock_key 不存在時(shí)才設(shè)置帝美。這條指令是 setnx 的增強(qiáng)版,在 setnx 基礎(chǔ)上增加了對(duì)過期時(shí)間的支持晤硕。那么我們?nèi)绾吾尫沛i呢悼潭?直接執(zhí)行 del lock_key?不行的舞箍,程序只能釋放由自己加的鎖舰褪,如果直接 del,那么有可能會(huì)刪除掉別的進(jìn)程加的鎖(比如當(dāng)前進(jìn)程執(zhí)行超時(shí)疏橄,原來的鎖過期了占拍,而此時(shí)另一個(gè)進(jìn)程剛好也加了個(gè) lock_key 的鎖,此時(shí)會(huì)把另一個(gè)進(jìn)程的鎖刪了)捎迫。
所以刪除前必須判斷 private_val 是不是當(dāng)前進(jìn)程生成的晃酒,所以必須先判斷再比較:
get lock_key
del lock_key
這樣實(shí)現(xiàn)有沒有問題呢?還是有那么一點(diǎn)小問題的:這里執(zhí)行了兩條 Redis 命令窄绒,不具備原子性贝次,可能出現(xiàn)第一條執(zhí)行成功了第二條失敗的情況(雖然概率很低),另外需要兩次網(wǎng)絡(luò)開銷彰导。有沒有優(yōu)化空間呢蛔翅,可以使用 Redis 的 eval 命令執(zhí)行 Lua 腳本來保證原子性(相關(guān)語言 SDK 都有支持):
eval 'if (redis.call('get', KEYS[1]) == ARGV[1]) then redis.call('del', KEYS[1]);end return 1;' lock_key private_val
(Lua 語言很簡(jiǎn)單,自行百度位谋, 1 小時(shí)學(xué)會(huì)山析。)
數(shù)據(jù)庫層面:
我們可以通過數(shù)據(jù)庫提供的唯一鍵約束來實(shí)現(xiàn)冪等性。
我們看看儲(chǔ)值卡扣費(fèi)場(chǎng)景倔幼。電商的儲(chǔ)值卡支付場(chǎng)景中盖腿,儲(chǔ)值卡扣費(fèi)環(huán)節(jié)至少要發(fā)生兩個(gè)操作:
- 產(chǎn)生一筆流水,至少包含訂單號(hào)和支付金額;
- 儲(chǔ)值卡賬戶扣除相應(yīng)金額翩腐;
如果儲(chǔ)值卡支付接口不做任何冪等性處理鸟款,那就有可能同一筆訂單會(huì)產(chǎn)生兩筆支付流水且卡賬戶被重復(fù)扣款,造成客訴茂卦。
這里我們除了可以采用前面的“請(qǐng)求鎖+select+insert”方案何什,還可以在數(shù)據(jù)庫層面增加唯一鍵約束。假如一筆訂單僅支持支付一次等龙,那么就可以用訂單號(hào)做唯一鍵約束处渣,當(dāng)同一筆訂單進(jìn)行多次支付(插入流水)時(shí)就會(huì)因唯一鍵沖突而插入失敗(賬戶余額變更操作和增加流水在一個(gè)數(shù)據(jù)庫事務(wù)中蛛砰,自然也不會(huì)成功)罐栈。
有些場(chǎng)景的唯一性約束體現(xiàn)在組合鍵上,比如簽到泥畅,用戶一天只能簽到一次荠诬,那么就可以用“用戶id+日期”這樣的組合唯一鍵。
當(dāng)然位仁,有些場(chǎng)景可能壓根就不存在這樣的唯一約束字段柑贞,比如增減積分、發(fā)券聂抢,此時(shí)必須創(chuàng)造出單獨(dú)的約束字段來實(shí)現(xiàn)唯一性約束钧嘶,比如給表增加一個(gè) uniqid 并建立唯一鍵索引。現(xiàn)在的問題是 uniqid 從哪里來琳疏?
這種情況下基本上接口提供方無法根據(jù)接口請(qǐng)求參數(shù)生成唯一標(biāo)識(shí)有决,必須由接口調(diào)用方提供這個(gè) uniqid。接口提供方(如券系統(tǒng))在寫入數(shù)據(jù)的時(shí)候(如給用戶發(fā)券)會(huì)將該 uniqid 存入轿亮,如果之前已經(jīng)寫入過疮薇,則會(huì)發(fā)生唯一鍵沖突,數(shù)據(jù)寫入失敗我注。
那么現(xiàn)在的問題是按咒,如何保證接口調(diào)用方生成的標(biāo)識(shí)是唯一的呢?如果調(diào)用方生成的標(biāo)識(shí)和其他請(qǐng)求的標(biāo)識(shí)沖突了但骨,就會(huì)導(dǎo)致本次接口調(diào)用永遠(yuǎn)會(huì)失敗励七。
一般有兩種方案:1. 調(diào)用方根據(jù)某種規(guī)則自行生成標(biāo)識(shí);2. 由接口提供方提供單獨(dú)的生成標(biāo)識(shí)的接口奔缠。
調(diào)用方自行生成掠抬,可以采用 uuid 算法生成(一般編程語言都有相應(yīng)的庫)。uuid 能很好地保證唯一性校哎,但缺點(diǎn)一方面是比較長(zhǎng)(至少占用 16 字節(jié))两波,另外它是無序的瞳步,對(duì) MySQL 這樣的 B+ 樹索引不是很友好,可以采用 twitter 開源的雪花算法(snowflake腰奋,網(wǎng)上也有現(xiàn)成的實(shí)現(xiàn)庫)方案來生成 64 bit 整型(long)標(biāo)識(shí)单起。
如果系統(tǒng)并發(fā)量不是特別高,而且也不想讓客戶端去生成唯一標(biāo)識(shí)劣坊,可以由業(yè)務(wù)系統(tǒng)或者獨(dú)立的發(fā)號(hào)器系統(tǒng)提供唯一標(biāo)識(shí)接口來獲取唯一標(biāo)識(shí)嘀倒。
發(fā)號(hào)器系統(tǒng)(有可能就是相關(guān)業(yè)務(wù)系統(tǒng)自身)可以采用現(xiàn)成的 uuid 或 snowflake 方案,也可以自行實(shí)現(xiàn)局冰。此處提供一種實(shí)現(xiàn)思路测蘑。
假如我們要生成的唯一標(biāo)識(shí)格式是 xxxxxxxxyyyyyyyyyyyyzzzz
,其中 x 是當(dāng)前日期康二,y 是 12 位十進(jìn)制(千億)碳胳,每天從 1 開始自增,z 是四位隨機(jī)數(shù)沫勿,主要防止萬一 y 位出現(xiàn)異常重復(fù)的情況下降低標(biāo)識(shí)符重復(fù)概率固逗。該唯一標(biāo)識(shí)在不考慮隨機(jī)位 z 的情況下,每天能生成約 9 千億個(gè)標(biāo)識(shí)藕帜。
發(fā)號(hào)器服務(wù)器一般不止一臺(tái),所以需要保證多臺(tái)服務(wù)器生成的 y 部分不會(huì)重復(fù)惜傲,我們采用中間服務(wù) Redis 來分配 y 部分洽故。
那么,是不是每次生成標(biāo)識(shí)符都要請(qǐng)求 Redis 呢盗誊?如此 Redis 的壓力可就大了时甚。所以 y 部分我們要采用批量分配策略,即發(fā)號(hào)器系統(tǒng)一次向 Redis 申請(qǐng)一個(gè)號(hào)段哈踱,比如一次申請(qǐng)包含 1 萬個(gè)值的 y 號(hào)段荒适,將號(hào)段的起止值記錄在本地內(nèi)存中,生成標(biāo)識(shí)符的時(shí)候先從本地號(hào)段中取 y 值开镣,只有本地號(hào)段用完了才向 Redis 申請(qǐng)新號(hào)段刀诬。
發(fā)號(hào)器系統(tǒng)的本地號(hào)段是記錄在內(nèi)存中的(進(jìn)程的全局變量),服務(wù)退出重啟后會(huì)重新向 Redis 申請(qǐng)?zhí)柖涡安啤K蕴?hào)段范圍建議不能太大陕壹,否則如果服務(wù)重啟次數(shù)較多可能會(huì)耗盡 y 號(hào)段。
流程如下:
總結(jié)一下如何用數(shù)據(jù)唯一鍵實(shí)現(xiàn)接口冪等性:
- 適用于插入數(shù)據(jù)的場(chǎng)景树埠,典型的如“流水+總賬”模式的業(yè)務(wù)(如儲(chǔ)值糠馆、積分、點(diǎn)贊等)怎憋。
- 優(yōu)先使用業(yè)務(wù)字段本身實(shí)現(xiàn)唯一性約束又碌,比如儲(chǔ)值卡消費(fèi)流水中的訂單號(hào)九昧。或者是若干字段(2毕匀、3 個(gè))的組合鍵唯一約束铸鹰,如點(diǎn)贊場(chǎng)景。
- 當(dāng)沒有業(yè)務(wù)字段做唯一約束時(shí)期揪,可創(chuàng)建單獨(dú)標(biāo)識(shí)字段做唯一約束掉奄,此時(shí)由調(diào)用方提供唯一標(biāo)識(shí)符。
- 需保證調(diào)用方標(biāo)識(shí)符的唯一性凤薛,可采用業(yè)界標(biāo)準(zhǔn)的 uuid姓建、snowflake 算法,也可以自己實(shí)現(xiàn)缤苫。標(biāo)識(shí)符可以由調(diào)用端自行生成速兔,也可以由發(fā)號(hào)器統(tǒng)一生成,根據(jù)自己的實(shí)際情況和并發(fā)量做決策活玲。
- 發(fā)號(hào)器的實(shí)現(xiàn)必須考慮其可擴(kuò)展性涣狗,需保證發(fā)號(hào)器集群生成的標(biāo)識(shí)具有唯一性。
- 數(shù)據(jù)庫唯一鍵約束可能會(huì)和請(qǐng)求鎖舒憾、“select+insert”方案一起使用镀钓。
關(guān)于接口冪等性還有個(gè)需要關(guān)注的問題:當(dāng)服務(wù)提供方發(fā)現(xiàn)本次調(diào)用已被處理(本次可能是調(diào)用方超時(shí)重試,也可能是其它異常調(diào)用)镀迂,應(yīng)該返回什么丁溅?
有些開發(fā)者想當(dāng)然地從業(yè)務(wù)判重角度將重復(fù)操作作為異常場(chǎng)景看待,不假思索地返回個(gè)錯(cuò)誤碼探遵,這會(huì)給調(diào)用端帶來困擾窟赏,很可能帶來數(shù)據(jù)完整性問題。
此時(shí)最簡(jiǎn)單的做法是直接返回 OK——如果開發(fā)團(tuán)隊(duì)中只有一種狀態(tài)碼表示“成功”的話(如 code=200)箱季。
有些開發(fā)團(tuán)隊(duì)借鑒 HTTP 狀態(tài)碼的定義涯穷,將 20X 狀態(tài)碼段定義為成功碼,此時(shí)可以就“操作成功”和“該操作已處理過”定義不同的狀態(tài)碼(如 200 表示成功藏雏,201 表示該操作已處理過)拷况,這樣既不干擾調(diào)用端的業(yè)務(wù)處理,也能讓業(yè)務(wù)端確切知道本次調(diào)用的實(shí)際處理情況掘殴。
前后端的冪等性:
考慮下面的場(chǎng)景:
張三在管理后臺(tái)創(chuàng)建券蝠嘉,點(diǎn)擊“創(chuàng)建”按鈕后半天沒響應(yīng)(網(wǎng)絡(luò)較慢),于是張三又連續(xù)點(diǎn)了若干次杯巨,結(jié)果去列表一看蚤告,創(chuàng)建了三四張券。
當(dāng)然你我第一反應(yīng)很可能是在前端做交互優(yōu)化:點(diǎn)擊按鈕后將按鈕置灰服爷,并提示“正在創(chuàng)建中...”杜恰,直到后端返回?cái)?shù)據(jù)后按鈕才可以再次點(diǎn)擊获诈。
上面的前端交互優(yōu)化確實(shí)可以解決絕大部分重復(fù)創(chuàng)建的問題。
不過心褐,試想一下這樣的場(chǎng)景:
用戶點(diǎn)擊創(chuàng)建按鈕后舔涎,后端服務(wù)處理較慢(如服務(wù)器負(fù)載高了),前端按鈕置灰逗爹,用戶不可點(diǎn)擊亡嫌。
過了一會(huì)(如 5 秒鐘),前端接口等待時(shí)間超過閾值掘而,前端 js 直接報(bào)超時(shí)錯(cuò)誤挟冠,告知用戶“服務(wù)處理超時(shí),請(qǐng)稍后重試”袍睡。
于是用戶再次點(diǎn)擊“創(chuàng)建”按鈕知染。
然后,用戶去券列表頁面斑胜,很可能會(huì)發(fā)現(xiàn)自己創(chuàng)建了兩張券控淡。
問題出在當(dāng)前端發(fā)現(xiàn)后端接口超時(shí)后,會(huì)認(rèn)為事務(wù)處理失敗止潘,于是提示用戶重試掺炭,但后端事務(wù)實(shí)際上仍在執(zhí)行(甚至有可能后端事務(wù)其實(shí)早都執(zhí)行完了,但在返回?cái)?shù)據(jù)時(shí)出現(xiàn)了網(wǎng)絡(luò)問題而超時(shí))凭戴,此時(shí)用戶再次點(diǎn)擊“創(chuàng)建”按鈕實(shí)際上會(huì)執(zhí)行兩次事務(wù)(創(chuàng)建兩張券)竹伸。
所以在前后端調(diào)用的場(chǎng)景中(主要是創(chuàng)建型事務(wù)的場(chǎng)景),同樣需要通過唯一標(biāo)識(shí)(如 uuid)來保證接口調(diào)用的冪等性簇宽。
首先我們想到用類似前面“請(qǐng)求鎖”方案(但這次不是加鎖):
在渲染創(chuàng)建頁面的時(shí)候,后端生成一個(gè)唯一標(biāo)識(shí)符 X吧享,將其保存到 Redis 中(設(shè)置一個(gè)合理的有效期)魏割,并將該標(biāo)識(shí)符返回給前端;
前端請(qǐng)求后端“創(chuàng)建優(yōu)惠券”接口時(shí)钢颂,帶上該標(biāo)識(shí)符钞它;
后端先比較該標(biāo)識(shí)符是否和 Redis 中的一致,標(biāo)識(shí)符沒問題才進(jìn)行后續(xù)的事務(wù)處理殊鞭;
后端事務(wù)處理成功后遭垛,刪除掉 Redis 中的標(biāo)識(shí)符;
前端在使用該標(biāo)識(shí)符請(qǐng)求后端操灿,后端由于檢測(cè)不到該標(biāo)識(shí)符锯仪,會(huì)直接返回錯(cuò)誤;
流程如下:
上面的流程有沒有問題呢趾盐?
它確實(shí)能阻止一部分重復(fù)提交庶喜,但不是全部小腊。
試想前端請(qǐng)求后端接口,后端接口超時(shí)了(但實(shí)際上后端事務(wù)仍然在執(zhí)行中)久窟,此時(shí)前端會(huì)讓用戶重試秩冈,用戶再次提交,這第二次接口請(qǐng)求仍然會(huì)帶上剛才的 flag斥扛,那這次 flag 校驗(yàn)是否會(huì)通過呢入问?可能會(huì),也可能不會(huì)稀颁,取決于第二次請(qǐng)求到達(dá)時(shí)芬失,前一次的事務(wù)有沒有處理完(從而刪除掉 flag)。假如前一次的事務(wù)(這里的事務(wù)不是說數(shù)據(jù)庫事務(wù)峻村,而是指該接口要做的事情)還沒有處理完麸折,那么這個(gè) flag 就仍然是合法的,那么第二次請(qǐng)求仍然會(huì)被處理粘昨。如下圖:
我們也不能在接口處理完之前刪除掉 Redis 中的 flag垢啼,因?yàn)槿绻聞?wù)處理失敗,是需要前端重新提交的张肾。
要想前后端交互真正的實(shí)現(xiàn)冪等性芭析,必須借助數(shù)據(jù)庫的唯一鍵約束。和前面的一樣吞瞪,我們給數(shù)據(jù)表增加一個(gè)專門字段(假如就叫 flag)做唯一性約束馁启,我們以券為例,數(shù)據(jù)表大致長(zhǎng)這樣:
這里的 flag 就是上面我們生成并存儲(chǔ)到 Redis 的那個(gè)唯一標(biāo)識(shí)芍秆,我們?cè)跀?shù)據(jù)庫插入券數(shù)據(jù)的時(shí)候一并寫進(jìn)去惯疙。由于 flag 字段是唯一鍵,如果先前已經(jīng)寫入過了妖啥,再寫入就會(huì)報(bào)唯一鍵沖突錯(cuò)誤霉颠,寫入失敗,從而保證了接口的冪等性荆虱。如此蒿偎,上圖中用戶再次點(diǎn)擊提交,雖然flag 校驗(yàn)仍然會(huì)成功怀读,但兩次處理只有一次會(huì)真正成功诉位,另一次在寫數(shù)據(jù)庫時(shí)會(huì)失敗(不能保證一定是第一次請(qǐng)求寫入成功菜枷,網(wǎng)絡(luò)調(diào)用不具備時(shí)序性)苍糠。
加上數(shù)據(jù)庫約束后兩次請(qǐng)求的處理過程如下:
有人可能覺得有了數(shù)據(jù)庫層的唯一性校驗(yàn),就可以去掉 Redis 那一層的校驗(yàn)啤誊。這是不行的椿息,如果去掉 Redis 這層校驗(yàn)歹袁,我們便無法保證前端傳的這個(gè) flag 是我們自己生成的,也就是說前端隨便傳個(gè) flag 就能寫庫了寝优。
總結(jié)一下前后端接口調(diào)用的冪等性實(shí)現(xiàn):
- 通過前端 js 限制用戶高頻次點(diǎn)擊導(dǎo)致的重復(fù)提交条舔,這是成本最低、最快見效的實(shí)現(xiàn)方式乏矾;
- 通過 Redis 實(shí)現(xiàn)標(biāo)識(shí)符校驗(yàn)孟抗,結(jié)合前端 js 控制,能夠滿足大部分的冪等性要求钻心;
- 再加上數(shù)據(jù)庫層面的唯一鍵約束凄硼,能夠真正實(shí)現(xiàn)前后端交互的冪等性;
講完冪等性捷沸,我們看看第二個(gè)接口設(shè)計(jì)原則:魯棒性摊沉。
魯棒性
“魯棒”這個(gè)詞真的誤人子弟,反正我第一次聽到這個(gè)詞時(shí)腦海中冒出的是一個(gè)粗魯?shù)拇鬂h揮舞著棒子不知在干啥痒给。
“魯棒”是音譯说墨,英文叫 Robustness,翻譯過來是“堅(jiān)固性苍柏,健壯性”的意思尼斧,所以接口的魯棒性是指接口的健壯性如何。
接口的魯棒性取決于它對(duì)異常場(chǎng)景的承載能力试吁。
什么樣的接口不具備魯棒性呢棺棵?如果一個(gè)接口嚴(yán)重依賴于外部輸入的合法性以及第三方服務(wù)的正確性,一旦外部輸入非預(yù)期內(nèi)容(如含有 SQL 注入的字符串)熄捍,或者所依賴的第三方服務(wù)(接口)崩潰了(如超時(shí))烛恤,該接口就會(huì)出現(xiàn)各種未知問題(最典型的是數(shù)據(jù)一致性問題,如卡賬扣款了但訂單還是未支付狀態(tài))余耽,那么我們說該接口是脆弱的缚柏,不具備魯棒性。
幾乎所有的程序員都能寫出可用的接口(實(shí)現(xiàn)正常流程)宾添,但至少有一半(其實(shí)不止)的程序員寫不出健壯的接口。
這里的異常主要包括:
- 輸入異常柜裸;
- 流程異常缕陕;
- 性能異常;
輸入異常:
“不要信任外部輸入”是常識(shí)疙挺,但不是所有人都正確處理這塊扛邑。這里主要包括以下幾塊:
- 參數(shù)類型限制;
- 缺省參數(shù)處理铐然;
- 惡意輸入的攔截蔬崩;
考慮到接口調(diào)用方編程語言的異構(gòu)性以及其他復(fù)雜因素恶座,參數(shù)類型盡量只使用數(shù)值類型和字符串,盡量不要用 bool 型(true沥阳、false)跨琳、Null——有些情況下對(duì)方可能給你傳的是字符串“true”而不是 bool 值 true,如果你打算用這些類型桐罕,請(qǐng)?jiān)诮涌趦?nèi)部消化掉字符串 "true"脉让、"false"。
接口參數(shù)應(yīng)遵循”最小化輸入“原則功炮,即調(diào)用端只需要關(guān)心他關(guān)心的參數(shù)溅潜,接口自身應(yīng)能正確處理參數(shù)缺省值。我見過有些接口有二三十個(gè)參數(shù)薪伏,每個(gè)參數(shù)都是必填的——調(diào)用端對(duì)不需要的參數(shù)必須傳缺省值(0 或空字符串)滚澜,對(duì)接的人一邊對(duì)接一邊崩潰,還經(jīng)常因某個(gè)參數(shù)傳入錯(cuò)誤導(dǎo)致接口報(bào)錯(cuò)嫁怀。
異常輸入這塊重點(diǎn)在字符串類型上设捐。
字符串的第一個(gè)威脅是 XSS 攻擊。企盼每個(gè)開發(fā)人員對(duì)每個(gè)入?yún)⒍甲雒撁籼幚硎遣滑F(xiàn)實(shí)的眶掌,所以這一步必須在開發(fā)框架層面提供支持挡育,控制器中拿到的參數(shù)應(yīng)該是已經(jīng)做過處理了的。雖然這是件很基礎(chǔ)(基礎(chǔ)到不值得拿出來一說)的事情朴爬,但我敢保證即寒,市面上有一半的系統(tǒng)都沒有做嚴(yán)格的參數(shù)處理——因?yàn)楸WC這點(diǎn)的唯一手段是將滲透測(cè)試作為測(cè)試的一個(gè)環(huán)節(jié)納入到工作流程中,但大部分中小公司的產(chǎn)品并沒有做滲透測(cè)試召噩。退而求次母赵,保證接口入?yún)⒔研缘拇我侄危ǖ珜?duì)于大部分中小公司是最實(shí)用的)是將參數(shù)處理納入到框架層面(有些框架天然支持這點(diǎn),有些則需要定制開發(fā))具滴。
XSS:跨站腳本攻擊(Cross Site Scripting凹嘲,為了不和層疊樣式表的縮寫沖突而寫成 XSS),是指惡意用戶通過在網(wǎng)站中注入 javascript 腳本實(shí)現(xiàn)攻擊(如獲取 Cookie 信息)构韵。
比如我們網(wǎng)站有個(gè)輸入框(普通文本框或者富文本)周蹭,用戶在里面輸入”<script>alert(document.cookie)</script>“,如果后端接口沒有對(duì)該輸入做任何處理就存入數(shù)據(jù)庫疲恢,那么當(dāng)這段文本在前端頁面渲染時(shí)該腳本就會(huì)被執(zhí)行獲取到 Cookie 信息凶朗。
那是不是把代碼里面 <script> 都去掉就行了呢?沒那么簡(jiǎn)單的显拳,比如用戶輸入 <img onerror="alert(document.cookie)" src="http://aaa"> 照樣能執(zhí)行棚愤。所以最好使用對(duì)應(yīng)語言現(xiàn)成的開源庫來過濾 XSS 腳本。
XSS 的威脅在于其生成的 js 腳本是在受信任環(huán)境執(zhí)行的(處于受信任域名下,而且是在合法的登錄會(huì)話中)宛畦,它可以獲取 Cookie(如果沒有做 HttpOnly 防護(hù))瘸洛、localStorage,以及調(diào)后端接口次和,其威脅甚至大于 CSRF(后面會(huì)提到)反肋。
字符串的第二個(gè)威脅是 SQL 注入屡限。這同樣是一個(gè)老掉牙的問題卒蘸,老到幾乎所有框架都提供了直接支持,只要你不在代碼里面寫原生 SQL 幾乎就不會(huì)出現(xiàn) SQL 注入問題——問題恰恰出在很多開發(fā)人員就是喜歡寫原生 SQL昨忆,各種參數(shù)拼接读规,一滲透一堆問題抓督,甚至表都讓人給刪了。開發(fā)人員寫原生 SQL 的原因有很多束亏,可能是開發(fā)人員對(duì)框架的數(shù)據(jù)庫操作模塊不熟悉铃在,又懶得去看文檔;也可能是開發(fā)人員寫的 SQL 比較復(fù)雜碍遍,用框架提供的方法實(shí)現(xiàn)起來比較別扭定铜;或者僅僅是個(gè)人偏好。
想要杜絕代碼中的原生 SQL怕敬,最直接的方法是代碼審查揣炕。代碼審查的一個(gè)環(huán)節(jié)專門審查 Model 層(或倉儲(chǔ)層)的 SQL 規(guī)范性——什么,你說你的 SQL 寫在控制器里面东跪?
一種更加自動(dòng)化的方式是開發(fā)個(gè)審查工具畸陡,自動(dòng)檢查 Model 層出現(xiàn)的字符串拼接,或者對(duì)某特定方法的調(diào)用虽填。
字符串的第三個(gè)威脅是格式丁恭。強(qiáng)制對(duì)每個(gè)輸入字符串都做長(zhǎng)度限制是個(gè)好習(xí)慣,它能防止一些不必要的麻煩——你的接口產(chǎn)生的數(shù)據(jù)會(huì)被別的地方用到斋日,不能保證別的地方都能正確處理這些超長(zhǎng)數(shù)據(jù)牲览。對(duì)特定字段做格式限制是必要的,比如郵件恶守、手機(jī)號(hào)第献、身份證號(hào)、性別兔港,防止用戶隨意輸入產(chǎn)生無效數(shù)據(jù)庸毫。
和前兩者一樣,指望開發(fā)人員在代碼中對(duì)入?yún)⒏袷阶龊侠硖幚硎抢щy的——瞅瞅自己公司數(shù)據(jù)庫中有多少無效的手機(jī)號(hào)押框、身份證號(hào)岔绸、車牌號(hào)就知道了理逊。參數(shù)格式需要在產(chǎn)品策劃階段加以定義橡伞,并納入到測(cè)試用例中盒揉;開發(fā)框架需要提供常見格式校驗(yàn)的能力(如郵箱、URL兑徘、身份證號(hào)等)刚盈,開發(fā)人員只需要簡(jiǎn)單的配置就可以實(shí)現(xiàn)參數(shù)格式校驗(yàn)——不是所有的開發(fā)人員都會(huì)寫郵箱驗(yàn)證的正則表達(dá)式的。
字符串的第四個(gè)威脅是空格挂脑。你沒看錯(cuò)藕漱,就是這么小小的空格,困擾了無數(shù)運(yùn)營和開發(fā)崭闲。反正我是遇到過多次因小小的空格造成的血案肋联。對(duì)于開發(fā)來說,去空格這件事卑微到不屑去做刁俭;對(duì)于運(yùn)營來說橄仍,檢查空格不但卑微而且無趣‰蛊荩空格的威脅力在于其本身極其沒有存在感侮繁,開發(fā)很難關(guān)注,運(yùn)營很難發(fā)現(xiàn)如孝,但出現(xiàn)問題時(shí)很難排查宪哩。
我們就遇到過一次支付失敗的問題,兩邊團(tuán)隊(duì)查日志第晰、查配置锁孟,眼睛都瞎了還找不出問題所在,最后一個(gè)偶然的機(jī)會(huì)但荤,某人發(fā)現(xiàn)運(yùn)營在填 appid 時(shí)末尾多了個(gè)空格罗岖!
不能指望開發(fā)人員能自覺地對(duì)所有字符串參數(shù)去首尾空格,必須在框架層面統(tǒng)一處理腹躁。
流程異常:
這里的流程異常不是說代碼沒有正確實(shí)現(xiàn)業(yè)務(wù)邏輯——那屬于功能異常桑包,不屬于魯棒性考慮的范圍。這里說的流程異常是指在正常執(zhí)行流中出現(xiàn)了不可控的異常纺非。
想想我們過去開發(fā)的接口哑了,有沒有出現(xiàn)過以下情況:
- 讀取磁盤中的文件——有沒有考慮讀取失敗會(huì)怎樣?
- 寫入磁盤文件——有沒有考慮寫入失敗會(huì)怎樣(如目錄不存在)烧颖?
- 讀取系統(tǒng)時(shí)間——有沒有考慮如果系統(tǒng)時(shí)間錯(cuò)誤會(huì)怎樣弱左?
- 計(jì)算某個(gè)比率(如中獎(jiǎng)率)——有沒有考慮除數(shù)是 0 的情況(如壓根沒人抽獎(jiǎng))?
- 調(diào)某個(gè)外部接口——有沒有考慮接口調(diào)用失斂换础(如超時(shí))的情況拆火?
- 更重要的,當(dāng)流程中的某一步失敗了,其他步該如何處理(以及已經(jīng)產(chǎn)生的數(shù)據(jù)如何處理)们镜?
以上異常有兩個(gè)特征:
- 大部分是不可控的(無法通過程序自身避免問題發(fā)生)币叹;
- 只要系統(tǒng)運(yùn)行時(shí)間足夠長(zhǎng),就一定會(huì)發(fā)生(除非系統(tǒng)自身沒有涉及到這些方面模狭,如壓根沒有涉及到遠(yuǎn)程調(diào)用)颈抚;
健壯的程序要能夠正確地處理這些異常,保證數(shù)據(jù)的一致性嚼鹉。這里有兩層含義:
- 程序要處理(而不是忽略)這些異常贩汉;
- 程序能正確地處理這些異常,讓程序在發(fā)生異常時(shí)的行為符合預(yù)期锚赤;
作為開發(fā)人員我們不能有”幸運(yùn)兒“思想:我的系統(tǒng)不會(huì)發(fā)生這些問題匹舞。但這不代表我們的程序一定能夠消化掉這些異常并讓流程繼續(xù)進(jìn)行下去——有時(shí)候讓流程終止才是唯一正確的方式,但由于程序沒有處理這些異常(或者處理不當(dāng))導(dǎo)致流程繼續(xù)進(jìn)行线脚,進(jìn)而導(dǎo)致數(shù)據(jù)一致性問題(比如在儲(chǔ)值卡充值場(chǎng)景中策菜,調(diào)支付接口失敗,但程序沒有判斷該異常酒贬,仍然往下執(zhí)行又憨,給用戶卡賬充了錢)。
處理這些異常的方式主要有以下幾種:
- 終止執(zhí)行流锭吨。比如儲(chǔ)值卡消費(fèi)場(chǎng)景蠢莺,如果儲(chǔ)值卡扣款接口調(diào)失敗了,則要終止執(zhí)行流零如,防止出現(xiàn)扣款失敗但訂單狀態(tài)變成已支付的數(shù)據(jù)一致性問題(實(shí)際上儲(chǔ)值卡消費(fèi)的異常場(chǎng)景遠(yuǎn)比這里說的復(fù)雜躏将,后面我會(huì)在單獨(dú)的文章中分析該場(chǎng)景);
- 預(yù)處理考蕾。比如寫文件的場(chǎng)景祸憋,可以先判斷一下目錄是否存在,不存在則先創(chuàng)建目錄然后再寫文件肖卧;計(jì)算比率時(shí)可先判斷分母(如抽獎(jiǎng)次數(shù))是否為 0蚯窥,如果為 0 則比率直接為 0,不再執(zhí)行除法運(yùn)算塞帐。
- 重試拦赠。這在遠(yuǎn)程調(diào)用時(shí)用得比較多,當(dāng)接口超時(shí)時(shí)葵姥,一段時(shí)間后(如 1 秒)重試一次荷鼠,還不行則終止執(zhí)行流。但需要注意榔幸,一般接口超時(shí)往往意味著對(duì)方系統(tǒng)負(fù)載高(或者網(wǎng)絡(luò)擁塞)允乐,大量的重試會(huì)加重對(duì)方系統(tǒng)負(fù)擔(dān)矮嫉,最終崩潰掉;另外重試也會(huì)導(dǎo)致本次請(qǐng)求長(zhǎng)時(shí)間占用本服務(wù)器資源牍疏,如果對(duì)方系統(tǒng)長(zhǎng)時(shí)間無法恢復(fù)敞临,本系統(tǒng)則會(huì)產(chǎn)生大量的請(qǐng)求進(jìn)程(大家都在那重試),最終引發(fā)雪崩麸澜。如果決定引入重試機(jī)制,則需要合理設(shè)置超時(shí)時(shí)間(比如 2 秒奏黑。時(shí)間越長(zhǎng)請(qǐng)求占用資源越久炊邦,越容易導(dǎo)致雪崩),重試次數(shù)也不能太多熟史,可能還要結(jié)合熔斷和限流一起使用馁害。
- 異步補(bǔ)償。對(duì)于執(zhí)行流中的非核心節(jié)點(diǎn)出現(xiàn)的異常(主要是遠(yuǎn)程調(diào)用失敗的場(chǎng)景)蹂匹,我們可以先做異常登記碘菜,然后執(zhí)行流繼續(xù)往下執(zhí)行。而后我們通過異步任務(wù)去重試這些異常節(jié)點(diǎn)限寞。比如用戶消費(fèi)返券的場(chǎng)景忍啸,在支付回調(diào)的處理流程中會(huì)調(diào)券接口給用戶發(fā)券,如果該接口調(diào)用失斅闹病(超時(shí))计雌,我們除了可采用重試機(jī)制,還可以在數(shù)據(jù)庫中(或消息隊(duì)列中)寫一條失敗待重試的記錄玫霎,由異步處理程序稍后重試凿滤。 相比同步重試機(jī)制,異步重試不會(huì)導(dǎo)致本次請(qǐng)求占用太久服務(wù)器資源庶近,本次請(qǐng)求的后續(xù)流程仍然能夠快速執(zhí)行完成翁脆;另外異步重試的時(shí)間間隔可以更長(zhǎng)(如 10 秒一次,或者隨著重試次數(shù)而增加時(shí)間間隔)鼻种,這樣對(duì)被調(diào)用系統(tǒng)的壓力也更小反番。 不過異步重試也是有限制條件的。首先相關(guān)節(jié)點(diǎn)可以異步化叉钥,后續(xù)節(jié)點(diǎn)不需要依賴該節(jié)點(diǎn)的輸出結(jié)果恬口;其次業(yè)務(wù)對(duì)該節(jié)點(diǎn)的時(shí)效性具有較寬的容忍度(如消費(fèi)返券的場(chǎng)景,即使延遲幾秒鐘發(fā)券也無所謂)沼侣。
性能異常:
健壯的接口應(yīng)具備一定的性能承諾能力——即并發(fā)處理能力(在一定并發(fā)量——比如 1000 qps——的情況下每個(gè)請(qǐng)求的平均處理時(shí)間)祖能。
性能問題來自三個(gè)方面:
- 自身代碼質(zhì)量導(dǎo)致的性能問題;
- 所依賴的服務(wù)出現(xiàn)性能問題而造成的連鎖反應(yīng)蛾洛;
- 異常調(diào)用量造成的額外壓力(如大促)养铸;
大部分接口的性能問題來自接口自身的實(shí)現(xiàn)缺陷——如從不使用緩存雁芙、很少創(chuàng)建索引。所以優(yōu)化接口性能總是要先從緩存和索引著手钞螟,這是成本最低兔甘、最立竿見影的做法。
有很大一部分的性能問題來自所依賴的服務(wù)(接口)鳞滨。一般有兩種解決辦法:
- 找到對(duì)方洞焙,讓對(duì)方優(yōu)化接口性能(如果是部門內(nèi)部團(tuán)隊(duì),該方案比較可行)拯啦;
- 將調(diào)用異步化澡匪;
在接口自身已經(jīng)達(dá)到優(yōu)化極限的情況下,還承受不了并發(fā)壓力褒链,說明需要水平擴(kuò)容了——往集群中再加幾臺(tái)服務(wù)器唁情。但現(xiàn)實(shí)往往沒那么簡(jiǎn)單,因?yàn)樾阅芷款i往往出現(xiàn)在存儲(chǔ)上而非業(yè)務(wù)服上甫匹,而存儲(chǔ)恰恰是最難擴(kuò)展的部分甸鸟。
這里不會(huì)去討論怎么設(shè)計(jì)高并發(fā)系統(tǒng),也不會(huì)去討論熔斷限流這些”高級(jí)“的話題(其實(shí)一點(diǎn)都不高級(jí))——這里要強(qiáng)調(diào)的是兵迅,在”言必高并發(fā)“的今天抢韭,對(duì)于大部分公司來說,性能優(yōu)化性價(jià)比最高的三劍客仍然是:緩存恍箭、索引篮绰、異步化。
除了這三種異常季惯,其實(shí)前面討論的冪等性也屬于魯棒性范疇吠各,它說的是接口在異常調(diào)用的情況下對(duì)數(shù)據(jù)一致性的保障能力。
安全性
前面講的 XSS 攻擊和 SQL 注入也屬于安全范疇勉抓,不過此處說的安全性是指防止接口被非法調(diào)用贾漏。
主要有兩種類型的接口調(diào)用:
- 前后端接口調(diào)用;
- 后端之間的接口調(diào)用藕筋;
兩種調(diào)用者的區(qū)別是纵散,前端完全暴露在外部(相當(dāng)于裸體),而后端調(diào)用者本身是處于各種保護(hù)之中的(相當(dāng)于穿了羽絨服)隐圾。
前后端調(diào)用:
前后端的信任是基于登錄的(賬號(hào)密碼登錄伍掀、手機(jī)號(hào)驗(yàn)證碼登錄、微信/支付寶 Oauth 授權(quán)登錄等)暇藏,用戶登錄成功后蜜笤,后端會(huì)生成一個(gè)登錄標(biāo)識(shí)給到前端,前端后續(xù)請(qǐng)求后端都會(huì)帶上該標(biāo)識(shí)盐碱。登錄標(biāo)識(shí)有兩層含義:
- 驗(yàn)證前后端交互的合法性:該前端此時(shí)能否調(diào)該接口把兔。
- 驗(yàn)證操作的合法性:本次接口調(diào)用是否有權(quán)操作其指定的數(shù)據(jù)(只能操作登錄用戶權(quán)限范圍內(nèi)的數(shù)據(jù))沪伙。
常用的登錄標(biāo)識(shí)有 session 和 token 兩種方案。
session 方案:
傳統(tǒng)的基于瀏覽器的 Web 應(yīng)用多采用 session 方案县好。用戶登錄成功后后端生成一個(gè)隨機(jī)串(sessionId)围橡,通過 Cookie 傳遞給前端;前端調(diào)后端接口時(shí)同樣通過 Cookie 將 sessionId 傳遞給后端缕贡,后端校驗(yàn) sessionId 的合法性翁授,然后執(zhí)行后續(xù)操作。流程如下:
前端調(diào)后端接口時(shí)由瀏覽器自動(dòng)將 Cookie 攜帶入 HTTP Header 中晾咪,而后端 sessionId 的生成與維護(hù)一般也由框架底層支持——就是說 session 方案基本是個(gè)開箱即用的方案收擦,實(shí)在是太方便了(方便到以至于很多人并不清楚 session 的運(yùn)作機(jī)制)。
方便是有代價(jià)的禀酱。session 方案存在以下幾個(gè)問題:
- 跨域問題。Cookie 默認(rèn)是不支持跨域的牧嫉,這對(duì)需要跨域訪問的站點(diǎn)可能是個(gè)問題剂跟。當(dāng)然解決方案也有多種,如將 Cookie 的 domain 屬性設(shè)置為一級(jí)域名酣藻;采用 sso曹洽。
- 分布式訪問問題。一般框架默認(rèn)的 session 存儲(chǔ)方案是本地文件存儲(chǔ)辽剧,這會(huì)導(dǎo)致在集群環(huán)境登錄失效——用戶登錄的時(shí)候在 A 服務(wù)器生成的 session送淆,自然存儲(chǔ)在 A 服務(wù)器本地,用戶后續(xù)的請(qǐng)求如果打到 B 服務(wù)器怕轿,由于 B 服務(wù)器沒有該用戶的 session偷崩,就會(huì)報(bào)錯(cuò)。解決方案也有很多種撞羽,如采用集中式存儲(chǔ)方案(一般采用 Redis阐斜,大多數(shù)框架也支持一鍵配置 Redis 作為 session 存儲(chǔ)方案);配置負(fù)載均衡規(guī)則诀紊,讓同一個(gè)客戶端的請(qǐng)求都打到同一臺(tái)服務(wù)器谒出。
- CSRF 攻擊。由于 sessionId 是通過 Cookie 傳輸?shù)牧诘欤睘g覽器自動(dòng)將 Cookie 寫入 HTTP Header 頭“這一做法帶來方便的同時(shí)也帶來了危險(xiǎn)——CSRF(跨站請(qǐng)求偽造攻擊)利用這一特性可以在別的網(wǎng)站上偽裝成合法用戶請(qǐng)求實(shí)施非法操作笤喳。當(dāng)然我們可以通過 CSRF Token 來防范 CSRF 攻擊。
- 狀態(tài)保持碌宴。由于 sessionId 本身并不攜帶用戶信息(如 userId)杀狡,所以服務(wù)器端必須將用戶基本信息和 sessionId 一同存儲(chǔ)起來,如此才能知道該登錄會(huì)話是由哪個(gè)用戶發(fā)起的贰镣。當(dāng)?shù)卿浟亢艽髸r(shí)捣卤,這是一筆不小的存儲(chǔ)開銷忍抽。
- 移動(dòng)端環(huán)境。有些移動(dòng)端環(huán)境不支持 Cookie董朝,此時(shí)開發(fā)人員不得不自行實(shí)現(xiàn) Cookie 存儲(chǔ)與傳輸鸠项。
上面的情況都是可以解決的——問題在于是不是所有人都解決了這些問題呢?肯定不是的子姜,現(xiàn)實(shí)中大量的網(wǎng)站沒有做 CSRF 防護(hù)祟绊,沒有將 Cookie 設(shè)置成 HttpOnly,沒有做 XSS 注入和 SQL 注入過濾哥捕。
所以有沒有其它方案能夠規(guī)避掉 session 方案的這些問題呢牧抽?
方案是有的,也就是目前業(yè)界非常青睞的 Token 方案遥赚。
Token 方案:
既然 session 方案的問題都出現(xiàn)在 Cookie 上(具體是 Cookie 的客戶端存儲(chǔ)和傳輸機(jī)制上)扬舒,那我們可以對(duì)原先的方案稍作改造,讓它不依賴于 Cookie凫佛。
后端生成登錄標(biāo)識(shí)(為了和 session 方案區(qū)分讲坎,此處我們叫它 token)后,通過自定義響應(yīng)頭(如就叫 Login-Token)將 token 返回給前端愧薛,前端將該 token 以適當(dāng)?shù)姆绞酱鎯?chǔ)起來(如 localStorage)晨炕;前端對(duì)后端的后續(xù)請(qǐng)求都在 HTTP 請(qǐng)求頭中帶上該 token,后端先校驗(yàn) token 的合法性毫炉,并通過 token 拿到登錄用戶信息瓮栗,然后執(zhí)行后續(xù)流程。
和 session 方案一樣瞄勾,Token 也是通過 HTTP Header 傳輸?shù)模–ookie 也是在 HTTP Header 中)费奸,只不過 Token 的存儲(chǔ)和傳輸都是由應(yīng)用層程序自己控制的,沒有利用瀏覽器的自動(dòng)機(jī)制进陡,CSRF 偽造請(qǐng)求時(shí)自然帶不上該參數(shù)货邓。
由于不需要依賴 Cookie,token 方案也就不存在跨域問題四濒,并且在移動(dòng)端環(huán)境也很好使用换况。
此 token 方案在服務(wù)器端的行為和 session 幾乎是完全一致的:它也需要生成一個(gè)隨機(jī)串(token),并且要將 token 串和用戶基本信息以適當(dāng)?shù)姆绞奖4嫫饋硪怨┖罄m(xù)使用盗蟆。
也就是說該 token 方案仍然需要保存狀態(tài)信息戈二。如果該狀態(tài)信息存儲(chǔ)在服務(wù)器本地,則同樣會(huì)存在分布式訪問問題喳资。
我們并沒有解決 session 方案的第 2觉吭、4 兩點(diǎn)問題。
兵來將擋仆邓,水來土掩鲜滩。
服務(wù)器端之所以要存儲(chǔ)狀態(tài)信息伴鳖,是因?yàn)?token 自身沒有攜帶狀態(tài)(用戶)信息——那如果我們讓 token 自身攜帶這些信息呢?
好像可行徙硅。比如我們這樣生成 token:
// 狀態(tài)信息(用戶信息)
stat_info = 'userid=12345&name=張三';
// 將狀態(tài)信息 base64 編碼后得到 token
token = base64_encode(stat_info);
如此榜聂,服務(wù)器后續(xù)從前端拿到 token 后 base64_decode 就能拿到用戶信息了。
可行嗎嗓蘑?
當(dāng)然不行须肆!
服務(wù)器端之所以存儲(chǔ) token 相關(guān)信息,一方面是為了后面能拿到登錄用戶信息桩皿,另外一方面是為了能夠校驗(yàn)客戶端傳過來的 token 是不是服務(wù)器端生成的豌汇,而不是客戶端自己偽造的(回想一下前面提到的”登錄標(biāo)識(shí)“的兩層含義)。
現(xiàn)在服務(wù)器端沒存 token 了泄隔,怎么檢驗(yàn)前端傳過來的 token 是否有效拒贱?
別氣餒。如果我們能夠讓前端偽造不了呢佛嬉?
所謂偽造逻澳,跟”篡改“是一個(gè)意思。業(yè)界防篡改的常用手段是簽名——對(duì)巷燥,我們給剛才生成的 token 加上私鑰簽名:
// 簽名秘鑰(從配置中心獲取赡盘,或者腳本定期動(dòng)態(tài)生成)
key = 'ajdhru4837%^#!kj78d';
// 狀態(tài)信息(用戶信息号枕、登錄過期時(shí)間)
stat_info = 'userid=12345&name=張三&expire=2022-03-25 12:00:00';
// 將狀態(tài)信息 base64 編碼
encode_info = base64_encode(stat_info);
// 簽名(此處用 HMACSHA256)
sign = hmac_sha256(encode_info, key);
// 將 encode_info 和 sign 簽名拼在一起生成 token
token = encode_info + "." + sign;
如上缰揪,我們得到的 token 串長(zhǎng)這樣子:xxxxxxxxxx.yyyy
,其中 x 部分是用戶信息 base64 編碼后的值葱淳,y 部分是對(duì) x 部分的簽名钝腺。
有了 y 部分的簽名,外部由于沒有簽名秘鑰赞厕,便無法修改或者偽造 x 部分的內(nèi)容了艳狐。
這個(gè)帶簽名的無狀態(tài)的 token 業(yè)界有個(gè)標(biāo)準(zhǔn)方案叫 JWT。
JWT:
JWT 是 JSON Web Token 的縮寫皿桑,是 RFC 7519 定義的鑒權(quán)和信息交互標(biāo)準(zhǔn)毫目。
從名字可知,它是用 json 格式存儲(chǔ)信息诲侮,主要用于 web 接口交互(但不限于前后端交互的場(chǎng)景)镀虐,在系統(tǒng)間(前后端、后端之間)接口交互時(shí)實(shí)現(xiàn)鑒權(quán)和非敏感信息傳輸沟绪。
先看看 JWT token 到底長(zhǎng)什么樣子:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
看到這串”亂碼“中兩個(gè)小小的點(diǎn)(.)沒刮便?它將這段字符串分成三個(gè)部分:
xxxxx.yyyyy.zzzzz
第一部分(x)和第二部分(y)都是 json 字符串的 base64 編碼(JWT 的 J 就是 json 的意思)。具體地绽慈,第一部分叫首部(Header)恨旱,放一些元數(shù)據(jù)(簽名算法等)辈毯;第二部分叫有效載荷(Payload),放的是具體要傳輸?shù)男畔⑺严停坏谌糠郑▃)是第一部分和第二部分的簽名串谆沃,防止前兩部分被篡改。
我們對(duì)上面 token 的前兩部分 base64_decode 看看里面是什么東西:
// 第一部分 decode 后
{
"alg": "HS256",
"typ": "JWT"
}
// 第二部分 decode 后
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
第一部分(首部)包含了類型(typ入客,此處是 JWT)和簽名算法(alg管毙,即用什么算法生成第三部分簽名串,此處用的是 HMAC_SHA256)桌硫;第二部分(有效載荷)可以自己定義(如上面的 name)夭咬,RFC 標(biāo)準(zhǔn)定義了一些通用的字段(如上面的 sub、iat)铆隘。
你有沒有發(fā)現(xiàn)卓舵,任何人都可以查看前兩部分的內(nèi)容?
是的膀钠,JWT 前兩部分是明文掏湾,所以不要放敏感信息(你也可以對(duì)前兩部分加密,但一般我們不這么搞)肿嘲。JWT 的真正用途是簽名而不是加密融击。
現(xiàn)在我們用 JWT 來實(shí)現(xiàn)前后端無狀態(tài)交互。
JWT token 生成過程如下:
// header
// 簽名算法也用 HS256(HMAC_SHA256雳窟,編程語言一般都提供了相應(yīng)的算法庫)
header = '{"alg": "HS256","typ": "JWT"}';
// payload
// 定義了三個(gè)非敏感信息:用戶編號(hào)尊浪、姓名、token 過期時(shí)間
payload = '{"user_id": 123456,"name": "張三","exp": "2022-03-25 12:00:00"}';
// header base64 后
base_header = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
// payload base64 后
base_payload = "eyJ1c2VyX2lkIjoxMjM0NTYsIm5hbWUiOiLlvKDkuIkiLCJleHAiOiIyMDIyLTAzLTI1IDEyOjAwOjAwIn0";
// 兩者拼接
content = base_header + "." + base_payload;
// 簽名秘鑰(從配置中心獲取封救,或者后臺(tái)腳本定期刷新)
key = "ajdhru4837%^#!kj78d";
// 用 HMAC_SHA256 簽名
sign = hmac_sha256(content);
// 得到最終的 token
token = content + "." + sign;
張三登錄成功后拇涤,后端將上面生成的 JWT token 通過 HTTP 響應(yīng)頭(假如叫 Authorization)返回給前端,而后前端請(qǐng)求后端都會(huì)帶上如下 HTTP Header:
Authorization:<jwt_token>
后端拿到前端傳的 token誉结,先對(duì)前兩部分計(jì)算簽名鹅士,和第三部分比較,如果一致惩坑,說明該 token 合法掉盅,并從從有效載荷中解析出用戶信息。
后端并沒有存儲(chǔ) token以舒,完全是從前端傳過來的 token 中解析出用戶(狀態(tài))信息趾痘,一方面避免了后端存儲(chǔ)的開銷,同時(shí)也解決了集群服務(wù)的訪問問題稀轨,堪稱完美扼脐!
我們?cè)谟行лd荷中增加了過期時(shí)間(exp),該 token 只在該時(shí)間之前有效。
這里有個(gè)問題瓦侮。我們假設(shè)用戶是在 2022-03-25 11:00:00 登錄的艰赞,登錄有效期是 1 個(gè)小時(shí),即 token 的過期時(shí)間是 2022-03-25 12:00:00肚吏。假設(shè)用戶在 2022-03-25 11:59:58 訪問某個(gè)頁面方妖,此時(shí) token 未過期,能正常訪問罚攀;用戶在該頁面停留了 2 秒鐘党觅,然后點(diǎn)擊某個(gè)按鈕,此時(shí) token 過期了斋泄,后端會(huì)返回”登錄過期“錯(cuò)誤杯瞻,前端就會(huì)跳轉(zhuǎn)到登錄界面——你能想象此時(shí)用戶心里有多少只馬在奔騰嗎?
所以和 session 方案一樣炫掐,必須要有 token 刷新機(jī)制魁莉,保證在用戶頻繁操作的情況下,token 不會(huì)過期募胃。
JWT 的 token 刷新機(jī)制很簡(jiǎn)單旗唁,我們驗(yàn)證前端的 token 沒問題后,檢查一下有效期痹束,如果過期了检疫,那自然就返回錯(cuò)誤;如果沒有過期祷嘶,我們會(huì)根據(jù)當(dāng)前時(shí)間生成一個(gè)新的 token 給到前端屎媳,前端用這個(gè)新 token 替換掉原來的 token 即可。后端在每次接口響應(yīng)頭部都加上:
Refresh-Token: <new token>
如此抹蚀,用戶只有在 1 小時(shí)內(nèi)沒有任何操作的情況下才會(huì)退出登錄剿牺。
無論是采用何種方案企垦,有一點(diǎn)需要記谆啡馈:前后端通信一定要使用 https,否則在登錄之初就已經(jīng)不安全了钞诡。
后端之間的調(diào)用:
后端相較于前端的一個(gè)優(yōu)勢(shì)是郑现,后端雙方都可以持有秘鑰。根據(jù)數(shù)據(jù)敏感度不同荧降,有兩種不同級(jí)別的保障需求:
- 防篡改接箫。對(duì)于一般的數(shù)據(jù),只需要保障數(shù)據(jù)在傳輸中不會(huì)被篡改即可朵诫。此種場(chǎng)景可采用 appid + secret 的數(shù)字簽名方案辛友;
- 防窺視。一些敏感性數(shù)據(jù),不但要防篡改废累,還要防止被非法接受者查看邓梅,此時(shí)需要采用加解密方案(如采用 RSA 算法);
數(shù)字簽名方案需要雙方事先協(xié)商秘鑰(secret)邑滨;非對(duì)稱加密方案需要事先協(xié)商公鑰私鑰對(duì)日缨。這里不詳細(xì)講解兩種方案的具體實(shí)現(xiàn)細(xì)節(jié),主要提一下很多人在設(shè)計(jì)接口鑒權(quán)時(shí)都忽視的一種風(fēng)險(xiǎn):接口重放攻擊掖看。
比如服務(wù)器 A 調(diào)服務(wù)器 B 接口:
https://www.b.com/somepath?name=lily&age=20
對(duì)請(qǐng)求參數(shù)使用秘鑰簽名后:
// 簽名算法由 B 決定匣距。如 md5(join(ksort(params)) + secret)
https://www.b.com/somepath?name=lily&age=20&appid=12344&sign=a8d73hakahjj2293asfasd234431sdr
這便是 A 調(diào) B 的完整請(qǐng)求參數(shù)。
服務(wù)器 B 接收到請(qǐng)求后哎壳,使用同樣的秘鑰和簽名算法對(duì)請(qǐng)求參數(shù)(sign 除外)進(jìn)行簽名毅待,發(fā)現(xiàn)和傳過來的 sign 一致,便認(rèn)為是合法請(qǐng)求归榕。
有什么問題嗎恩静?
一年后,只要雙方的 secret 和簽名算法沒變蹲坷,上面這個(gè) url 仍然是個(gè)合法請(qǐng)求——這是個(gè)永不失效的簽名驶乾。
一般為了排查問題,調(diào)用雙方一般都會(huì)把請(qǐng)求信息記錄日志循签,如果日志內(nèi)容遭泄露级乐,里面所有的請(qǐng)求都能被重放。
所以我們必須讓簽名有個(gè)有效期县匠,過了一定的時(shí)間后原來的簽名就自動(dòng)失效了风科。
我們?cè)谡?qǐng)求參數(shù)中加入請(qǐng)求時(shí)間,B 接收到請(qǐng)求后乞旦,先判斷該時(shí)間跟 B 的本地時(shí)間差是否在一定范圍內(nèi)(如 5 分鐘)贼穆,超過這個(gè)時(shí)間范圍則拒絕請(qǐng)求(當(dāng)然這要求雙方服務(wù)器的時(shí)間不能錯(cuò)得離譜)。這樣就相當(dāng)于簽名只有 5 分鐘的有效期兰粉,大大降低被重放的概率故痊。
// 帶上時(shí)間戳,服務(wù) B 先檢測(cè) timestamp 值是否過期
// 由于 timestamp 字段也被納入到簽名參數(shù)中玖姑,調(diào)用方無法修改 timestamp 的值
https://www.b.com/somepath?name=lily&age=20×tamp=1647792000&appid=12344&sign=8judq67kahjj2293asfas5dh1k93
除了簽名和加密愕秫,還可以結(jié)合其他方面加固接口的安全性,如對(duì)外接口(非局域網(wǎng)調(diào)用)必須使用 https焰络,采用 IP 白名單機(jī)制等戴甩。
后記
接口設(shè)計(jì)除了上面提到的冪等性、魯棒性和安全性闪彼,還有其他很多值得探討的東西甜孤,包括接口的易用性、返回參數(shù)結(jié)構(gòu)的一致性、前后端協(xié)作方式等缴川,不一而足囱稽。
好的接口設(shè)計(jì)并不是個(gè)人的事,而是團(tuán)隊(duì)的事:
- 要盡可能地將保障能力前置(前置到框架二跋、運(yùn)維層面)战惊,讓具體開發(fā)者要做的事盡可能少。沒有誰能保證自己寫的所有接口的所有方面都處理得面面俱到——這個(gè)參數(shù)忘了去空格扎即,那個(gè)參數(shù)忘了做 XSS 過濾吞获。更何況一個(gè)接口往往不是由一個(gè)人開發(fā)和維護(hù)的。
- 需要有質(zhì)量審查機(jī)制谚鄙。如果有可能各拷,由測(cè)試團(tuán)隊(duì)給接口做滲透測(cè)試和性能測(cè)試。代碼審查(以及工具審查)也能發(fā)現(xiàn)一部分問題闷营。
- 需要強(qiáng)化團(tuán)隊(duì)成員的相關(guān)意識(shí)烤黍。如防御性編程、充分利用緩存和索引傻盟、異步化編程速蕊,這些往往是意識(shí)問題。
- 選擇合適的開發(fā)框架娘赴。需考察框架對(duì) XSS规哲、CSRF、SQL 注入诽表、格式校驗(yàn)唉锌、簽名、隊(duì)列竿奏、調(diào)度等的支持情況和上手難易度袄简,以及團(tuán)隊(duì)成員的熟悉度——如果一部分人不熟悉,則要組織培訓(xùn)泛啸。