程序員過關斬將

重複的請求並不好過濾

爲什麼要做重複請求的過濾呢?不過濾不行嗎?

過濾重複請求很難嗎?加一個請求ID不就好了嗎?

每個技術難點的話題,肯定是由一個產品需求引發的,俗話說:如果沒有產品經理,程序員將不需要聽診器,但是會失業!!

產生背景

重複請求能夠對系統造成傷害是架構中很難避免的一個設計問題,一般情況下,讀請求很少會造成致命性的故障,主要是系統的寫請求,很多時候一個重複寫的動作,會是我們程序員加班的緣由。比如:用戶使用積分兌換物品,重複的請求會造成用戶積分的重複扣減,而作爲線上系統,如果日誌等輔助打的不好的話,排查原因其實需要很多時間。

一般的產品經理設計系統的時候並不會涉及到這類異常情況,但是一旦出現問題,產品經理就會找到程序員罵娘,多麼悲哀的故事,人家付出5分精力設計的系統,我們卻要花費10分的精力去編碼和維護。

重複的業務請求,有的時候對系統造成的影響很大,所以程序員在設計的時候尤其要注意,產生的原因有很多:

  • 黑客進行了攔截,人爲的重放了請求
  • 客戶端因爲某些原因,用戶在很短的時間內重放了請求
  • 一些中間件(比如網關)重放了請求
  • 未知的其他情況

道理很簡單,用一張圖表達的會更清爽一些

圖片
image

抽象出來是不是很簡單?但是落地卻並非像這張圖一樣簡單!!

從這張圖上一眼就可以看到,整個過程的重點難點在於過濾器這個邏輯設計部分,這部分可以和業務代碼融合在一起,有的時候也可以相分離,比如:有的網關可以內嵌腳本(比如:lua),就完全可以做到和業務無關,但是通常情況下,落地的代碼卻和業務息息相關。

客戶端處理

客戶端處理重複請求是一種可以有效過濾正常請求的手段,爲什麼這麼說呢?當一個用戶正常操作的時候,客戶端完全可以利用loading的方式或者其他過濾重複手段來達到目的,比如:當用戶點擊一個按鈕的時候,彈出loading窗口方式用戶再次操作。

再比如:客戶端可以設置一個類似於布隆過濾的數據結構,配合對應的過濾算法也可以達到過濾重複請求的效果。

不過,客戶端的任何解決方案也只是治標不治本,畢竟,客戶端在整個系統架構中,是最不可靠的終端。

請求標識

重複請求過濾的關鍵在於過濾器的邏輯設計,目前最常用,落地最多當屬使用請求ID的方式。大體流程如下:

  1. 客戶端發送請求的時候,會生成隨機的請求ID,隨着業務參數一起傳送到服務端
  2. 服務端會根據傳送上來的請求ID做是否重複的判斷

服務器的判斷邏輯其實有很多落地方案了,比如最常見的利用redis來存儲請求ID,以下是僞代碼(NetCore):

public class Para
{
    public string ReqId{get ;set ;}  

    //其他業務參數
}

public bool IsExsit(Para p)
{
    //利用redis來判斷當前的key是否存在
     bool isExsit=redisMethond(p.ReqId);
     //如果存在,則說明是重複請求,如果不存在說明不是重複請求,並且添加到redis
     if(!isExsit){
         AddRedis(p.ReqId);
     }
     return isExsit;

}

一般網上的文章都到此爲止了,這種方案有沒有問題呢?答案:有

問題1

正常的客戶端重複請求,一般情況下真的會根據我們寫的代碼過濾掉重複請求,爲什麼說一般情況呢?那是因爲分佈式的原因,極限情況下也會導致重複的請求到業務處理端,比如以下情況:

  1. 請求被路由到了A服務器,A服務器會去請求Redis,判斷是否有相同的請求ID存在,如果是第一次請求,Redis會返回不存在
  2. 同樣的時間,客戶端或者黑客重放了同樣的請求,這個請求被路由到了B服務器,B服務器同樣會請求Redis來判斷是否存在,這個時候由於A服務器還沒回寫Redis,所以B服務器得到的結果也是不存在該請求
  3. 這樣就導致了業務端收到了兩次同樣的請求,會導致業務不可預期的結果

可見,一個小小重複過濾請求,可能還需要分佈式鎖的出場纔可以

問題2

即便請求中加了唯一的請求ID,但是這個ID並沒有安全保證,或者說,這個ID是可以篡改的。當黑客攔截到請求,隨便改一下請求ID,在重放就搞定你了。所以,加的請求ID,還需要一個安全機制來保證安全,不然這個參數其實意義不大。

