提示:
- 所有體系課見專欄:Go 項目開發極速入門實戰課;
- 歡迎加入 云原生 AI 實戰 星球,12+ 高質量體系課、20+ 高質量實戰項目助你在 AI 時代建立技術競爭力(聚焦于 Go、云原生、AI Infra);
- 本節課最終源碼位于 fastgo 項目的 feature/s13 分支;
更詳細的課程版本見:Go 項目開發中級實戰課:26 | 業務實現(3):實現 Biz 層代碼
Biz 層依賴 Store 層,所以實現了 Store 層代碼之后,便可以實現 Biz 層代碼。Biz 層代碼,主要用來實現系統中 REST 資源的各類業務操作,例如用戶資源的增刪改查等。
整個 fastgo 項目的設計較為規范,規范化的項目設計帶來的優點之一是開發方式的一致性和開發效率的提升。fastgo 項目 Biz 層的開發方式與 Store 層的開發方式保持一致。
API 接口定義
在開發 Biz 層代碼之前,需要先定義好 API 接口的請求入參和返回參數。為此,新建了 pkg/api/apiserver/v1/post.go 文件和 pkg/api/apiserver/v1/user.go 文件,分別保存了用戶接口和博客接口的請求入參和返回參數。
因為請求入參和返回參數(例如 CreateUserRequest
和 CreateUserResponse
)會提供給接口調用方(客戶端),所以需要將接口定義保存在 pkg/api 目錄下。另外,考慮到未來 fastgo 可能會加入多個服務,每個服務都有自己的 API 定義,fastgo 項目選擇了將每個服務的 API 定義保存在獨立的服務目錄下,例如 pkg/api/apiserver。
考慮到未來 API 接口的版本升級,fastgo 項目將接口進行了版本化處理,v1 版本的接口保存在 pkg/api/apiserver/v1 目錄下,v2 版本的接口保存在 pkg/api/apiserver/v2 目錄下。
IBiz 接口定義及實現
Biz 層代碼保存 internal/apiserver/biz/biz.go 文件中,接口名為 IBiz,定義如下:
// IBiz 定義了業務層需要實現的方法.
type IBiz interface {// 獲取用戶業務接口.UserV1() userv1.UserBiz// 獲取帖子業務接口.PostV1() postv1.PostBiz// 獲取帖子業務接口(V2版本).// PostV2() post.PostBiz
}
IBiz
接口包含了 User 資源和 Post 資源 v1 版本的接口,通過抽象工廠設計模式返回對應資源的接口。在 Go 項目開發中,業務層代碼的代碼量通常最大、變動最頻繁,并且隨著項目的迭代,可能會出現不兼容的變更。
這時需要對外暴露 v2 版本的 API 接口。因此,為了提高代碼的可維護性并保留未來的擴展能力,Biz 層代碼的存放結構如下:
internal/apiserver/biz/
├── biz.go
├── v1/ # v1 版本代碼實現
│ ├── post/ # 提高代碼可維護性,不同資源的代碼實現分別存放在不同的目錄中
│ │ └── post.go
│ └── user/
│ └── user.go
└── v2/ # 保留擴展能力:v2 代碼保存目錄
上述代碼,將不同版本的代碼保存在不同的版本化目錄中,不同 REST 資源的業務邏輯實現保存在跟資源對應的目錄中。不同資源的業務邏輯代碼均在其對應的目錄中實現,可以在目錄級別隔離不同資源的代碼實現,有利于提高代碼的穩定性,并降低維護的復雜度。
Biz 層依賴于 Store 層的實現,所以在創建 IBiz
實例時,需要傳入 IStore
類型的實例,IBiz
實例由 NewBiz
函數創建:
// biz 是 IBiz 的一個具體實現.
type biz struct {store store.IStore
}// 確保 biz 實現了 IBiz 接口.
var _ IBiz = (*biz)(nil)// NewBiz 創建一個 IBiz 類型的實例.
func NewBiz(store store.IStore) *biz {return &biz{store: store}
}
IBiz
的實現跟 IStore
的實現是保持一致,其他代碼,本節不再詳解。
UserBiz 接口定義及實現
User 資源的 Biz 層代碼實現位于 internal/apiserver/biz/v1/user/user.go 文件中,其接口定義為 UserBiz,代碼如下:
// UserBiz 定義處理用戶請求所需的方法.
type UserBiz interface {Create(ctx context.Context, rq *apiv1.CreateUserRequest) (*apiv1.CreateUserResponse, error)Update(ctx context.Context, rq *apiv1.UpdateUserRequest) (*apiv1.UpdateUserResponse, error)Delete(ctx context.Context, rq *apiv1.DeleteUserRequest) (*apiv1.DeleteUserResponse, error)Get(ctx context.Context, rq *apiv1.GetUserRequest) (*apiv1.GetUserResponse, error)List(ctx context.Context, rq *apiv1.ListUserRequest) (*apiv1.ListUserResponse, error)UserExpansion
}// UserExpansion 定義用戶操作的擴展方法.
type UserExpansion interface {
}
UserBiz
接口中的方法,同樣也分為了兩大類:標準資源 CURD 接口和擴展接口,擴展接口中實現了用戶登錄、Token 刷新、密碼修改等方法。實現 UserBiz
接口的 Go 結構體是 *userBiz
。
創建用戶:Create 方法實現
userBiz
結構體的 Create
方法實現如下:
// Create 實現 UserBiz 接口中的 Create 方法.
func (b *userBiz) Create(ctx context.Context, rq *apiv1.CreateUserRequest) (*apiv1.CreateUserResponse, error) {var userM model.User_ = copier.Copy(&userM, rq)if err := b.store.User().Create(ctx, &userM); err != nil {return nil, err}return &apiv1.CreateUserResponse{UserID: userM.UserID}, nil
}
為了提高開發效率,減少不必要的代碼量,Create
方法使用了 [github.com/jinzhu/copier](https://github.com/jinzhu/copier)
的 Copy
函數給目標結構體變量 userM
賦值。
在 Create
方法中,通過 b.store.User().Create(ctx, &userM)
方法調用,將數據保存在數據庫中。
在 Go 項目開發中,數據庫禁止保存明文密碼。用戶密碼在入庫前需要進行加密處理。為了加密明文密碼字符串,fastgo 引入了 github.com/onexstack/onexstack/pkg/auth
包,auth 包中的 Encrypt
函數可以用來加密一個明文密碼字符串。
因為在入庫前都需要對明文密碼進行加密,所以,很自然的想到可以通過實現 GORM 框架的 BeforeCreate
鉤子來實現。修改 internal/apiserver/model/hook.go 文件,添加以下代碼:
import (..."github.com/onexstack/onexstack/pkg/auth"
)// BeforeCreate 在創建數據庫記錄之前加密明文密碼.
func (m *User) BeforeCreate(tx *gorm.DB) error {// Encrypt the user password.var err errorm.Password, err = auth.Encrypt(m.Password)if err != nil {return err}return nil
}
更新用戶:Update 方法實現
userBiz
結構體的 Update
方法實現如下:
// Update 實現 UserBiz 接口中的 Update 方法.
func (b *userBiz) Update(ctx context.Context, rq *apiv1.UpdateUserRequest) (*apiv1.UpdateUserResponse, error) {userM, err := b.store.User().Get(ctx, where.T(ctx))if err != nil {return nil, err}if rq.Username != nil {userM.Username = *rq.Username}if rq.Email != nil {userM.Email = *rq.Email}if rq.Nickname != nil {userM.Nickname = *rq.Nickname}if rq.Phone != nil {userM.Phone = *rq.Phone}if err := b.store.User().Update(ctx, userM); err != nil {return nil, err}return &apiv1.UpdateUserResponse{}, nil
}
在更新用戶時,根據是否傳入待更新的字段來判斷是否更新該字段。這樣的設計方式,可以通過一個更新接口實現字段的選擇性更新。
查詢用戶列表:List 方法實現
userBiz
結構體的 List
方法實現如下述代碼所示:
// List 實現 UserBiz 接口中的 List 方法.
func (b *userBiz) List(ctx context.Context, rq *apiv1.ListUserRequest) (*apiv1.ListUserResponse, error) {whr := where.P(int(rq.Offset), int(rq.Limit))count, userList, err := b.store.User().List(ctx, whr)if err != nil {return nil, err}var m sync.Mapeg, ctx := errgroup.WithContext(ctx)// 設置最大并發數量為常量 MaxConcurrencyeg.SetLimit(known.MaxErrGroupConcurrency)// 使用 goroutine 提高接口性能for _, user := range userList {eg.Go(func() error {select {case <-ctx.Done():return nildefault:count, _, err := b.store.Post().List(ctx, where.T(ctx))if err != nil {return err}converted := conversion.UserodelToUserV1(user)converted.PostCount = countm.Store(user.ID, converted)return nil}})}if err := eg.Wait(); err != nil {slog.ErrorContext(ctx, "Failed to wait all function calls returned", "err", err)return nil, err}users := make([]*apiv1.User, 0, len(userList))for _, item := range userList {user, _ := m.Load(item.ID)users = append(users, user.(*apiv1.User))}slog.DebugContext(ctx, "Get users from backend storage", "count", len(users))return &apiv1.ListUserResponse{TotalCount: count, Users: users}, nil
}
List
方法會查詢所有的用戶列表,并統計用戶所屬的博客數,這種遍歷多個列表,并且針對列表中每個元素都有耗時處理邏輯的代碼,可能會導致 List
方法執行時間較久。為了提高 List
方法的性能,List
方法中使用了 errgroup 包,并發查詢每個用戶的博客數。
在上述代碼中 eg.SetLimit
方法調用的作用是限制程序中同時運行的 Go 協程數量,以避免過多協程并發任務導致的系統資源過載(如高 CPU 和內存占用或高 I/O 消耗)。通過 eg.Go
啟動的 goroutine 會按照 SetLimit
的限制規則執行。當已經有 MaxErrGroupConcurrency
個任務在運行時,新任務會阻塞,直到某個正在運行的任務完成。
因為會并發處理 userList
列表中的每個元素,所以需要一個并發安全的數據類型,保存處理后的數據。Go 標準庫提供了 sync.Map
數據類型,該類型是并發安全的,可以直接在 Go 協程中使用 sync.Map
的 Store
方法添加 key-value 對。在上述代碼中,使用 m.Store
保存了 *apiv1.User
類型的數據。
Store 層返回的數據類型為 *model.UserM
,需要轉換為 Biz 層使用的數據類型 *apiv1.User
。*model.UserM
和 *apiv1.User
數據類型之間的相互轉換,在 Biz 層經常會發生,為了提高代碼的可維護性,將這類轉換實現統一保存在 internal/apiserver/pkg/conversion 目錄下的 conversion 包中。
Store 層返回的用戶列表是降序排列的,為了保證 List
返回的列表也是降序排列的,上述代碼的最后,使用以下代碼段重新排列了 sync.Map
類型變量 m
中保存的數據:
for _, item := range userList {user, _ := m.Load(item.ID)users = append(users, user.(*apiv1.User))
}