Go Web 應用中常見的反模式

kevin發表於2021-08-14

作者:Miłosz Smółka
譯者:Kevin
原文地址:https://threedots.tech/post/common-anti-patterns-in-go-web-applications/
譯文地址:https://www.4async.com/2021/08/common-anti-patterns-in-go-web-applications/

在我職業生涯的某個階段,我對我所構建的軟體不再感到興奮。

我最喜歡的工作內容是底層的細節和複雜的演算法。在轉到面向使用者的應用開發之後,這些內容基本消失了。程式設計似乎是利用現有的庫和工具把資料從一處移至另一處。到目前為止,我所學到的關於軟體的知識不再那麼有用了。

讓我們面對現實吧:大多數 Web 應用無法解決棘手的技術挑戰。他們需要做到的是正確的對產品進行建模,並且比競爭對手更快的改進產品。

這起初看起來似乎是那麼的無聊,但是你很快會意識到實現這個目標比聽起來要難。這是一項完全不同的挑戰。即使它們技術上實現並沒有那麼複雜,但時解決它們會對產品產生巨大影響並且讓人獲得滿足。

Web 應用面臨的最大挑戰不是變成了一個無法維護的屎山,而是會減慢你的速度,讓你的業務最終失敗。

這是他們如何在 Go 中發生和我是如何避免他們的。

鬆耦合是關鍵

應用難以維護的一個重要原因是強耦合。

在強耦合應用中,任何你嘗試觸動的東西都有可能產生意想不到的副作用。每次重構的嘗試都會發現新的問題。最終,你決定字號從頭重寫整個專案。在一個快速增長的產品中,你是不可能凍結所有的開發任務去完成重寫已經構建的應用的。而且你不能保證這次你把所有事都完成好。

相比之下,鬆耦合應用保持了清晰的邊界。他們允許更換一些損壞的部分不影響專案的其他部分。它們更容易構建和維護。但是,為什麼他們如此罕見呢?

微服務許諾了鬆耦合時實踐,但是我們現在已經過了他們的炒作年代,而難以維護的應用仍舊存在。有些時候這反而變得更糟糕了:我們落入了分散式單體的陷阱,處理和之前相同的問題,而且還增加了網路開銷。

從強耦合單體應用到分散式單體

❌ 反模式:分散式單體
在你瞭解邊界之前,不要將你的應用切分成為微服務。

微服務並不會降低耦合,因為拆分服務的次數並不重要。重要的是如何連線各個服務

從模組化單體應用到鬆耦合微服務

✅ 策略:鬆耦合
以實現鬆耦合的模組為目標。如何部署它們(作為模組化單體應用或微服務)是一個實現細節。

DRY 引入了耦合

