云计算百科
云计算领域专业知识百科平台

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/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 的三条纪律

  • 只做装配,不放业务逻辑。 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/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)
    }

    三个细节别漏:

  • 幂等键要用"业务上天然唯一"的东西,比如渠道流水号,不要用时间戳或 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.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": []}

    四条约定:

  • code 成功时是 0,失败时是 8 位业务码。 不要成功也返回 06100000,前端判断成本高。
  • 列表不要套太多层。 {"data":{"list":{"items":[…]}}} 这种三层嵌套,前端每次取值都要写 res.data.data.list.items。直接 {"data":[…], "total": 100}。
  • 绝不暴露不安全的错误信息。 数据库连接串、内网 IP、SQL 语句、堆栈,一个都不能出现在响应里。这些只进日志。
  • msg 要能直接展示给用户。 前端不该再做错误码字典映射——那个字典迟早和后端不同步。
  • 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 条纪律

  • 中间件连接信息用统一模板。 十个人十种写法(有人写 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.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 专栏《项目实战》

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Go 项目骨架 25 个目录只用 9 个:错误码错 1 位,线上排查多花 3 小时
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!