你好,我是利威尔xu。
这是「项目实战」专栏的第一篇。前面在面试八股和保姆级系列里聊了太多原理,这次换个角度:从 0 把一个 Go 服务搭起来,到底该建哪些目录、接口怎么定、错误码怎么编、配置怎么放。这一篇是整个专栏的地基,后面所有实战项目都按这套骨架来。
点名一下受众:能写 Go、能跑通 Gin 的 CRUD,但没从零搭过一个能被三个人以上协作的工程骨架的同学。如果你已经在维护一个"所有代码都塞 main.go"的项目,这篇基本就是你的改造清单。
建议先收藏——目录取舍表和错误码分段表是干活的速查件,不是读完就忘的爽文。
文章目录
-
- 一、先看结论:25 个目录,我们只留了 9 个
-
- 我们现在的骨架
- 二、/internal、/cmd、/vendor 三个必须讲清的
-
- /internal 不是约定,是编译器强制的
- /cmd 的三条纪律
- /vendor 什么时候该用,什么时候绝对别用
- 三、API 设计:5 条硬规则和 3 个反例
- 四、安全性与幂等性:那张表格背后是真金白银
- 五、错误码:为什么不能全局统一,六段编号怎么算
-
- 为什么不推荐全局错误码
- 按项目 + 服务 + 模块 + 错误码四级编号
- Go 里的落地写法
- 六、HTTP 状态码与响应体:三层各管什么
-
- 状态码的分工
- 统一响应结构
- 七、配置:四类的边界在哪
-
- 配置设计 6 条纪律
- 配置安全的 3 条红线
- viper 落地 + 三个真实的坑
- 八、8 个面试追问
一、先看结论:25 个目录,我们只留了 9 个
先把我认为最容易被误传的一件事说清楚。
网上流传的 Go 项目目录结构(golang-standards/project-layout)有 25 个顶层目录,很多人以为这是"标准"。它不是。那个仓库自己第一句就写着这不是官方标准,Go 官方从来没规定过目录结构。
我把那 25 个目录逐个过了一遍,按"我们团队实际留没留"分了三档:
| cmd/ | 程序入口,一个子目录一个可执行文件 | ✅ 必留 | 多入口时无可替代 |
| internal/ | 私有代码,外部无法 import | ✅ 必留 | Go 编译器强制,不是约定 |
| pkg/ | 可被外部导入的公共包 | ⚠️ 按需 | 不对外发库就别建,容易变垃圾场 |
| api/ | 接口定义文件(proto / OpenAPI) | ✅ 必留 | 前后端契约的唯一来源 |
| configs/ | 配置文件模板 | ✅ 必留 | 配上 example 文件给新人抄 |
| docs/ | 设计文档、部署手册 | ✅ 必留 | 出事时的救命资料 |
| scripts/ | 构建、检查脚本 | ✅ 必留 | Makefile 里每条指令的实体 |
| Makefile | 编译入口 | ✅ 必留 | 一条 make build 统一所有人 |
| go.mod | 模块定义 | ✅ 必留 | — |
| deployments/ | K8s / compose 编排 | ⚠️ 按需 | 有 CI 平台就不用手写文件夹分层 |
| build/ | Dockerfile、打包产物 | ⚠️ 按需 | Dockerfile 放根目录也完全没问题 |
| test/ | 集成测试 | ⚠️ 按需 | 单元测试跟代码同目录更好 |
| web/ | 服务端模板、SPA | ⚠️ 按需 | 纯 API 服务直接删 |
| assets/ | 前端图片、CSS、JS | ⚠️ 按需 | 同上,Go 仓库不该背前端资源 |
| website/ | GitHub Pages 站点数据 | ❌ 删 | 一个服务不需要 |
| examples/ | 示例代码 | ❌ 删 | 有文档就够了 |
| init/ | systemd / supervisord 配置 | ❌ 删 | 容器时代不需要 |
| third_party/ | 魔改的第三方源码 | ❌ 删 | 真魔改就 fork 自己的仓库 |
| tools/ | 项目工具 | ❌ 删 | 和 scripts/、pkg/ 重复 |
| vendor/ | 依赖源码副本 | ❌ 删 | 见第二节 |
| ci/ | 持续集成文件 | ❌ 删 | 现在都在 .github/workflows |
| githooks/ | Git 钩子 | ❌ 删 | 用 .githooks 或 husky |
| changelogs/ | 变更日志 | ❌ 删 | 用 release notes |
| benchmark/ | 基准测试 | ❌ 删 | _test.go 里就能写 |
| configs 下的 _example | 配置示例 | ✅ 必留 | 别把真实配置提交上去 |
9 个必留、6 个按需、10 个删掉,这就是我们现在的形状。
为什么删这么狠?不是洁癖,是被镜像体积坑过。
去年一个 Go 服务上线,镜像推上去 1.2G。查了半天,原因是仓库里塞了 web/ 和 assets/ 两个目录(加起来 900M 的 node_modules 和构建产物),Dockerfile 里写的是 COPY . .,全打进镜像了。本来这个服务的镜像应该是 80M。
后来改了两件事:一是删掉这两个目录(前端独立仓库),二是 Dockerfile 用白名单 COPY。镜像回到 76M,冷启动从 40 秒降到 6 秒。
空目录是负债,不是资产。 一个新人看到 25 个目录,第一反应永远是"我该往哪放",然后随手挑一个,三个月后同一个功能散落在三个目录里。
我们现在的骨架
.
├── api/ # 接口契约:OpenAPI yaml / proto
├── cmd/
│ ├── api-server/ # 对外 HTTP 服务入口
│ └── worker/ # 异步任务消费者入口
├── configs/
│ ├── config.yaml # 默认配置(不含敏感值)
│ └── config.example.yaml # 给新人抄的模板
├── docs/
│ ├── design/ # 设计文档
│ └── runbook.md # 排障手册
├── internal/
│ ├── handler/ # HTTP 层:参数校验、组装响应
│ ├── service/ # 业务层:事务边界在这里
│ ├── repository/ # 存储层:只碰 DB 和缓存
│ ├── model/ # 领域对象
│ └── pkg/ # 项目内复用但不是业务的东西
├── pkg/ # 明确打算给别的项目用的
├── scripts/
│ ├── build.sh
│ └── lint.sh
├── Makefile
└── go.mod
每个目录一句话职责,新人一看就知道代码往哪放:
- api/ —— 只放契约,不放实现
- cmd/ —— 只做装配:读配置、连依赖、注册路由、启动、优雅退出,一行业务逻辑都没有
- internal/handler —— 只做 HTTP 转换,不写 if-else 业务判断
- internal/service —— 业务规则,事务边界
- internal/repository —— SQL 和 Redis 命令,只在这层出现
- pkg/ —— 只有在"确定别的项目要用"时才建
想先系统过一遍 Go 的知识地图,我在《新粉必看》里按四板块排好了顺序,可以对照着看:Go 后端 + Agent/RAG 学习路线。
二、/internal、/cmd、/vendor 三个必须讲清的
这三个目录几乎所有面试都会问,但答对的不到一半。
/internal 不是约定,是编译器强制的
很多人以为 internal 只是个命名习惯,团队自觉遵守。不是。go build 会直接拒绝。
Go 的规则是:任何导入路径里包含 internal 的包,只能被 internal 的父目录树内的包导入。
我们拿真实代码验证一下。假设项目结构:
myapp/
├── internal/
│ └── auth/token.go # package auth
└── main.go # package main,同级,能导入
other/
└── x/main.go # 外部项目,导入就报错
在 myapp/main.go 里写 import "myapp/internal/auth",编译通过。换个外部模块写同样的 import,go build 直接给你:
use of internal package myapp/internal/auth not allowed
这条报错是编译器抛的,不是你 CI 里配的 lint。
这个机制解决两个真实问题:
第一,把运营后台的代码和 C 端代码隔离开。 我们之前有个服务,运营管理接口和用户接口在同一个包里,某个 UpdateBalance 函数被 C 端误引用,上线后用户能直接改自己的余额。加 internal 之后这种误引用在编译期就挂了。
第二,杜绝"随手导入"。 有些包你写出来就是给内部用的,比如某个带缓存的查询封装、某个只能单例的连接池。放 internal 下,别人想用就得先复制——复制这个动作本身就是一道思考门槛。
对应地,pkg/ 是反过来:你明确希望被外部导入的东西才放这里。如果一个项目没有对外发 SDK 的打算,pkg/ 可以不建——建了也只会变成"不知道放哪的代码临时仓库"。
/cmd 的三条纪律
/vendor 什么时候该用,什么时候绝对别用
先说一个你给的素材里(也是很多博客里)的笔误:生成 vendor 目录的命令是 go mod vendor,不是 go vendor。
vendor 是把依赖源码复制一份进你的仓库。它换来的是:构建时不联网、依赖版本完全锁定、可审计(安全团队能直接翻源码)。
代价也很直接:仓库膨胀(一个中等服务大概多 30-80M)、升级依赖时 diff 爆炸、code review 噪音巨大。
我的判断标准:
- 要交付到客户内网、或者有合规审计要求 → 用 vendor
- 普通微服务,CI 能联网拉 module → 不用,靠 go.sum 锁版本就够了
- 写给别人用的库 → 绝对不用,用库的人会被你的 vendor 恶心到
三、API 设计:5 条硬规则和 3 个反例
RESTful 谁都会说两句,但真到定接口的时候,最常见的还是"每个接口一个动词"。
5 条规则,我按最容易踩的顺序排:
规则 1:资源名用名词,且用复数。
| GET /api/getUserList | GET /api/v1/users |
| POST /api/createOrder | POST /api/v1/orders |
| POST /api/deleteUser | DELETE /api/v1/users/123 |
复数不是强迫症。/users 天然表达"这是一个集合",/users/123 就是集合里的一个元素,层级关系不用另外解释。
规则 2:能变成资源属性或 HTTP 方法的,不要新增路径。
- 想改状态 → 用 PATCH /orders/123 + body 里带 {"status":"paid"}
- 想做"顶置用户" → 不要 /users/123/top,改成 PATCH /users/123 {"pinned":true}
判断办法:如果这个操作的结果是"某个资源的一个字段变了",那它就是一次资源修改。
规则 3:URL 用小写字母 + 中横线,版本号除外。
/api/v1/order-items 对,/api/v1/orderItems、/api/v1/Order_Items 都不对。大小写混用在某些代理和 Nginx 规则下会被改写,线上会莫名其妙 404。
规则 4:URL 层级不超过两层,更深的用查询参数。
/api/v1/users/123/orders/456/items 这种就别写了。改成 /api/v1/order-items?order_id=456。原因很实际:层级一深,权限校验就要一层层往上查,代码和性能双输。
规则 5:批量删除不要发明新动词。
两个方案都能过:
- POST /api/v1/users/batch-delete + body {"ids":[1,2,3]}(表达清晰,但引入了动词)
- DELETE /api/v1/users?ids=1,2,3(更 REST,但 URL 长度受网关限制,几百个 id 会被截断)
我们线上用第一种。理由是 ids 可能有几百个,塞 query 里会被 414 掉。
版本号放哪里? /api/v1/ 前缀,不要用 Header。原因很简单:浏览器地址栏、Nginx 日志、抓包工具、给客服看的链接,全都能直接看到版本。放 Header 里排查问题时你会想骂人。
四、安全性与幂等性:那张表格背后是真金白银
两个概念先分清:
- 安全性(safe):这个请求不改变资源状态。GET 是安全的,你可能被 CDN 缓存、被爬虫狂刷,都没有副作用。
- 幂等性(idempotent):同一个请求执行一次和执行 N 次,对资源的结果一样。
| POST | 否 | 否 |
| GET | 是 | 是 |
| DELETE | 否 | 是 |
| PUT | 否 | 是 |
很多人卡在"DELETE 不改变状态却幂等"。幂等说的不是"状态没变",而是"最终状态一致"。DELETE /users/123 第一次删掉了,第二次返回 404,但资源最终状态都是"不存在",所以幂等。
PUT 同理:PUT /users/123 {"name":"xu"} 调十次,库里就是 name=xu,不会变成十个 xu。
POST 不幂等,这是要命的地方。
我们踩过的坑:支付渠道回调我们的 POST /api/v1/payments/notify,渠道方因为网络超时重试了 4 次。我们的实现是"收到回调就加一次账",结果用户充了 100,账上多了 400。
修复不是"让渠道别重试"(你控制不了),而是让 POST 变成幂等。标准做法是幂等键:
// internal/service/payment.go
func (s *PaymentService) HandleNotify(ctx context.Context, req *NotifyReq) error {
// 幂等键:渠道流水号 + 事件类型,天然唯一
key := fmt.Sprintf("pay:notify:%s:%s", req.ChannelTradeNo, req.EventType)
// SETNX:只有第一次能成功,TTL 覆盖渠道的最长重试窗口(24h)
ok, err := s.redis.SetNX(ctx, key, "1", 24*time.Hour).Result()
if err != nil {
return fmt.Errorf("redis setnx: %w", err)
}
if !ok {
// 重复回调,直接返回成功,别让渠道继续重试
return nil
}
// 这里才是真正的业务处理
return s.repo.UpdateBalance(ctx, req.UserID, req.Amount)
}
三个细节别漏:
再补一句:SetNX 成功后如果业务处理失败,记得把 key 删掉(或者用更严谨的"预占 + 确认"两段式),否则这个订单就永远卡住了。这是另一个坑,下一篇讲分布式事务时展开。
顺便说,这段代码里 SetNX 能用,前提是你知道 Redis 单命令的原子性边界在哪。如果你对 atomic、sync 底层的实现还不熟,可以先看这篇:Go GMP 调度器 10 个高频追问。
接口兼容怎么办? 一句话:需要改逻辑就新建接口,用版本号区分(/v2/)。不要在原接口上偷偷改语义——你永远不知道有多少老客户端在依赖旧行为。
五、错误码:为什么不能全局统一,六段编号怎么算
为什么不推荐全局错误码
全局错误码的意思是:整个公司维护一个大枚举,10001 = 参数错误、10002 = 未登录、10003 = 余额不足……
三个致命问题:
按项目 + 服务 + 模块 + 错误码四级编号
我们用的格式是 8 位数字,每两位一段:
06100325
│││││└┴─ 25 :具体错误码(模块内 00-99)
││└┴─── 03 :模块编号(03 = 支付模块)
└┴───── 10 :服务编号(10 = 交易服务)
06 :项目组编号(06 = 电商项目组)
拆开看:06 项目组 → 10 交易服务 → 03 支付模块 → 25 第 25 个错误。
为什么每段只有两位(上限 99)? 因为两位是你被迫做"合并同类项"的约束。一个模块的错误码超过 99 个,说明不是错误码不够用,是这个模块的职责太杂,该拆了。这个上限本身就是一个架构报警器。
每一段怎么分配,进项目第一天就要定死。 我们的约定是:
| 项目组 | 2 | 架构组统一分 | 几乎不变 |
| 服务 | 2 | 架构组统一分 | 新服务上线时 |
| 模块 | 2 | 服务负责人分 | 加新模块时 |
| 错误码 | 2 | 模块负责人分 | 随时 |
Go 里的落地写法
错误码不能只是一个 int 常量,它得带上三样东西:给人看的消息、给客户端的提示、对应的 HTTP 状态码。
// internal/pkg/errcode/errcode.go
package errcode
import "net/http"
type Code struct {
Code int // 8 位业务码
Msg string // 内部日志用:说清是什么错了
UserMsg string // 返回给用户:告诉他怎么办
Status int // 对应的 HTTP 状态码
}
var (
// 06 10 03 段:交易服务-支付模块
PayOrderNotFound = &Code{06100301, "payment order not found", "订单不存在,请返回订单列表刷新后重试", http.StatusNotFound}
PayAmountInvalid = &Code{06100302, "payment amount <= 0", "支付金额有误,请返回订单页确认金额", http.StatusBadRequest}
PayDuplicateCall = &Code{06100325, "duplicate notify ignored", "这笔支付已处理完成,无需重复操作", http.StatusOK}
)
func (c *Code) Error() string { return c.Msg }
关键在于 Msg 和 UserMsg 分开:
- Msg 进日志,写清楚"错在哪",方便你搜
- UserMsg 返回前端,写清楚"怎么办",绝不能出现技术细节
这正是"对用户接口应该告诉用户怎么做,而不是错在哪里"的意思。用户看到"DB connection refused"只会来骂你,看到"系统繁忙,请 30 秒后重试"才知道该干嘛。
六、HTTP 状态码与响应体:三层各管什么
状态码的分工
| 200 | 请求成功执行 | 业务代码 |
| 401 | 未认证(没登录、token 过期) | 认证中间件 |
| 403 | 已认证但无权限 | 鉴权中间件 |
| 404 | 资源不存在 | 业务代码 / 路由 |
| 500 | 服务端自身出了问题 | panic 恢复中间件 |
| 503 | 服务暂时不可用 | 网关、限流器、容器编排层 |
两个最容易搞混的:
401 vs 403。 401 是"我不知道你是谁",403 是"我知道你是谁,但你不能干这个"。前端处理完全不同:401 跳登录页,403 弹无权限提示。
500 vs 503。 500 是你的代码写错了,503 是"我现在不能服务"(限流、熔断、健康检查失败、正在发布)。503 通常是别人抛的,不是你的业务代码。
统一响应结构
{"success": true, "code": 0, "msg": "", "data": []}
四条约定:
Go 里我是这么封的:
// internal/pkg/response/response.go
package response
import (
"net/http"
"github.com/gin-gonic/gin"
"myapp/internal/pkg/errcode"
)
type Body struct {
Success bool `json:"success"`
Code int `json:"code"`
Msg string `json:"msg"`
Data interface{} `json:"data"`
}
func OK(c *gin.Context, data interface{}) {
c.JSON(http.StatusOK, Body{Success: true, Code: 0, Data: data})
}
func Fail(c *gin.Context, e *errcode.Code) {
// 内部细节只进日志,响应里只给 UserMsg
c.Error(e)
c.JSON(e.Status, Body{Success: false, Code: e.Code, Msg: e.UserMsg})
}
注意 Fail 里那行 c.Error(e):日志里记的是 Msg(含技术细节),响应里给的是 UserMsg(人能看懂的话)。这条分离做好,线上排障会舒服很多——你搜日志能搜到精确原因,用户也不会看到不该看的。
七、配置:四类的边界在哪
配置是最容易乱的地方。我们按"变更方式"分四类:
| 静态配置 | 改完要发版 | DB 地址、Redis 地址、中间件连接串 | configs/config.yaml |
| 动态配置 | 运行时热更新 | 限流阈值、开关、灰度比例 | 配置中心(Etcd / Nacos / Apollo) |
| 环境变量 | 容器启动时注入 | 环境标识、Pod 名、密钥 | 容器 env |
| 常量 | 不改 | 业务固定值(比如"最多 3 次重试") | 代码常量 |
判断标准就一句话:这东西多久变一次、变了要不要发版。
- 一年改一次 → 静态配置
- 一天改几次 → 动态配置(但要小心,见后面的坑)
- 每个环境不同 → 环境变量
- 从来不改 → 常量,别做成配置项
配置设计 6 条纪律
配置安全的 3 条红线
viper 落地 + 三个真实的坑
// internal/config/config.go
package config
import (
"strings"
"time"
"github.com/spf13/viper"
)
type Config struct {
Env string `mapstructure:"env"`
HTTP HTTPConfig `mapstructure:"http"`
Database DatabaseConfig `mapstructure:"database"`
}
type HTTPConfig struct {
Addr string `mapstructure:"addr"`
RequestTimeout time.Duration `mapstructure:"request_timeout"` // 写 30s 而不是 30
}
type DatabaseConfig struct {
DSN string `mapstructure:"dsn"`
MaxOpenConns int `mapstructure:"max_open_conns"`
}
func Load(path string) (*Config, error) {
v := viper.New()
v.SetConfigFile(path)
v.SetConfigType("yaml")
// 环境变量覆盖:APP_HTTP__ADDR 会覆盖 http.addr
v.SetEnvPrefix("APP")
v.SetEnvKeyReplacer(strings.NewReplacer(".", "__"))
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
return nil, err
}
var c Config
if err := v.Unmarshal(&c); err != nil {
return nil, err
}
return &c, c.validate() // 范围校验放最后,配置不合法直接拒绝启动
}
三个坑,都是我们踩过并写进 runbook 的:
坑 1:环境变量覆盖顺序。 AutomaticEnv() 只对 Unmarshal 认识不到的键生效,嵌套结构经常覆盖不上。我们的解法是——密钥这类必须用环境变量的东西,不走 viper,直接 os.Getenv。别在一个库里同时指望两套语义。
坑 2:WatchConfig() 是个常驻 goroutine。 热更新看起来很美,但你如果只在 main 里调一次就没管它,这个 goroutine 永远不会退出,测试时还会莫名读到上一个测试的配置。要用就得配 OnConfigChange,并且想清楚"配置变了之后,已经建立的连接池怎么办"——很多配置是不能热的。
坑 3:viper.Debug() 会把所有配置打到 stdout。 有人为了排查临时加了一行,忘了删,密码就进日志了。这就是红线 2 的由来。
我的建议很保守:大部分服务不需要动态配置。 先把静态配置 + 环境变量做干净,真有"一天改几次"的需求再上配置中心。
八、8 个面试追问
这几个问题在我们的面试里出现频率最高,答案我直接给。
Q1:internal 目录是约定还是强制? 强制。Go 编译器会给 use of internal package … not allowed。它限制的是"导入路径含 internal 的包,只能被 internal 父目录树内的代码导入"。
Q2:cmd 下为什么要分多个子目录? 一个子目录 = 一个独立二进制。api-server 和 worker 独立编译、独立扩缩容,共用 internal/,避免把整个服务复制成两份。
Q3:DELETE 为什么是幂等的,它明明改了状态? 幂等看的是"多次执行后最终状态一致",不是"状态没变"。删两次,资源都是"不存在",最终一致。
Q4:POST 怎么做到幂等? 业务侧引入幂等键(渠道流水号 / 客户端生成的 request id),落库前用 Redis SETNX 或数据库唯一索引做去重,重复请求直接返回成功。TTL 要覆盖对方的重试窗口。
Q5:为什么不用全局统一错误码? 撞号、无法定位来源、只增不减。按项目/服务/模块/错误码分段,码本身自带归属信息。
Q6:业务码和 HTTP 状态码是什么关系? HTTP 状态码表达"这一层发生了什么"(成功/未认证/无权限/不存在/服务端错/不可用),业务码表达"具体哪个业务的哪个错误"。两者一起用,别只用一个。
Q7:配置为什么不能全部动态化? 因为很多配置依赖"进程启动时的状态"——连接池大小、监听端口、序列化方式。热更新这些只会带来不一致。动态配置只应该给"读一次就能生效"的开关类参数。
Q8:敏感配置怎么管? 不进代码仓库,走环境变量或密钥管理服务;日志和响应里全程脱敏;配置中心按角色控权限。
💡 本章面试要点
- 目录结构没有官方标准,能说清"哪些目录做了什么取舍"比背 25 个目录更值钱
- internal 是编译器强制,这是能拉开差距的细节
- 幂等的判据是"最终状态一致",POST 幂等靠幂等键 + SETNX/唯一索引
- 错误码分段的真正价值是自带归属信息 + 99 上限逼你拆模块
- Msg 进日志、UserMsg 返前端,这条分离是工程素养的分水岭
这套骨架我现在每个新项目都照抄一遍,改的只有 cmd/ 下的入口名和 internal/ 里的模块划分。下一篇我会在这套骨架上落第一个真实项目:用 Gin + GORM 做一套带分层、带事务、带幂等的订单服务,从建表到接口到压测走一遍,最后把今天这张错误码表填满。
如果这篇对你有用,收藏一下——犯错的时候你就是靠这张表定位的。也顺手订阅「项目实战」专栏,后面每篇都是在这套骨架上叠功能,跳着看会断。
最后一个问题,一句话就能答:你们项目现在有几个顶层目录?里面有几个是三个月没人碰过的? 评论区聊聊,我看看有没有比我更狠的。
作者:利威尔xu | CSDN 专栏《项目实战》
网硕互联帮助中心


评论前必须登录!
注册