業務簽名

由於單純添加請求ID,並不能解決問題,所以我們需要一種保證請求ID的機制,目前來看,普遍的落地方案是根據業務參數生成摘要,也就是所謂的加簽操作。加簽操作可以有效的防止參數被篡改。如果你做過微信相關的開發,你會發現和微信服務器的交互也是基於加簽操作的。而生成的簽名可以作爲請求ID,以下是僞代碼:

    //客戶端生成簽名
    string sigh=MD5($"參數1=值1&參數2=值2&time=當前時間戳")

以上只是例子,雖然MD5算法有產生重複數據的可能性,但是對於當前這個業務場景來說足夠了。細心的同學會發現,參數當中加了一個時間戳的參數,這個是我故意加的,這個時間戳在這個場景下會出現問題,什麼問題呢?

時間戳問題

當前的請求場景是要過濾重複的請求,什麼樣的請求算是重複請求呢?關鍵是這個定義要明確,我看了很多重複過濾請求的文章,重複請求這個概念其實定義的不好,這個是和具體業務場景相關的。舉個栗子:當用戶一秒內重複點擊某個按鈕算是重複請求,那10秒內重複點擊呢?用戶一秒之內對同一個商品下單算重複請求,那10秒內呢?

這個定義就涉及到了上面所說的時間戳參數的問題,時間戳是否要參與生成簽名,要根據具體的業務場景來定義,不過,我還是要建議,請求的參數中帶上時間戳,無論它參不參與簽名,至於爲什麼這麼做,當時間長了你就知道了

寫在最後

過濾重複請求這個需求,並沒有像想象中那麼容易,並非只要加上一個請求ID就完事了,它涉及到安全以及分佈式的問題,在某些場景下(比如:秒殺)還會涉及到性能以及高可用等非功能性問題,所以那些說:只需要一個請求ID就能過濾的同學,請不要再誤導別人了,技術是神聖不可侵犯的。

還是那句話:具體的業務影響到具體的代碼實現,脫離業務講架構其實就是耍流氓

領導說我的類的職責不單一

爲什麼類的職責要單一化?

類的職責單一化很容易嗎?

首先,我要提醒一下看到這篇文章的同學,我認爲保證類(一定是類嗎?)的單一職責並不容易

軟件開發過程中,自古就流傳着幾大規則,無論如何這裏都要和大家闡述一遍

單一職責原則

一個類應該只有一個發生變化的原因

開閉原則

軟件實體應該是可擴展,而不可修改的。也就是說,對擴展是開放的,而對修改是封閉的。這個原則是諸多面向對象編程原則中最抽象、最難理解的一個。

里氏替換原則

所有引用基類的地方必須能透明地使用其子類的對象,換句話說,子類在任何引用基類的地方都可以替換成子類。

依賴倒置原則

這個原則說的詳細一點其實可以概括爲兩點:

  1. 高層模塊不應該直接依賴於底層模塊,應該依賴於抽象
  2. 抽象不應該依賴於具體實現,具體實現應該依賴於抽象
接口隔離原則

程序不依賴於不使用的接口,換句話說,一個程序只依賴於它需要的接口。

單純從概念上講呢,單一職責原則可算是最簡單易懂的一種原則了,就像設計模式中的單例模式一樣無趣,是這樣嗎?

誰的職責

說實話,看過不少講解“職責單一”設計原則的文章,都是以類來闡述。其實我覺得不對,職責單一設計原則本質上是軟件設計原則的一種思想,具有指導意義。至於誰的職責需要單一,是一個僞命題,不僅僅指面向對象編程中的類,系統的模塊,甚至於微服務在架構設計中也應該遵循此規則。

在面向對象設計的理解中,程序最基本的組成單位是類(class),多個類組成模塊(module),多個模塊組成服務(service),多個服務組成系統(system),一般的軟件系統都會存在以上幾個概念。

無論是類,還是模塊,還是服務,還是系統,我認爲設計的時候都要保證“單一職責”。

單一真的容易嗎

說到“單一”職責,每個人都有不同的看法

class UserInfo
{
    //用戶id
    public int UserId{get ;set ;}
    //用戶登錄賬號
    public string Account{get;set ;}
    //用戶登錄密碼
    public string Pwd{get ;set;}
    //用戶姓名
    public string Name{get ;set ;}

}

以上是最常見的用戶信息實體,你認爲它職責單一嗎?說一說,我自己的看法:

站在用戶信息的角度來說,這個類代表的是用戶信息,它就是單一的,這也是大多數人的看法,有錯嗎?其實沒錯。因爲在當前場景下,它確實是這樣。

