371 lines
12 KiB
Markdown
371 lines
12 KiB
Markdown
# Model 分層規範
|
||
|
||
本文件定義 Gateway 專案中 **業務模型**(`internal/model/{module}/`)的目錄結構與撰寫約定,參考 Clean Architecture 分層。
|
||
|
||
> Gateway 的 HTTP 層(handler / logic / types)仍由 goctl 生成;本規範適用於 **`internal/model/{module}/`**(例如 `internal/model/member/`)——entity、repository、usecase 等。**不使用 `pkg/`。**
|
||
|
||
專案總覽與錯誤分層見 [README.md](../README.md)。
|
||
|
||
## 目錄結構
|
||
|
||
以下以 `member` 模組為例,路徑前綴為 `internal/model/member/`:
|
||
|
||
```
|
||
internal/model/
|
||
└── member/
|
||
├── entity/ # 持久化資料模型(MongoDB document)
|
||
├── enum/ # 領域值物件 / 列舉(Platform、Status…)
|
||
├── repository/ # Repository 介面 + 實作
|
||
├── usecase/ # UseCase 介面、Request/Response DTO、實作
|
||
├── config/ # 模組用設定 struct
|
||
├── errors.go # 模組 sentinel(ErrNotFound 等),非第二套 8 碼
|
||
├── const.go # 模組常數
|
||
├── redis.go # Redis key 命名與 helper
|
||
└── mock/ # mockgen 產物
|
||
├── repository/
|
||
└── usecase/
|
||
```
|
||
|
||
### 依賴方向
|
||
|
||
```
|
||
usecase(實作) → repository(介面)
|
||
→ entity、enum
|
||
→ usecase(介面 + DTO)
|
||
|
||
repository(實作) → repository(介面)
|
||
→ entity
|
||
|
||
entity、enum → 只依賴標準庫或第三方型別
|
||
|
||
internal/logic → model/{module}/usecase 介面 only(不 import repository、entity)
|
||
```
|
||
|
||
## 1. Entity(`entity/`)
|
||
|
||
每個 MongoDB collection 對應一個 struct,放在 `internal/model/{module}/entity/`。
|
||
|
||
**規則:**
|
||
|
||
- 檔名使用 snake_case 對應 collection 語意,如 `account.go`、`user.go`。
|
||
- struct 名稱使用 PascalCase 單數,如 `Account`、`User`。
|
||
- 必須實作 `CollectionName() string`,回傳 MongoDB collection 名稱。
|
||
- 欄位 tag:`bson` 必填;對外 JSON 序列化才加 `json`。
|
||
- 主鍵使用 `primitive.ObjectID`,tag 為 `` `bson:"_id,omitempty" json:"id,omitempty"` ``。
|
||
- 時間戳記統一用 `*int64`,欄位名 `CreateAt` / `UpdateAt`,值為 UTC nanoseconds。
|
||
- 可選欄位用指標型別(`*string`、`*int64`)。
|
||
- 領域列舉引用 `enum/` 下的型別,不在 entity 內重複定義。
|
||
|
||
**範例:**
|
||
|
||
```go
|
||
package entity
|
||
|
||
import (
|
||
"gateway/internal/model/member/enum"
|
||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||
)
|
||
|
||
type Account struct {
|
||
ID primitive.ObjectID `bson:"_id,omitempty" json:"id,omitempty"`
|
||
LoginID string `bson:"login_id"`
|
||
Token string `bson:"token"`
|
||
Platform enum.Platform `bson:"platform"`
|
||
UpdateAt *int64 `bson:"update_at,omitempty" json:"update_at,omitempty"`
|
||
CreateAt *int64 `bson:"create_at,omitempty" json:"create_at,omitempty"`
|
||
}
|
||
|
||
func (a *Account) CollectionName() string {
|
||
return "account"
|
||
}
|
||
```
|
||
|
||
## 2. 值物件 / 列舉(`enum/`)
|
||
|
||
業務列舉、狀態碼等放在 `internal/model/{module}/enum/`。
|
||
|
||
**規則:**
|
||
|
||
- 以具名型別包裝底層型別(`type Status int32`)。
|
||
- 常數集中定義,附中文或英文註解說明語意。
|
||
- 提供轉換方法:`ToInt32()`、`ToInt64()`、`ToString()`、`CodeToString()` 等。
|
||
- 字串 ↔ 列舉映射用 private map + public getter,未知值回傳零值或 sentinel。
|
||
|
||
**範例:**
|
||
|
||
```go
|
||
package enum
|
||
|
||
type Platform int8
|
||
|
||
const (
|
||
Digimon Platform = 1 + iota
|
||
Google
|
||
Line
|
||
Apple
|
||
)
|
||
|
||
func (p Platform) ToInt64() int64 { return int64(p) }
|
||
func (p Platform) ToString() string { /* map lookup */ }
|
||
```
|
||
|
||
同一檔案或目錄下可放 `*_test.go` 驗證轉換邏輯。
|
||
|
||
## 3. Repository 介面(`repository/`)
|
||
|
||
**規則:**
|
||
|
||
- 一個 entity 一個 `XxxRepository` interface。
|
||
- 方法第一個參數固定為 `context.Context`。
|
||
- 參數 / 回傳值使用 `entity` 型別,不暴露 driver 細節(除 index migration 等必要場景)。
|
||
- Index migration 以獨立 interface 嵌入,命名 `{Entity}IndexUP`,方法名含版本號,如 `Index20241226001UP`。
|
||
- 介面檔案不含實作、不含 import 基礎設施 package(`mon`、`mgo` 等僅在實作檔出現)。
|
||
|
||
**範例:**
|
||
|
||
```go
|
||
package repository
|
||
|
||
import (
|
||
"context"
|
||
"gateway/internal/model/member/entity"
|
||
"go.mongodb.org/mongo-driver/mongo"
|
||
)
|
||
|
||
type AccountRepository interface {
|
||
Insert(ctx context.Context, data *entity.Account) error
|
||
FindOne(ctx context.Context, id string) (*entity.Account, error)
|
||
Update(ctx context.Context, data *entity.Account) (*mongo.UpdateResult, error)
|
||
Delete(ctx context.Context, id string) (int64, error)
|
||
AccountIndexUP
|
||
}
|
||
|
||
type AccountIndexUP interface {
|
||
Index20241226001UP(ctx context.Context) (*mongo.Cursor, error)
|
||
}
|
||
```
|
||
|
||
## 4. Repository 實作(`repository/`)
|
||
|
||
**規則:**
|
||
|
||
- struct 名稱 `{Entity}Repository`,建構子 `New{Entity}Repository(param {Entity}RepositoryParam)`。
|
||
- Param struct 集中注入 `Conf`、`CacheConf`、`DBOpts`、`CacheOpts`。
|
||
- 建構時以 entity 的 `CollectionName()` 初始化 DocumentDB;失敗時 `panic`(啟動期錯誤)。
|
||
- CRUD 透過 `mgo.DocumentDBWithCacheUseCase` 操作,搭配模組 `redis.go` 的 key helper。
|
||
- `Insert`:ID 為 zero 時自動產生 ObjectID 並寫入 `CreateAt` / `UpdateAt`。
|
||
- `Update`:自動更新 `UpdateAt`。
|
||
- `FindOne` / `Delete`:無效 ObjectID → `*errs.Error`(`ResInvalidMeasureID`)或模組 `ErrInvalidObjectID`;查無資料 → 模組 `ErrNotFound`(見第 7 節錯誤)。
|
||
- 整合測試放在同 package 的 `*_test.go`,可用 testcontainer + miniredis。
|
||
|
||
**範例:**
|
||
|
||
```go
|
||
type AccountRepositoryParam struct {
|
||
Conf *mgo.Conf
|
||
CacheConf cache.CacheConf
|
||
DBOpts []mon.Option
|
||
CacheOpts []cache.Option
|
||
}
|
||
|
||
type accountRepository struct {
|
||
DB mgo.DocumentDBWithCacheUseCase
|
||
}
|
||
|
||
func NewAccountRepository(param AccountRepositoryParam) AccountRepository {
|
||
e := entity.Account{}
|
||
documentDB, err := mgo.MustDocumentDBWithCache(
|
||
param.Conf, e.CollectionName(), param.CacheConf, param.DBOpts, param.CacheOpts,
|
||
)
|
||
if err != nil {
|
||
panic(err)
|
||
}
|
||
return &accountRepository{DB: documentDB}
|
||
}
|
||
```
|
||
|
||
## 5. UseCase 介面與 DTO(`usecase/`)
|
||
|
||
**規則:**
|
||
|
||
- 業務入口定義為 interface,如 `AccountUseCase`;大介面可拆成多個小 interface 再 compose。
|
||
- Request / Response struct 放在同一 package,命名 `{Action}Request`、`{Action}Response`。
|
||
- DTO 只含 `json` tag 與欄位註解,不含 bson tag(DTO 不直接映射 DB)。
|
||
- 更新類 Request 的可選欄位用指標,以便區分「未傳入」與「傳入零值」。
|
||
- 共用分頁 struct 放 `common.go`,如 `Pager`。
|
||
|
||
**範例:**
|
||
|
||
```go
|
||
package usecase
|
||
|
||
import "gateway/internal/model/member/enum"
|
||
|
||
type AccountUseCase interface {
|
||
CreateLoginUser(ctx context.Context, req *CreateLoginUserRequest) (*CreateLoginUserResponse, error)
|
||
}
|
||
|
||
type CreateLoginUserRequest struct {
|
||
LoginID string `json:"login_id"`
|
||
Platform enum.Platform `json:"platform"`
|
||
Token string `json:"token"`
|
||
}
|
||
```
|
||
|
||
## 6. UseCase 實作(`usecase/`)
|
||
|
||
**規則:**
|
||
|
||
- struct 名稱描述業務聚合,如 `MemberUseCase`。
|
||
- 以 `{Name}UseCaseParam` 注入所有 repository 與 `config.Config`。
|
||
- 建構子命名 `Must{Name}UseCase(param) AccountUseCase`,回傳 interface 型別。
|
||
- 實作 struct 嵌入 Param:`type MemberUseCase struct { MemberUseCaseParam }`。
|
||
- 方法簽名與 interface 一致;內部組裝 `entity`,呼叫 repository。
|
||
- 錯誤一律回傳 `gateway/internal/library/errors` 的 `*errs.Error`(見第 7 節)。
|
||
- 可測性:將難 mock 的純函式抽成 package 級變數(如 `var HashPasswordFunc = HashPassword`)。
|
||
|
||
**範例:**
|
||
|
||
```go
|
||
type MemberUseCaseParam struct {
|
||
Account repository.AccountRepository
|
||
User repository.UserRepository
|
||
Config config.Config
|
||
}
|
||
|
||
type MemberUseCase struct {
|
||
MemberUseCaseParam
|
||
}
|
||
|
||
func MustMemberUseCase(param MemberUseCaseParam) AccountUseCase {
|
||
return &MemberUseCase{param}
|
||
}
|
||
```
|
||
|
||
## 7. 錯誤處理
|
||
|
||
全專案對外只使用 `gateway/internal/library/errors`(`var errb = errs.For(code.Facade)`)。模組根目錄的 `errors.go` **只放 sentinel**,不另建 8 碼常數表。
|
||
|
||
### 7.1 模組 sentinel(`errors.go`)
|
||
|
||
```go
|
||
package member
|
||
|
||
import "errors"
|
||
|
||
var (
|
||
ErrNotFound = errors.New("member: not found")
|
||
ErrInvalidObjectID = errors.New("member: invalid object id")
|
||
)
|
||
```
|
||
|
||
### 7.2 Repository
|
||
|
||
| 狀況 | 回傳 |
|
||
|------|------|
|
||
| `mongo.ErrNoDocuments` | `ErrNotFound`(由 usecase 轉 `ResNotFound`) |
|
||
| ObjectID 格式錯 | `errb.ResInvalidMeasureID(...)` 或 `ErrInvalidObjectID` |
|
||
| duplicate key | `errb.DBDuplicate(...)` |
|
||
| 連線 / 暫時不可用 | `errb.DBUnavailable(...).WithCause(err)` |
|
||
| 其他 driver 錯 | `errb.DBError(...).WithCause(err)` |
|
||
|
||
基礎設施錯誤須在 repository 邊界對照完成,並以 `WithCause` 保留原始 err 供 `errlog` 使用。
|
||
|
||
### 7.3 UseCase
|
||
|
||
- 業務規則:`errb.ResAlreadyExist`、`errb.ResInvalidState`、`errb.AuthForbidden` 等。
|
||
- `errors.Is(err, member.ErrNotFound)` → `errb.ResNotFound("member", id).WithCause(err)`。
|
||
- 已是 `*errs.Error` 且語意正確(如 `DBDuplicate`)→ 原樣 `return err`。
|
||
|
||
### 7.4 Logic / Handler
|
||
|
||
- Logic:僅 `InputMissingRequired` 等 HTTP 輸入錯誤;usecase 回傳的 err **不二次包裝**。
|
||
- Handler:`response.Write`,由 `errs.FromError` 決定 HTTP 狀態與 body。
|
||
|
||
## 8. 模組共用檔案
|
||
|
||
| 檔案 | 用途 |
|
||
|------|------|
|
||
| `errors.go` | 模組 sentinel(`ErrNotFound` 等) |
|
||
| `const.go` | 模組字面常數 |
|
||
| `redis.go` | Redis key 型別、`With()` 組合、`GetXxxRedisKey()` helper |
|
||
| `config/config.go` | UseCase 需要的設定 struct(不含 go-zero RestConf) |
|
||
|
||
Redis key 統一帶業務 prefix,避免跨服務衝突:
|
||
|
||
```go
|
||
type RedisKey string
|
||
|
||
const AccountRedisKey RedisKey = "member:account"
|
||
|
||
func (key RedisKey) With(s ...string) RedisKey { /* join with ":" */ }
|
||
func GetAccountRedisKey(id string) string {
|
||
return AccountRedisKey.With(id).ToString()
|
||
}
|
||
```
|
||
|
||
## 9. Mock(`mock/`)
|
||
|
||
Repository / UseCase 介面變更後,用 mockgen 重新生成:
|
||
|
||
```bash
|
||
mockgen -source=./internal/model/member/repository/account.go \
|
||
-destination=./internal/model/member/mock/repository/account.go \
|
||
-package=mockrepository
|
||
```
|
||
|
||
- 產物放在 `mock/repository/` 或 `mock/usecase/`,**不要手改**。
|
||
- UseCase 單元測試注入 mock,不啟動真實 DB。
|
||
|
||
## 10. 命名對照表
|
||
|
||
| 概念 | 命名 |
|
||
|------|------|
|
||
| Entity struct | `Account`、`User` |
|
||
| Collection 方法 | `CollectionName()` |
|
||
| Repository 介面 | `AccountRepository` |
|
||
| Repository 實作 struct | `accountRepository` 或 `AccountRepository` |
|
||
| Repository 建構子 | `NewAccountRepository` |
|
||
| Repository 參數 | `AccountRepositoryParam` |
|
||
| UseCase 介面 | `AccountUseCase` |
|
||
| UseCase 實作 | `MemberUseCase` |
|
||
| UseCase 建構子 | `MustMemberUseCase` |
|
||
| 請求 DTO | `CreateLoginUserRequest` |
|
||
| 回應 DTO | `GetUIDByAccountResponse` |
|
||
| Index migration | `Index{YYYYMMDD}{seq}UP` |
|
||
| 測試檔 | 與被測檔案同目錄,`*_test.go` |
|
||
|
||
## 11. 新增模組 / Model 檢查清單
|
||
|
||
1. 建立 `internal/model/{module}/` 目錄結構。
|
||
2. 在 `entity/` 新增 struct + `CollectionName()`。
|
||
3. 若有列舉 / 狀態,在 `enum/` 定義值物件。
|
||
4. 在 `repository/` 宣告 interface 並實作 CRUD + index migration + `*_test.go`。
|
||
5. 在 `errors.go` 補充 sentinel(若需要);在 `redis.go` 補 cache key(若需要)。
|
||
6. 在 `usecase/` 定義 interface、DTO 與實作 + 單元測試。
|
||
7. 執行 mockgen 更新 `mock/`。
|
||
8. 在 `internal/svc/service_context.go` 組裝 repository → usecase。
|
||
9. 在 `generate/api/` 定義路由,`make gen-api`。
|
||
10. 在 `internal/logic/` 實作 types 映射,**只**呼叫 UseCase interface。
|
||
11. `make gen-doc`、`go test ./...`。
|
||
|
||
## 12. 與 Gateway HTTP 層的關係
|
||
|
||
```
|
||
HTTP Request
|
||
↓
|
||
handler(goctl 生成)→ response.Write
|
||
↓
|
||
logic(goctl 生成框架,手寫映射)
|
||
↓ 轉換 types ↔ usecase DTO
|
||
usecase(internal/model/{module}/usecase)
|
||
↓
|
||
repository(internal/model/{module}/repository)
|
||
↓
|
||
MongoDB / Redis
|
||
```
|
||
|
||
- `internal/types`:HTTP 請求 / 回應型別,由 `.api` 生成。
|
||
- `internal/model/{module}/usecase` DTO:業務層資料結構,logic 負責兩者映射。
|
||
- 錯誤自 usecase 以 `*errs.Error` 往上冒泡;logic 原樣傳遞,handler 經 `response.Write` 輸出 8 碼 JSON。
|