譯者 | 崔皓
策劃 | 云昭
1、開篇
不懂原始類型的程序員,往往由于急于求成,上手很快,最后卻發現被各種Bug耽誤進度。本文以經典的郵箱類型、貨幣類型、密碼類型為例,利用好類型系統能夠很好地改進編碼方式,同時為技術人找回“打穩地基”的快樂。
2、字符串類型變身成為郵箱類型
筆者已經厭倦了使用原始類型,并試圖通過使用原始類型,來為一個領域進行建模。
字符串值(String)類型不僅僅用來保存用戶的電子郵件地址或國籍信息,還可以有更豐富的用途。我需要一個EmailAddress的類型,并定義它不能為空,同時希望有單一的入口來創建該類型的對象。在返回一個值之前,需要被驗證和規范化。
同時,也希望該數據類型有一些方法,如.Domain()或.NonAliasValue(),在輸入foo+bar@gmail.com時,會分別調用這兩個方法并返回gmail.com和foo@gmail.com。
在類型設計中應該考慮這種有用的功能,該功能的引入有助于防止錯誤的發生,并提高了類型的可維護性。
3、設計良好、功能實用的類型
例如,一個EmailAddress可以提供兩個方法來檢查是否相等。
lEquals方法用來判斷兩個(規范化)的電子郵件地址是否相同,如果相同將返回true。
該方法對于foo@gmail.com和foo+bar@gmail.com的輸入會判斷相同,因此也會返回true。(這里假設兩個郵箱都是同一個人注冊的,因此相同需要判斷兩個郵箱“相等”)
特定類型的方法在不同的使用場景下都會發揮不同的作用。如果用戶jane@gmail.com注冊,但又用Jane@gmail.com登錄,那么用戶的登錄不應該失敗(僅僅存在首字母大小寫的區別)。同樣的,如果戶用使用電子郵件地址(foo@gmail.com)和另一個注冊賬戶(foo+svc@gmail.com)聯系客戶支持,相同就需要對這兩個郵箱進行有效匹配。這些都是典型應用場景,如果沒有散落在代碼庫中的業務邏輯,僅憑一個簡單的字符串是無法滿足的。
注意:根據Office RFC描述,電子郵件地址中@符號之前的部分可以區分大小寫,但所有主要的電子郵件主機都將其視為不區分大小寫,因此,域名類型也考慮這方面的問題。
4、好的類型可以防止Bug
順著上面郵箱類型的例子,如果我們想走得更遠,假如希望一個電子郵件地址可以被驗證或未被驗證。通常的做法是,通過向個人的收件箱發送一個獨特的代碼來驗證電子郵件地址。這些 "商業 "上的互動也可以通過類型系統來表達。例如,創建一個叫做VerifiedEmailAddress的第二個類型。該類型可以繼承自EmailAddress。并且確保代碼中只有一個地方可以產生VerifiedEmailAddress的實例,即負責驗證用戶地址的服務。如此這般,應用程序的其他部分可以依靠這個新類型來防止Bug。
任何發送電子郵件的功能都可以依靠該類來驗證的電子郵件地址的安全性。想象一下,如果電子郵件地址是通過簡單的字符串來表達的,會是怎樣的情況。
因此,要找到相關的用戶賬戶,檢查一些模糊的標志,如HasVerifiedEmail或IsActive,確保這些標志設置是正確的,而不會在默認構造函數中被錯誤地初始化為真。有太多的錯誤空間由于使用了原始字符串,導致有些檢查不到位的情況,這種使用原始類型的表達方式被認為是懶惰和缺乏想象力的編程。
5、富類型免受錯誤的侵擾
另一個很好的例子是貨幣!我已經數不清有多少應用程序使用十進制來表達貨幣值。也已經數不清有多少應用程序使用十進制類型表達貨幣值。為什么呢?
這種類型有很多問題,甚至很難理解。每個與錢打交道的領域都應該有專門的貨幣類型。貨幣類型應該包括貨幣和運算符重載(或其他安全功能),以防止出現100美元與20英鎊相乘這樣的愚蠢錯誤。此外,并非每種貨幣在小數點后都只有兩位數。有些貨幣,如巴林或科威特第納爾有三位。如果你在致力處理投資或銀行貸款,那么你最好確保你呈現的Unidad de Fomento有4個小數點。這些問題已經很重要了,足以保證有一個專門的Moneytype,但這還遠遠不夠。
除非在系統內部完成所有功能,否則就不得不與第三方系統打交道。例如,大多數支付網關都是以整數值來請求和響應資金。由于整數值不能涵蓋類似浮點數(雙數類型)的四舍五入運算,因此比浮點數更受歡迎。唯一需要注意的是,數值必須以小單位(如美分、便士、迪拉姆、格羅茲、科佩克等)傳輸,這意味著如果你的程序處理小數點數值,在與外部API對話時,你將不得不不斷地來回轉換它們。如前所述,并不是每種貨幣都使用兩個小數點,所以不是每次都是簡單的乘/除以100。事情很快就會變得很困難,如果這些業務規則被封裝成一個簡潔的單一類型,事情就會被大大簡化。
如果這還不夠復雜的話,各國也有不同的貨幣格式來表示貨幣。在英國,"一萬英鎊和五十便士 "將被表示為10,000.50,但在德國,"一萬歐元和五十美分 "將被顯示為10.000,50。試想一下,如果這些規則沒有放到統一的貨幣類型中,那么在整個代碼庫中會有多少與貨幣相關的代碼被分割開來。
此外,一個專門的貨幣類型可以包括許多功能,這將使貨幣價值的工作變得輕而易舉。
當然,建立這樣一個Money類型在開始的時候會有點費勁,但是一旦它被實現并經過測試,那么代碼庫的其他部分就可以帶來更大的安全性,并防止大多數的Bug的產生,否則這些Bug會隨著時間的推移而慢慢出現。即使像Money.FromUnit(decimal v, Currency c)或Money.FromMinorUnit(int v, Currency c)這樣的小功能看起來并不多,但它使參與連續開發的程序員能夠意識到,用戶輸入或外部API收到的值是否包含在其中,這樣可以在一開始就防止Bug的產生。
6、聰明的類型設計減少副作用
富類型的偉大之處在于,可以以任何的方式來塑造它們。這里展示另外一個例子,富類型如何將團隊從巨大的操作開銷中拯救出來,甚至防止安全漏洞。
相信很多系統中的代碼庫都有一個類似于字符串secretKey或字符串password的東西,它作為函數的參數。那么在什么情況下有可能出錯呢?
如下(偽)代碼:
這里出現的問題是,如果在認證過程中拋出一個異常,那么這個應用程序將用戶的明文密碼寫入日志。當然,這段代碼一開始就不應該存在,這種情況會隨著時間的推移而發生。大多數這樣的錯誤都是隨著時間的推移而逐步發生的。
最初,UserLogin類可以有一組不同的屬性,在最初的代碼審查中,這段代碼可能沒有問題。幾年后,有人可能修改了UserLogin類以包括明文密碼。這個功能甚至不會出現在代碼提交的差異中,因此會逃過代碼審查。于是就引入了安全漏洞。然而,如果引入一個富類型(專有類型),就可以避免類似錯誤的發生。
在C#中(以這個語言為例),當一個對象被寫入日志時,ToString()方法會被自動調用。有了這些知識,我們就可以設計一個這樣的密碼類型。
雖然是一個微小的變化,但在系統的任何地方都不可能意外地輸出一個明文密碼。這不是很好嗎?
當然,在實際的認證過程中,你可能仍然需要明文值,那么就需要通過非常明確的命名方法Cleartext()來實現的,所以對這個操作的敏感性沒有任何含糊,它自動引導開發者有意和謹慎地使用這個方法。
處理用戶的PII(如國家保險號、稅號等)也是同樣的原則。使用專門的類型對這些信息進行建模。覆蓋默認函數,如.ToString()。ToString()的默認函數,并通過相應的命名函數暴露敏感數據。你永遠不會把PII泄露到日志和其他地方,以后可能需要一個巨大的操作來再次刷掉它。
小伎倆發揮了大作用!
7、形成習慣
每當開發者處理那些有特殊規則、行為或敏感數據的時候,不妨考慮如何能通過創建一個顯式類型來幫助自己。
讓我們再舉一個密碼類型的例子,可以走得更遠!
密碼在被存儲到數據庫之前會進行散列計算,但這個哈希值不是一個簡單的字符串。在某些時候,我們將不得不在登錄過程中把以前存儲的哈希值與新計算的哈希值進行比較。但并不是每個開發人員都是安全專家,比較兩個哈希字符串可能會使代碼受到攻擊。
檢查兩個密碼哈希值是否相等的推薦方法是以非優化的方式進行。
[MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization) ]
注:代碼示例取自原始ASP.NET Core資源庫
因此,將這一特殊功能編碼為一個專門的類型才是合理的。
如果一個PasswordHasher只返回PasswordHash類型的值,即使是對業務不太了解的開發者也會使用一種安全的形式來檢查相等。
在建立領域模型方面要考慮周全! 當然,編程中的一切都沒有明確的對錯之分,人們的個人使用情況總是有更多的細微差別,這些不是在一篇文章中所能表達的,但筆者建議是,考慮如何使類型系統對開發者的幫助很大。現在許多現代編程語言都有非常豐富的類型系統,我們可能忽視了它們沒有利用好這些類型改進編碼方式。
原文鏈接:https://dusted.codes/the-type-system-is-a-programmers-best-friend
譯者介紹
崔皓,51CTO社區編輯,資深架構師,擁有18年的軟件開發和架構經驗,10年分布式架構經驗。