隨着業務的發展,用戶的信息字段會越來越多,比如:用戶的年齡,性別,學歷....等等。看着越來越大的UserInfo類,是否該拆分呢?

這個時候我覺得你可以根據用戶信息的類型來進行拆分,畢竟大而全的類其實並不好。怎麼拆分呢?比如:可以根據用戶登錄場景拆分出用戶認證的類型

class UserAuth
{
     //用戶id
    public int UserId{get ;set ;}
    //用戶登錄賬號
    public string Account{get;set ;}
    //用戶登錄密碼
    public string Pwd{get ;set;}
}

可以根據用戶信息在系統中的出現頻率和重要度拆分出用戶基本信息和用戶擴展信息

class UserBasicInfo
{
     //用戶id
    public int UserId{get ;set ;}
     //用戶姓名
    public string Name{get ;set ;}
    //用戶手機號
    public string Phone{get ;set ;}

    //其他基本屬性
}
class UserExtendnfo
{    
     //用戶郵箱
    public string Email{get ;set ;}
    //用戶QQ號
    public string QQ{get ;set ;}

    //其他屬性
}

當然這裏我只是舉個栗子,如果用戶的Email和手機號一樣常用,可以把Email屬性提到基本屬性中。

以上只是以用戶信息爲例,根據不同的用途進行拆分的一個栗子。在不同的業務背景下,不同的業務階段,對同一個類的拆分可能會有很大不同。有的時候,你所認爲的"正確“會隨着系統的發展慢慢變成”錯誤“,當然這種”錯誤“並不可怕,畢竟系統的架構都是慢慢迭代出來的。

總之呢,評價一個類是否一定滿足單一原則,並沒有一個統一標準和規範,在實際的開發中,也沒有必要進行過度設計,在項目初級,完全可以是一個滿足業務需求的大而全的類,隨着業務的發展,你必然會經歷拆分的過程,這也是軟件發展的必然階段。

以上只是針對類這個最基本的面向對象單位來聊了聊,上升到模塊以及系統也是一樣的道理,微服務也是隨着軟件開發的不斷演進而出現的,其實從職責上來看,微服務也是職責單一原則的產物,而這個這則單一更多的是傾向於業務單一性,並非功能單一性。

那職責拆分的越細越好嗎?我不這麼認爲,當一個類或者模塊甚至系統,被拆分過細的時候,就會面臨着維護的問題,拿微服務來說,當微服務的數量過多,就會面臨着治理等一系列問題,這也是K8s要解決的問題之一。

拆分原則

說到底,雖然職責單一很難在主觀上給予準確判斷,但是還是有一些通用規則可以借鑑,這裏以類爲例

  • 高內聚。系統在修改任一功能的時候,只需要修改一處地方,如果你需要修改多處才能滿足某個需求,很有可能你的職責劃分的不合理
  • 屬性過多。當一個類屬性過多的時候,可以考慮把這個類進行職責的拆分。而至於多少個纔算多呢?當查找某個屬性令你頭疼的時候,說明已經到了可以拆分的程度了(自己杜撰)
  • 依賴過多。當一個類型中依賴的資源過多的時候,可以進行拆分
  • 獨立變化。當一個類的某些屬性被大量使用而且會經常變化的時候,可以考慮把這些屬性進行拆分成獨立的類。

說到職責單一,這裏順便提一下接口的設計,接口的設計更要遵循職責單一的原則,接口本質上是對業務的抽象,不同的業務應該抽象成不同的接口,以保證每個類,每個模塊,每個系統都可以獨立擴展。

論系統設計的高可擴展性

寫在最後

沒有絕對好的方法可以讓所有人都認爲你的拆分是正確的“職責單一”,有的時候,怎麼樣才能職責單一真是要靠“靈感”

少年派登錄安全的奇幻遐想

據說,這篇也是快餐,完全符合年輕人口味

說到登錄,無人不知無人不曉。每一個有用戶體系的相關係統都會有登錄的入口,登錄是爲了確認操作人的正確性。說到登錄安全,其實是一個很偉大的命題,不過常用的手段也不過爾爾。

避免明文

這個設計到用戶憑證信息的表設計,切記避免明文存儲用戶的密碼信息。還記得以前很多大廠的密碼泄露事件嗎?

在數據表的設計中,除了用戶密碼的摘要列之外,需要添加所謂的“salt”列,其實是隨機生成的一個字符串,用於和用戶密碼的摘要聯合生成最終的摘要。

loginName salt pwd
182xxxxxxxx 隨機字符串 散列值
182xxxxxxxx 隨機字符串 散列值

