登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

拆分内部模块并用语义化版本维护依赖边界

来源:17golang原创

时间:2026-10-07 11:54:41 492浏览 收藏

把一个目录拆成独立 Go module,真正获得的不是“目录更整齐”,而是独立的发布、版本与兼容承诺。只有被多个消费者复用、需要独立演进并且公开 API 已经相对稳定的能力,才值得跨出原 module;仍频繁联动的业务实现保留在同一 module 或 internal 包里更合适。

本文从零搭建一个小项目:money 提供金额能力,order 是消费它的服务。两个目录各有 go.mod,本地用 go.work 联调,正式依赖则使用 money/v1.0.0 这类子目录语义化标签。

Go Modules 官方参考:https://go.dev/ref/mod

最小可用方案
  • 一个 module 对应一组共同发布、共同版本化的包,不要按每个 package 机械拆 module。
  • 同仓库子目录模块的标签必须带目录前缀,例如 money/v1.0.0。
  • go.work 只负责本地多模块组合,生产依赖仍应写入消费者的 go.mod。
  • 兼容新增升级 minor,兼容修复升级 patch,破坏性变更升级 major;从 v2 开始模块路径带 /v2。

项目目标:把稳定能力拆成独立发布单元

示例仓库只有两个顶层目录:money/ 是可发布模块,包含公开包和只允许模块内部使用的 internal/validate;order/ 是消费模块。仓库根目录的 go.work 仅用于本地开发。

这三个边界不要混淆:

  • package:同一目录中一起编译的 Go 源文件。
  • module:一起发布、版本化和分发的一组 package,由根目录的 go.mod 标识。
  • internal:编译器约束的导入范围;它可以存在于 module 内,但不等于独立 module。
单仓库中 money 模块、order 模块、公开 API、internal 实现和 go.work 的静态边界
图1:多模块仓库边界结构图。money 与 order 各自拥有 go.mod,order 只依赖 money 的公开 API;go.work 仅在本地工作区把两个模块组合起来。

拆分前先检查三个条件:能力是否被至少两个独立消费者需要;能否用少量公开类型和函数表达契约;是否愿意为已发布的 v1 API 承担兼容责任。只满足“文件很多”并不是拆 module 的理由。

环境准备:创建两个独立 module

以下命令使用示例模块路径 example.com/acme/money 和 example.com/acme/order。真实项目应替换为可访问的仓库路径。

# 创建同仓库中的两个模块目录。
mkdir -p money/internal/validate order/cmd/order

# 每个模块单独初始化 go.mod,形成独立发布边界。
cd money && go mod init example.com/acme/money
cd ../order && go mod init example.com/acme/order
cd ..

# 工作区只服务本地联调,不替代消费者 go.mod 中的正式版本。
go work init ./money ./order

完成后,money/go.mod 和 order/go.mod 各自声明模块路径;根目录 go.work 的 use 列表让 Go 命令在本地优先使用这两个工作区模块。不要为了联调把永久的本地绝对路径写进可发布配置。

核心代码:只暴露稳定的金额契约

money 模块公开 Amount 和构造函数,把货币代码校验藏在 internal/validate。这样消费者依赖的是业务契约,而不是校验实现。

package money

import (
    "fmt"

    "example.com/acme/money/internal/validate"
)

// Amount 以最小货币单位保存金额,避免浮点精度进入公开契约。
type Amount struct {
    Cents    int64
    Currency string
}

