登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  Golang >  Go教程

Go expvar.Publish 怎么暴露自定义运行指标

来源:17golang原创

时间:2026-10-04 16:24:57 466浏览 收藏

expvar.Publish 的作用,是把一个实现了 expvar.Var 的对象按名称放进进程级全局注册表。随后,标准处理器会把这些变量连同 cmdline、memstats 一起以 JSON 暴露出来。自定义运行指标最省事的写法通常不是手写 Var,而是用 expvar.Func 包装一个返回结构体或 map 的函数,再用 Publish 注册。

官方文档:https://pkg.go.dev/expvar

先明确接入范围:expvar 是进程内 JSON 指标表

我在给一个小型 HTTP 服务补运行状态时,最初准备自己维护一个 /status JSON 接口。真正动手后发现,计数器的并发更新、字段注册和 JSON 输出都在重复造轮子。迁移到 expvar 后,业务代码只负责更新值,注册表和输出由标准库处理。

expvar.Var 的契约很小:只有一个 String() string,但返回内容必须是有效 JSON。包内置的 Int、Float、String、Map 和 Func 都已经满足这一要求。内置变量的 Set、Add 等修改操作是原子的,适合直接放在并发请求路径里。

业务处理器、Int 计数器、Map 分类计数、Func 快照、全局注册表和 debug vars 路由的静态模块关系
图1:业务更新对象、expvar 全局注册表与 JSON 输出端点的静态结构图,不是运行截图。

先选变量类型,避免把所有状态塞进一个 Func

类型适合内容更新方式注意点
expvar.Int请求数、进行中请求数Add、SetNewInt 创建时已经注册
expvar.Map按状态码或任务类型分组Add、Set键空间要有上限,避免高基数
expvar.Func读取时计算的组合快照调用回调函数回调要快、无副作用、不能 panic
自定义 Var已有特殊状态对象自行实现String 必须返回有效 JSON

如果只是单个数值,优先使用 NewInt 或 NewFloat。如果需要把几个已经存在的状态组合成一次读取快照,才使用 Func。这样可以看出哪些值持续累积、哪些值在请求到达时临时计算。

用 Publish 注册自定义动态快照

下面把构建标识、就绪状态和累计请求数组合成一个 app_snapshot。expvar.Func 会在变量被读取时调用函数,并用 JSON 编码返回值;因此无需手工拼接 JSON 字符串。

package main

import (
    "expvar"
    "net/http"
)

var requestsTotal = expvar.NewInt("app_requests_total")

func init() {
    // Publish 注册动态快照,名称在整个进程内必须唯一
    expvar.Publish("app_snapshot", expvar.Func(func() any {
        return struct {
            Build         string `json:"build"`
            Ready         bool   `json:"ready"`
            RequestsTotal int64  `json:"requests_total"`
        }{
            Build:         "dev",
            Ready:         true,
            RequestsTotal: requestsTotal.Value(),
        }
    }))
}

func hello(w http.ResponseWriter, r *http.Request) {
    // 内置 Int 的 Add 是原子更新,可用于并发请求计数
    requestsTotal.Add(1)
    _, _ = w.Write([]byte("ok"))
}

这里有一个容易重复注册的点:expvar.NewInt("app_requests_total") 内部已经调用 Publish。不要再对同一个指针和同一个名称调用一次 Publish,否则重复名称会触发 log.Panic。给名称加应用前缀,也能减少不同包之间的碰撞。

把请求计数拆成总量、并发量和状态码

单个总数只能说明服务收到过多少请求。为了看出当前压力和结果分布,可以再增加一个进行中计数器与一个有界状态码 Map。状态码的取值集合有限,适合作为 Map 的键;用户 ID、完整 URL、错误文本则可能持续膨胀,不应该直接成为键。

package main

import (
    "expvar"
    "net/http"
    "strconv"
)

var (
    requestsInFlight = expvar.NewInt("app_requests_in_flight")
    responsesByStatus = expvar.NewMap("app_responses_by_status")
)

type statusWriter struct {
    http.ResponseWriter
    status      int
    wroteHeader bool
}

