Go 邏輯分支優化實戰

TL;DR

  1. 通過函數式編程和 map 結構可以優化複雜的 if-else 邏輯

  2. 提前返回可以避免不必要的資源消耗,防止全表掃描等問題

  3. 避免在循環中使用 defer 語句,可能導致資源泄露

  4. 使用 for 循環對相同邏輯進行壓縮,提高代碼的簡潔性和可擴展性


代碼的世界,將自然語言的需求轉換成執行的邏輯。如何轉化成代碼之後仍然儘可能接近自然語言的可理解性,是我們一直探求的內容。

代碼接近自然語言的可理解性,相應的可維護性就會提高,因爲維護的人更能直觀的瞭解代碼的意圖,知道了代碼的意圖,修改的過程中引入 BUG 的概率就會更低。

我們將通過一個實際的交易計算案例,探討如何通過合理的排序方法優化數據操作,並實現靈活的手續費計算邏輯,同時結合業務中常見的數據庫操作和編程模式,來提高我們代碼可維護性。

交易業務實現案例

下面我們來看一個交易計算手續費的實際案例。

交易的手續費會隨着筆數增加而變化

1-5 筆 是 20 元

6-20 筆 是 10 元

21 筆 是 1 元

比如交易了 6 筆,則最終的手續費是 5*20 + 10 = 110 元。

張三、李四、王五分別交易了下面的次數,並且有最後一次交易的日期。

張三交易了 4 筆,最後一次交易的日期爲 2024.2.24

李四交易了 10 筆,最後一次交易的日期爲 2024.3.1

王五交易了 50 筆,最後一次交易的日期爲 2024.9.21

實現以下兩個問題:

  1. 根據傳入不通的排序關鍵字進行不同的排序,輸出排序後的名字結果。

  2. 根據交易的筆數計算出每個人的手續費

排序的實現

首先我們先通過直譯的方式實現排序的代碼。

User 結構體存儲了名字、交易筆數和最後交易時間。

type User struct {
 Name           string
 TransactionNum int
 LastTransactionDate time.Time
}

實現 Users 的排序方法,根據傳入的排序列不同進行排序。

func SortUsers(users []User, sortBy string) []User {
 switch sortBy {
 case "name":
  sort.Slice(users, func(i, j int) bool {
   return users[i].Name < users[j].Name
  })
 case "transactionNum":
  sort.Slice(users, func(i, j int) bool {
   return users[i].TransactionNum < users[j].TransactionNum
  })
 case "lastTransactionDate":
  sort.Slice(users, func(i, j int) bool {
   return users[i].LastTransactionDate.Before(users[j].LastTransactionDate)
  })
 }
 return users
}

通過 main 來進行調用

func main() {
 // 初始化用戶數據
 users := []User{...}

 // 按交易筆數排序
 sortedUsers := sortUsers(users, "transactionNum")
}

直譯後我們發現每次增加一種排序規則,都需要增加一個 case

case "sortKey":
 sort.Slice(users, func(i, j int) bool {
  ...
 })
}

我們可以利用函數是第一公民的特性,將排序方法抽取成公共的 map 變量。

然後 key 是排序的關鍵字, value 則是比較邏輯

type SortKey string

const (
 Name                SortKey = "name"
 TransactionNum      SortKey = "transactionNum"
 LastTransactionDate SortKey = "lastTransactionDate"
)

type UserSortFunc func(userA, userB User) bool

func sortByName(userA, userB User) bool {
 return userA.Name < userB.Name
}

func sortByTransactionNum(userA, userB User) bool {
 return userA.TransactionNum < userB.TransactionNum
}

func sortByLastTransactionDate(userA, userB User) bool {
 return userA.LastTransactionDate.Before(userB.LastTransactionDate)
}

// 排序函數映射
var sortFunctions = map[SortKey]UserSortFunc{
 Name:                sortByName,
 TransactionNum:      sortByTransactionNum,
 LastTransactionDate: sortByLastTransactionDate,
}

type User struct {
 Name                string
 TransactionNum      int
 LastTransactionDate time.Time
}

這裏不僅僅抽出了 map ,也將原來字符串字面量定義成常量,避免外部傳入的時候傳入一個錯誤的 key

排序函數則可以修改成:

func SortUsers(users []User, sortBy SortKey) []User {
 sortFunc, ok := sortFunctions[sortBy]
 // 這裏可以根據自己的邏輯補充
 // 比如沒有排序規則則返回原切片或者是使用默認排序均可
 if !ok {
  //...
 }

 sort.Slice(users, sortFunc(users))
 return users
}

