项目目录结构

Go 官网并没有给出一个目录结构的标准模板,但是 golang-standards 倒是给出了一个。

中文readme

在此基础上,我的项目目录结构具体使用如下:【持续更新!!!】

https://github.com/gw-gong/template_project

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
.
├── api
│   ├── router
│   │   ├── service01.go
│   │   └── service02.go
│   └── rpc
│   ├── <service_name>.pb.go
│   ├── <service_name>.proto
│   └── makefile
├── cmd
│   ├── service01
│   │   └── main.go
│   └── service02
│   └── main.go
├── config
│   ├── protocol
│   │   └── ...
│   ├── service01
│   │   ├── common
│   │   │   ├── config.go
│   │   │   └── config.yaml
│   │   └── net
│   │   ├── config.go
│   │   └── config.yaml
│   └── service02
│   ├── common
│   │   ├── config.go
│   │   └── config.yaml
│   └── net
│   ├── config.go
│   └── config.yaml
├── docs
│   └── <diy>
├── go.mod
├── internal
│   ├── app
│   │   ├── service01
│   │   │   ├── handler
│   │   │   │   ├── handler.go
│   │   │   │   └── protocol.go
│   │   │   └── rpc
│   │   │   └── <service_name>.go
│   │   └── service02
│   │   ├── handler
│   │   │   ├── handler.go
│   │   │   └── protocol.go
│   │   └── rpc
│   │   └── <service_name>.go
│   └── pkg
│   ├── protocol
│   │   └── <structs_define>
│   ├── db
│   │   ├── mysql_client
│   │   │   ├── interface_and_impl.go
│   │   │   └── mysql.go
│   │   └── redis_client
│   │   ├── interface_and_impl.go
│   │   └── redis.go
│   ├── middleware
│   │   └── <middleware_name>.go
│   └── util
│   └── some_tools.go
└── web
├── <page01>.html
├── <page02>.html
├── error_pages
│   └── ...
└── static
├── css
│   └── xxx.css
└── js
└── xxx.js




Request ID

1. Request ID 是什么?

  • 定义:唯一标识一次请求的字符串,用于日志追踪、全链路监控。
  • 作用
    • 关联同一请求的所有日志(前端→网关→服务→数据库)。
    • 快速定位故障点(如错误日志中查找特定 Request ID)。
    • 支持分布式系统中的请求链路分析。

2. 生成方式

方案 长度 唯一性 有序性 适用场景
UUIDv4 36 字符 全球唯一 无序 高并发、跨系统追踪
ULID 26 字符 全球唯一 有序 需要时间排序的场景
自定义短 ID 16-20 字符 业务内唯一 可选 存储 / 传输成本敏感场景
哈希截取 8-12 字符 有极低冲突概率 无序 内部系统、临时追踪

代码示例(Go)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import (
"github.com/google/uuid"
"github.com/oklog/ulid/v2"
"crypto/rand"
"encoding/base64"
)

// UUIDv4
uuid.New().String() // "550e8400-e29b-41d4-a716-446655440000"

// ULID(有序且唯一)
ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String() // "01ARZ3NDEKTSV4RRFFQ69G5FAV"

// 自定义短ID(8字节随机数 → Base64编码后约11字符)
func ShortID() string {
b := make([]byte, 8)
rand.Read(b)
return base64.RawURLEncoding.EncodeToString(b) // 如 "abc123xyz789"
}

3. 存储位置选择

位置 优点 缺点 适用场景
HTTP Header 无侵入、性能高、标准实践 前端需特殊处理(跨域) 分布式系统、API 网关
JSON Body 用户可见、兼容性强 侵入业务结构、需解析 JSON 客服系统、单体应用
同时使用 兼顾追踪效率和用户体验 增加传输开销 对可见性和自动化都有要求的场景

最佳实践:优先使用 Header(如 X-Request-ID),必要时补充到 Body。


4. 命名规范

  • X-Request-ID:
    • X- 前缀:标识自定义 HTTP Header(避免与标准字段冲突)。
    • Request-ID:明确语义为 “请求唯一标识”。
  • 其他常见命名:
    • X-Trace-ID:侧重分布式追踪链路。
    • X-Correlation-ID:关联多个相关请求(如批量操作)。

5. 代码实现(Go)

中间件生成并设置 Request ID

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func RequestIDMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 从请求中获取或生成新的 Request ID
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.New().String()
}

// 设置到响应 Header
w.Header().Set("X-Request-ID", reqID)

