Go接口设计与依赖注入实战

13 阅读7分钟

前言

Go的接口是隐式实现的,不需要显式声明"实现了某个接口"。这种设计让代码解耦更自然,但也容易写出难以测试和维护的代码。

本文整理接口设计的常见模式和依赖注入的实践方法,目标是写出更易测试、更灵活的Go代码。


1. 接口设计原则

1.1 接口要小

Go社区有句话:接口越小越好。标准库里大量的接口只有一两个方法。

// io包里的经典小接口
type Reader interface {
    Read(p []byte) (n int, err error)
}

type Writer interface {
    Write(p []byte) (n int, err error)
}

type Closer interface {
    Close() error
}

// 组合成更大的接口
type ReadWriter interface {
    Reader
    Writer
}

type ReadWriteCloser interface {
    Reader
    Writer
    Closer
}

为什么小接口更好

  1. 实现成本低,更多类型可以满足
  2. 组合灵活,按需组合
  3. Mock简单,测试容易

1.2 在使用方定义接口

不要在实现方定义接口,而是在使用方按需定义。

// ❌ 不推荐:在实现方定义大接口
package user

type UserService interface {
    GetUser(id int) (*User, error)
    CreateUser(u *User) error
    UpdateUser(u *User) error
    DeleteUser(id int) error
    ListUsers(page, size int) ([]*User, error)
    SearchUsers(query string) ([]*User, error)
    // ... 更多方法
}

type userServiceImpl struct {
    db *sql.DB
}

func NewUserService(db *sql.DB) UserService {
    return &userServiceImpl{db: db}
}
// ✅ 推荐:在使用方按需定义小接口
package order

// 只定义需要的方法
type UserGetter interface {
    GetUser(id int) (*user.User, error)
}

type OrderService struct {
    users UserGetter // 依赖接口,不是具体实现
}

func (s *OrderService) CreateOrder(userID int, items []Item) error {
    u, err := s.users.GetUser(userID)
    if err != nil {
        return err
    }
    // 创建订单逻辑
}

1.3 接受接口,返回结构体

// ✅ 推荐
func NewServer(logger Logger, db Database) *Server {
    return &Server{
        logger: logger,
        db:     db,
    }
}

// ❌ 不推荐
func NewServer(logger Logger, db Database) ServerInterface {
    return &Server{
        logger: logger,
        db:     db,
    }
}

原因

  • 返回具体类型,调用方可以访问所有方法
  • 返回接口会限制调用方,失去灵活性
  • 接口应该在使用方定义,不是实现方

2. 常见接口设计模式

2.1 策略模式

不同的算法封装成接口,运行时切换。

// 定义策略接口
type Compressor interface {
    Compress(data []byte) ([]byte, error)
    Decompress(data []byte) ([]byte, error)
}

// Gzip实现
type GzipCompressor struct{}

func (g *GzipCompressor) Compress(data []byte) ([]byte, error) {
    var buf bytes.Buffer
    w := gzip.NewWriter(&buf)
    if _, err := w.Write(data); err != nil {
        return nil, err
    }
    if err := w.Close(); err != nil {
        return nil, err
    }
    return buf.Bytes(), nil
}

func (g *GzipCompressor) Decompress(data []byte) ([]byte, error) {
    r, err := gzip.NewReader(bytes.NewReader(data))
    if err != nil {
        return nil, err
    }
    defer r.Close()
    return io.ReadAll(r)
}

// Zstd实现
type ZstdCompressor struct{}

func (z *ZstdCompressor) Compress(data []byte) ([]byte, error) {
    // zstd压缩实现
}

func (z *ZstdCompressor) Decompress(data []byte) ([]byte, error) {
    // zstd解压实现
}

// 使用方
type FileStorage struct {
    compressor Compressor
}

func (s *FileStorage) Save(filename string, data []byte) error {
    compressed, err := s.compressor.Compress(data)
    if err != nil {
        return err
    }
    return os.WriteFile(filename, compressed, 0644)
}

2.2 装饰器模式

在不修改原有实现的情况下增加功能。

// 基础接口
type HTTPClient interface {
    Do(req *http.Request) (*http.Response, error)
}