這樣我們每次需要增加新的排序規則,只需要增加 SortKey 的類型和定義方法即可。

由於已經用 SortKey 做了類型限制,所以外部調用方傳入錯誤的 key 也會在編譯時就報錯。

這裏無論如何外部都需要傳入一個 SortKey ,我們再簡化一層,sortFunctions 去掉,直接傳入 UserSortFunc 方法

func **SortUsers**(users []User, sortFunc UserSortFunc) {
 sort.Slice(users, func(i, j int) bool {
  return sortFunc(users[i], users[j])
 })
}

再進一步, SortUsers 實際上是一個模板,無論傳入 []User 還是 []Order 都能夠使用,唯一不同的是比較字段的區別,那隻需要實現比較字段的方法。

這樣做之後,我們可以在 SortItems 裏面做一些通用規則:

type Comparator[T any] func(a, b T) bool

func SortItems[T any](items []T, compare Comparator[T]) {
 startMemory := memoryUsage() // 記錄開始時的內存使用
 start := time.Now()

 sort.Slice(items, func(i, j int) bool {
  return compare(items[i], items[j])
 })

 elapsed := time.Since(start)
 endMemory := memoryUsage() // 記錄結束時的內存使用

 // 打印排序耗時和內存使用變化
 fmt.Printf("Sorting took %s, Memory increased by %d bytes\n", elapsed, endMemory-startMemory)
}

手續費的計算

根據交易筆數計算手續費

func calculateFee(transactionNum int) int {
 if transactionNum <= 5 {
  return transactionNum * 20
 } else if transactionNum <= 20 {
  return 5*20 + (transactionNum-5)*10
 } else {
  return 5*20 + 15*10 + (transactionNum-20)*1
 }
}

通過 main 函數使用

func main() {
 users := []User{...}

 // 計算每個人的手續費
 for _, user := range users {
  fee := calculateFee(user.TransactionNum)
 }
}

我們進行區間轉化,不同區間對應不同的費用,來實現不同邏輯,去除複雜的 if-else 分支

// 定義手續費區間及其對應規則
var feeBrackets = []struct {
 MaxTransactions    int
 FeePerTransaction  int
}{
 {5, 20},
 {15, 10},
 {1<<31 - 1, 1}, // 無上限的最大值,交易筆數 > 20時的手續費
}

func min(a, b int) int {
 if a < b {
  return a
 }
 return b
}

// 根據交易筆數計算手續費
func calculateFee(transactionNum int) (totalFee int) {
 for _, bracket := range feeBrackets {
  // 獲取區間交易筆數
  transactions := min(transactionNum, bracket.MaxTransactions)
  totalFee += transactions * bracket.FeePerTransaction
  
  // 已經計算的交易筆數可以減掉,等到全部計算完成則返回結果
  transactionNum -= transactions
  if transactionNum <= 0 {
   break
  }
 }
 return totalFee
}

這樣做不僅讓代碼看起來更加的簡潔,而且在後續的擴展中,我們如果需要增加區間,只需要在 feeBrackets 中增加規則即可。

從上面的代碼可以看已經相對來說比較簡潔易懂,但是不知道大家是否有遇到這樣一個場景,我們剛接手一份代碼的時候,並不熟悉內部有多少的工具方法和字段,當需求要求要在另外一個模塊裏面也獲取用戶的交易費用展示給用戶時,我們的第一反應是通過 user. 的方式看用戶上是否有對應的字段。

這個時候我們因爲計算費用的方法是額外寫的,要獲取用戶的交易費用,只能夠通過 calculateFee(user.TransactionNum) 來獲取,這對於第一次接觸代碼的人來說其實比較難反應過來。

如果我們一個代碼文件十分龐大,並且熟悉代碼的人已經離職,我們往往沒有耐心去翻找看是否有方法進行計算,而是通過實現一個新的方法來完成需求,這就重複造了已有的輪子。

根據以上的場景,我們還可以通過 OOP 做進一步的優化。

calculateFee 是計算用戶交易的費用,如果沒發生交易,那麼對應的交易手續費爲 0,我們可以將這個費用作用用戶的額外屬性。

通過 GetTransactionFee 來獲取,這種方式也讓開發者在需要用戶信息字段的時候,直接通過 IDE 的提示就可以找到並賦值,而不需要進入進入包內進行翻找。

