Client 层封装规范
概述
Client 层负责封装第三方服务的调用细节,为 Service 层提供简洁、统一的接口。本规范定义了 Client 的结构设计、方法签名和目录组织标准。
设计原则
原则 1:单一职责
每个 Client 只负责一个外部服务的交互。
go
// ✅ 正确:每个 Client 职责明确
type StorageClient struct {
// 只负责对象存储服务
}
type EmailClient struct {
// 只负责邮件发送服务
}
type SMSClient struct {
// 只负责短信发送服务
}
// ❌ 错误:一个 Client 负责多个服务
type NotificationClient struct {
emailClient *smtp.Client
smsClient *sms.Client
// 同时包含邮件和短信,职责不单一
}原则 2:配置注入
Client 通过配置结构体初始化,不硬编码配置值。
go
// ✅ 正确:配置注入
func NewStorageClient(cfg *StorageConfig) (*StorageClient, error) {
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid config: %w", err)
}
return &StorageClient{
endpoint: cfg.Endpoint,
accessKey: cfg.AccessKey,
secretKey: cfg.SecretKey,
bucket: cfg.Bucket,
}, nil
}
// ❌ 错误:硬编码配置
func NewStorageClient() (*StorageClient, error) {
return &StorageClient{
endpoint: "https://s3.amazonaws.com",
accessKey: "AKIAIOSFODNN7EXAMPLE", // 硬编码密钥
bucket: "my-bucket",
}, nil
}原则 3:错误转换
将第三方服务的错误转换为业务语义错误,隐藏实现细节。
go
// 定义业务错误
var (
ErrStorageAccessDenied = errors.New("storage access denied")
ErrStorageFileNotFound = errors.New("storage file not found")
ErrStorageUploadFailed = errors.New("storage upload failed")
)
// ✅ 正确:转换第三方错误为业务错误
func (c *StorageClient) Upload(ctx context.Context, file *File) error {
_, err := c.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(file.Key),
Body: file.Data,
})
if err != nil {
// 转换 S3 错误为业务错误
if isAccessDeniedError(err) {
return ErrStorageAccessDenied
}
return fmt.Errorf("%w: %v", ErrStorageUploadFailed, err)
}
return nil
}
// ❌ 错误:直接暴露第三方错误
func (c *StorageClient) Upload(ctx context.Context, file *File) error {
_, err := c.s3Client.PutObject(ctx, &s3.PutObjectInput{...})
return err // 直接返回 S3 错误,暴露实现细节
}原则 4:上下文传递
所有 Client 方法必须接受 context.Context 作为第一个参数。
go
// ✅ 正确:接受 context
func (c *AIClient) Generate(ctx context.Context, prompt string) (*Response, error) {
req, _ := http.NewRequestWithContext(ctx, "POST", c.endpoint, body)
resp, err := c.httpClient.Do(req)
// 支持超时控制和请求取消
return parseResponse(resp), err
}
// ❌ 错误:不接受 context
func (c *AIClient) Generate(prompt string) (*Response, error) {
req, _ := http.NewRequest("POST", c.endpoint, body)
resp, err := c.httpClient.Do(req)
// 无法控制超时和取消操作
return parseResponse(resp), err
}Client 结构体设计
标准结构体模板
go
// clients/storage/client.go
package storage
import (
"context"
"io"
"your-project/config"
)
// Client 对象存储客户端
type Client struct {
// 配置
config *config.StorageConfig
// 第三方 SDK 客户端
s3Client *s3.Client
// 可选:HTTP 客户端(用于 RESTful API)
httpClient *http.Client
}
// NewClient 创建客户端实例
func NewClient(cfg *config.StorageConfig) (*Client, error) {
// 1. 配置校验
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid storage config: %w", err)
}
// 2. 初始化第三方 SDK
s3Client := s3.NewFromConfig(aws.Config{
Region: cfg.Region,
Credentials: credentials.NewStaticCredentialsProvider(
cfg.AccessKey,
cfg.SecretKey,
"",
),
})
// 3. 构造 Client
client := &Client{
config: cfg,
s3Client: s3Client,
httpClient: &http.Client{
Timeout: cfg.Timeout,
},
}
return client, nil
}
// Close 关闭客户端,释放资源
func (c *Client) Close() error {
// 如果有需要关闭的连接或资源,在此释放
return nil
}关键要点:
- ✅ 私有字段:Client 结构体字段使用小写(私有)
- ✅ 配置存储:保存配置对象,便于方法中使用
- ✅ SDK 封装:持有第三方 SDK 客户端实例
- ✅ 资源清理:实现
Close()方法支持优雅关闭
配置结构体示例
go
// config/storage.go
package config
import (
"errors"
"time"
)
// StorageConfig 对象存储配置
type StorageConfig struct {
Type string `toml:"type"` // 存储类型:s3/cos/oss
Endpoint string `toml:"endpoint"` // 服务端点
Region string `toml:"region"` // 区域
AccessKey string `toml:"access_key"` // 访问密钥
SecretKey string `toml:"secret_key"` // 密钥
Bucket string `toml:"bucket"` // 存储桶名称
Timeout time.Duration `toml:"timeout"` // 超时时间
}
// Validate 校验配置
func (c *StorageConfig) Validate() error {
if c.Endpoint == "" {
return errors.New("storage endpoint is required")
}
if c.AccessKey == "" {
return errors.New("storage access_key is required")
}
if c.SecretKey == "" {
return errors.New("storage secret_key is required")
}
if c.Bucket == "" {
return errors.New("storage bucket is required")
}
if c.Timeout == 0 {
c.Timeout = 30 * time.Second // 默认超时
}
return nil
}方法签名规范
标准方法签名
Client 方法必须遵循以下签名格式:
go
func (c *Client) MethodName(ctx context.Context, params...) (result, error)规范要点:
- 第一个参数必须是
context.Context - 最后一个返回值必须是
error - 方法名使用大驼峰(PascalCase)
- 参数使用小驼峰(camelCase)
完整方法示例
go
// Upload 上传文件到对象存储
func (c *Client) Upload(ctx context.Context, key string, data io.Reader) (*UploadResult, error) {
// 1. 参数校验
if key == "" {
return nil, errors.New("key is required")
}
if data == nil {
return nil, errors.New("data is required")
}
// 2. 调用第三方服务
result, err := c.s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(c.config.Bucket),
Key: aws.String(key),
Body: data,
})
if err != nil {
return nil, c.convertError(err)
}
// 3. 构造响应
uploadResult := &UploadResult{
Key: key,
URL: c.getPublicURL(key),
ETag: *result.ETag,
Size: result.ContentLength,
UploadAt: time.Now(),
}
return uploadResult, nil
}
// Download 下载文件
func (c *Client) Download(ctx context.Context, key string) (io.ReadCloser, error) {
if key == "" {
return nil, errors.New("key is required")
}
result, err := c.s3Client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.config.Bucket),
Key: aws.String(key),
})
if err != nil {
if isNotFoundError(err) {
return nil, ErrStorageFileNotFound
}
return nil, c.convertError(err)
}
return result.Body, nil
}
// Delete 删除文件
func (c *Client) Delete(ctx context.Context, key string) error {
if key == "" {
return errors.New("key is required")
}
_, err := c.s3Client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(c.config.Bucket),
Key: aws.String(key),
})
if err != nil {
return c.convertError(err)
}
return nil
}
// GetURL 获取文件访问 URL
func (c *Client) GetURL(ctx context.Context, key string) (string, error) {
if key == "" {
return "", errors.New("key is required")
}
// 生成预签名 URL(有效期 1 小时)
presignClient := s3.NewPresignClient(c.s3Client)
request, err := presignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.config.Bucket),
Key: aws.String(key),
}, func(opts *s3.PresignOptions) {
opts.Expires = time.Hour
})
if err != nil {
return "", c.convertError(err)
}
return request.URL, nil
}请求/响应结构体
定义专门的请求和响应结构体:
go
// UploadResult 上传结果
type UploadResult struct {
Key string `json:"key"` // 对象键
URL string `json:"url"` // 访问 URL
ETag string `json:"etag"` // ETag
Size int64 `json:"size"` // 文件大小
UploadAt time.Time `json:"upload_at"` // 上传时间
}
// File 文件信息
type File struct {
Key string
Data io.Reader
ContentType string
Metadata map[string]string
}错误处理
定义业务错误
go
// clients/storage/errors.go
package storage
import "errors"
var (
ErrStorageAccessDenied = errors.New("storage access denied")
ErrStorageFileNotFound = errors.New("storage file not found")
ErrStorageUploadFailed = errors.New("storage upload failed")
ErrStorageDownloadFailed = errors.New("storage download failed")
ErrStorageDeleteFailed = errors.New("storage delete failed")
ErrInvalidConfig = errors.New("invalid storage config")
)错误转换函数
go
// convertError 将第三方错误转换为业务错误
func (c *Client) convertError(err error) error {
if err == nil {
return nil
}
// 根据错误类型转换
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
switch apiErr.ErrorCode() {
case "AccessDenied":
return ErrStorageAccessDenied
case "NoSuchKey":
return ErrStorageFileNotFound
case "NoSuchBucket":
return errors.New("storage bucket not found")
}
}
// 未知错误,包装原始错误
return fmt.Errorf("storage error: %w", err)
}
// isNotFoundError 判断是否为文件不存在错误
func isNotFoundError(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return apiErr.ErrorCode() == "NoSuchKey"
}
return false
}
// isAccessDeniedError 判断是否为访问拒绝错误
func isAccessDeniedError(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
return apiErr.ErrorCode() == "AccessDenied"
}
return false
}目录组织
推荐目录结构
clients/
├── storage/ # 对象存储 Client
│ ├── client.go # Client 实现
│ ├── models.go # 请求/响应模型
│ ├── errors.go # 错误定义
│ └── client_test.go # 单元测试
├── ai/ # AI 服务 Client
│ ├── client.go
│ ├── models.go
│ ├── errors.go
│ └── client_test.go
├── email/ # 邮件服务 Client
│ ├── client.go
│ ├── models.go
│ ├── errors.go
│ └── client_test.go
├── sms/ # 短信服务 Client
│ └── client.go
└── cache/ # 缓存服务 Client
└── client.go文件职责说明
| 文件 | 职责 |
|---|---|
client.go | Client 结构体、构造函数、业务方法 |
models.go | 请求/响应结构体定义 |
errors.go | 业务错误定义、错误转换函数 |
client_test.go | 单元测试和集成测试 |
Service 调用 Client
Service 依赖注入
go
// services/product.go
package services
import (
"context"
"your-project/clients/storage"
"your-project/models"
"gorm.io/gorm"
)
type ProductService struct {
db *gorm.DB
storageClient *storage.Client
}
// NewProductService 构造函数注入 Client
func NewProductService(db *gorm.DB, storageClient *storage.Client) *ProductService {
return &ProductService{
db: db,
storageClient: storageClient,
}
}
// CreateWithImage 创建商品并上传图片
func (s *ProductService) CreateWithImage(ctx context.Context, name string, price float64, imageData io.Reader) (*models.Product, error) {
// 1. 上传图片到对象存储(调用 Client)
imageKey := fmt.Sprintf("products/%s.jpg", uuid.New().String())
uploadResult, err := s.storageClient.Upload(ctx, imageKey, imageData)
if err != nil {
return nil, fmt.Errorf("图片上传失败: %w", err)
}
// 2. 创建商品记录
product := &models.Product{
Name: name,
Price: price,
ImageURL: uploadResult.URL,
}
if err := s.db.WithContext(ctx).Create(product).Error; err != nil {
// 上传成功但创建失败,清理已上传的图片
s.storageClient.Delete(ctx, imageKey)
return nil, err
}
return product, nil
}App 容器初始化
go
// app/app.go
package app
import (
"context"
"your-project/clients/storage"
"your-project/config"
"your-project/services"
"gorm.io/gorm"
)
type App struct {
config *config.Config
db *gorm.DB
// Clients
storageClient *storage.Client
aiClient *ai.Client
emailClient *email.Client
// Services
productService *services.ProductService
userService *services.UserService
}
func (a *App) Init(ctx context.Context, cfg *config.Config) error {
a.config = cfg
// 1. 初始化数据库
if err := a.initDatabase(ctx); err != nil {
return err
}
// 2. 初始化 Clients
if err := a.initClients(); err != nil {
return err
}
// 3. 初始化 Services(注入 Clients)
a.initServices()
return nil
}
func (a *App) initClients() error {
// 初始化对象存储 Client
storageClient, err := storage.NewClient(&a.config.Storage)
if err != nil {
return fmt.Errorf("failed to init storage client: %w", err)
}
a.storageClient = storageClient
// 初始化 AI Client
aiClient, err := ai.NewClient(&a.config.AI)
if err != nil {
return fmt.Errorf("failed to init ai client: %w", err)
}
a.aiClient = aiClient
return nil
}
func (a *App) initServices() {
// 注入 Clients 到 Services
a.productService = services.NewProductService(a.db, a.storageClient)
a.userService = services.NewUserService(a.db, a.emailClient)
}
func (a *App) Close(ctx context.Context) error {
// 关闭 Clients
if a.storageClient != nil {
a.storageClient.Close()
}
if a.aiClient != nil {
a.aiClient.Close()
}
return nil
}调用约束
允许的调用关系
✅ Service → Client (Service 调用 Client)
✅ Service → Service (Service 跨调用,通过依赖注入)禁止的调用关系
❌ Handler → Client (Handler 禁止直接调用 Client)
❌ Client → Service (Client 禁止调用 Service,避免循环依赖)
❌ Client → GORM (Client 禁止直接操作数据库)正确的调用链路:
HTTP 请求 → Handler → Service → Client → 第三方服务
↓
GORM → 数据库检查清单
实施 Client 层时,确保以下要点:
- [ ] Client 结构体只负责一个外部服务
- [ ] 构造函数接受配置结构体参数
- [ ] 构造函数校验配置完整性和有效性
- [ ] 方法签名第一个参数是
context.Context - [ ] 方法签名最后一个返回值是
error - [ ] 定义业务错误常量(如
ErrStorageUploadFailed) - [ ] 转换第三方错误为业务错误
- [ ] 实现
Close()方法支持资源清理 - [ ] 不暴露第三方 SDK 的实现细节