強耦合十分常見,因為我們很早就學到了不要重複自己 (Don't Repeat Yourself, DRY) 原則。

簡短的規則很容易被大家記住,但是簡短的三個單詞很難概括所有的細節。《程式設計師修煉之道: 從小工到專家》這本書提供了一個更長的版本:

每條知識在系統中都必須有一個單一的、明確的、權威的表述。

"每一條知識"這個說法相當極端。大多數程式設計困境的答案是看情況而定,DRY 也不例外。

當你讓兩個事物使用相同抽象的時候,你就引入了耦合。如果你嚴格遵循 DRY 原則,你就需要在這個抽象之前增加抽象。

在 Go 中保持 DRY

相比於其他現代語言,Go 是清晰的,缺少很多特性,沒有太多的語法糖來隱藏複雜性。

我們習慣了捷徑,所以一開始很難接受 Go 的冗長。就像我們已經開發出一種去尋找一種更加聰明的編寫程式碼的方式的本能。

最典型的例子就是錯誤處理。如果你有編寫 Go 的經驗,你會覺得下面的程式碼片段很自然

if err != nil {
    return err
}

但是對新手而言,一遍又一遍的重複這三行就是似乎在破壞 DRY 原則。他們經常想辦法來規避這種樣板方法,但是卻沒有什麼好的結果。

最終,大家都接受了 Go 的工作方式。它讓你重複你自己,不過這並不是 DRY 告訴你的你要避免重複。

單一資料模型帶來的應用耦合

Go 中有一個特性引入了強耦合,但會讓你認為你自己在遵循 DRY 原則。這就是在一個結構體中使用多個標籤。 這似乎是一個好主意,因為我們經常對不同的事物使用相似的模型。

這裡有一個流行的方式儲存單個User模型的方法:

type User struct {
    ID           int        `json:"id" gorm:"autoIncrement primaryKey"`
    FirstName    string     `json:"first_name" validate:"required_without=LastName"`
    LastName     string     `json:"last_name" validate:"required_without=FirstName"`
    DisplayName  string     `json:"display_name"`
    Email        string     `json:"email,omitempty" gorm:"-"`
    Emails       []Email    `json:"emails" validate:"required,dive" gorm:"constraint:OnDelete:CASCADE"`
    PasswordHash string     `json:"-"`
    LastIP       string     `json:"-"`
    CreatedAt    *time.Time `json:"-"`
    UpdatedAt    *time.Time `json:"-"`
}

type Email struct {
    ID      int    `json:"-" gorm:"primaryKey"`
    Address string `json:"address" validate:"required,email" gorm:"size:256;uniqueIndex"`
    Primary bool   `json:"primary"`
    UserID  int    `json:"-"`
}

完整程式碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/01-tightly-coupled/internal/user.go

這種方式通過很少的幾行程式碼讓你可以只維護單一的結構體實現功能。

然而,在單一模型中擬合所有的內容需要很多技巧。API 可能不需要保護某些欄位,因此他們需要通過json:"-"隱藏起來。只有一個 API 使用到了Email欄位,那麼 ORM 就需要跳過它,並且需要在常規的 JSON 返回中通過omitempty進行隱藏。

更重要的是,這個解決方案帶來一個最糟糕的問題:API、儲存和邏輯之間產生了強耦合。

當你想要更新結構體中的任何東西時,你都不知道還有什麼會發生修改。你會在更新資料庫 Schema 或者更新驗證規則時破壞 API 的約定。

模型越複雜,你面臨的問題就越多。

比如,json標籤表示 JSON 而不是 HTTP。但是讓你引入同樣是格式化到 JSON,但是格式與 API 不同的事件時會發生什麼?你需要不停的新增 hack 讓所有功能正常工作。

最終,你的團隊會避免對結構體的修改,因為在你動了結構體之後你無法確定會出現什麼樣的問題。

❌ 反模式:單一模型
不要給一個模型多個責任。
每個結構欄位不要使用多個標籤。

複製消除耦合

減少耦合最簡單的方法是拆分模型。

我們提取 API 使用的部分作為 HTTP 模型:

type CreateUserRequest struct {
    FirstName string `json:"first_name" validate:"required_without=LastName"`
    LastName  string `json:"last_name" validate:"required_without=FirstName"`
    Email     string `json:"email" validate:"required,email"`
}

type UpdateUserRequest struct {
    FirstName *string `json:"first_name" validate:"required_without=LastName"`
    LastName  *string `json:"last_name" validate:"required_without=FirstName"`
}

type UserResponse struct {
    ID          int             `json:"id"`
    FirstName   string          `json:"first_name"`
    LastName    string          `json:"last_name"`
    DisplayName string          `json:"display_name"`
    Emails      []EmailResponse `json:"emails"`
}

type EmailResponse struct {
    Address string `json:"address"`
    Primary bool   `json:"primary"`
}

完整程式碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/02-loosely-coupled/internal/http.go

資料庫相關部分作為儲存模型:

type UserDBModel struct {
    ID           int            `gorm:"column:id;primaryKey"`
    FirstName    string         `gorm:"column:first_name"`
    LastName     string         `gorm:"column:last_name"`
    Emails       []EmailDBModel `gorm:"foreignKey:UserID;constraint:OnDelete:CASCADE"`
    PasswordHash string         `gorm:"column:password_hash"`
    LastIP       string         `gorm:"column:last_ip"`
    CreatedAt    *time.Time     `gorm:"column:created_at"`
    UpdatedAt    *time.Time     `gorm:"column:updated_at"`
}

type EmailDBModel struct {
    ID      int    `gorm:"column:id;primaryKey"`
    Address string `gorm:"column:address;size:256;uniqueIndex"`
    Primary bool   `gorm:"column:primary"`
    UserID  int    `gorm:"column:user_id"`
}

完整程式碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/02-loosely-coupled/internal/db.go

起初,看上去我們會在所有地方使用相同的User模型。現在,很明顯我們過早的避免了重複。API 和儲存的結構很相似,但足夠不同到需要拆分成不同的模型。

在 Web 應用中,你 API 返回(讀模型)與儲存在資料庫中的檢視(寫模型)並不相同

儲存程式碼無需知道 HTTP 的模型,因此我們需要進行結構轉換。

func userResponseFromDBModel(u UserDBModel) UserResponse {
    var emails []EmailResponse
    for _, e := range u.Emails {
        emails = append(emails, emailResponseFromDBModel(e))
    }

    return UserResponse{
        ID:          u.ID,
        FirstName:   u.FirstName,
        LastName:    u.LastName,
        DisplayName: displayName(u.FirstName, u.LastName),
        Emails:      emails,
    }
}

func emailResponseFromDBModel(e EmailDBModel) EmailResponse {
    return EmailResponse{
        Address: e.Address,
        Primary: e.Primary,
    }
}

func userDBModelFromCreateRequest(r CreateUserRequest) UserDBModel {
    return UserDBModel{
        FirstName: r.FirstName,
        LastName:  r.LastName,
        Emails: []EmailDBModel{
            {
                Address: r.Email,
            },
        },
    }
}

完整程式碼: github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/02-loosely-coupled/internal/http.go

這就是所有你需要的程式碼:將一種型別對映到另一種型別的函式。編寫這種平淡無奇的程式碼可能看起來十分無聊,但是它對解耦至關重要。

建立一個使用序列化或者reflect實現用於對映結構體的通用解決方案看上去十分誘人。請抵制它。編寫模版比除錯對映的邊緣情況會更節省時間和精力。 簡單的函式對團隊中每個人都更容易理解。魔法轉換器會在一段時間後變得難以理解,即使對你而言也是如此。

✅ 策略:模型單一責任。
通過使用單獨的模型來實現鬆耦合。編寫簡單明瞭的函式用以在它們之間進行轉換。

如果你害怕太多的重複,請考慮一下最壞的情況。如果你最終多了幾個隨著應用程式增長不變的結構,你可以將它們合併回一個。與強耦合程式碼相比,修復重複程式碼是微不足道的。

生成模版

如果你擔心手寫這些所有程式碼,有一個管用的方法可以規避。使用可以為你生成模版的庫

你可以生成諸如:

生成的程式碼可以提供強型別保護,因此你無需在通用函式中傳遞interface{}型別的資料。你可以保證編譯時檢查的同時無需手寫程式碼。

下面是生成的模型的例子。

// PostUserRequest defines model for PostUserRequest.
type PostUserRequest struct {

    // E-mail
    Email string `json:"email"`

    // First name
    FirstName string `json:"first_name"`

    // Last name
    LastName string `json:"last_name"`
}

// UserResponse defines model for UserResponse.
type UserResponse struct {
    DisplayName string          `json:"display_name"`
    Emails      []EmailResponse `json:"emails"`
    FirstName   string          `json:"first_name"`
    Id          int             `json:"id"`
    LastName    string          `json:"last_name"`
}

完整程式碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/03-loosely-coupled-generated/internal/http_types.go

type User struct {
    ID           int64       `boil:"id" json:"id" toml:"id" yaml:"id"`
    FirstName    string      `boil:"first_name" json:"first_name" toml:"first_name" yaml:"first_name"`
    LastName     string      `boil:"last_name" json:"last_name" toml:"last_name" yaml:"last_name"`
    PasswordHash null.String `boil:"password_hash" json:"password_hash,omitempty" toml:"password_hash" yaml:"password_hash,omitempty"`
    LastIP       null.String `boil:"last_ip" json:"last_ip,omitempty" toml:"last_ip" yaml:"last_ip,omitempty"`
    CreatedAt    null.Time   `boil:"created_at" json:"created_at,omitempty" toml:"created_at" yaml:"created_at,omitempty"`
    UpdatedAt    null.Time   `boil:"updated_at" json:"updated_at,omitempty" toml:"updated_at" yaml:"updated_at,omitempty"`

    R *userR `boil:"-" json:"-" toml:"-" yaml:"-"`
    L userL  `boil:"-" json:"-" toml:"-" yaml:"-"`
}

完整程式碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/03-loosely-coupled-generated/models/users.go

有時你可能會想要編寫程式碼生成工具。這其實並不難,結果需要是每個人都可以閱讀和理解的常規 Go 程式碼。常見的替代方案是使用reflect,但是這很難掌握和除錯。當然,首先要考慮的是付出的努力是否值得。在大多數情況下,手寫程式碼已經足夠快了。

✅ 策略:生成重複工作的部分
生成的程式碼為你提供強型別和編譯時安全性。選擇它而不是reflect

不要過度使用庫

只將生成的程式碼用於它應該做的事情。如果你想避免手工編寫模版,但仍需要保留一些專用的模型。不要以單一模型反模式作為結束

當你想遵循 DRY 原則時,很容易落入這個陷阱。

例如,sqlcsqlboiler都是從 SQL 查詢中生成程式碼。sqlc 允許在生成的模型上新增 JSON 標籤,甚至允許讓你選擇camelCase還是snake_case。sqlboiler 在所有模型上預設新增了jsontomlyaml標籤。這顯然是不是讓使用者僅僅把這個模型僅用於儲存。

看一下 sqlc 的 issue 列表,我發現很多開發者要求更多的靈活性,比如重新命名生成的欄位整個跳過一些 JSON 欄位。有人甚至提到他們需要某種在 REST API 中隱藏某些敏感欄位的方法

所有這些都是鼓勵在單一模型中擔負更多職責。它可以讓你寫更少的程式碼,但是請務必考慮這種耦合是否值得。

同樣,需要注意結構體標籤中隱藏的魔法,比如,gorm 中提供的許可權功能:

type User struct {
    Name string `gorm:"<-:create"` // 允許讀取和建立
    Name string `gorm:"<-:update"` // 允許讀取和更新
    Name string `gorm:"<-"`        // 允許讀取和寫入(建立和更新)
    Name string `gorm:"<-:false"`  // 允許讀取,禁用寫許可權
    Name string `gorm:"->"`        // 只讀模式(除非單獨配置,否則禁用寫許可權)
    Name string `gorm:"->;<-:create"` // 允許讀取和建立
    Name string `gorm:"->:false;<-:create"` // 只允許建立(禁止從資料庫中讀取)
    Name string `gorm:"-"`  // 在讀寫模型時忽略這個欄位
}

完整程式碼:gorm.io/docs/models.html#Field-Level-Permission

你同樣可以使用 [validator] 庫進行復雜的比較,比如參考其他欄位:

type User {
    FirstName    string `validate:"required_without=LastName"`
    LastName     string `validate:"required_without=FirstName"`
}

它為你節省了一點編寫程式碼的時間,但是這意味著你放棄了編譯期檢查。在結構體標籤中很容易出現錯別字,在驗證和許可權等敏感地方使用這種會帶來風險。這同樣也會讓很多不那麼熟悉庫的語法糖的人感到困擾。

我並不是指摘這些提到的庫,他們都有自己的用途。但是這些示例展示了我們如何把 DRY 做到極致,這樣我們就不用編寫更多的程式碼了。

❌ 反模式:選擇魔法來節省編寫程式碼的時間
不要過度使用庫以避免冗餘。

避免隱式標籤名

大多數庫不要求標籤必須存在,此時會預設使用欄位名稱。

在重構專案時,有人可能會重新命名欄位,但是他沒有想過編輯 API 返回或者資料模型。如果沒有標籤,這就會導致 API 約定或者資料儲存過程被破壞。

請始終填寫所有標間,即使你必須相容同一名稱兩次,這並不違反 DRY 原則。

譯者注:其實 Go 之前有個類似 proposal 提過在 1.16 中簡化這一寫法,但是後面發現存在一些問題被回滾了。

❌反模式:省略結構標籤
如果庫使用它們,則不要跳過結構標籤。

type Email struct {
    ID      int    `gorm:"primaryKey"`
    Address string `gorm:"size:256;uniqueIndex"`
    Primary bool
    UserID  int
}

✅戰術:顯式結構標籤
始終填充結構標籤,即使欄位名稱相同。

type Email struct {
    ID      int    `gorm:"column:id;primaryKey"`
    Address string `gorm:"column:address;size:256;uniqueIndex"`
    Primary bool   `gorm:"column:primary"`
    UserID  int    `gorm:"column:user_id"`
}

將邏輯與實現細節分開

通過生成模型將 API 與儲存解耦是一個好的開始。但是,我們仍舊需要保留在 HTTP 處理中的驗證過程。

type createRequest struct {
    Email     string `validate:"required,email"`
    FirstName string `validate:"required_without=LastName"`
    LastName  string `validate:"required_without=FirstName"`
}

validate := validator.New()
err = validate.Struct(createRequest(postUserRequest))
if err != nil {
    log.Println(err)
    w.WriteHeader(http.StatusBadRequest)
    return
}

完整程式碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/03-loosely-coupled-generated/internal/http.go

驗證是你能在大多數 Web 應用中可以找到的業務邏輯中的一環。通常,他們會更加複雜,比如:

  • 僅在特定情況下顯示欄位
  • 檢查許可權
  • 取決於角色而隱藏欄位
  • 計算價格
  • 根據幾個因素採取行動

將邏輯和實現細節混在一起(比如將他們放在 HTTP handler 中)是一種快速交付 MVP 的方法。但是這也引入了最壞的技術債務。這就是為什麼你會被供應商鎖定,為什麼你需要不停的新增 hack 拉支援新功能。

❌ 反模式:將邏輯和細節混在一起
不要將你的應用程式邏輯與實現細節混在一起。

商業邏輯需要單獨的層。更改實現(資料庫引擎、HTTP 庫、基礎架構、Pub/Sub 等)應是可能的,而無需對邏輯部件進行任何更改。

你做這種分離並不是因為你想要更改資料庫引擎,這種情況很少會發生。但是,關注點的分離可以讓你的程式碼更容易理解和修改。你知道你在修改什麼,並且有沒有副作用。 這樣就很難在關鍵部分引入 bug。

要分離應用層,我們需要新增額外的模型和對映。

type User struct {
    id        int
    firstName string
    lastName  string
    emails    []Emailf
}

func NewUser(firstName string, lastName string, emailAddress string) (User, error) {
    if firstName == "" && lastName == "" {
        return User{}, ErrNameRequired
    }

    email, err := NewEmail(emailAddress, true)
    if err != nil {
        return User{}, err
    }

    return User{
        firstName: firstName,
        lastName:  lastName,
        emails:    []Email{email},
    }, nil
}

type Email struct {
    address string
    primary bool
}

func NewEmail(address string, primary bool) (Email, error) {
    if address == "" {
        return Email{}, ErrEmailRequired
    }

    // A naive validation to make the example short, but you get the idea
    if !strings.Contains(address, "@") {
        return Email{}, ErrInvalidEmail
    }

    return Email{
        address: address,
        primary: primary,
    }, nil
}

完整程式碼:github.com/ThreeDotsLabs/go-web-app-antipatterns/01-coupling/04-loosely-coupled-app-layer/internal/user.go

這就是當我需要更新業務邏輯時我需要修改的程式碼。這明顯很無聊,但是我知道我修改了什麼

當我們新增另一個 API(比如 gRPC)或者外部系統(如 Pub/Sub)時,我們需要同樣的工作。每個部分都是用單獨的模型,我們在應用層對映轉換它們。

因為應用層維護了所有的驗證和其他商業邏輯,他會讓我們無論是使用 HTTP 還是 gRPC API 都沒什麼區別。API 只是應用的入口。

✅ 策略:應用層
將產品最重要的程式碼劃分成單獨的層。

上面的程式碼片段都來自於同一個程式碼庫,並且實現了經典的使用者域。所有示例都暴露相同的 API 並且使用相同的測試套件。

以下是他們的比較:

強耦合 鬆耦合 基於程式碼生成的鬆耦合 鬆耦合的應用層
耦合
模版 手動 手動 生成 生成
程式碼行數 292 345 298 408
生成的程式碼 0 0 2154 2154

標準的 Go 專案結構

如果你看過這個倉庫,你會發現在每個例子中只有一個包。

Go 目前沒有官方的目錄組織結構,不過你可以找到很多微服務例子或者 REST 模版倉庫建議你如何拆分。他們通常有精心設計的目錄機構,有人甚至提到他們遵循了簡潔架構或者六邊形架構。

我一般第一件確認的事情是如何儲存模型的。大多數情況下,他們使用了 JSON 和資料庫標籤混合的結構體。

這是一種錯覺:包看起來進行了很好的切分,但實際上他們仍舊通過一個模型被緊密的耦合在了一起。新人用來學習的很多流行例子中,這些問題也很常見。

具有諷刺意味的是,標準的 Go 專案結構仍舊在社群中繼續被討論,然而模型耦合反模式卻很常見。如果你的應用程式的型別耦合了,任何目錄的組織形式都不會改變什麼

在檢視示例結構時,請記住他們可能是為另外一種不同型別的應用程式設計的。對於開源的基礎設施工具、Web 應用後端和標準庫而言,沒有一種方法同時對他們有效。

包分層和切分微服務的問題非常類似。重要的不是如何劃分他們,而是他們彼此之間如何連線。

當你專注於鬆耦合時,目錄結構就會變得更加清晰。你可以將實現細節與業務邏輯區分開。你把相互引用的事物分組,並將不互相引用的事物拆分開。

在我準備的示例中,我可以輕鬆的將 HTTP 相關的程式碼和資料庫相關的程式碼拆分至單獨的包中。這會避免名稱空間的汙染。模型之間已經沒有耦合,所以這些操作就變成了具體的細節。

❌反模式:過度考慮目錄結構
不要通過分割目錄來啟動專案。不管你怎麼做,這是一個慣例。
你不太可能在編寫程式碼之前把事情做好。

✅策略:鬆耦合程式碼
重要的部分不是目錄結構,而是包和結構是如何進行相互引用的。

保持簡單化

假設你想要建立一個使用者,這個使用者有一個 ID 欄位。最簡單的方法可以看起來像這樣:

type User struct {
    ID string `validate:"required,len=32"`
}

func (u User) Validate() error {
    return validate.Struct(u)
}

這段程式碼能夠正常工作。但是,你無法判斷該結構在任何時候都是正確的。你依靠一些額外東西來呼叫驗證並處理錯誤。

另一種方法是採用良好的舊式封裝。

type User struct {
    id UserID
}

type UserID struct {
    id string
}

func NewUserID(id string) (UserID, error) {
    if id == "" {
        return UserID{}, ErrEmptyID
    }

    if len(id) != 32 {
        return UserID{}, ErrInvalidLength
    }

    return UserID{
        id: id,
    }, nil
}

func (u UserID) String() string {
    return u.id
}

此片段更清晰、更冗長。如果你建立了一個新的UserID並且沒有收到任何錯誤,你可以確定建立是成功的。此外,你可以輕鬆地將錯誤對映到 API 的正確響應。

無論你選擇哪種方法,你都需要對使用者 ID 的基本複雜性進行建模。從純粹的實現的角度來看,將 ID 保持在字串中是最簡單的解決方案。

Go 應該很簡單,但這並不意味著你應該只使用原始型別。對於複雜的行為,請使用反映產品工作方式的程式碼。否則,你最終會獲得一個簡化的模型。

❌反模式:過度簡化
不要用瑣碎的程式碼來模擬複雜的行為。

✅策略:編寫明確的程式碼
保證程式碼是明確的,即使它很冗長。
使用封裝來確保你的結構始終處於有效狀態。

即使所有欄位都未匯出,也可以在包外建立空結構。唯一要做的是在接受UserID作為引數時,你需要檢查一下合法性。
你可以使用if id == UserID{}或編寫專門的IsZero()方法來進行。

從資料庫 Schema 開始

假設我們需要新增一個使用者建立和加入團隊的功能。

按照關係型方法,我們需要新增一個teams表和另外一個將使用者和它進行關聯的表。我們叫它membership

按照關係方法,我們將新增一張桌子和另一張加入它的表格。讓我們稱之為。teamsusersmembership

我們已經有了UserStorage,所以很自然的新增兩個新的結構體:TeamStorageMembershipStorage。他們會為每個表格提供 CRUD 方法。

新增新團隊的程式碼可能看起來是這個樣子的:

func CreateTeam(teamName string, ownerID int) error {
    teamID, err := teamStorage.Create(teamName)
    if err != nil {
        return err
    }

    return membershipStorage.Create(teamID, ownerID, MemberRoleOwner)
}

這種方法有一個問題:我們沒有在事務中建立團隊和成員記錄。如果出現問題,我們可能最終擁有一支沒有分配所有者的團隊。

首先想到的第一個解決方案是在方法之間傳遞事務。

func CreateTeam(teamName string, ownerID int) error {
    tx, err := db.Begin()
    if err != nil {
        return err
    }

    defer func() {
        if err == nil {
            err = tx.Commit()
        } else {
            rollbackErr := tx.Rollback()
            if rollbackErr != nil {
                log.Error("Rollback failed:", err)
            }
        }
    }()

    teamID, err := teamStorage.Create(tx, teamName)
    if err != nil {
        return err
    }

    return membershipStorage.Create(tx, teamID, ownerID, MemberRoleOwner)
}

但是,這樣的話實現細節(事務處理)就會洩漏到了邏輯層。它通過基於defer的錯誤處理汙染了一個可讀的函式。

下面是一個練習:考慮如何在文件資料庫中對此進行建模。比如,我們可以將所有成員保留在團隊文件中。

在這種情況下,新增成員就可以在TeamStorage中完成,這樣我們就不需要單獨的MembershipStorage。但是切換資料庫就變更了我們模型的假設,這不是很奇怪嗎?

現在很顯然,我們通過引入"成員身份"概念洩露了實現細節。"建立新成員身份",這隻會困擾我們的銷售或者客戶服務同事。當你開始說一種不同於公司其他成員的語言時,這通常一個嚴重的危險訊號

反模式❌:從資料庫 Schema 開始
不要將模型建立在資料庫模式的基礎上。你最終會暴露實現細節。

TeamStorage用於儲存團隊資訊,但它不是與teams SQL 表無關。這是關於我們產品的團隊概念。

每個人都明白建立一個團隊需要一個所有者,我們可以為此暴露一個方法。這個方法會將所有的查詢放在一個事務中執行查詢。

teamStorage.Create(teamName, ownerID, MemberRoleOwner)

同樣,我們也可以有一個加入團隊的方法。

teamStorage.JoinTeam(teamID, memberID, MemberRoleGuest)

membership表依舊存在,但是實現細節被隱藏在TeamStorage中。

✅策略:從領域開始
你的儲存方法應遵循產品的行為。不要他們的事務細節。

你的網路應用程式不是單純的 CRUD

教程通常都是以"簡單的 CRUD"為特色,因此它們似乎是任何 Web 應用的基礎構建模組。這是個虛無縹緲的傳說。如果你所有的產品需要的是 CRUD,你就是在浪費時間和金錢從零開始構建。

框架和無程式碼工具使得啟動 CRUD 變得更容易,但我們仍然向開發人員支付構建自定義軟體的費用。即使是 GitHub Copilot 也不知道你的產品除了模版之外是如何工作的。

正是特殊的規則和奇怪的細節使你的應用程式與眾不同。這不是你分散在四個 CRUD 操作之上的邏輯。 它是你銷售的產品的核心。

在 MVP 階段,從 CRUD 開始快速構建可工作版本是很誘人的。但這就像使用電子表格而不是專用軟體。一開始,你會獲得類似的效果,但每個新功能都需要更多的 hack。

反模式❌:從 CRUD 開始
不要圍繞四個 CRUD 操作的想法來設計你的應用程式。

✅策略:瞭解你的領域
花時間瞭解你的產品是如何工作的,並在程式碼中建模。

我描述的許多策略都是眾所周知的模式背後的想法:

  • SOLID 中的單一責任原則(每個模型只有一項責任)。
  • 簡潔架構(鬆耦合的包,將邏輯與實現細節隔離)。
  • CQRS(使用不同的讀取模型和寫入模型)。

有些甚至接近域驅動設計:

  • 值物件(始終保持結構處於有效狀態)。
  • 聚合和倉庫(無論資料庫表的數量如何,都以事務方式儲存領域物件)。
  • 無處不在的語言(使用每個人都能理解的語言)。

這些模式似乎大多與企業級應用相關。但其中大多數是簡單明瞭的核心思想,比如本文中的策略。它們同樣適用於處理複雜的業務行為 Web 應用程式、

你不需要閱讀大量書籍或複製其他語言的程式碼來遵循這些模式。你可以通過實踐檢驗的技術編寫慣用的 Go 程式碼。如果你想了解更多關於他們的內容,可以看看我們的免費電子書

如果你想在反模式倉庫中新增更多示例以及有關主題,請在評論中告知我們。

更多原創文章乾貨分享,請關注公眾號
  • Go Web 應用中常見的反模式
  • 加微信實戰群請加微信(註明:實戰群):gocnio

相關文章