// 标准库的http.Client满足这个接口
var _ HTTPClient = (*http.Client)(nil)

// 带日志的装饰器
type LoggingClient struct {
    client HTTPClient
    logger Logger
}

func (c *LoggingClient) Do(req *http.Request) (*http.Response, error) {
    start := time.Now()
    c.logger.Info("request", "method", req.Method, "url", req.URL.String())
    
    resp, err := c.client.Do(req)
    
    c.logger.Info("response", 
        "method", req.Method,
        "url", req.URL.String(),
        "duration", time.Since(start),
        "status", resp.StatusCode,
    )
    return resp, err
}

// 带重试的装饰器
type RetryClient struct {
    client     HTTPClient
    maxRetries int
    backoff    time.Duration
}

func (c *RetryClient) Do(req *http.Request) (*http.Response, error) {
    var lastErr error
    for i := 0; i <= c.maxRetries; i++ {
        resp, err := c.client.Do(req)
        if err == nil && resp.StatusCode < 500 {
            return resp, nil
        }
        lastErr = err
        time.Sleep(c.backoff * time.Duration(i+1))
    }
    return nil, fmt.Errorf("max retries exceeded: %w", lastErr)
}

// 组合使用
func NewHTTPClient() HTTPClient {
    base := &http.Client{Timeout: 10 * time.Second}
    withRetry := &RetryClient{client: base, maxRetries: 3, backoff: time.Second}
    withLogging := &LoggingClient{client: withRetry, logger: defaultLogger}
    return withLogging
}

2.3 适配器模式

将不兼容的接口转换为期望的接口。

// 目标接口
type Cache interface {
    Get(key string) ([]byte, error)
    Set(key string, value []byte, ttl time.Duration) error
    Delete(key string) error
}

// Redis客户端(第三方库的接口)
type RedisClient struct {
    // ...
}

func (r *RedisClient) GetString(key string) (string, error) { ... }
func (r *RedisClient) SetEx(key string, value string, seconds int) error { ... }
func (r *RedisClient) Del(keys ...string) error { ... }

// 适配器
type RedisAdapter struct {
    client *RedisClient
}

func (a *RedisAdapter) Get(key string) ([]byte, error) {
    s, err := a.client.GetString(key)
    if err != nil {
        return nil, err
    }
    return []byte(s), nil
}

func (a *RedisAdapter) Set(key string, value []byte, ttl time.Duration) error {
    return a.client.SetEx(key, string(value), int(ttl.Seconds()))
}

func (a *RedisAdapter) Delete(key string) error {
    return a.client.Del(key)
}

// 使用
var _ Cache = (*RedisAdapter)(nil)

3. 依赖注入

3.1 构造函数注入

最常用的方式,依赖通过构造函数传入。

// 定义依赖接口
type Logger interface {
    Info(msg string, args ...interface{})
    Error(msg string, args ...interface{})
}

type UserRepository interface {
    GetByID(id int) (*User, error)
    Save(u *User) error
}

type EmailSender interface {
    Send(to, subject, body string) error
}

// 服务定义
type UserService struct {
    logger Logger
    repo   UserRepository
    email  EmailSender
}

// 构造函数注入
func NewUserService(logger Logger, repo UserRepository, email EmailSender) *UserService {
    return &UserService{
        logger: logger,
        repo:   repo,
        email:  email,
    }
}

func (s *UserService) Register(email, password string) (*User, error) {
    s.logger.Info("registering user", "email", email)
    
    user := &User{
        Email:    email,
        Password: hashPassword(password),
    }
    
    if err := s.repo.Save(user); err != nil {
        s.logger.Error("failed to save user", "error", err)
        return nil, err
    }
    
    if err := s.email.Send(email, "Welcome", "Welcome to our service!"); err != nil {
        s.logger.Error("failed to send welcome email", "error", err)
        // 不返回错误,发邮件失败不影响注册
    }
    
    return user, nil
}

3.2 函数选项模式

适合有多个可选依赖的情况。

type Server struct {
    addr    string
    logger  Logger
    db      Database
    cache   Cache
    timeout time.Duration
}

type Option func(*Server)

func WithLogger(l Logger) Option {
    return func(s *Server) {
        s.logger = l
    }
}

