广告位 · 页头(后台-模块管理-新建模块后替换此区域)

Golang如何拆分业务逻辑到子包?Golang package分层实践

把HTTP处理、数据库访问、业务判断全部写在一个main包或单个handler包中,是Go项目起步时常见的做法。这种写法在接口数量不多时的确高效,可一旦业务开始增长,订单、支付、库存、营销等逻辑互相穿插,一个函数动辄上百行,修改一处就需要全局搜索。想把代码维护成本降下来,第一件事往往不是引入更多框架,而是先把包结构拆对。

Golang如何拆分业务逻辑到子包?Golang package分层实践

一、先明确分层目标:让依赖单向流动

Go的package不是简单的目录分类,它同时承担着可见性和编译单元两个职责。同包内小写标识符互相可见,跨包只能使用大写标识符;同时Go编译器不允许包之间出现循环导入。这个限制看似麻烦,实际上能逼迫开发者把依赖方向设计清楚。常见的分层做法是把代码分成传输层、业务层、数据层和模型层。

典型目录可以这样规划:

shop/
cmd/api/main.go
internal/handler/
internal/service/
internal/repository/
internal/model/
go.mod

其中handler只负责解析HTTP参数、调用service并写响应;service承载核心业务规则和事务编排;repository负责数据库、缓存等外部存储访问;model存放结构体和接口。依赖方向是handler依赖service,service依赖repository和model,model不依赖任何业务包。把方向固定下来后,后续新增功能时就不会出现从repository反向调用service的奇怪代码。

这里需要说明的是,internal目录是Go官方推荐的私有包保护机制。放在internal下的包只能被其父目录树内的代码导入,外部模块无法引用,对应用服务来说非常合适。cmd目录则存放可执行程序入口,通常只做装配工作,不写具体业务逻辑。

二、按层拆还是按模块拆:先看项目边界

分层目录看起来直观,但业务复杂后会出现另一个问题:service目录里混杂了订单、用户、支付、营销等所有领域逻辑,文件数量可能膨胀到几十个。此时可以引入第二种策略——按功能模块拆分。每个模块内部再保留自己的handler、service、repository结构。

按模块拆分的目录可能长这样:

shop/
cmd/api/main.go
internal/order/handler.go
internal/order/service.go
internal/order/repository.go
internal/user/handler.go
internal/user/service.go
internal/user/repository.go
internal/pkg/db/
internal/pkg/response/
go.mod

按模块拆的好处是修改某个业务域时,所有相关文件都在一个目录下,新人更容易定位。挑战在于模块之间如果直接互相调用,容易出现order依赖user、user又依赖order的情况。解决办法是让模块之间通过接口或事件解耦。例如订单服务需要查询用户信息,可以让order/service.go定义一个小接口UserReader,由user模块或其他适配层提供实现,而不是直接导入user包的具体类型。

我的建议是:接口数量在20个以内的小型项目直接按层拆即可;当团队按业务域分工,或者单个业务域代码已经超过一个层包的可控范围,就逐步迁移到按模块拆分。也可以采用混合模式,先按模块,再在模块内部按层,这样既有清晰领域边界,又保留分层约束。

三、用接口和构造函数注入把依赖接到一起

拆包后最常遇到的问题是循环依赖。假设service包需要调用repository包,而repository包为了写日志又要导入service包,Go编译器会直接报import cycle not allowed。解决思路是高层模块定义接口,低层模块只负责实现,装配阶段再注入具体对象。Go的接口是隐式实现的,这比Java式的显式implements更适合解耦。

下面以订单创建为例。order/service.go中不直接导入repository包,而是声明一个OrderRepository接口:

package service
import (
    "context"
    "errors"
)
type Order struct {
    ID     int64
    UserID int64
    Amount float64
}
type OrderRepository interface {
    Create(ctx context.Context, order Order) error
    FindByID(ctx context.Context, id int64) (Order, error)
}
type OrderService struct {
    repo OrderRepository
}
func NewOrderService(repo OrderRepository) *OrderService {
    return &OrderService{repo: repo}
}
func (s *OrderService) CreateOrder(ctx context.Context, o Order) error {
    if o.Amount <= 0 {
        return errors.New("amount must be positive")
    }
    return s.repo.Create(ctx, o)
}

