2. Go服务工程化基础
很多Go服务的第一版都长得很像:一个main.go里塞进HTTP路由、数据库连接、配置读取、日志打印和业务逻辑,编译出来能跑,部署到服务器上也能响应请求。但当服务需要多环境部署、需要排查线上问题、需要滚动发布时,这种单体结构会迅速暴露问题:配置写死在代码里,改一个端口就要重新编译;日志用fmt.Println输出,没有级别也没有请求上下文;进程收到终止信号直接退出,正在处理的请求被硬生生切断。本节要解决的就是从“能跑的main.go”到“可部署服务”之间的工程化差距,核心围绕四件事:模块划分、配置管理、日志、优雅退出。
模块划分:让main.go只做组装
工程化的第一步不是引入框架,而是明确职责边界。一个可部署的Go服务通常可以划分为以下几层:入口层(main.go,只负责读取配置、初始化依赖、启动和等待退出)、配置层(internal/config,负责加载和校验配置)、传输层(internal/handler或internal/api,负责HTTP/gRPC的请求解析与响应)、业务层(internal/service,承载核心逻辑)、数据访问层(internal/repository,封装数据库或外部存储访问)、可观测层(internal/log、internal/metrics)。
关键原则是依赖单向流动:handler依赖service,service依赖repository,repository不反向依赖上层。main.go作为组装根,把各层实例串起来。这样做的直接收益是:业务逻辑可以脱离HTTP框架单独测试,repository可以用接口替换为内存实现,配置和日志可以独立演进。
cmd/
server/
main.go // 组装与生命周期
internal/
config/config.go // 配置结构与加载
handler/http.go // 路由与请求处理
service/order.go // 业务逻辑
repository/db.go // 数据访问
log/logger.go // 日志初始化
目录结构本身不是目的,目的是让“改配置”“换日志库”“加一个接口”这些操作的影响范围可预测。如果每次改动都要在同一个文件里翻找,说明模块划分还没有真正落地。
配置管理:从硬编码到分层加载
可部署服务必须做到“同一份二进制,不同环境不同配置”。推荐做法是:配置结构体用struct定义,通过环境变量或配置文件加载,并在启动时完成校验。常见优先级为:命令行参数 > 环境变量 > 配置文件 > 默认值。对于容器化部署,环境变量是最自然的注入方式,因为Kubernetes的ConfigMap和Secret最终都会以环境变量或挂载文件的形式进入容器。
type Config struct {
HTTPAddr string `env:"HTTP_ADDR" default:":8080"`
LogLevel string `env:"LOG_LEVEL" default:"info"`
DBDSN string `env:"DB_DSN"`
ShutdownTTL time.Duration `env:"SHUTDOWN_TTL" default:"15s"`
}
func Load() (*Config, error) {
cfg := &Config{}
if err := env.Parse(cfg); err != nil {
return nil, err
}
if cfg.DBDSN == "" {
return nil, errors.New("DB_DSN is required")
}
return cfg, nil
}
这里有两个容易被忽略的点。第一,启动即校验:缺少必填项时直接返回错误并退出,而不是等到第一次请求才失败。第二,敏感信息不落盘:数据库密码、Token等通过Secret注入,不写进配置文件,也不打印到日志里。配置加载完成后,建议在启动日志中输出非敏感字段的最终值,便于确认“这个实例到底用了什么配置”。
日志:结构化、带级别、可关联请求
可部署服务的日志不是给人“看热闹”的,而是给排障和监控用的。标准做法是使用结构化日志库(如log/slog或zap),输出JSON格式,包含时间、级别、消息和关键字段。日志级别至少区分debug、info、warn、error,并且级别由配置控制,生产环境通常用info,排障时临时调到debug。
更重要的是请求关联。每个进入的HTTP请求应生成一个request ID,写入日志字段,并在响应头中返回。这样当用户反馈“某个请求失败了”,可以通过request ID把该请求经过的handler、service、repository日志串起来。日志中不要输出完整请求体或密码,避免敏感信息泄露。
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: levelFrom(cfg.LogLevel),
}))
logger.Info("server starting", "addr", cfg.HTTPAddr, "env", cfg.Env)
优雅退出:让进程“体面地”结束
容器化环境中,Pod被删除或滚动更新时,Kubernetes会先发送SIGTERM,等待一段时间后再发送SIGKILL。如果服务收到SIGTERM就立即退出,正在处理的请求会失败,连接池也不会被正确关闭。优雅退出的目标是:停止接收新请求,等待正在处理的请求完成,释放数据库连接、关闭监听端口,最后退出。
Go标准库提供了http.Server.Shutdown,配合signal.NotifyContext可以简洁地实现这一过程。下面是一个可参考的骨架:
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
srv := &http.Server{Addr: cfg.HTTPAddr, Handler: router}
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("listen failed", "err", err)
os.Exit(1)
}
}()
<-ctx.Done()
logger.Info("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTTL)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed", "err", err)
}
logger.Info("server exited")
这里ShutdownTTL需要与Kubernetes的terminationGracePeriodSeconds配合:如果应用等待15秒,而K8s只给10秒,进程仍会被强杀。通常建议应用侧的超时略小于K8s的宽限期,留出缓冲。此外,数据库连接、消息队列消费者等资源也应在Shutdown之后依次关闭,顺序与启动时相反。
把四件事串起来:启动与退出的完整生命周期
把上述四部分组合起来,一个可部署服务的启动顺序通常是:加载配置 → 初始化日志 → 建立数据库连接 → 构建repository → 构建service → 构建handler和路由 → 启动HTTP服务器 → 等待退出信号。退出顺序则相反:收到信号 → 停止接收新请求 → 等待在途请求 → 关闭数据库 → 关闭日志 → 退出。
这个顺序不是形式主义。如果先关数据库再等请求,正在执行的查询会报错;如果日志在退出前就被关闭,最后的错误信息会丢失。建议在main.go中用一个run() error函数承载完整生命周期,main只负责调用并处理返回值,这样既便于测试,也让退出路径集中可控。
完成本节改造后,服务不再是一个“跑起来就行”的main.go,而是一个配置可注入、日志可检索、退出可预期、模块可替换的部署单元。后续的容器镜像构建、健康检查、滚动发布,都建立在这个基础之上。