Golang Web应用清洁架构实践指南

Golang Web应用清洁架构实践指南 你好,

我阅读了许多文章(通常过于简单,无法解答我的疑惑),也读了一些书,并进行了大量实践,但我仍然对如何构建一个Web应用程序的架构存在疑问。或者至少,对于一个复杂的应用程序,我发现自己编写的函数承担了太多职责,并且无法以良好的方式进行重构。

对于我的Web应用程序,我通常使用:

  • sqlc 来生成SQL样板代码
  • chi 路由器或其他替代方案
  • templ 用Go构建HTML

以下是我的项目布局(大致):

cmd/   # 主应用程序在此
internal/
    database/               # 初始化数据库、创建连接等的代码
       db/migrations/*sql   # SQL代码迁移(用于dbmate或替代方案)
    html
        handlers/           # 所有的处理器
        views/              # 所有用于生成HTML的templ代码
    middlewares/
    repository/             # 由sqlc生成的代码
    ...
pkg/
    config                  # 处理应用程序配置的配置包
    user                    # 例如,处理用户相关
    token                   # 例如,处理用户认证
    subscription            # 例如,处理用户订阅
    ...
queries/                    # 包含所有将被sqlc用来生成样板代码的查询

user、token、subscription等包通常处理基本操作(创建、获取、删除、更新)。我以相同的方式编写它们,这是一个服务层。

例如,对于我的user包,我有这样的代码:

package user
...

type Service struct {
	queries *repository.Queries
}

func NewService(db repository.DBTX) *Service {
	queries := repository.New(db)
	return &Service{
		queries: queries,
	}
}

func (s *Service) CreateNewUser(...)

// GetUserFromEmail 返回具有给定电子邮件的用户
func (s *Service) GetUserFromEmail(ctx context.Context, email string) (repository.ClientsUser, error) {
	return s.queries.GetUserWithItsEmail(ctx, email)
}

...

现在,对于我的handlers包,由于它们需要使用所有服务,其构造函数可能会变得非常庞大。在我当前的项目中,它看起来像这样:

package handlers

func NewController(cfg config.ConfigApp, userSvc *user.Service,
	tokenSvc *token.Service, fileSvc *file.Service,
	countrySvc *country.Service, redisClient *redis.Client,
	store storage.Storage, tSvc *turnstile.TurnstileSvc,
	mailSvc mailservice.SendMailer,
	stripeSvc *stripesvc.StripeSvc,
	subscriptionSvc *subscription.Service,
	productSvc *product.Service) *Controller {
	return &Controller{
		cfg:             cfg,
		userSvc:         userSvc,
		tokenSvc:        tokenSvc,
		fileSvc:         fileSvc,
		countrySvc:      countrySvc,
		storage:         store,
		turnstileSvc:    tSvc,
		mailSvc:         mailSvc,
		stripeSvc:       stripeSvc,
		subscriptionSvc: subscriptionSvc,
		productSvc:      productSvc,
	}
}

...

拥有这样一个控制器看起来合理吗?(即使我有时可以传递nil,用这个来编写单元测试也非常烦人。)

现在是我的主要问题。我的处理器中包含了业务逻辑。例如,对于一个允许用户上传文件的Web应用。业务逻辑可能会根据用户是否有订阅而不同,并且根据订阅类型的不同而不同。

我发现自己像这样编写上传处理器:

  • 使用tokensvc检查JWT令牌是否有效(这部分应该放在中间件中,这没问题)
  • 使用usersvc从令牌中的电子邮件识别用户
  • 获取请求的大小,以便稍后检查用户的订阅是否允许上传
  • 获取用户的活跃产品
  • 计算已上传的文件以检查用户是否可以上传当前文件
  • 我有一些基于订阅的业务逻辑
  • 然后在这里处理文件上传

这个处理器使用了:

  • token 服务
  • subscription 服务
  • product 服务
  • user 服务

我在想,用户服务本身是否不应该使用订阅服务、产品服务等?我的主要业务逻辑是围绕这些的。

如果你有耐心读到这里,谢谢你。


更多关于Golang Web应用清洁架构实践指南的实战教程也可以访问 https://www.itying.com/category-94-b0.html

3 回复

有道理。谢谢。我不知道该如何命名这样的服务,我会考虑一下。谢谢。

更多关于Golang Web应用清洁架构实践指南的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html


基于SOLID原则,听起来你应该将相关的服务聚合到一个内聚的模块或服务中,控制器可以依赖于此。例如,如果 userSvctokenSvcfileSvccountrySvc 紧密相关,可以考虑将它们包装在一个组合服务中。

根据你的描述,你已经有了一个不错的基础架构,但在依赖管理和业务逻辑组织上遇到了典型的清洁架构挑战。以下是我的分析和建议:

1. 依赖注入优化

你的控制器构造函数确实过于庞大。可以使用依赖容器或选项模式来简化:

// internal/di/container.go
package di

type Container struct {
    cfg             config.ConfigApp
    userSvc         *user.Service
    tokenSvc        *token.Service
    // ... 其他依赖
}

func NewContainer(cfg config.ConfigApp) (*Container, error) {
    db, err := database.New(cfg.Database)
    if err != nil {
        return nil, err
    }
    
    queries := repository.New(db)
    
    return &Container{
        cfg:      cfg,
        userSvc:  user.NewService(queries),
        tokenSvc: token.NewService(queries),
        // ... 初始化其他服务
    }, nil
}

// handlers/controller.go
package handlers

type Controller struct {
    container *di.Container
}

func NewController(container *di.Container) *Controller {
    return &Controller{
        container: container,
    }
}

2. 业务逻辑分离

对于文件上传这类复杂业务逻辑,应该创建专门的用例层:

// internal/usecase/fileupload.go
package usecase

type FileUploadUseCase struct {
    userRepo         user.Repository
    subscriptionRepo subscription.Repository
    productRepo      product.Repository
    storage          storage.Storage
}

func NewFileUploadUseCase(
    userRepo user.Repository,
    subscriptionRepo subscription.Repository,
    productRepo product.Repository,
    storage storage.Storage,
) *FileUploadUseCase {
    return &FileUploadUseCase{
        userRepo:         userRepo,
        subscriptionRepo: subscriptionRepo,
        productRepo:      productRepo,
        storage:          storage,
    }
}

func (uc *FileUploadUseCase) Execute(ctx context.Context, req UploadRequest) (*UploadResponse, error) {
    // 验证用户
    user, err := uc.userRepo.GetByID(ctx, req.UserID)
    if err != nil {
        return nil, err
    }
    
    // 检查订阅状态
    subscription, err := uc.subscriptionRepo.GetActive(ctx, user.ID)
    if err != nil {
        return nil, err
    }
    
    // 获取产品限制
    product, err := uc.productRepo.GetByID(ctx, subscription.ProductID)
    if err != nil {
        return nil, err
    }
    
    // 检查存储限制
    usedSpace, err := uc.storage.GetUsedSpace(ctx, user.ID)
    if err != nil {
        return nil, err
    }
    
    if usedSpace+req.FileSize > product.MaxStorage {
        return nil, ErrStorageLimitExceeded
    }
    
    // 执行上传
    fileURL, err := uc.storage.Upload(ctx, req.File, user.ID)
    if err != nil {
        return nil, err
    }
    
    return &UploadResponse{
        FileURL: fileURL,
        UserID:  user.ID,
    }, nil
}

3. 接口定义和依赖倒置

为每个服务定义接口,减少直接依赖:

// internal/domain/user/repository.go
package user

type Repository interface {
    GetByID(ctx context.Context, id int64) (*User, error)
    GetByEmail(ctx context.Context, email string) (*User, error)
    Create(ctx context.Context, user *User) error
    Update(ctx context.Context, user *User) error
}

// internal/infrastructure/persistence/user_repository.go
package persistence

type UserRepository struct {
    queries *repository.Queries
}

func (r *UserRepository) GetByID(ctx context.Context, id int64) (*user.User, error) {
    dbUser, err := r.queries.GetUserByID(ctx, id)
    if err != nil {
        return nil, err
    }
    
    return &user.User{
        ID:        dbUser.ID,
        Email:     dbUser.Email,
        CreatedAt: dbUser.CreatedAt,
    }, nil
}

4. 处理器简化

处理器只负责HTTP相关逻辑:

// internal/handlers/file_upload.go
package handlers

func (c *Controller) HandleFileUpload(w http.ResponseWriter, r *http.Request) {
    // 从上下文获取用户(中间件已设置)
    userID, ok := r.Context().Value("userID").(int64)
    if !ok {
        http.Error(w, "unauthorized", http.StatusUnauthorized)
        return
    }
    
    // 解析请求
    file, header, err := r.FormFile("file")
    if err != nil {
        http.Error(w, "invalid file", http.StatusBadRequest)
        return
    }
    defer file.Close()
    
    // 构建请求对象
    req := usecase.UploadRequest{
        UserID:   userID,
        File:     file,
        FileSize: header.Size,
        FileName: header.Filename,
    }
    
    // 执行业务逻辑
    resp, err := c.fileUploadUseCase.Execute(r.Context(), req)
    if err != nil {
        switch err {
        case usecase.ErrStorageLimitExceeded:
            http.Error(w, "storage limit exceeded", http.StatusForbidden)
        default:
            http.Error(w, "internal server error", http.StatusInternalServerError)
        }
        return
    }
    
    // 返回响应
    json.NewEncoder(w).Encode(resp)
}

5. 测试简化

使用接口可以轻松进行单元测试:

// internal/usecase/fileupload_test.go
package usecase_test

func TestFileUploadUseCase(t *testing.T) {
    mockUserRepo := &mockUserRepository{}
    mockSubRepo := &mockSubscriptionRepository{}
    mockProductRepo := &mockProductRepository{}
    mockStorage := &mockStorage{}
    
    usecase := NewFileUploadUseCase(
        mockUserRepo,
        mockSubRepo,
        mockProductRepo,
        mockStorage,
    )
    
    // 设置mock行为
    mockUserRepo.On("GetByID", mock.Anything, int64(1)).
        Return(&user.User{ID: 1, Email: "test@example.com"}, nil)
    
    // 执行测试
    resp, err := usecase.Execute(context.Background(), UploadRequest{
        UserID:   1,
        FileSize: 1024,
    })
    
    assert.NoError(t, err)
    assert.NotNil(t, resp)
}

6. 项目结构调整建议

internal/
├── domain/
│   ├── user/
│   │   ├── entity.go
│   │   ├── repository.go
│   │   └── service.go
│   ├── subscription/
│   └── product/
├── usecase/
│   ├── fileupload/
│   │   ├── usecase.go
│   │   └── port.go
│   └── userregistration/
├── infrastructure/
│   ├── persistence/
│   │   ├── user_repository.go
│   │   └── sqlc_adapter.go
│   ├── web/
│   │   ├── handlers/
│   │   └── middlewares/
│   └── storage/
└── di/
    └── container.go

这种结构的关键优势:

  1. 依赖方向清晰:高层模块不依赖低层模块,都依赖抽象
  2. 业务逻辑集中:用例层包含完整的业务规则
  3. 易于测试:通过接口可以轻松mock依赖
  4. 可维护性高:每个模块职责单一,变更影响范围小

你的服务层应该专注于领域逻辑,而不是协调多个服务。通过用例层来组合多个领域服务,处理器只负责HTTP层面的转换和响应。

回到顶部