如果非要寫一個過程的話:

  • 當用戶首次註冊的時候,系統隨機生成salt,然後和密碼按照規則拼接成一個字符串,然後求散列值,並存儲在pwd列中
  • 客戶端請求登陸接口,上傳用戶的賬號和密碼,這裏的密碼推薦md5的摘要(js也可以生成md5)
  • 服務端接收到請求,根據用戶的賬號查詢對應的salt
  • 把上傳的密碼和salt根據規則拼接,然後生成摘要
  • 把上一步生成的摘要和數據庫的pwd進行對比,相同則登錄成功,不同則登錄失敗

爲什麼非要加入salt呢?有了salt不僅可以加大黑客破解的難度,而且同樣密碼的用戶存儲的pwd列也不相同,在用戶信息安全性上又提高了一點。

驗證碼

驗證碼是一種比較廉價的但是很有效的防止別人亂搞的手段,它通過一種只有真人才能識別的防僞手段來阻止危險。

圖片
image

以上是12306的登錄界面,看驗證碼的方式,是不是已經騷到了極點。如果你的登錄接口不希望別人暴力破解的話,驗證碼是必須的。

對於普通的網站,驗證碼程序其實可以做的很簡單就足夠用了,就像以下

圖片
image

用到的技術是服務端把驗證碼的內容繪製在一張帶有紋路的圖片上,把碼的內容存儲在一個地方,並分配一個key,把這個key返回客戶端,當客戶端登錄的時候攜帶者這個key和用戶填入的驗證碼內容來確定驗證碼是否正確。

我曾經看過有人把驗證碼的校驗放到客戶端,要記住,客戶端其實是無安全可言的,哪怕是那些做了混淆的App。

手機驗證碼

目前幾乎所有的系統都支持手機驗證碼登錄,爲什麼這麼普及是有原因的。

  • 首先,這種方式便捷,用戶無需記住密碼,試想一下,用戶要記住自己常用的幾十個網站密碼是很難的,而且手機現在幾乎都不離身
  • 其次,手機驗證碼方式安全係數比較高,因爲手機號現在都採用了實名制,手機號被盜的可能性比較小,而且現在的手機都有指紋鎖,就算手機丟了也不怕
  • 最後,系統都採用手機號登錄,可以高效的拉進和用戶的距離,而且也有利於國家的監管工作,畢竟根據手機號就可以追蹤到用戶的所有信息了
設備號

登錄的時候把當前設備的標識上傳到服務端進行識別,我覺得對於登錄來說很重要。爲什麼呢?

在現在App漫天飛的時代,在App上是要實現自動登錄的,換句話說,用戶登錄過一次這個App,當用戶下次打開的時候,需要實現自動登錄,這在用戶體驗上會比每次都登錄好很多。但是這就面臨着一個問題:需要把用戶登錄的憑證保存在本地,切換到瀏覽器中,這些憑證信息可能會保存在Cookie中或者local storage中,當然憑證肯定是要加密的。我們要保證的是這些憑證就算是被黑客知道了,也不能正常登錄。

那怎麼才能保證呢?答案是設備。在用戶的登錄請求中一定要上傳設備號(瀏覽器也可以用js生成的),服務端存儲着用戶的有效設備列表,當然這個有效設備需要產品經理給出明確的定義,比如最常見的:登錄過5次的設備。當然說到設備還有一個主設備的概念,至於怎麼樣才能定義主設備,也是需要產品方給出定義的,像最常見的:手機端是主設備。像微信現在登錄pc端是需要手機端掃碼的,切換到業務,可以看做需要主設備確認的請求才能執行。

安全設備概念在多點登錄的場景下非常有用,尤其是需要互踢的需求下。

登錄時間

服務端一定要記住用戶最後一次的登錄時間,在很多情況下需要記住用戶在某個設備上的最後登錄時間。這樣做不止是爲了記錄分析用戶的登錄行爲,還可以分析長期未登錄的用戶,使他的登錄憑據失效,強制他重新登錄。

HTTPS

雖然一個證書每幾個錢,但是https起到的作用在安全性上還是很大的。本質上它採用的也是加密算法,比http要耗費cpu,傳輸速度上要慢一些。但是它可以有效的防止中間人劫持,防止用戶信息外漏,而且可以防止被釣魚網站攻擊,有效識別網站真實身份,像其他的有利於SEO,地址欄出現安全鎖等就不說了。

寫在最後

以上所說只是一些最常見的手段,除此之外,比如IP黑名單機制,限流機制等都可以加固登錄的安全。

快不快?希望各位把登錄安全在留言區做補充!!

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章