文章目录
- 引言:项目结构与代码组织
- 一、本篇学习目标与准备
- 二、核心概念与实现思路
- 1. 目录结构
- 2. 为什么要拆分
- 3. 分层架构
- 4. 关键目录说明
- cmd/
- internal/
- 为什么不用 pkg/?
- 5. 组合根(Composition Root)
- 6. 包的导入路径
- 7. 测试组织
- 三、课程实战:运行、源码与测试
- 1. 怎么运行
- 核心源码拆解:`CreateUser`
- 源码中的测试:`TestCreateUserStoreError`
- 四、实战练习
- 扩展练习 1:新增后台入口
- 扩展练习 2:重构消费方接口
- 五、常见问题与排错
- 问题 1:为什么复制文件以后 import 全部报错?
- 问题 2:包依赖形成循环,应该怎么拆?
- 六、与前文的联系和工程应用
- 1. 与前面课程的关系
- 2. 真实场景
- 七、本篇小结
- 下期预告
✍创作者:全栈弄潮儿
🏡 个人主页:全栈弄潮儿的个人主页
🏙️ 个人社区,欢迎你的加入:全栈开发社区
📙 专栏:Go零基础实战
引言:项目结构与代码组织
单文件练习可以让你专注语法,但功能越来越多以后,接口、业务和存储需要各自清晰的位置。本篇把用户服务拆进 cmd 和 internal,跟着导入关系理解包的职责,再观察程序入口如何把依赖组合成可运行的服务。
一、本篇学习目标与准备
- 理解 module path、包路径和 internal 边界。
- 按 Handler、Service、Store、Model 组织代码。
- 在组合根组装依赖并运行分层测试。
课程目录:backend-training/Go/19-project-structure。配套示例按课独立组织,每个目录都有自己的go.mod,使用 Go 1.22 或更高版本。运行和测试前进入该课目录。
正文代码包含源码节选与机制示意,节选依赖同课的类型和辅助函数;完整程序与测试放在配套源码中。
阅读提示:internal限制按目录树父级确定,并非简单按模块名划分。目录选择取决于项目职责,参见 Go 模块布局文档。
二、核心概念与实现思路
1. 目录结构
19-project-structure/ go.mod 模块定义 cmd/ server/ main.go 程序入口(组合根) internal/ 私有代码,外部项目不能 import model/ user.go 领域模型 errors.go 业务错误定义 store/ memory.go 存储接口 + 内存实现 service/ user.go 业务逻辑 user_test.go 单元测试(用 Mock) handler/ user.go HTTP 接口 user_test.go 集成测试(用 httptest)2. 为什么要拆分
单文件main.go适合小程序。随着代码增长:
- 所有逻辑挤在一个文件,难以导航。
- 测试和业务代码混在一起。
- 无法复用:另一个程序想用你的 store?只能复制粘贴。
拆分成包后:
- 每个包职责单一,文件小而聚焦。
- 测试和源码在同一个包,但分开文件。
- 包可以被多个入口程序复用。
3. 分层架构
cmd/server/main.go 程序入口,组装依赖 | v internal/handler HTTP 接口层:解析请求、调用 service、编码响应 | v internal/service 业务逻辑层:校验、编排、调用 store | v internal/store 数据存储层:接口定义 + 具体实现 | v internal/model 领域模型层:User 结构体、错误定义依赖方向从上往下:handler 依赖 service,service 依赖 store,所有层依赖 model。
model 是最底层,不依赖任何其他包。
4. 关键目录说明
cmd/
程序入口放在cmd/下。一个项目可能有多个入口:
cmd/ server/ 主服务 migrator/ 数据库迁移工具 worker/ 后台任务进程每个入口共享internal/里的代码,但有自己的main函数。
internal/
internal的规则由父目录范围决定:导入方必须位于该 internal 父目录所包含的目录树内。顶层 internal 通常保护项目内部实现,这不是简单按“同一个 go.mod”划定的规则。
为什么不用 pkg/?
Go 社区曾经流行pkg/目录放可复用的代码。但本篇按内部服务的需求选择:
- 如果代码真的可复用,拆成独立的 module(单独的 go.mod)。
- 如果只是项目内部使用,放在
internal/里。
本篇不使用pkg/,所有代码都在internal/里。
5. 组合根(Composition Root)
// cmd/server/main.gofuncmain(){userStore:=store.NewMemoryStore()userService:=service.NewUserService(userStore)userHandler:=handler.NewUserHandler(userService)mux:=http.NewServeMux()userHandler.Register(mux)// ...}所有依赖在一个地方创建、注入、连接。换数据库只需要改一行:
userStore:=store.NewPostgresStore(db)// 换成 PostgreSQLhandler 和 service 不需要任何改动。
6. 包的导入路径
// go.mod 中声明:module github.com/example/userapp// .go 文件中导入内部包import("github.com/example/userapp/internal/handler""github.com/example/userapp/internal/service""github.com/example/userapp/internal/store")模块名(module path)是导入路径的前缀。真实项目通常用github.com/用户名/项目名作为模块名。
7. 测试组织
Go 的惯例:测试文件和源文件在同一个包,文件名以_test.go结尾。
internal/service/ user.go 业务代码 user_test.go 测试代码(同一个 package service)好处:测试可以访问包内部未导出的字段和方法(如果需要的话)。同包测试可以访问未导出成员,本篇也测试了错误映射函数;外部测试包则主要面向导出 API。
三、课程实战:运行、源码与测试
1. 怎么运行
# 运行服务go run ./cmd/server# 运行所有测试gotest./...# 运行某个包的测试gotest./internal/service/...# 数据竞争检测gotest-race./...核心源码拆解:CreateUser
以下节选自本篇的internal/service/user.go,需要与同课的类型和辅助函数一起使用。
func(s*UserService)CreateUser(ctx context.Context,name,emailstring,ageint)(model.User,error){user:=model.User{Name:name,Email:email,Age:age}iferr:=user.Validate();err!=nil{returnmodel.User{},err}exists,err:=s.store.ExistsByEmail(ctx,email)iferr!=nil{returnmodel.User{},fmt.Errorf("check email failed: %w",err)}ifexists{returnmodel.User{},model.ErrEmailExists}saved,err:=s.store.Save(ctx,user)iferr!=nil{returnmodel.User{},fmt.Errorf("save user failed: %w",err)}returnsaved,nil}源码中的测试:TestCreateUserStoreError
测试节选来自同课的测试文件,辅助函数也在该文件中。阅读时关注输入条件与断言,不只看是否调用了某个函数。
funcTestCreateUserStoreError(t*testing.T){store:=newMockStore()store.existsError=errors.New("db connection lost")svc:=NewUserService(store)_,err:=svc.CreateUser(context.Background(),"Ada","ada@example.com",36)iferr==nil{t.Fatal("expected error from store failure")}}四、实战练习
扩展练习 1:新增后台入口
在 cmd/worker 中建立独立 main,复用 internal/service;比较不同入口与相同业务层的关系。
扩展练习 2:重构消费方接口
把 Service 需要的存储能力在 Service 包中定义,由 MemoryStore 隐式满足,比较与本课将接口放在 store 包的组织方式。
五、常见问题与排错
问题 1:为什么复制文件以后 import 全部报错?
导入前缀必须与 go.mod 中的 module 路径一致。先确认模块根目录,再按相同前缀导入内部包;不要把 module 声明放进普通 .go 文件。
问题 2:包依赖形成循环,应该怎么拆?
先画出 Handler → Service → Store 的依赖关系。下层不要回头导入上层,公共模型可以独立,必要时将小接口移到消费方;增加目录本身不会消除循环。
六、与前文的联系和工程应用
1. 与前面课程的关系
- 第 15 课(依赖注入):本篇是 DI 的完整实践,组合根在 main.go 中组装。
- 第 18 课(测试策略):本篇的测试分层和第 18 课一致——service 层用 Mock,handler 层用 httptest。
- 第 5 课(HTTP handler):handler 包就是第 5 课的升级版,加上了分层。
- 第 14 课(中间件链):中间件可以在 main.go 中套在 mux 外面。
2. 真实场景
生产级 Go 项目通常还有:
config/:配置加载(第 9 课)。middleware/:中间件(第 14 课)。database/:数据库连接和迁移。version/:版本信息。Makefile:常用命令封装。.github/workflows/:CI/CD 配置。docker-compose.yml:本地开发环境。openapi/:API 文档。
七、本篇小结
通过本篇,我们完成了:
- 理解 module path、包路径和 internal 边界。
- 按 Handler、Service、Store、Model 组织代码。
- 在组合根组装依赖并运行分层测试。
请记住这三个结论:
- 目录服务于职责和依赖关系,没有必须照搬的万能结构。
- cmd 组织可执行入口,internal 限制内部包导入范围。
- 所有依赖集中组装,分层测试更容易定位错误。
下期预告
下一篇,我们将学习事务与幂等性:写一个不会重复扣款的转账接口,继续把 Go 的基本机制放进真实后端场景中练习。
敬请期待!
✍坚持原创,求关注,点赞,收藏