Skip to content

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)

规范要点

  1. 第一个参数必须是 context.Context
  2. 最后一个返回值必须是 error
  3. 方法名使用大驼峰(PascalCase)
  4. 参数使用小驼峰(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.goClient 结构体、构造函数、业务方法
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 的实现细节

相关规范