// 存入上下文,用于后续日志
ctx := context.WithValue(r.Context(), "requestID", reqID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

日志中记录 Request ID

1
2
3
4
5
func MyHandler(w http.ResponseWriter, r *http.Request) {
reqID := r.Context().Value("requestID").(string)
log.Printf("[%s] 处理请求", reqID) // 日志中包含 Request ID
// ...
}

可选:同时返回 Request ID 到 Body

1
2
3
4
5
response := map[string]interface{}{
"data": "业务数据",
"request_id": reqID, // 放入响应体
}
json.NewEncoder(w).Encode(response)

6. 注意事项

  1. 跨域问题
    若需前端获取 Header,服务器需配置:

    1
    w.Header().Set("Access-Control-Expose-Headers", "X-Request-ID")
  2. 性能优化

    • 使用 ULID 替代 UUID(有序性减少数据库索引碎片)。
    • 短 ID 减少存储 / 传输开销(如 12 字符 Base62 编码)。
  3. 兼容性

    • 与 OpenTracing、W3C Trace Context 等标准结合时,考虑字段映射(如 traceparent)。

7. 常见问题

问题 解决方案
日志分散无法关联 在所有服务的日志中强制添加 Request ID
前端获取不到 Header 配置 CORS Access-Control-Expose-Headers
Request ID 生成性能瓶颈 使用预生成池或原子操作生成 ID
长 ID 导致存储成本高 使用短 ID 或压缩算法(如 Base62)

8. 工具推荐

  • Go 库:
    • github.com/google/uuid:生成 UUIDv4。
    • github.com/oklog/ulid:生成有序 ULID。
    • go.uber.org/zap:日志库支持上下文传递 Request ID。
  • 分布式追踪:
    • Jaeger、Zipkin、Skywalking 等自动注入 Trace ID。




版本号

摘抄自孔令飞项目onex

在做 Go 项目开发时,我建议你把所有组件都加入版本机制。原因主要有两个:一是通过版本号,我们可以很明确地知道组件是哪个版本,从而定位到该组件的功能和代码,方便我们定位问题。二是发布组件时携带版本号,可以让使用者知道目前的项目进度,以及使用版本和上一个版本的功能差别等。

目前业界主流的版本规范是语义化版本规范,也是OneX 系统采用的版本规范。那什么是语义化版本规范呢?


什么是语义化版本规范(SemVer)?

语义化版本规范(SemVer,Semantic Versioning)是 GitHub 起草的一个具有指导意义的、统一的版本号表示规范。它规定了版本号的表示、增加和比较方式,以及不同版本号代表的含义。

在这套规范下,版本号及其更新方式包含了相邻版本间的底层代码和修改内容的信息。语义化版本格式为:主版本号.次版本号.修订号(X.Y.Z),其中 X、Y 和 Z 为非负的整数,且禁止在数字前方补零。

版本号可按以下规则递增:

  • 主版本号(MAJOR):当做了不兼容的 API 修改。
  • 次版本号(MINOR):当做了向下兼容的功能性新增及修改。这里有个不成文的约定需要你注意,偶数为稳定版本,奇数为开发版本。
  • 修订号(PATCH):当做了向下兼容的问题修正。

例如,v1.2.3 是一个语义化版本号,版本号中每个数字的具体含义见下图:

img

你可能还看过这么一种版本号:v1.2.3-alpha。这其实是把先行版本号(Pre-release)和版本编译元数据,作为延伸加到了主版本号.次版本号.修订号的后面,格式为 X.Y.Z-先行版本号,如下图所示:

img

我们来分别看下先行版本号和版本编译元数据是什么意思。

先行版本号意味着,该版本不稳定,可能存在兼容性问题,格式为:X.Y.Z-[一连串以句点分隔的标识符] ,比如下面这几个例子:

1
2
3
4
1.0.0-alpha
1.0.0-alpha.1
1.0.0-0.3.7
1.0.0-x.7.z.92

编译版本号,一般是编译器在编译过程中自动生成的,我们只定义其格式,并不进行人为控制。下面是一些编译版本号的示例:
时间戳、编译哈希值、编译次数都可以,也可以手动加

1
2
3
1.0.0-alpha+001
1.0.0+20130313144700
1.0.0-beta+exp.sha.5114f85

注意,先行版本号和编译版本号只能是字母、数字,且不可以有空格


语义化版本控制规范

语义化版本控制规范比较多,这里我给你介绍几个比较重要的。如果你需要了解更详细的规范,可以参考 这个链接 的内容。

  • 标记版本号的软件发行后,禁止改变该版本软件的内容,任何修改都必须以新版本发行。
  • 主版本号为零(0.y.z)的软件处于开发初始阶段,一切都可能随时被改变,这样的公共 API 不应该被视为稳定版。1.0.0 的版本号被界定为第一个稳定版本,之后的所有版本号更新都基于该版本进行修改。
  • 修订号 Z(x.y.Z | x > 0)必须在只做了向下兼容的修正时才递增,这里的修正其实就是 Bug 修复。
  • 次版本号 Y(x.Y.z | x > 0)必须在有向下兼容的新功能出现时递增,在任何公共 API 的功能被标记为弃用时也必须递增,当有改进时也可以递增。其中可以包括修订级别的改变。每当次版本号递增时,修订号必须归零。
  • 主版本号 X(X.y.z | X > 0)必须在有任何不兼容的修改被加入公共 API 时递增。其中可以包括次版本号及修订级别的改变。每当主版本号递增时,次版本号和修订号必须归零。

如何确定版本号?

说了这么多,我们到底该如何确定版本号呢?

这里我给你总结了这么几个经验。
第一,在实际开发的时候,我建议你使用 0.1.0 作为第一个开发版本号,并在后续的每次发行时递增次版本号。
第二,当我们的版本是一个稳定的版本,并且第一次对外发布时,版本号可以定为 1.0.0。
第三,当我们严格按照 Angular commit message 规范提交代码时,版本号可以这么来确定:

  • fix 类型的 commit 可以将修订号+1。
  • feat 类型的 commit 可以将次版本号+1。
  • 带有 BREAKING CHANGE 的 commit 可以将主版本号+1。

错误包装

格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
```

**核心概念**

错误包装(error wrapping):在传递错误时,既添加上下文信息(如操作场景),又保留原始错误的类型和链关系,方便后续追溯和判断原始错误。

**关键工具**

1. **`fmt.Errorf` + `%w`**
- Go 1.13 引入,标准库原生支持的错误包装方式。
- `%w` 是专门用于包装错误的格式化动词,用它包装的错误会形成 “错误链”,保留原始错误信息。
2. **`errors` 包工具**
- `errors.Is(err, target)`:检查错误链中是否包含目标错误(如 `os.ErrNotExist`)。
- `errors.As(err, target)`:从错误链中提取特定类型的错误。
- `errors.Unwrap(err)`:解开包装,获取被包装的原始错误(通常无需手动调用,`Is`/`As` 会自动处理)。

**完整示例**

```go
package main

import (
"errors"
"fmt"
"os"
)

func main() {
// 模拟一个底层错误(如文件不存在)
originalErr := os.ErrNotExist // 原始错误:"file does not exist"

// 1. 不使用 %w:仅字符串拼接,丢失原始错误类型
badWrappedErr := fmt.Errorf("操作失败(无包装):%v", originalErr)
fmt.Println("不使用%w的情况:")
fmt.Println("错误信息:", badWrappedErr)
fmt.Println("是否为os.ErrNotExist?", errors.Is(badWrappedErr, os.ErrNotExist)) // 输出:false(无法识别)

// 2. 使用 %w:保留原始错误,形成错误链
goodWrappedErr := fmt.Errorf("操作失败(有包装):%w", originalErr)
fmt.Println("\n使用%w的情况:")
fmt.Println("错误信息:", goodWrappedErr)
fmt.Println("是否为os.ErrNotExist?", errors.Is(goodWrappedErr, os.ErrNotExist)) // 输出:true(可识别)

// 3. 多层包装仍可追溯
doubleWrappedErr := fmt.Errorf("上层操作失败:%w", goodWrappedErr)
fmt.Println("\n多层包装的情况:")
fmt.Println("错误信息:", doubleWrappedErr)
fmt.Println("是否为os.ErrNotExist?", errors.Is(doubleWrappedErr, os.ErrNotExist)) // 输出:true(深层识别)
}

输出结果

1
2
3
4
5
6
7
8
9
10
11
不使用%w的情况:
错误信息: 操作失败(无包装):file does not exist
是否为os.ErrNotExist? false

使用%w的情况:
错误信息: 操作失败(有包装):file does not exist
是否为os.ErrNotExist? true

多层包装的情况:
错误信息: 上层操作失败:操作失败(有包装):file does not exist
是否为os.ErrNotExist? true

总结

  • fmt.Errorf("%w", err) 包装错误,既添加上下文,又保留原始错误链。
  • errors.Is/errors.As 检查错误链,轻松追溯原始错误。
  • 避免仅用字符串拼接错误(丢失类型信息),优先使用 %w 标准化处理。

接口命名规范

xxxer

没有合适的就xxxManager

db层 xxxStorer

包名规范

  1. 尽量一个单词,并且能简化,单词均使用单数
  2. 多个单词的情况,直接小写连接在一起,目录名一样,避免脱峰和下划线

日志打印

必须要做到

  • 入口出添加需要logwith的信息,至少要有request id或者是trace id,如何决定?预言出现错误的时候除了request id或者是trace id,你还会用user id等字段来查日志。

  • 入口和出口至少打印一个info

  • 敏感信息需要脱敏

  • 开发完成后,要预演练每个报错的位置是否有足够的错误日志能够帮助定位问题

建议做到

  • 建议出口位置打印latency