func WithDatabase(db Database) Option {
    return func(s *Server) {
        s.db = db
    }
}

func WithCache(c Cache) Option {
    return func(s *Server) {
        s.cache = c
    }
}

func WithTimeout(d time.Duration) Option {
    return func(s *Server) {
        s.timeout = d
    }
}

func NewServer(addr string, opts ...Option) *Server {
    s := &Server{
        addr:    addr,
        logger:  defaultLogger,  // 默认值
        timeout: 30 * time.Second,
    }
    
    for _, opt := range opts {
        opt(s)
    }
    
    return s
}

// 使用
server := NewServer(":8080",
    WithLogger(customLogger),
    WithDatabase(mysqlDB),
    WithTimeout(60*time.Second),
)

3.3 手动组装依赖

对于小型项目,在main函数里手动组装足够。

func main() {
    // 基础设施层
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    
    db, err := sql.Open("mysql", os.Getenv("DATABASE_URL"))
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    
    redisClient := redis.NewClient(&redis.Options{
        Addr: os.Getenv("REDIS_URL"),
    })
    
    // 仓储层
    userRepo := repository.NewUserRepository(db)
    orderRepo := repository.NewOrderRepository(db)
    
    // 服务层
    emailSender := email.NewSMTPSender(os.Getenv("SMTP_HOST"))
    cache := cache.NewRedisCache(redisClient)
    
    userService := service.NewUserService(logger, userRepo, emailSender)
    orderService := service.NewOrderService(logger, orderRepo, userRepo, cache)
    
    // HTTP层
    userHandler := handler.NewUserHandler(userService)
    orderHandler := handler.NewOrderHandler(orderService)
    
    // 路由
    mux := http.NewServeMux()
    mux.HandleFunc("/users", userHandler.Handle)
    mux.HandleFunc("/orders", orderHandler.Handle)
    
    // 启动
    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }
    
    log.Fatal(server.ListenAndServe())
}

3.4 使用Wire做依赖注入

项目大了之后,手动组装依赖很繁琐。Wire是Google出的依赖注入代码生成工具。

// wire.go
//go:build wireinject
// +build wireinject

package main

import (
    "github.com/google/wire"
)

func InitializeApp() (*App, error) {
    wire.Build(
        // 提供者
        NewConfig,
        NewLogger,
        NewDatabase,
        NewRedisClient,
        
        // 仓储
        repository.NewUserRepository,
        repository.NewOrderRepository,
        
        // 服务
        service.NewUserService,
        service.NewOrderService,
        
        // Handler
        handler.NewUserHandler,
        handler.NewOrderHandler,
        
        // App
        NewApp,
    )
    return nil, nil
}

运行wire命令,自动生成组装代码。


4. 测试中的接口使用

4.1 手写Mock

简单场景下,手写Mock最直接。

// 生产代码
type UserRepository interface {
    GetByID(id int) (*User, error)
    Save(u *User) error
}

// 测试用Mock
type MockUserRepository struct {
    GetByIDFunc func(id int) (*User, error)
    SaveFunc    func(u *User) error
}

func (m *MockUserRepository) GetByID(id int) (*User, error) {
    if m.GetByIDFunc != nil {
        return m.GetByIDFunc(id)
    }
    return nil, nil
}

func (m *MockUserRepository) Save(u *User) error {
    if m.SaveFunc != nil {
        return m.SaveFunc(u)
    }
    return nil
}

// 测试
func TestUserService_Register(t *testing.T) {
    mockRepo := &MockUserRepository{
        SaveFunc: func(u *User) error {
            if u.Email == "" {
                return errors.New("email required")
            }
            u.ID = 1
            return nil
        },
    }
    
    mockEmail := &MockEmailSender{
        SendFunc: func(to, subject, body string) error {
            return nil
        },
    }
    
    svc := NewUserService(testLogger, mockRepo, mockEmail)
    
    user, err := svc.Register("test@example.com", "password")
    
    assert.NoError(t, err)
    assert.Equal(t, 1, user.ID)
}

4.2 使用mockgen

项目大了用mockgen自动生成Mock代码。

# 安装
go install go.uber.org/mock/mockgen@latest