在order/repository.go或者基础设施包中提供MySQL实现:

package repository
import (
    "context"
    "database/sql"
    "ipipp.com/shop/internal/order/service"
)
type MySQLOrderRepository struct {
    db *sql.DB
}
func NewMySQLOrderRepository(db *sql.DB) *MySQLOrderRepository {
    return &MySQLOrderRepository{db: db}
}
func (r *MySQLOrderRepository) Create(ctx context.Context, o service.Order) error {
    _, err := r.db.ExecContext(ctx,
        "INSERT INTO orders(user_id, amount) VALUES(?, ?)",
        o.UserID, o.Amount,
    )
    return err
}
func (r *MySQLOrderRepository) FindByID(ctx context.Context, id int64) (service.Order, error) {
    var o service.Order
    err := r.db.QueryRowContext(ctx,
        "SELECT id, user_id, amount FROM orders WHERE id = ?",
        id,
    ).Scan(&o.ID, &o.UserID, &o.Amount)
    return o, err
}

构造函数NewOrderService接收接口类型,main.go只负责创建MySQL仓库并注入:

package main
import (
    "database/sql"
    "ipipp.com/shop/internal/order/repository"
    "ipipp.com/shop/internal/order/service"
)
func main() {
    db, _ := sql.Open("mysql", "user:pass@tcp(127.0.0.1:3306)/shop")
    repo := repository.NewMySQLOrderRepository(db)
    svc := service.NewOrderService(repo)
    _ = svc
}

通过这种方式,service包完全不知道MySQL实现的存在,后续想换成PostgreSQL或内存实现,只需要新增一个实现类并在main中替换即可。更重要的是包依赖图保持单向:main依赖repository和service,repository依赖service中的接口定义,没有反向引用。

四、拆分时最容易犯的四个错误

第一个误区是过度拆分。每个结构体单独一个包、每个接口一个目录,会让导入路径变得极长,阅读代码时不断跳转。包的数量应该与团队认知负担匹配,通常一个模块内不超过十几个文件就没必要继续拆。

第二个误区是公共包变成垃圾场。utils、common、helpers这类包往往什么都能放,最终包内函数互相依赖其他业务包,形成隐式循环依赖。更推荐的做法是按职责命名,例如internal/pkg/db专门封装数据库连接,internal/pkg/response专门处理HTTP响应格式。如果某个工具函数只被一个模块使用,就放在那个模块内部。

第三个误区是模型在不同层之间重复定义。例如handler层有一个UserRequest,service层有UserBO,repository层有UserDO,它们字段几乎完全一样,只是名字不同。虽然某些场景需要隔离请求和存储结构,但多数情况下可以在service或model包定义统一的领域结构体,handler只做参数绑定,repository直接持久化该结构体的子集。重复定义会让转换代码占据大量篇幅。

第四个误区是错误处理被吞掉。跨包调用时,service层如果直接返回repository的底层错误,上层就不得不依赖数据库驱动包来理解错误。更好的做法是在边界处包装错误:repository返回fmt.Errorf("create order: %w", err),service决定是否重试,handler决定返回500还是400。

下面是一段常见错误示范:

func (h *Handler) CreateOrder(w http.ResponseWriter, r *http.Request) {
    var req struct {
        UserID int64   `json:"user_id"`
        Amount float64 `json:"amount"`
    }
    json.NewDecoder(r.Body).Decode(&req)
    orderID, err := h.Service.CreateOrder(req.UserID, req.Amount)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(map[string]int64{"order_id": orderID})
}

这段代码把参数校验、错误分类、响应序列化都堆在handler中。更好的做法是让handler只负责HTTP协议,业务规则交给service,错误分类也由service返回明确的错误类型,再由handler统一映射。拆分不是简单移动文件,而是把职责重新分配。目录结构只是表象,真正的分层发生在依赖方向和接口边界上。只要坚持上层依赖下层、具体依赖抽象、模块内部高内聚,Go项目的包结构就能随业务扩展保持清晰可控。

广告位 · 文章底部(后台-模块管理替换此区域)