Go Web應用中常見的反模式

在我職業(yè)生涯的某個階段,我對我所構建的軟件不再感到興奮。
我最喜歡的工作內容是底層的細節(jié)和復雜的算法。在轉到面向用戶的應用開發(fā)之后,這些內容基本消失了。編程似乎是利用現有的庫和工具把數據從一處移至另一處。到目前為止,我所學到的關于軟件的知識不再那么有用了。
讓我們面對現實吧:大多數Web應用無法解決棘手的技術挑戰(zhàn)。他們需要做到的是正確的對產品進行建模,并且比競爭對手更快的改進產品。
這起初看起來似乎是那么的無聊,但是你很快會意識到實現這個目標比聽起來要難。這是一項完全不同的挑戰(zhàn)。即使它們技術上實現并沒有那么復雜,但時解決它們會對產品產生巨大影響并且讓人獲得滿足。
Web應用面臨的最大挑戰(zhàn)不是變成了一個無法維護的屎山,而是會減慢你的速度,讓你的業(yè)務最終失敗。
這是他們如何在Go中發(fā)生和我是如何避免他們的。
松耦合是關鍵
應用難以維護的一個重要原因是強耦合。
在強耦合應用中,任何你嘗試觸動的東西都有可能產生意想不到的副作用。每次重構的嘗試都會發(fā)現新的問題。最終,你決定字號從頭重寫整個項目。在一個快速增長的產品中,你是不可能凍結所有的開發(fā)任務去完成重寫已經構建的應用的。而且你不能保證這次你把所有事都完成好。
相比之下,松耦合應用保持了清晰的邊界。他們允許更換一些損壞的部分不影響項目的其他部分。它們更容易構建和維護。但是,為什么他們如此罕見呢?
微服務許諾了松耦合時實踐,但是我們現在已經過了他們的炒作年代,而難以維護的應用仍舊存在。有些時候這反而變得更糟糕了:我們落入了分布式單體的陷阱,處理和之前相同的問題,而且還增加了網絡開銷。

? 反模式:分布式單體
在你了解邊界之前,不要將你的應用切分成為微服務。
微服務并不會降低耦合,因為拆分服務的次數并不重要。重要的是如何連接各個服務。

? 策略:松耦合
以實現松耦合的模塊為目標。如何部署它們(作為模塊化單體應用或微服務)是一個實現細節(jié)。
DRY引入了耦合
強耦合十分常見,因為我們很早就學到了不要重復自己(Don’t Repeat Yourself, DRY)原則。
簡短的規(guī)則很容易被大家記住,但是簡短的三個單詞很難概括所有的細節(jié)。《程序員修煉之道: 從小工到專家》這本書提供了一個更長的版本:
每條知識在系統(tǒng)中都必須有一個單一的、明確的、權威的表述。
“每一條知識"這個說法相當極端。大多數編程困境的答案是看情況而定,DRY也不例外。
當你讓兩個事物使用相同抽象的時候,你就引入了耦合。如果你嚴格遵循DRY原則,你就需要在這個抽象之前增加抽象。

在Go中保持DRY
相比于其他現代語言,Go是清晰的,缺少很多特性,沒有太多的語法糖來隱藏復雜性。
我們習慣了捷徑,所以一開始很難接受Go的冗長。就像我們已經開發(fā)出一種去尋找一種更加聰明的編寫代碼的方式的本能。
最典型的例子就是錯誤處理。如果你有編寫Go的經驗,你會覺得下面的代碼片段很自然
但是對新手而言,一遍又一遍的重復這三行就是似乎在破壞DRY原則。他們經常想辦法來規(guī)避這種樣板方法,但是卻沒有什么好的結果。
最終,大家都接受了Go的工作方式。它讓你重復你自己,不過這并不是DRY告訴你的你要避免重復。
單一數據模型帶來的應用耦合
Go中有一個特性引入了強耦合,但會讓你認為你自己在遵循DRY原則。這就是在一個結構體中使用多個標簽。 這似乎是一個好主意,因為我們經常對不同的事物使用相似的模型。
這里有一個流行的方式保存單個User
模型的方法:
完整代碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/01-tightly-coupled/internal/user.go
這種方式通過很少的幾行代碼讓你可以只維護單一的結構體實現功能。
然而,在單一模型中擬合所有的內容需要很多技巧。API可能不需要保護某些字段,因此他們需要通過json:"-"
隱藏起來。只有一個API使用到了Email
字段,那么ORM就需要跳過它,并且需要在常規(guī)的JSON返回中通過omitempty
進行隱藏。
更重要的是,這個解決方案帶來一個最糟糕的問題:API、存儲和邏輯之間產生了強耦合。
當你想要更新結構體中的任何東西時,你都不知道還有什么會發(fā)生修改。你會在更新數據庫Schema或者更新驗證規(guī)則時破壞API的約定。
模型越復雜,你面臨的問題就越多。
比如,json
標簽表示JSON而不是HTTP。但是讓你引入同樣是格式化到JSON,但是格式與API不同的事件時會發(fā)生什么?你需要不停的添加hack讓所有功能正常工作。
最終,你的團隊會避免對結構體的修改,因為在你動了結構體之后你無法確定會出現什么樣的問題。

