程序能跑还不够,还要能排查
很多入门程序只关注“功能能不能运行”。但真实服务上线后,另一个问题会立刻出现:出了问题怎么看?连接哪个端口?数据文件在哪里?请求为什么失败?程序启动时到底读到了什么配置?如果日志混乱、配置散落,排查会非常痛苦。
Go 标准库提供了基本日志能力,也提供了读取环境变量、命令行参数和文件的工具。入门阶段不一定要马上引入复杂配置中心或结构化日志库,但应该从一开始养成两个习惯:配置集中读取并校验,日志在关键边界记录清楚。
这篇文章会写一个小服务的配置加载和日志初始化。我们会使用 flag、os.Getenv、log 和结构体,把端口、数据路径、运行环境这些配置收拢起来。示例不复杂,却很接近真实项目启动流程。
标准库 log 的基本用法
最简单的日志:
log.Println("server starting")
带格式:
log.Printf("listen on %s", addr)
遇到不可恢复错误,可以:
log.Fatal(err)
log.Fatal 会打印日志并调用 os.Exit(1)。它适合在 main 里处理启动失败,例如端口监听失败、配置缺失、数据库连接失败。业务函数里不要随便 log.Fatal,因为它会直接结束整个进程,让调用方没有处理机会。
创建独立 logger:
logger := log.New(os.Stdout, "app ", log.LstdFlags|log.Lshortfile)
logger.Println("hello")
第二个参数是前缀,第三个参数是日志选项。log.LstdFlags 输出日期和时间,log.Lshortfile 输出文件名和行号。开发阶段行号有用,生产环境是否开启要看日志量和需求。
配置应该集中成结构体
不要在代码各处直接读环境变量:
port := os.Getenv("PORT")
dataFile := os.Getenv("DATA_FILE")
这样配置来源散落,默认值也不清楚。更好的方式是定义结构体:
type Config struct {
Env string
Addr string
DataFile string
Debug bool
}
加载函数:
func LoadConfig() (Config, error) {
env := getenv("APP_ENV", "development")
port := getenv("PORT", "8080")
dataFile := getenv("DATA_FILE", "data.json")
cfg := Config{
Env: env,
Addr: ":" + port,
DataFile: dataFile,
Debug: getenv("DEBUG", "false") == "true",
}
if cfg.DataFile == "" {
return Config{}, fmt.Errorf("DATA_FILE is required")
}
return cfg, nil
}
func getenv(key string, fallback string) string {
value := strings.TrimSpace(os.Getenv(key))
if value == "" {
return fallback
}
return value
}
配置加载后,其他代码只依赖 Config,不关心配置来自环境变量、文件还是命令行。以后切换配置来源,影响范围也小。
命令行参数和环境变量怎么取舍
命令行参数适合本地工具和启动时显式指定:
addr := flag.String("addr", ":8080", "listen address")
dataFile := flag.String("data", "data.json", "data file")
flag.Parse()
环境变量适合部署平台注入,比如容器、CI、云服务:
PORT=8080 APP_ENV=production ./app
你也可以让命令行参数优先级高于环境变量:
func LoadConfigFromFlags() (Config, error) {
defaultPort := getenv("PORT", "8080")
defaultData := getenv("DATA_FILE", "data.json")
port := flag.String("port", defaultPort, "listen port")
dataFile := flag.String("data", defaultData, "data file")
debug := flag.Bool("debug", getenv("DEBUG", "false") == "true", "enable debug logs")
flag.Parse()
return Config{
Env: getenv("APP_ENV", "development"),
Addr: ":" + *port,
DataFile: *dataFile,
Debug: *debug,
}, nil
}
这个模式很实用:默认从环境变量来,本地调试时用命令行覆盖。关键是优先级要固定,不要今天这里环境变量优先,明天那里配置文件优先。
启动时打印必要配置
服务启动时可以打印一小段配置摘要:
func logStartup(logger *log.Logger, cfg Config) {
logger.Printf("env=%s addr=%s dataFile=%s debug=%v",
cfg.Env, cfg.Addr, cfg.DataFile, cfg.Debug)
}
不要打印敏感信息,比如密码、token、数据库完整连接串。可以打印脱敏后的主机名或配置是否存在。
比如:
func maskSecret(value string) string {
if value == "" {
return "(empty)"
}
return "(set)"
}
日志的目的不是把所有变量倒出来,而是在排查时回答关键问题:程序运行在哪个环境,监听哪个端口,使用哪个数据路径,重要开关是否开启。
在 HTTP 中记录请求日志
写一个中间件:
func RequestLogger(logger *log.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Printf("method=%s path=%s duration=%s",
r.Method, r.URL.Path, time.Since(start))
})
}
使用:
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
server := &http.Server{
Addr: cfg.Addr,
Handler: RequestLogger(logger, mux),
}
这能记录每个请求的基本信息。更完整的请求日志还应该记录状态码。标准 ResponseWriter 不直接暴露状态码,需要包装:
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
中间件里使用:
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(recorder, r)
logger.Printf("method=%s path=%s status=%d duration=%s",
r.Method, r.URL.Path, recorder.status, time.Since(start))
这就是很多 Web 框架请求日志的基本原理。
不要把日志和错误处理混在一起
底层函数返回错误:
func loadData(path string) ([]byte, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read data file %s: %w", path, err)
}
return data, nil
}
上层边界记录:
data, err := loadData(cfg.DataFile)
if err != nil {
logger.Printf("load data failed: %v", err)
return err
}
不要每一层都 log.Printf,否则同一个错误会被打印多次。一般规则是:函数负责加上下文并返回错误,入口边界负责记录错误。HTTP handler、后台任务入口、main 函数都是合适边界。
一个完整启动流程
组合起来:
func run() error {
cfg, err := LoadConfigFromFlags()
if err != nil {
return err
}
logger := log.New(os.Stdout, "app ", log.LstdFlags)
logStartup(logger, cfg)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, "ok")
})
server := &http.Server{
Addr: cfg.Addr,
Handler: RequestLogger(logger, mux),
}
logger.Printf("listening on %s", cfg.Addr)
return server.ListenAndServe()
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
这个结构非常适合小服务。run 返回错误,main 统一处理。配置、日志、路由、server 都在启动阶段显式组装。以后增加数据库连接、缓存客户端或后台任务,也可以继续沿用这个结构。
小结
日志和配置不是高级话题,而是程序能否长期运行的基本条件。配置应该集中读取、设置默认值、启动时校验;日志应该在启动、请求、外部调用失败和任务边界处记录清楚;敏感信息不要输出;底层函数返回错误,上层边界记录日志。
Go 标准库已经能覆盖入门阶段的大部分需求。你可以先用 log、flag、os.Getenv 和结构体把基础做好。等项目真的需要 JSON 日志、日志级别、动态配置或配置中心时,再引入外部库也不迟。
一个服务是否专业,不只看功能多不多,也看出问题时能不能快速定位。把日志和配置从一开始写清楚,会让后续开发轻很多。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。