// User 類型的方法,計算該用戶的手續費
func (u *User) GetTransactionFee() int {
 return calculateFee(u.TransactionNum)
}

func main() {
 // 計算每個人的手續費
 for _, user := range users {
  fee := user.GetTransactionFee()
 }
}

數據庫操作

在開發中我們最常打交道的就是數據庫,應用層的代碼優化能很大程度上避免在數據庫層出現問題

提前返回

我們找到那些會導致代碼會快速失敗的邏輯分支,進行提前返回。

比如我們在接收到一個請求的時候,如果傳入的參數都不合法,那麼沒有必要繼續往下去執行邏輯,直接返回錯誤即可。

這樣做也能資源消耗,甚至有時候可以避免全表掃描。

比如我們會進行 SQL 邏輯拼接,如果只有一個 WHERE 條件

SELECT *  FROM user WHERE user_id = ?;

有可能因爲 user_id 未傳入,導致執行的語句變爲

SELECT * FROM user;

對於一個線上壓力比較大的庫來說,這種掃描無疑會造成服務抖動,嚴重的時候可能會導致數據庫無法處理其他正常的請求,無法正常提供服務。

資源泄露

defer 語句不要在循環中使用,defer 語句會在函數返回的時候才執行

for i := 0; i < 1000; i++ {
    db, err := sql.Open("mysql", dataSource)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()  // 連接會等到整個函數退出後才關閉
    // 執行數據庫操作
}

這樣執行的話需要等到循環處理完,纔會執行 defer 裏面的內容,有可能會造成內存泄漏。

我們可以將邏輯抽離成函數,然後再每執行完一次循環後就執行 defer 語句回收資源

func query() {
    db, err := sql.Open("mysql", dataSource)
    if err != nil {
        return err
    }
    defer db.Close() 

    // 執行數據庫操作
}

// ...
for i := 0; i < 1000; i++ {
    query(dataSource)
}

這樣在每次執行完操作之後都會回收資源,也避免因爲應用層打開過多的連接導致數據庫無法接受新的連接。

這裏爲了舉例方便,實際上在使用數據庫連接的時候我們更多的是在應用層維護了連接池,如果沒有及時釋放在應用層就已經沒辦法獲取更多的連接。不會導致其他應用連接數據庫。

循環觸發相同邏輯

通過 for 循環對相同的邏輯進行壓縮, 我們通過 k8s 寫入 natChains 的例子來看

for _, chainName := range []utiliptables.Chain{kubeServicesChain, kubeNodePortsChain, kubePostroutingChain, kubeMarkMasqChain} {
 proxier.natChains.Write(utiliptables.MakeChainLine(chainName))
}

通過這樣寫之後後續要增加 Chain 只需要再切片中增加名字即可。

這個時候你可能會問,我平鋪開寫也沒有多麻煩呀。Control + D 複製一行,改個名字就好了,並沒有多複雜

proxier.natChains.Write(utiliptables.MakeChainLine(kubeServicesChain))
proxier.natChains.Write(utiliptables.MakeChainLine(kubeNodePortsChain))
proxier.natChains.Write(utiliptables.MakeChainLine(kubePostroutingChain))
proxier.natChains.Write(utiliptables.MakeChainLine(kubeMarkMasqChain))

那現在我們更進一步,如果我們想給每個 Write 都加上耗時上報。

這個時候顯然第一種 for  增加起來會更加簡潔易懂。

for _, chainName := range []utiliptables.Chain{kubeServicesChain, kubeNodePortsChain, kubePostroutingChain, kubeMarkMasqChain} {
 // 增加前置邏輯
 
 proxier.natChains.Write(utiliptables.MakeChainLine(chainName))
 
 // 增加後置邏輯
}

在業務開發中我們如果要批量刷新多種類型的緩存,也可以用這種 for 循環的方式來實現

func (f *FeatureServiceAPI) RefreshFeatureCache(ctx context.Context) error {
 cacheTypes := []feature.CacheType{
  feature.CacheType_PopularArticles,
  feature.CacheType_UserBehaviourPapers,
  feature.CacheType_UserPublishedPapers,
  feature.CacheType_UserCooperators,
 }
 for _, cache := range cacheTypes {
  _, err := f.featureServer.RefreshCache(ctx, &featureserver.RefreshCacheRequest{
   Cache: cache,
  })
  if err != nil {
   // 可以統一增加錯誤處理和重試
   return errors.WithStack(err)
  }
 }
 return nil
}

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/w1H8MuAzA0TNyqYnyFwXRg