? 反模式:單一模型
不要給一個模型多個責任。
每個結構字段不要使用多個標簽。
復制消除耦合
減少耦合最簡單的方法是拆分模型。
我們提取API使用的部分作為HTTP模型:
完整代碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/02-loosely-coupled/internal/http.go
數據庫相關部分作為存儲模型:
完整代碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/02-loosely-coupled/internal/db.go
起初,看上去我們會在所有地方使用相同的User
模型?,F在,很明顯我們過早的避免了重復。API和存儲的結構很相似,但足夠不同到需要拆分成不同的模型。
在Web應用中,你API返回(讀模型)與存儲在數據庫中的視圖(寫模型)并不相同。
存儲代碼無需知道HTTP的模型,因此我們需要進行結構轉換。

完整代碼: github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/02-loosely-coupled/internal/http.go
這就是所有你需要的代碼:將一種類型映射到另一種類型的函數。編寫這種平淡無奇的代碼可能看起來十分無聊,但是它對解耦至關重要。
創(chuàng)建一個使用序列化或者reflect
實現用于映射結構體的通用解決方案看上去十分誘人。請抵制它。編寫模版比調試映射的邊緣情況會更節(jié)省時間和精力。 簡單的函數對團隊中每個人都更容易理解。魔法轉換器會在一段時間后變得難以理解,即使對你而言也是如此。
? 策略:模型單一責任。
通過使用單獨的模型來實現松耦合。編寫簡單明了的函數用以在它們之間進行轉換。
如果你害怕太多的重復,請考慮一下最壞的情況。如果你最終多了幾個隨著應用程序增長不變的結構,你可以將它們合并回一個。與強耦合代碼相比,修復重復代碼是微不足道的。
生成模版
如果你擔心手寫這些所有代碼,有一個管用的方法可以規(guī)避。使用可以為你生成模版的庫。
你可以生成諸如:
由OpenAPI定義的HTTP模型和路由(oapi-codegen或者其他庫)。
由SQL schema定義的數據庫模型和相關代碼([sqlboiler](https://github.com/volatiletech/sqlboiler和其他ORM)。
通過Protobuf文件生成gPRC模型。
生成的代碼可以提供強類型保護,因此你無需在通用函數中傳遞interface{}
類型的數據。你可以保證編譯時檢查的同時無需手寫代碼。
下面是生成的模型的例子。
完整代碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/03-loosely-coupled-generated/internal/http_types.go
完整代碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/03-loosely-coupled-generated/models/users.go
有時你可能會想要編寫代碼生成工具。這其實并不難,結果需要是每個人都可以閱讀和理解的常規(guī)Go代碼。常見的替代方案是使用reflect
,但是這很難掌握和調試。當然,首先要考慮的是付出的努力是否值得。在大多數情況下,手寫代碼已經足夠快了。
? 策略:生成重復工作的部分
生成的代碼為你提供強類型和編譯時安全性。選擇它而不是reflect
。
不要過度使用庫
只將生成的代碼用于它應該做的事情。如果你想避免手工編寫模版,但仍需要保留一些專用的模型。不要以單一模型反模式作為結束。
當你想遵循DRY原則時,很容易落入這個陷阱。
例如,sqlc和sqlboiler都是從SQL查詢中生成代碼。sqlc允許在生成的模型上添加JSON標簽,甚至允許讓你選擇camelCase
還是snake_case
。sqlboiler在所有模型上默認添加了json
,toml
和yaml
標簽。這顯然是不是讓用戶僅僅把這個模型僅用于存儲。
看一下sqlc的issue列表,我發(fā)現很多開發(fā)者要求更多的靈活性,比如重命名生成的字段和整個跳過一些JSON字段。有人甚至提到他們需要某種在REST API中隱藏某些敏感字段的方法。
所有這些都是鼓勵在單一模型中擔負更多職責。它可以讓你寫更少的代碼,但是請務必考慮這種耦合是否值得。
同樣,需要注意結構體標簽中隱藏的魔法,比如,gorm中提供的權限功能:
完整代碼:gorm.io/docs/models.html#Field-Level-Permission
你同樣可以使用[validator]庫進行復雜的比較,比如參考其他字段:
它為你節(jié)省了一點編寫代碼的時間,但是這意味著你放棄了編譯期檢查。在結構體標簽中很容易出現錯別字,在驗證和權限等敏感地方使用這種會帶來風險。這同樣也會讓很多不那么熟悉庫的語法糖的人感到困擾。
我并不是指摘這些提到的庫,他們都有自己的用途。但是這些示例展示了我們如何把DRY做到極致,這樣我們就不用編寫更多的代碼了。
? 反模式:選擇魔法來節(jié)省編寫代碼的時間
不要過度使用庫以避免冗余。
避免隱式標簽名
大多數庫不要求標簽必須存在,此時會默認使用字段名稱。
在重構項目時,有人可能會重命名字段,但是他沒有想過編輯API返回或者數據模型。如果沒有標簽,這就會導致API約定或者數據存儲過程被破壞。
請始終填寫所有標間,即使你必須兼容同一名稱兩次,這并不違反DRY原則。
譯者注:其實Go之前有個類似proposal提過在1.16中簡化這一寫法,但是后面發(fā)現存在一些問題被回滾了。
?反模式:省略結構標簽
如果庫使用它們,則不要跳過結構標簽。
?戰(zhàn)術:顯式結構標簽
始終填充結構標簽,即使字段名稱相同。
將邏輯與實現細節(jié)分開
通過生成模型將API與存儲解耦是一個好的開始。但是,我們仍舊需要保留在HTTP處理中的驗證過程。
完整代碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/03-loosely-coupled-generated/internal/http.go
驗證是你能在大多數Web應用中可以找到的業(yè)務邏輯中的一環(huán)。通常,他們會更加復雜,比如:
僅在特定情況下顯示字段
檢查權限
取決于角色而隱藏字段
計算價格
根據幾個因素采取行動
將邏輯和實現細節(jié)混在一起(比如將他們放在HTTP handler中)是一種快速交付MVP的方法。但是這也引入了最壞的技術債務。這就是為什么你會被供應商鎖定,為什么你需要不停的添加hack拉支持新功能。
? 反模式:將邏輯和細節(jié)混在一起
不要將你的應用程序邏輯與實現細節(jié)混在一起。
商業(yè)邏輯需要單獨的層。更改實現(數據庫引擎、HTTP 庫、基礎架構、Pub/Sub 等)應是可能的,而無需對邏輯部件進行任何更改。
你做這種分離并不是因為你想要更改數據庫引擎,這種情況很少會發(fā)生。但是,關注點的分離可以讓你的代碼更容易理解和修改。你知道你在修改什么,并且有沒有副作用。 這樣就很難在關鍵部分引入bug。
要分離應用層,我們需要添加額外的模型和映射。

完整代碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/04-loosely-coupled-app-layer/internal/user.go
這就是當我需要更新業(yè)務邏輯時我需要修改的代碼。這明顯很無聊,但是我知道我修改了什么。
當我們添加另一個API(比如gRPC)或者外部系統(tǒng)(如Pub/Sub)時,我們需要同樣的工作。每個部分都是用單獨的模型,我們在應用層映射轉換它們。
因為應用層維護了所有的驗證和其他商業(yè)邏輯,他會讓我們無論是使用HTTP還是gRPC API都沒什么區(qū)別。API只是應用的入口。

? 策略:應用層
將產品最重要的代碼劃分成單獨的層。
上面的代碼片段都來自于同一個代碼庫,并且實現了經典的用戶域。所有示例都暴露相同的API并且使用相同的測試套件。
以下是他們的比較:

標準的Go項目結構
如果你看過這個倉庫,你會發(fā)現在每個例子中只有一個包。
Go目前沒有官方的目錄組織結構,不過你可以找到很多微服務例子或者REST模版?zhèn)}庫建議你如何拆分。他們通常有精心設計的目錄機構,有人甚至提到他們遵循了簡潔架構或者六邊形架構。
我一般第一件確認的事情是如何存儲模型的。大多數情況下,他們使用了JSON和數據庫標簽混合的結構體。
這是一種錯覺:包看起來進行了很好的切分,但實際上他們仍舊通過一個模型被緊密的耦合在了一起。新人用來學習的很多流行例子中,這些問題也很常見。
具有諷刺意味的是,標準的Go項目結構仍舊在社區(qū)中繼續(xù)被討論,然而模型耦合反模式卻很常見。如果你的應用程序的類型耦合了,任何目錄的組織形式都不會改變什么。
在查看示例結構時,請記住他們可能是為另外一種不同類型的應用程序設計的。對于開源的基礎設施工具、Web應用后端和標準庫而言,沒有一種方法同時對他們有效。
包分層和切分微服務的問題非常類似。重要的不是如何劃分他們,而是他們彼此之間如何連接。
當你專注于松耦合時,目錄結構就會變得更加清晰。你可以將實現細節(jié)與業(yè)務邏輯區(qū)分開。你把相互引用的事物分組,并將不互相引用的事物拆分開。
在我準備的示例中,我可以輕松的將HTTP相關的代碼和數據庫相關的代碼拆分至單獨的包中。這會避免命名空間的污染。模型之間已經沒有耦合,所以這些操作就變成了具體的細節(jié)。
?反模式:過度考慮目錄結構
不要通過分割目錄來啟動項目。不管你怎么做,這是一個慣例。
你不太可能在編寫代碼之前把事情做好。
?策略:松耦合代碼
重要的部分不是目錄結構,而是包和結構是如何進行相互引用的。
保持簡單化
假設你想要創(chuàng)建一個用戶,這個用戶有一個ID字段。最簡單的方法可以看起來像這樣:
這段代碼能夠正常工作。但是,你無法判斷該結構在任何時候都是正確的。你依靠一些額外東西來調用驗證并處理錯誤。
另一種方法是采用良好的舊式封裝。
此片段更清晰、更冗長。如果你創(chuàng)建了一個新的UserID
并且沒有收到任何錯誤,你可以確定創(chuàng)建是成功的。此外,你可以輕松地將錯誤映射到 API 的正確響應。
無論你選擇哪種方法,你都需要對用戶ID的基本復雜性進行建模。從純粹的實現的角度來看,將 ID 保持在字符串中是最簡單的解決方案。
Go應該很簡單,但這并不意味著你應該只使用原始類型。對于復雜的行為,請使用反映產品工作方式的代碼。否則,你最終會獲得一個簡化的模型。
?反模式:過度簡化
不要用瑣碎的代碼來模擬復雜的行為。
?策略:編寫明確的代碼
保證代碼是明確的,即使它很冗長。
使用封裝來確保你的結構始終處于有效狀態(tài)。
即使所有字段都未導出,也可以在包外創(chuàng)建空結構。唯一要做的是在接受
UserID
作為參數時,你需要檢查一下合法性。
你可以使用if id == UserID{}
或編寫專門的IsZero()
方法來進行。
從數據庫Schema開始
假設我們需要添加一個用戶創(chuàng)建和加入團隊的功能。
按照關系型方法,我們需要添加一個teams
表和另外一個將用戶和它進行關聯(lián)的表。我們叫它membership
。