# 生成
mockgen -source=repository.go -destination=mock_repository.go -package=repository
// 使用生成的Mock
func TestUserService_Register(t *testing.T) {
    ctrl := gomock.NewController(t)
    defer ctrl.Finish()
    
    mockRepo := NewMockUserRepository(ctrl)
    mockEmail := NewMockEmailSender(ctrl)
    
    // 设置期望
    mockRepo.EXPECT().
        Save(gomock.Any()).
        DoAndReturn(func(u *User) error {
            u.ID = 1
            return nil
        })
    
    mockEmail.EXPECT().
        Send("test@example.com", gomock.Any(), gomock.Any()).
        Return(nil)
    
    svc := NewUserService(testLogger, mockRepo, mockEmail)
    
    user, err := svc.Register("test@example.com", "password")
    
    assert.NoError(t, err)
    assert.Equal(t, 1, user.ID)
}

4.3 表驱动测试

配合接口,表驱动测试很方便。

func TestUserService_GetUser(t *testing.T) {
    tests := []struct {
        name    string
        userID  int
        setup   func(*MockUserRepository)
        want    *User
        wantErr bool
    }{
        {
            name:   "user exists",
            userID: 1,
            setup: func(m *MockUserRepository) {
                m.GetByIDFunc = func(id int) (*User, error) {
                    return &User{ID: 1, Email: "test@example.com"}, nil
                }
            },
            want: &User{ID: 1, Email: "test@example.com"},
        },
        {
            name:   "user not found",
            userID: 999,
            setup: func(m *MockUserRepository) {
                m.GetByIDFunc = func(id int) (*User, error) {
                    return nil, ErrNotFound
                }
            },
            wantErr: true,
        },
        {
            name:   "database error",
            userID: 1,
            setup: func(m *MockUserRepository) {
                m.GetByIDFunc = func(id int) (*User, error) {
                    return nil, errors.New("connection refused")
                }
            },
            wantErr: true,
        },
    }
    
    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            mockRepo := &MockUserRepository{}
            tt.setup(mockRepo)
            
            svc := NewUserService(testLogger, mockRepo, nil)
            
            got, err := svc.GetUser(tt.userID)
            
            if tt.wantErr {
                assert.Error(t, err)
                return
            }
            
            assert.NoError(t, err)
            assert.Equal(t, tt.want, got)
        })
    }
}

5. 实际项目结构

一个典型的分层结构:

myapp/
├── cmd/
│   └── server/
│       └── main.go          # 组装依赖,启动服务
├── internal/
│   ├── domain/              # 领域模型
│   │   ├── user.go
│   │   └── order.go
│   ├── repository/          # 数据访问层
│   │   ├── interface.go     # 接口定义
│   │   ├── user.go          # 实现
│   │   └── user_test.go
│   ├── service/             # 业务逻辑层
│   │   ├── user.go
│   │   └── user_test.go
│   └── handler/             # HTTP处理层
│       ├── user.go
│       └── user_test.go
├── pkg/                     # 可复用的库
│   ├── logger/
│   └── cache/
└── go.mod

接口定义位置的选择:

// internal/repository/interface.go
// 仓储接口定义在repository包,因为实现也在这里
type UserRepository interface {
    GetByID(id int) (*domain.User, error)
    Save(u *domain.User) error
    // ...
}

// internal/service/user.go
// 服务层依赖接口,但接口可能定义在其他包
type UserService struct {
    repo   repository.UserRepository  // 或者在service包里定义小接口
    logger Logger
}

总结

要点说明
接口要小一两个方法最好,便于实现和Mock
在使用方定义接口按需定义,避免大而全的接口
接受接口返回结构体构造函数接收接口类型,返回具体类型
构造函数注入最常用的依赖注入方式
函数选项模式适合可选依赖多的情况
Mock测试小项目手写Mock,大项目用mockgen
分层结构domain/repository/service/handler

核心思想

  1. 面向接口编程,但不要过度设计。不是所有东西都要抽接口,有明确需求再抽。
  2. 依赖倒置。高层模块不依赖低层模块,都依赖抽象。
  3. 可测试性。写代码时想着怎么测试,自然会写出松耦合的代码。

接口设计没有银弹,关键是理解原则背后的原因,然后根据项目实际情况做取舍。