func (w *statusWriter) WriteHeader(code int) {
    // 只记录第一次状态码,保持与 net/http 的响应语义一致
    if !w.wroteHeader {
        w.status = code
        w.wroteHeader = true
    }
    w.ResponseWriter.WriteHeader(code)
}

func withMetrics(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 进入时加一,退出时恢复当前并发量
        requestsTotal.Add(1)
        requestsInFlight.Add(1)
        defer requestsInFlight.Add(-1)

        sw := &statusWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(sw, r)
        responsesByStatus.Add(strconv.Itoa(sw.status), 1)
    })
}

这段中间件只记录稳定维度。若业务需要更细的路径统计,应先把路由归一成固定模板,例如 /users/:id,不要直接使用带参数的原始 URL。

把 Handler 放到内部路由,再读取目标字段

导入 expvar 会在默认 ServeMux 注册 /debug/vars。如果项目使用自定义 mux,调用 expvar.Handler() 可以安装到非标准位置。下面使用 Go 1.22 及以上的带方法路由,并单独监听本机回环地址,避免把运行参数和内存统计直接暴露到公网。

func main() {
    appMux := http.NewServeMux()
    appMux.Handle("GET /hello", withMetrics(http.HandlerFunc(hello)))

    debugMux := http.NewServeMux()
    // Go 1.22+ 使用方法路由,并仅监听本机内部端口
    debugMux.Handle("GET /debug/vars", expvar.Handler())
    go func() {
        _ = http.ListenAndServe("127.0.0.1:9090", debugMux)
    }()

    // 业务端口和调试端口分离,便于设置不同访问策略
    _ = http.ListenAndServe(":8080", appMux)
}

Go 官方文档说明,从 Go 1.22 开始,默认的 /debug/vars 请求必须使用 GET。读取时可以只筛选关心的字段,避免终端一次输出完整的 memstats:

# 读取内部端点,并只保留本文注册的三个指标
curl -fsS http://127.0.0.1:9090/debug/vars \
  | jq '{app_requests_total, app_requests_in_flight, app_responses_by_status, app_snapshot}'
expvar Publish、Func 回调、有效 JSON、唯一名称、内部路由和采集器之间的静态依赖关系
图2:Publish 注册契约、读取时计算与访问边界的静态依赖图,不代表某次真实请求结果。

回归检查:迁移后重点看六个风险

  • 名称唯一:同一个进程里的所有发布变量共用注册表,重复名称会直接 panic。
  • JSON 有效:自定义 Var.String 必须返回合法 JSON;优先用 Func 让标准库编码。
  • 回调足够快:Func 在读取指标时执行,不要在里面访问慢数据库、发网络请求或修改业务状态。
  • 键空间有限:Map 不会自动替你限制标签数量,避免把用户输入当键。
  • 端点不公开:默认内容包含命令行参数和内存统计,应使用内部监听、反向代理鉴权或网络访问控制。
  • 采集格式匹配:expvar 输出 JSON,不是 Prometheus 文本格式;接入现有采集系统前先确认适配方式。

常见问题

Publish 必须写在 init 里吗?

文档建议在包创建变量时从 init 调用,便于启动阶段完成固定注册。技术上可在其他位置调用,但并发动态注册会增加生命周期和重名判断的复杂度。

为什么已经 NewInt 还会重名 panic?

NewInt、NewMap 等构造函数会自动发布变量。通常是多个包用了同一个名称,或代码又手动调用了一次 Publish。

expvar 能替代完整监控系统吗?

它适合轻量进程状态、调试端点和 JSON 采集源,但不提供历史存储、告警、查询语言或仪表盘。生产监控仍要由采集与存储系统承接。

迁移清单

把自制状态接口迁移到 expvar 时,先把指标分成原子数值、有限分类和动态快照;分别使用 Int、Map、Func;确认名称唯一与 JSON 合法;再把 Handler 放进受限的内部路由。完成后,用一次 GET 只读取目标字段,并检查计数增长、并发量归零、状态码键集合稳定。这样 Publish 才真正减少维护成本,而不是把一个自制接口换成另一个不可控入口。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>