按照關系方法,我們將添加一張桌子和另一張加入它的表格。讓我們稱之為。teamsusersmembership
我們已經有了UserStorage
,所以很自然的添加兩個新的結構體:TeamStorage
和MembershipStorage
。他們會為每個表格提供CRUD方法。
添加新團隊的代碼可能看起來是這個樣子的:
這種方法有一個問題:我們沒有在事務中創(chuàng)建團隊和成員記錄。如果出現問題,我們可能最終擁有一支沒有分配所有者的團隊。
首先想到的第一個解決方案是在方法之間傳遞事務。
但是,這樣的話實現細節(jié)(事務處理)就會泄漏到了邏輯層。它通過基于defer
的錯誤處理污染了一個可讀的函數。
下面是一個練習:考慮如何在文檔數據庫中對此進行建模。比如,我們可以將所有成員保留在團隊文檔中。

在這種情況下,添加成員就可以在TeamStorage
中完成,這樣我們就不需要單獨的MembershipStorage
。但是切換數據庫就變更了我們模型的假設,這不是很奇怪嗎?
現在很顯然,我們通過引入"成員身份"概念泄露了實現細節(jié)?!皠?chuàng)建新成員身份”,這只會困擾我們的銷售或者客戶服務同事。當你開始說一種不同于公司其他成員的語言時,這通常一個嚴重的危險信號。
反模式?:從數據庫Schema開始
不要將模型建立在數據庫模式的基礎上。你最終會暴露實現細節(jié)。
TeamStorage
用于存儲團隊信息,但它不是與teams
SQL表無關。這是關于我們產品的團隊概念。
每個人都明白創(chuàng)建一個團隊需要一個所有者,我們可以為此暴露一個方法。這個方法會將所有的查詢放在一個事務中執(zhí)行查詢。
同樣,我們也可以有一個加入團隊的方法。
membership
表依舊存在,但是實現細節(jié)被隱藏在TeamStorage
中。
?策略:從領域開始
你的存儲方法應遵循產品的行為。不要他們的事務細節(jié)。
你的網絡應用程序不是單純的CRUD
教程通常都是以"簡單的 CRUD"為特色,因此它們似乎是任何 Web 應用的基礎構建模塊。這是個虛無縹緲的傳說。如果你所有的產品需要的是CRUD,你就是在浪費時間和金錢從零開始構建。
框架和無代碼工具使得啟動 CRUD 變得更容易,但我們仍然向開發(fā)人員支付構建自定義軟件的費用。即使是 GitHub Copilot 也不知道你的產品除了模版之外是如何工作的。
正是特殊的規(guī)則和奇怪的細節(jié)使你的應用程序與眾不同。這不是你分散在四個CRUD操作之上的邏輯。 它是你銷售的產品的核心。
在 MVP 階段,從 CRUD 開始快速構建可工作版本是很誘人的。但這就像使用電子表格而不是專用軟件。一開始,你會獲得類似的效果,但每個新功能都需要更多的hack。
反模式?:從 CRUD 開始
不要圍繞四個 CRUD 操作的想法來設計你的應用程序。
?策略:了解你的領域
花時間了解你的產品是如何工作的,并在代碼中建模。
我描述的許多策略都是眾所周知的模式背后的想法:
SOLID中的單一責任原則(每個模型只有一項責任)。
簡潔架構(松耦合的包,將邏輯與實現細節(jié)隔離)。
CQRS(使用不同的讀取模型和寫入模型)。
有些甚至接近域驅動設計:
值對象(始終保持結構處于有效狀態(tài))。
聚合和倉庫(無論數據庫表的數量如何,都以事務方式保存領域對象)。
無處不在的語言(使用每個人都能理解的語言)。
這些模式似乎大多與企業(yè)級應用相關。但其中大多數是簡單明了的核心思想,比如本文中的策略。它們同樣適用于處理復雜的業(yè)務行為Web應用程序、
你不需要閱讀大量書籍或復制其他語言的代碼來遵循這些模式。你可以通過實踐檢驗的技術編寫慣用的Go代碼。如果你想了解更多關于他們的內容,可以看看我們的免費電子書。
如果你想在反模式倉庫中添加更多示例以及有關主題,請在評論中告知我們。