// New 构造合法金额;校验细节保留在模块内部。
func New(cents int64, currency string) (Amount, error) {
    if cents 

internal/validate 可以被 money 模块内的包使用,但位于允许父目录之外的消费者无法导入它。这个限制能阻止 order 绕过公开 API 绑定内部细节。

package validate

import "fmt"

// Currency 只接受示例项目支持的货币代码。
func Currency(code string) error {
    switch code {
    case "CNY", "USD":
        return nil
    default:
        return fmt.Errorf("unsupported currency: %s", code)
    }
}

消费模块只导入 example.com/acme/money:

package main

import (
    "fmt"
    "log"

    "example.com/acme/money"
)

func main() {
    // 消费者只依赖公开构造函数,不接触 internal 校验包。
    total, err := money.New(2599, "CNY")
    if err != nil {
        log.Fatal(err)
    }
    fmt.Printf("order total: %d %s\n", total.Cents, total.Currency)
}

本地运行:用 go.work 联调,不提交临时 replace

工作区启用后,可在仓库根目录同时测试两个模块。这里的目标不是证明发布版本可下载,而是快速验证当前工作树中的跨模块修改。

# 查看工作区中参与联调的模块路径。
go work edit -json

# 对两个工作区模块执行测试,及时发现公开 API 改动造成的编译错误。
go test ./money/... ./order/...

# 直接运行消费端,确认公开契约可以正常组合。
go run ./order/cmd/order

不要把 replace example.com/acme/money => ../money 当作正式依赖长期提交。replace 只在主模块中生效,发布 order 后不会替下游替你找到本地目录。团队可按仓库策略决定是否提交 go.work,但 CI 至少要额外在每个 module 目录独立执行测试,避免工作区意外掩盖缺失依赖。

部署与集成:用语义化标签替代本地路径

Go 模块版本以 v 开头,并遵循 major.minor.patch。由于 money 位于仓库子目录,版本标签需要加子目录前缀。第一次稳定发布可以这样准备:

# 先在 money 模块内整理依赖并运行测试。
cd money
go mod tidy
go test ./...
cd ..

# 子目录模块标签必须包含目录前缀,标签指向已提交的稳定快照。
git tag money/v1.0.0
git push origin money/v1.0.0

标签发布后,order 应记录正式版本依赖。私有仓库还需要在构建环境正确配置 GOPRIVATE 和凭据,但不要把令牌写进 go.mod、文章或脚本。

# 在消费模块中写入可复现的正式版本依赖。
cd order
go get example.com/acme/money@v1.0.0
go mod tidy

# 禁用工作区后再测试一次,确认发布依赖可以独立解析。
GOWORK=off go test ./...

最终 order/go.mod 应有明确的 require,而不是本地路径:

module example.com/acme/order

go 1.25.0

// 正式构建依赖不可变的语义化版本,而不是开发机目录。
require example.com/acme/money v1.0.0
v1 兼容版本、v2 新模块路径和 order 消费者之间的静态关系
图2:语义化版本兼容域。v1.1.0 表示兼容新增,v1.1.1 表示不改变公开接口的修复;破坏性变更进入带 /v2 后缀的新模块路径。

版本维护:让版本号直接表达依赖风险

改动版本选择模块路径
新增兼容函数,不影响旧调用v1.1.0example.com/acme/money
修复实现错误,公开接口不变v1.1.1example.com/acme/money
删除字段、改变参数或语义不兼容v2.0.0example.com/acme/money/v2
试验期且不承诺兼容v0.x.yexample.com/acme/money

从 v2 开始,Go 要求主版本后缀进入模块路径。money/go.mod 需要声明 module example.com/acme/money/v2,消费者的 import 也改为对应路径。v1 与 v2 因路径不同,可以出现在同一个构建图中,这正是破坏性版本不会静默替换旧代码的关键。

不要用“内部模块”作为忽略语义化版本的理由。只要它被另一个 module 依赖,版本就是变更契约。私有仓库同样需要标签、变更说明和兼容策略;区别只是分发范围,不是依赖风险。

验收:同时验证工作区和发布边界

  • money 与 order 各自拥有独立且可解析的 go.mod。
  • order 只能导入 money 的公开包,无法导入其 internal 实现。
  • 工作区模式下,跨模块改动能够立即联调。
  • GOWORK=off 时,消费者仍能依赖已发布标签完成测试。
  • 子目录标签使用 money/vX.Y.Z,版本号与公开 API 兼容级别一致。
  • 破坏性版本同时修改 module path 和 import path,而不是只打一个 v2 标签。

常见问题

每个 package 都应该有自己的 go.mod 吗?

不应该。module 是发布和版本单元。一起发布、共享兼容周期的 package 放在同一 module 中,只有独立演进与复用压力明确时再拆。

有 go.work 后还需要 require 吗?

需要。go.work 组合本地模块,require 记录可复现的正式版本。禁用工作区的 CI 测试可以检查依赖声明是否完整。

子目录模块为什么不能直接打 v1.0.0?

同一仓库可能包含多个模块,标签必须用模块子目录作为前缀,Go 才能把版本映射到正确模块。本文示例应使用 money/v1.0.0。

私有模块需要语义化版本吗?

需要。语义化版本描述的是兼容性,不取决于仓库是否公开。私有模块还应配置 GOPRIVATE,避免把私有路径交给公共代理或校验服务。

这套结构的关键是把开发便利与发布契约分开:go.work 让同仓库修改保持顺畅,go.mod 和语义化标签让消费者获得稳定、可回滚、可审计的依赖边界。模块拆分因此不再只是目录重排,而成为明确的工程治理手段。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>