Skip to content

Go 项目实践 ​

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

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

学习目标 ​

学完本页你应该能回答:

  1. Go 项目常见目录怎么设计。
  2. cmd、internal、pkg 分别适合放什么。
  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 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 负责排查。不要过度设计,但也不能把所有代码堆在一起。