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
有道理。谢谢。我不知道该如何命名这样的服务,我会考虑一下。谢谢。
更多关于Golang Web应用清洁架构实践指南的实战系列教程也可以访问 https://www.itying.com/category-94-b0.html
基于SOLID原则,听起来你应该将相关的服务聚合到一个内聚的模块或服务中,控制器可以依赖于此。例如,如果 userSvc、tokenSvc、fileSvc 和 countrySvc 紧密相关,可以考虑将它们包装在一个组合服务中。
根据你的描述,你已经有了一个不错的基础架构,但在依赖管理和业务逻辑组织上遇到了典型的清洁架构挑战。以下是我的分析和建议:
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
这种结构的关键优势:
- 依赖方向清晰:高层模块不依赖低层模块,都依赖抽象
- 业务逻辑集中:用例层包含完整的业务规则
- 易于测试:通过接口可以轻松mock依赖
- 可维护性高:每个模块职责单一,变更影响范围小
你的服务层应该专注于领域逻辑,而不是协调多个服务。通过用例层来组合多个领域服务,处理器只负责HTTP层面的转换和响应。

