Go 项目实践
Go 项目强调简单、清晰、可维护。目录结构不需要过度设计,但入口、配置、业务、数据访问、外部调用、测试和部署边界必须明确。
很多初学者写 Go 项目会走两个极端:要么所有代码堆在 main.go,要么一开始就拆出大量抽象、接口、目录。正确做法是根据项目规模逐步拆分,保持依赖方向清晰,避免循环依赖和过度设计。
学习目标
学完本页你应该能回答:
- Go 项目常见目录怎么设计。
cmd、internal、pkg分别适合放什么。- Handler、Service、Repository 依赖方向怎么控制。
- Go 项目为什么不要一上来抽大量接口。
- 配置、日志、错误、测试、CI、部署应该怎么落地。
- 商业项目如何组织 Web API、后台任务和 CLI。
- 线上问题如何通过日志、pprof、Context 和测试排查。
Go 项目结构原则
Go 的项目结构不是越复杂越好。好的结构应该满足:
- 新人能快速找到入口。
- 业务逻辑不和 HTTP 细节混在一起。
- 数据库访问不散落在各处。
- 包之间依赖方向清晰。
- 测试容易写。
- 部署和配置可复现。
推荐目录结构
中小型 Web 服务可以这样组织:
asset-service/
cmd/
server/
main.go
worker/
main.go
internal/
config/
config.go
domain/
asset.go
handler/
asset_handler.go
service/
asset_service.go
repository/
asset_repository.go
client/
hospital_client.go
middleware/
logging.go
recovery.go
pkg/
errors/
errors.go
scripts/
migrate.sh
Dockerfile
go.mod
go.sum
README.md目录说明:
| 目录 | 作用 | 注意 |
|---|---|---|
cmd | 程序入口,一个子目录对应一个可执行程序 | 入口尽量薄 |
internal | 当前项目内部代码,外部模块不能导入 | 放核心业务 |
pkg | 可以被其他项目复用的公共包 | 不要滥用 |
domain | 领域对象、核心数据结构 | 不依赖 HTTP/DB |
handler | HTTP 入参出参 | 不写复杂业务 |
service | 业务规则、流程编排 | 控制事务和调用链 |
repository | 数据访问 | SQL/ORM 封装 |
client | 外部系统调用 | HTTP/RPC/AI |
middleware | 日志、鉴权、恢复、限流 | 包装 Handler |
依赖方向
flowchart TD
A["cmd 入口"] --> B["handler"]
A --> C["config"]
B --> D["service"]
D --> E["repository"]
D --> F["client"]
D --> G["domain"]
E --> G
F --> G推荐方向:外层依赖内层,具体入口组装依赖。
不要让 Repository 依赖 Handler,不要让 Domain 依赖 Web 框架。否则后续写 CLI、消息消费者、测试都会被 HTTP 框架绑住。
main.go 应该做什么
main.go 应该负责组装和启动,不应该写大量业务逻辑。
package main
import (
"log"
"net/http"
"asset-service/internal/config"
"asset-service/internal/handler"
"asset-service/internal/repository"
"asset-service/internal/service"
)
func main() {
cfg := config.Load()
assetRepo := repository.NewAssetRepository()
assetService := service.NewAssetService(assetRepo)
assetHandler := handler.NewAssetHandler(assetService)
mux := http.NewServeMux()
mux.HandleFunc("POST /assets", assetHandler.Create)
server := &http.Server{
Addr: cfg.HTTPAddr,
Handler: mux,
}
log.Printf("server started addr=%s", cfg.HTTPAddr)
log.Fatal(server.ListenAndServe())
}如果 main.go 里出现大量业务判断、SQL、HTTP 调用,说明结构需要拆分。
配置管理
配置不要写死在代码里。
错误做法:
const dsn = "root:123456@tcp(127.0.0.1:3306)/app"推荐从环境变量读取:
package config
import "os"
type Config struct {
HTTPAddr string
MySQLDSN string
LogLevel string
}
func Load() Config {
return Config{
HTTPAddr: getEnv("HTTP_ADDR", ":8080"),
MySQLDSN: os.Getenv("MYSQL_DSN"),
LogLevel: getEnv("LOG_LEVEL", "INFO"),
}
}
func getEnv(key string, defaultValue string) string {
value := os.Getenv(key)
if value == "" {
return defaultValue
}
return value
}关键配置如数据库密码、API Key,生产环境必须由环境变量、配置中心或 Secret 注入,不要提交到 Git。
错误处理设计
Go 强调显式错误返回。项目里要区分业务错误和系统错误。
package apperror
type AppError struct {
Code string
Message string
}
func (e *AppError) Error() string {
return e.Message
}
func New(code string, message string) *AppError {
return &AppError{Code: code, Message: message}
}Service 中返回业务错误:
func (s *AssetService) Create(ctx context.Context, name string) (Asset, error) {
if name == "" {
return Asset{}, apperror.New("ASSET_NAME_REQUIRED", "资产名称不能为空")
}
return s.repo.Save(ctx, name)
}Handler 统一转换:
if err != nil {
var appErr *apperror.AppError
if errors.As(err, &appErr) {
writeError(w, http.StatusBadRequest, appErr.Code, appErr.Message)
return
}
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "系统异常")
return
}这样前端拿到稳定错误码,日志里保留详细错误。
接口应该定义在哪里
Go 里有一句常见经验:接口应该由使用方定义,而不是实现方提前定义一堆接口。
错误倾向:
type AssetRepositoryInterface interface {
Save(ctx context.Context, asset Asset) (Asset, error)
}
type AssetRepositoryImpl struct {}如果只有一个实现,一开始就这样写可能只是增加复杂度。
更自然的写法:Service 需要什么,就在 Service 附近定义最小接口。
type assetRepository interface {
Save(ctx context.Context, asset Asset) (Asset, error)
}
type AssetService struct {
repo assetRepository
}好处:
- 接口小,只包含 Service 真正需要的方法。
- 测试时容易替换 fake。
- 不会为了“面向接口”制造过度抽象。
日志和 request_id
Go 项目日志至少要包含 request_id、path、status、cost_ms、error。
中间件示例:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
requestID := r.Header.Get("X-Request-Id")
if requestID == "" {
requestID = uuid.NewString()
}
ctx := context.WithValue(r.Context(), requestIDKey, requestID)
next.ServeHTTP(w, r.WithContext(ctx))
log.Printf(
"request_id=%s method=%s path=%s cost_ms=%d",
requestID,
r.Method,
r.URL.Path,
time.Since(start).Milliseconds(),
)
})
}日志不要记录密码、Token、身份证号、医疗隐私等敏感信息。
测试策略
Go 项目至少要有:
| 测试 | 覆盖 |
|---|---|
| 单元测试 | Service 业务规则、纯函数 |
| Handler 测试 | 参数校验、响应结构、状态码 |
| Repository 测试 | SQL、事务、数据映射 |
| 并发测试 | goroutine、channel、锁 |
| race 检测 | 数据竞争 |
表格驱动测试:
func TestValidateSourceSystem(t *testing.T) {
tests := []struct {
name string
source string
wantErr bool
}{
{"his ok", "HIS", false},
{"empty", "", true},
{"unknown", "CRM", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateSourceSystem(tt.source)
if (err != nil) != tt.wantErr {
t.Fatalf("wantErr=%v err=%v", tt.wantErr, err)
}
})
}
}运行:
go test ./...
go test -race ./...
go vet ./...CI 门禁
最小 CI 流程:
flowchart TD
A["提交代码"] --> B["go fmt 检查"]
B --> C["go vet"]
C --> D["go test ./..."]
D --> E["go test -race ./..."]
E --> F{"是否通过"}
F -- "通过" --> G["允许合并"]
F -- "失败" --> H["修复后再提交"]CI 的意义是让质量检查自动化,不依赖“我本地记得跑”。
Dockerfile 示例
Go 很适合多阶段构建。
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./cmd/server
FROM alpine:3.20
WORKDIR /app
COPY --from=builder /app/server /app/server
EXPOSE 8080
ENTRYPOINT ["/app/server"]注意:
- 构建阶段和运行阶段分离,镜像更小。
- 不要把配置和密钥写进镜像。
- 运行时通过环境变量注入配置。
- 生产要考虑健康检查、日志采集、优雅关闭。
商业场景:采集 Agent + API 服务
医疗数据采集与资产平台可以用 Go 做两个程序:
| 程序 | 作用 |
|---|---|
cmd/server | 提供任务创建、状态查询、资产 API |
cmd/worker | 消费任务,连接医院系统采集数据 |
核心设计:
server创建任务后写数据库或消息队列。worker拉取任务,用 worker pool 控制并发。- 每个任务传 Context,支持取消和超时。
- 采集结果写 Repository,失败写审计日志。
- 服务开启 pprof,线上排查 goroutine 和 CPU。
流程:
flowchart TD
A["创建采集任务 API"] --> B["写入任务表"]
B --> C["Worker 拉取任务"]
C --> D["Context 控制任务生命周期"]
D --> E["调用医院接口"]
E --> F["清洗并写入资产库"]
F --> G["更新任务状态"]
G --> H["日志和指标记录"]线上排查
CPU 高
- 开启 pprof。
- 查看 CPU profile。
- 定位热点函数。
- 判断是死循环、JSON 编解码、大量正则、压缩、加密还是业务计算。
内存高
- 查看 heap profile。
- 检查大 slice、map、缓存、一次性读取大文件。
- 检查响应体、rows、文件是否关闭。
- 检查是否有 goroutine 泄漏间接持有对象。
goroutine 持续增长
- 查看 goroutine profile。
- 看是否阻塞在 channel、锁、网络 IO、数据库连接。
- 检查 Context 是否取消。
- 检查 worker pool 是否无限堆任务。
接口慢
- 根据 request_id 查日志。
- 拆 Handler、Service、Repository、外部接口耗时。
- 查数据库慢 SQL 和连接池。
- 查下游服务超时和重试。
- 查是否有锁竞争或 goroutine 堆积。
常见坑
| 问题 | 后果 | 正确做法 |
|---|---|---|
| 所有代码写 main.go | 难维护、难测试 | 按入口、业务、数据访问拆分 |
| 提前抽大量接口 | 复杂度上升 | 接口由使用方按需定义 |
| Handler 写业务 | 难复用、难测试 | 业务放 Service |
| 不传 Context | 取消和超时失效 | 函数第一个参数传 ctx |
| 忘记关闭 Body/Rows | 连接泄漏 | defer Close() |
| 不跑 race | 数据竞争上线才暴露 | go test -race ./... |
| 没有 pprof | 线上性能靠猜 | 服务开启受保护的 pprof |
| 配置写死 | 环境混乱和泄密 | 环境变量或配置中心 |
面试标准回答
Go 项目目录怎么设计?
我会把入口放 cmd,项目内部代码放 internal,可复用公共能力才放 pkg。Web 项目通常按 handler、service、repository、domain、client、middleware 拆分。入口负责组装依赖,Handler 处理 HTTP,Service 处理业务,Repository 处理数据访问。
Go 项目为什么不要过度设计?
Go 强调简单和组合。如果一开始就抽大量接口、复杂分层和通用框架,会增加理解成本。接口应该由使用方按需定义,目录也应该随着业务复杂度逐步演进,而不是为了“看起来高级”提前设计。
如何保证 Go 项目质量?
代码提交前运行 go fmt、go vet、go test ./...,并用 go test -race ./... 检查数据竞争。核心业务写表格驱动测试,服务保留日志 request_id、Context 超时、pprof 和统一错误处理。
Go 服务线上怎么排查?
接口慢先按 request_id 查链路耗时,再拆数据库、外部接口、锁和业务计算。CPU、内存、goroutine 问题用 pprof。goroutine 增长重点看 channel、锁、IO、Context 取消和 worker pool 并发控制。
关联知识点
小结
Go 项目实践的核心是“简单但边界清楚”。入口负责组装,Handler 负责协议,Service 负责业务,Repository 负责数据,Context 负责生命周期,测试和 CI 负责质量,日志和 pprof 负责排查。不要过度设计,但也不能把所有代码堆在一起。
