Go 项目骨架 25 个目录只用 9 个:错误码错 1 位,线上排查多花 3 小时 你好我是利威尔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/workflowsgithooks/Git 钩子❌ 删用.githooks或 huskychangelogs/变更日志❌ 删用 release notesbenchmark/基准测试❌ 删_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编译通过。换个外部模块写同样的 importgo build直接给你use of internal package myapp/internal/auth not allowed这条报错是编译器抛的不是你 CI 里配的 lint。这个机制解决两个真实问题第一把运营后台的代码和 C 端代码隔离开。我们之前有个服务运营管理接口和用户接口在同一个包里某个UpdateBalance函数被 C 端误引用上线后用户能直接改自己的余额。加internal之后这种误引用在编译期就挂了。第二杜绝随手导入。有些包你写出来就是给内部用的比如某个带缓存的查询封装、某个只能单例的连接池。放internal下别人想用就得先复制——复制这个动作本身就是一道思考门槛。对应地pkg/是反过来你明确希望被外部导入的东西才放这里。如果一个项目没有对外发 SDK 的打算pkg/可以不建——建了也只会变成不知道放哪的代码临时仓库。/cmd 的三条纪律只做装配不放业务逻辑。cmd/api-server/main.go里应该是读配置 → 初始化 DB/Redis → 注册路由 → 启动 → 等信号优雅退出。子目录名 编译出来的可执行文件名。cmd/api-server/编出来就是api-server别叫cmd/server1。一个子目录一个进程。cmd/api-server、cmd/worker、cmd/migrate是三个独立二进制共用internal/。这样 worker 扩容时不用把 HTTP 服务也复制一份。/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/getUserListGET /api/v1/usersPOST /api/createOrderPOST /api/v1/ordersPOST /api/deleteUserDELETE /api/v1/users/123复数不是强迫症。/users天然表达这是一个集合/users/123就是集合里的一个元素层级关系不用另外解释。规则 2能变成资源属性或 HTTP 方法的不要新增路径。想改状态 → 用PATCH /orders/123 body 里带{status:paid}想做顶置用户 → 不要/users/123/top改成PATCH /users/123 {pinned:true}判断办法如果这个操作的结果是某个资源的一个字段变了那它就是一次资源修改。规则 3URL 用小写字母 中横线版本号除外。/api/v1/order-items对/api/v1/orderItems、/api/v1/Order_Items都不对。大小写混用在某些代理和 Nginx 规则下会被改写线上会莫名其妙 404。规则 4URL 层级不超过两层更深的用查询参数。/api/v1/users/123/orders/456/items这种就别写了。改成/api/v1/order-items?order_id456。原因很实际层级一深权限校验就要一层层往上查代码和性能双输。规则 5批量删除不要发明新动词。两个方案都能过POST /api/v1/users/batch-delete body{ids:[1,2,3]}表达清晰但引入了动词DELETE /api/v1/users?ids1,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}调十次库里就是namexu不会变成十个 xu。POST 不幂等这是要命的地方。我们踩过的坑支付渠道回调我们的POST /api/v1/payments/notify渠道方因为网络超时重试了 4 次。我们的实现是收到回调就加一次账结果用户充了 100账上多了 400。修复不是让渠道别重试你控制不了而是让 POST 变成幂等。标准做法是幂等键// internal/service/payment.gofunc(s*PaymentService)HandleNotify(ctx context.Context,req*NotifyReq)error{// 幂等键渠道流水号 事件类型天然唯一key:fmt.Sprintf(pay:notify:%s:%s,req.ChannelTradeNo,req.EventType)// SETNX只有第一次能成功TTL 覆盖渠道的最长重试窗口24hok,err:s.redis.SetNX(ctx,key,1,24*time.Hour).Result()iferr!nil{returnfmt.Errorf(redis setnx: %w,err)}if!ok{// 重复回调直接返回成功别让渠道继续重试returnnil}// 这里才是真正的业务处理returns.repo.UpdateBalance(ctx,req.UserID,req.Amount)}三个细节别漏幂等键要用业务上天然唯一的东西比如渠道流水号不要用时间戳或 UUID——那等于没做幂等。TTL 要覆盖对方的最长重试窗口一般是 24 小时。TTL 太短会在窗口外重放太长会占内存。重复请求要返回成功不能返回错误。返回错误对方会继续重试你只是把风暴延后。再补一句SetNX成功后如果业务处理失败记得把 key 删掉或者用更严谨的预占 确认两段式否则这个订单就永远卡住了。这是另一个坑下一篇讲分布式事务时展开。顺便说这段代码里SetNX能用前提是你知道 Redis 单命令的原子性边界在哪。如果你对atomic、sync底层的实现还不熟可以先看这篇Go GMP 调度器 10 个高频追问。接口兼容怎么办一句话需要改逻辑就新建接口用版本号区分/v2/。不要在原接口上偷偷改语义——你永远不知道有多少老客户端在依赖旧行为。五、错误码为什么不能全局统一六段编号怎么算为什么不推荐全局错误码全局错误码的意思是整个公司维护一个大枚举10001 参数错误、10002 未登录、10003 余额不足……三个致命问题枚举冲突。十几个团队往同一个文件里加三个月必然撞号。撞号之后线上报10002你根本不知道是哪个团队定义的。定位不了来源。错误码本身不带我是谁只能反查文档。没人敢删。谁也不知道哪个码还有没有客户端在判断最后变成一个只增不减的垃圾表。按项目 服务 模块 错误码四级编号我们用的格式是 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.gopackageerrcodeimportnet/httptypeCodestruct{Codeint// 8 位业务码Msgstring// 内部日志用说清是什么错了UserMsgstring// 返回给用户告诉他怎么办Statusint// 对应的 HTTP 状态码}var(// 06 10 03 段交易服务-支付模块PayOrderNotFoundCode{06100301,payment order not found,订单不存在请返回订单列表刷新后重试,http.StatusNotFound}PayAmountInvalidCode{06100302,payment amount 0,支付金额有误请返回订单页确认金额,http.StatusBadRequest}PayDuplicateCallCode{06100325,duplicate notify ignored,这笔支付已处理完成无需重复操作,http.StatusOK})func(c*Code)Error()string{returnc.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:[]}四条约定code成功时是 0失败时是 8 位业务码。不要成功也返回 06100000前端判断成本高。列表不要套太多层。{data:{list:{items:[...]}}}这种三层嵌套前端每次取值都要写res.data.data.list.items。直接{data:[...], total: 100}。绝不暴露不安全的错误信息。数据库连接串、内网 IP、SQL 语句、堆栈一个都不能出现在响应里。这些只进日志。msg要能直接展示给用户。前端不该再做错误码字典映射——那个字典迟早和后端不同步。Go 里我是这么封的// internal/pkg/response/response.gopackageresponseimport(net/httpgithub.com/gin-gonic/ginmyapp/internal/pkg/errcode)typeBodystruct{Successbooljson:successCodeintjson:codeMsgstringjson:msgDatainterface{}json:data}funcOK(c*gin.Context,datainterface{}){c.JSON(http.StatusOK,Body{Success:true,Code:0,Data:data})}funcFail(c*gin.Context,e*errcode.Code){// 内部细节只进日志响应里只给 UserMsgc.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 条纪律中间件连接信息用统一模板。十个人十种写法有人写db_host有人写database.host最后没人知道全貌。给个模板 封装库。配置值要校验范围。端口写 99999、超时写 0、超时写 -1这些在启动时就该 panic 掉。宁可启动失败不要带病运行。命名要语义化 带单位。timeout: 30是秒还是毫秒写成request_timeout_seconds: 30或request_timeout: 30s。配置变更也要 review。配置错误导致的事故比代码 bug 多得多而且更难查。不用的配置及时删。留着的废弃配置项总有一天会被某个新人当真改。记录变更历史 可回滚。配置中心要能点回滚不然改坏了只能手忙脚乱改回去。配置安全的 3 条红线敏感配置不准硬编码。DB 密码、AK/SK、私钥一个字都不能写进.go或提交进 git。敏感配置禁止任何形式的输出。日志、panic 信息、接口响应、健康检查端点全不能带。严格控制访问权限。配置中心里谁能看谁能改要分角色。viper 落地 三个真实的坑// internal/config/config.gopackageconfigimport(stringstimegithub.com/spf13/viper)typeConfigstruct{Envstringmapstructure:envHTTP HTTPConfigmapstructure:httpDatabase DatabaseConfigmapstructure:database}typeHTTPConfigstruct{Addrstringmapstructure:addrRequestTimeout time.Durationmapstructure:request_timeout// 写 30s 而不是 30}typeDatabaseConfigstruct{DSNstringmapstructure:dsnMaxOpenConnsintmapstructure:max_open_conns}funcLoad(pathstring)(*Config,error){v:viper.New()v.SetConfigFile(path)v.SetConfigType(yaml)// 环境变量覆盖APP_HTTP__ADDR 会覆盖 http.addrv.SetEnvPrefix(APP)v.SetEnvKeyReplacer(strings.NewReplacer(.,__))v.AutomaticEnv()iferr:v.ReadInConfig();err!nil{returnnil,err}varc Configiferr:v.Unmarshal(c);err!nil{returnnil,err}returnc,c.validate()// 范围校验放最后配置不合法直接拒绝启动}三个坑都是我们踩过并写进 runbook 的坑 1环境变量覆盖顺序。AutomaticEnv()只对Unmarshal认识不到的键生效嵌套结构经常覆盖不上。我们的解法是——密钥这类必须用环境变量的东西不走 viper直接os.Getenv。别在一个库里同时指望两套语义。坑 2WatchConfig()是个常驻 goroutine。热更新看起来很美但你如果只在main里调一次就没管它这个 goroutine 永远不会退出测试时还会莫名读到上一个测试的配置。要用就得配OnConfigChange并且想清楚配置变了之后已经建立的连接池怎么办——很多配置是不能热的。坑 3viper.Debug()会把所有配置打到 stdout。有人为了排查临时加了一行忘了删密码就进日志了。这就是红线 2 的由来。我的建议很保守大部分服务不需要动态配置。先把静态配置 环境变量做干净真有一天改几次的需求再上配置中心。八、8 个面试追问这几个问题在我们的面试里出现频率最高答案我直接给。Q1internal 目录是约定还是强制强制。Go 编译器会给use of internal package ... not allowed。它限制的是导入路径含 internal 的包只能被 internal 父目录树内的代码导入。Q2cmd 下为什么要分多个子目录一个子目录 一个独立二进制。api-server和worker独立编译、独立扩缩容共用internal/避免把整个服务复制成两份。Q3DELETE 为什么是幂等的它明明改了状态幂等看的是多次执行后最终状态一致不是状态没变。删两次资源都是不存在最终一致。Q4POST 怎么做到幂等业务侧引入幂等键渠道流水号 / 客户端生成的 request id落库前用 RedisSETNX或数据库唯一索引做去重重复请求直接返回成功。TTL 要覆盖对方的重试窗口。Q5为什么不用全局统一错误码撞号、无法定位来源、只增不减。按项目/服务/模块/错误码分段码本身自带归属信息。Q6业务码和 HTTP 状态码是什么关系HTTP 状态码表达这一层发生了什么成功/未认证/无权限/不存在/服务端错/不可用业务码表达具体哪个业务的哪个错误。两者一起用别只用一个。Q7配置为什么不能全部动态化因为很多配置依赖进程启动时的状态——连接池大小、监听端口、序列化方式。热更新这些只会带来不一致。动态配置只应该给读一次就能生效的开关类参数。Q8敏感配置怎么管不进代码仓库走环境变量或密钥管理服务日志和响应里全程脱敏配置中心按角色控权限。本章面试要点目录结构没有官方标准能说清哪些目录做了什么取舍比背 25 个目录更值钱internal是编译器强制这是能拉开差距的细节幂等的判据是最终状态一致POST 幂等靠幂等键 SETNX/唯一索引错误码分段的真正价值是自带归属信息 99 上限逼你拆模块Msg进日志、UserMsg返前端这条分离是工程素养的分水岭这套骨架我现在每个新项目都照抄一遍改的只有cmd/下的入口名和internal/里的模块划分。下一篇我会在这套骨架上落第一个真实项目用 Gin GORM 做一套带分层、带事务、带幂等的订单服务从建表到接口到压测走一遍最后把今天这张错误码表填满。如果这篇对你有用收藏一下——犯错的时候你就是靠这张表定位的。也顺手订阅「项目实战」专栏后面每篇都是在这套骨架上叠功能跳着看会断。最后一个问题一句话就能答你们项目现在有几个顶层目录里面有几个是三个月没人碰过的评论区聊聊我看看有没有比我更狠的。作者利威尔xu | CSDN 专栏《项目实战》