Skip to content

Go 项目实践

Go 项目强调简单、清晰、可维护。目录结构不需要过度设计,但入口、配置、业务、数据访问、外部调用、测试和部署边界必须明确。

很多初学者写 Go 项目会走两个极端:要么所有代码堆在 main.go,要么一开始就拆出大量抽象、接口、目录。正确做法是根据项目规模逐步拆分,保持依赖方向清晰,避免循环依赖和过度设计。

学习目标

学完本页你应该能回答:

  1. Go 项目常见目录怎么设计。
  2. cmdinternalpkg 分别适合放什么。
  3. Handler、Service、Repository 依赖方向怎么控制。
  4. Go 项目为什么不要一上来抽大量接口。
  5. 配置、日志、错误、测试、CI、部署应该怎么落地。
  6. 商业项目如何组织 Web API、后台任务和 CLI。
  7. 线上问题如何通过日志、pprof、Context 和测试排查。

Go 项目结构原则

Go 的项目结构不是越复杂越好。好的结构应该满足:

  1. 新人能快速找到入口。
  2. 业务逻辑不和 HTTP 细节混在一起。
  3. 数据库访问不散落在各处。
  4. 包之间依赖方向清晰。
  5. 测试容易写。
  6. 部署和配置可复现。

推荐目录结构

中小型 Web 服务可以这样组织:

text
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
handlerHTTP 入参出参不写复杂业务
service业务规则、流程编排控制事务和调用链
repository数据访问SQL/ORM 封装
client外部系统调用HTTP/RPC/AI
middleware日志、鉴权、恢复、限流包装 Handler

依赖方向

mermaid
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 应该负责组装和启动,不应该写大量业务逻辑。

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 调用,说明结构需要拆分。

配置管理

配置不要写死在代码里。

错误做法:

go
const dsn = "root:123456@tcp(127.0.0.1:3306)/app"

推荐从环境变量读取:

go
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 强调显式错误返回。项目里要区分业务错误和系统错误。

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 中返回业务错误:

go
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 统一转换:

go
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 里有一句常见经验:接口应该由使用方定义,而不是实现方提前定义一堆接口。

错误倾向:

go
type AssetRepositoryInterface interface {
    Save(ctx context.Context, asset Asset) (Asset, error)
}

type AssetRepositoryImpl struct {}

如果只有一个实现,一开始就这样写可能只是增加复杂度。

更自然的写法:Service 需要什么,就在 Service 附近定义最小接口。

go
type assetRepository interface {
    Save(ctx context.Context, asset Asset) (Asset, error)
}

type AssetService struct {
    repo assetRepository
}

好处:

  1. 接口小,只包含 Service 真正需要的方法。
  2. 测试时容易替换 fake。
  3. 不会为了“面向接口”制造过度抽象。

日志和 request_id

Go 项目日志至少要包含 request_id、path、status、cost_ms、error。

中间件示例:

go
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 检测数据竞争

表格驱动测试:

go
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)
            }
        })
    }
}

运行:

bash
go test ./...
go test -race ./...
go vet ./...

CI 门禁

最小 CI 流程:

mermaid
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 很适合多阶段构建。

dockerfile
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"]

注意:

  1. 构建阶段和运行阶段分离,镜像更小。
  2. 不要把配置和密钥写进镜像。
  3. 运行时通过环境变量注入配置。
  4. 生产要考虑健康检查、日志采集、优雅关闭。

商业场景:采集 Agent + API 服务

医疗数据采集与资产平台可以用 Go 做两个程序:

程序作用
cmd/server提供任务创建、状态查询、资产 API
cmd/worker消费任务,连接医院系统采集数据

核心设计:

  1. server 创建任务后写数据库或消息队列。
  2. worker 拉取任务,用 worker pool 控制并发。
  3. 每个任务传 Context,支持取消和超时。
  4. 采集结果写 Repository,失败写审计日志。
  5. 服务开启 pprof,线上排查 goroutine 和 CPU。

流程:

mermaid
flowchart TD
    A["创建采集任务 API"] --> B["写入任务表"]
    B --> C["Worker 拉取任务"]
    C --> D["Context 控制任务生命周期"]
    D --> E["调用医院接口"]
    E --> F["清洗并写入资产库"]
    F --> G["更新任务状态"]
    G --> H["日志和指标记录"]

线上排查

CPU 高

  1. 开启 pprof。
  2. 查看 CPU profile。
  3. 定位热点函数。
  4. 判断是死循环、JSON 编解码、大量正则、压缩、加密还是业务计算。

内存高

  1. 查看 heap profile。
  2. 检查大 slice、map、缓存、一次性读取大文件。
  3. 检查响应体、rows、文件是否关闭。
  4. 检查是否有 goroutine 泄漏间接持有对象。

goroutine 持续增长

  1. 查看 goroutine profile。
  2. 看是否阻塞在 channel、锁、网络 IO、数据库连接。
  3. 检查 Context 是否取消。
  4. 检查 worker pool 是否无限堆任务。

接口慢

  1. 根据 request_id 查日志。
  2. 拆 Handler、Service、Repository、外部接口耗时。
  3. 查数据库慢 SQL 和连接池。
  4. 查下游服务超时和重试。
  5. 查是否有锁竞争或 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 fmtgo vetgo 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 负责排查。不要过度设计,但也不能把所有代码堆在一起。