项目介绍
地址:https://github.com/arl/statsviz
功能:statsviz
可以将Go程序运行时的各种内部数据进行可视化的展示,如可以展示:堆、对象、协程、GC等信息。
环境搭建
以go 1.19
版本为例:
$ go version
go version go1.19.2 darwin/amd64
创建工程目录和文件:
$ mkdir test_statsviz && cd test_statsviz
$ touch main.go
main.go
的内容如下:
package main
import (
"log"
"net/http"
"github.com/arl/statsviz"
)
func main() {
statsviz.RegisterDefault()
log.Println(http.ListenAndServe(":6061", nil))
}
项目编译,编译成功后会生成可执行文件test_statsviz
,直接执行即可:
$ go mod init test_statsviz
$ go mod tidy
$ go build
$ ls -1
go.mod
go.sum
main.go
test_statsviz
$ ./test_statsviz
打开网页:http://localhost:6061/debug/s...,就能看到结果啦。
核心知识点
一、前后端数据交互方式
前后端的通信和数据交互采用 websocket
实现,简写为 ws
,介绍 websocket
的材料大家可以自行搜索。
1、后端websocket实现
后端 websocket
使用了开源包 gorilla/websocket,下面按照调用顺序简述一下具体的实现步骤。
- 用户程序
main.go
里通过调用statsviz.RegisterDefault()
进行注册,这样gorilla/websocket
在运行的时候会自动启动一个websocket服务端,链接地址为:ws://localhost:6061/debug/statsviz/ws
。
// main.go
func main() {
statsviz.RegisterDefault()
log.Println(http.ListenAndServe(":6061", nil))
}
gorilla/websocket
通过http.ServeMux
结构类型来完成具体的注册过程。
// register.go
// RegisterDefault registers statsviz HTTP handlers on the default serve mux.
func RegisterDefault(opts ...OptionFunc) error {
return Register(http.DefaultServeMux, opts...)
}
这里的 http.DefaultServeMux
是由Go标准库 net/http 提供的默认ServeMux
。ServeMux
类型是HTTP请求的多路转接器,它会将每一个接收的请求的URL与一个注册模式的列表进行匹配,并调用和URL最匹配的模式的处理器。当然,如果不想使用 http.DefaultServeMux
的话,可以调用函数 func NewServeMux() *ServeMux
自行创建一个ServeMux
。
// register.go
const (
defaultRoot = "/debug/statsviz"
defaultSendFrequency = time.Second
)
// Register registers statsviz HTTP handlers on the provided mux.
func Register(mux *http.ServeMux, opts ...OptionFunc) error {
s := &server{
mux: mux,
root: defaultRoot,
freq: defaultSendFrequency,
}
for _, opt := range opts {
if err := opt(s); err != nil {
return err
}
}
s.register()
return nil
}
type server struct {
mux *http.ServeMux
freq time.Duration
root string
}
func (s *server) register() {
s.mux.Handle(s.root+"/", IndexAtRoot(s.root))
s.mux.HandleFunc(s.root+"/ws", NewWsHandler(s.freq))
}
在 func (s *server) register()
函数中,s.mux.Handle(s.root+"/", IndexAtRoot(s.root))
用来处理浏览器页面请求,s.mux.HandleFunc(s.root+"/ws", NewWsHandler(s.freq))
用来提供 websocket
连接。
// handlers.go
// NewWsHandler returns a handler that upgrades the HTTP server connection to the WebSocket
// protocol and sends application statistics at the given frequency.
//
// If the upgrade fails, an HTTP error response is sent to the client.
func NewWsHandler(frequency time.Duration) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
defer ws.Close()
// Explicitly ignore this error. We don't want to spam var frequency time.Duration
// each time the other end of the websocket connection closes.
_ = sendStats(ws, frequency)
}
}
NewWsHandler
函数会新建一个websocket.Upgrader
并调用upgrader.Upgrade()
方法将普通的HTTP链接升级为websocket
连接。最后通过调用_ = sendStats(ws, frequency)
实现定时向浏览器客户端发送数据的功能。
// statsviz.go
// sendStats indefinitely send runtime statistics on the websocket connection.
func sendStats(conn *websocket.Conn, frequency time.Duration) error {
tick := time.NewTicker(frequency)
defer tick.Stop()
// If the websocket connection is initiated by an already open web ui
// (started by a previous process for example) then plotsdef.js won't be
// requested. So, call plots.config manually to ensure that the data
// structures inside 'plots' are correctly initialized.
plots.Config()
for range tick.C {
w, err := conn.NextWriter(websocket.TextMessage)
if err != nil {
return err
}
if err := plots.WriteValues(w); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
}
panic("unreachable")
}
可以看到,sendStats()
函数里是个死循环,其会反复调用gorilla/websocket
库提供的接口conn.NextWriter()
、plots.WriteValues(w)
和w.Close()
来完成数据的发送。
// internal/plot/plots.go
// WriteValues writes into w a JSON object containing the data points for all
// plots at the current instant.
func (pl *List) WriteValues(w io.Writer) error {
pl.mu.Lock()
defer pl.mu.Unlock()
metrics.Read(pl.samples)
// lastgc time series is used as source to represent garbage collection
// timestamps as vertical bars on certain plots.
gcStats := debug.GCStats{}
debug.ReadGCStats(&gcStats)
m := make(map[string]interface{})
for _, p := range pl.plots {
if p.isEnabled() {
m[p.name()] = p.values(pl.samples)
}
}
// In javascript, timestamps are in ms.
m["lastgc"] = []int64{gcStats.LastGC.UnixMilli()}
m["timestamp"] = time.Now().UnixMilli()
if err := json.NewEncoder(w).Encode(m); err != nil {
return fmt.Errorf("failed to write/convert metrics values to json: %v", err)
}
return nil
}
WriteValues()
实际上完成了打点数据的获取,最后通过json.NewEncoder(w).Encode(m)
将数据发送到websocket
客户端,也就发送到浏览器。
2、前端websocket实现
前端则使用浏览器自带的 websocket 组件完成。浏览器实现websocket
客户端来接收服务端发送过来的数据,前端websocket
的实现相比后端来讲实现起来要简单一些。简单来说,就是创建websocket
实例,然后接收数据。
// internal/static/js/app.js
/* WebSocket connection handling */
const connect = () => {
const uri = buildWebsocketURI();
let ws = new WebSocket(uri);
console.info(`Attempting websocket connection to server at ${uri}`);
ws.onopen = () => {
console.info("Successfully connected");
timeout = 250; // reset connection timeout for next time
};
ws.onclose = event => {
console.error(`Closed websocket connection: code ${event.code}`);
setTimeout(connect, clamp(timeout += timeout, 250, 5000));
};
ws.onerror = err => {
console.error(`Websocket error, closing connection.`);
ws.close();
};
let initDone = false;
ws.onmessage = event => {
let data = JSON.parse(event.data)
if (!initDone) {
configurePlots(PlotsDef);
stats.init(PlotsDef, dataRetentionSeconds);
attachPlots();
$('#play_pause').change(() => { paused = !paused; });
$('#show_gc').change(() => {
show_gc = !show_gc;
updatePlots();
});
$('#select_timerange').click(() => {
const val = parseInt($("#select_timerange option:selected").val(), 10);
timerange = val;
updatePlots();
});
initDone = true;
return;
}
stats.pushData(data);
if (paused) {
return
}
updatePlots(PlotsDef.events);
}
}
connect();
如上,先通过let ws = new WebSocket(uri);
创建一个新实例,然后在ws.onmessage = event => {}
事件回调函数中,获取数据并处理:let data = JSON.parse(event.data)
。
二、前后端数据处理
前后端通信的数据采用json
格式,实际数据样例如下。其中,除了lastgc
和timestamp
之外,每个字段对应页面上的一张图表。
{"cgo":[0],"gc-pauses":[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"gc-stack-size":[4096],"goroutines":[8],"heap-details":[7864320,1469544,524288,4194304],"heap-global":[3457024,1433600,2973696],"lastgc":[1667970144639],"live-bytes":[1469544],"live-objects":[15168],"mspan-mcache":[72080,9520,4800,10800],"runnable-time":[107,0,0,0,0,0,0,0,12,4,1,2,5,1,2,2,1,1,2,2,3,2,3,2,1,6,2,1,3,2,1,0,1,1,0,0,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0],"sched-events":[0,0],"size-classes":[2849,9606,998,457,598,150,30,31,3,39,19,23,7,0,44,4,2,50,9,3,30,1,40,0,0,10,10,5,3,2,12,6,24,2,1,0,5,7,4,1,0,0,0,20,15,1,19,0,0,0,2,4,0,0,0,0,0,1,2,0,0,0,0,0,0,2,15,2],"timestamp":1667970167443}
1、后端数据采集
结构体 List
管理所有的绘图数据,定义如下:
// List holds all the plots that statsviz knows about. Some plots might be
// disabled, if they rely on metrics that are unknown to the current Go version.
type List struct {
plots []plot
once sync.Once // ensure Config is called once
cfg *Config
idxs map[string]int // map metrics name to idx in samples and descs
descs []metrics.Description
mu sync.Mutex // protects samples in case of concurrent calls to WriteValues
samples []metrics.Sample
}
其中,plots
用于管理绘图结构,samples
存储具体的采样数据。对于plot
接口,定义如下:
type plot interface {
name() string
isEnabled() bool
layout([]metrics.Sample) interface{}
values([]metrics.Sample) interface{}
}
由于不同图表所需的数据不一定相同,所以每一张图表都定义自己的结构体。如:heap (global)
图表的结构体定义为:
type heapGlobal struct {
enabled bool
idxobj int
idxunused int
idxfree int
idxreleased int
}
heap (details)
图标的结构体定义为:
type heapDetails struct {
enabled bool
idxobj int
idxunused int
idxfree int
idxreleased int
idxstacks int
idxgoal int
}
其他结构体还有:liveObjects
、liveBytes
、mspanMcache
、goroutines
、sizeClasses
、gcpauses
、runnableTime
、schedEvents
、cgo
、gcStackSize
。这些所有的图表专业的结构体均实现了plot
接口。
回到代码,还是看WriteValues
函数:
/ WriteValues writes into w a JSON object containing the data points for all
// plots at the current instant.
func (pl *List) WriteValues(w io.Writer) error {
pl.mu.Lock()
defer pl.mu.Unlock()
metrics.Read(pl.samples)
// lastgc time series is used as source to represent garbage collection
// timestamps as vertical bars on certain plots.
gcStats := debug.GCStats{}
debug.ReadGCStats(&gcStats)
m := make(map[string]interface{})
for _, p := range pl.plots {
if p.isEnabled() {
m[p.name()] = p.values(pl.samples)
}
}
// In javascript, timestamps are in ms.
m["lastgc"] = []int64{gcStats.LastGC.UnixMilli()}
m["timestamp"] = time.Now().UnixMilli()
if err := json.NewEncoder(w).Encode(m); err != nil {
return fmt.Errorf("failed to write/convert metrics values to json: %v", err)
}
return nil
}
先调用metrics.Read(pl.samples)
函数将采样数据存储到pl.samples
中,然后额外增加了两个字段lastgc
和timestamp
,最后通过json.NewEncoder(w).Encode(m)
将数据打包成json
格式发送给客户端浏览器。
2、前端数据展示
前端拿到数据后调用updatePlots()
函数完成数据的更新展示。
// internal/static/js/app.js
/* WebSocket connection handling */
const connect = () => {
...
ws.onmessage = event => {
let data = JSON.parse(event.data)
if (!initDone) {
configurePlots(PlotsDef);
stats.init(PlotsDef, dataRetentionSeconds);
attachPlots();
$('#play_pause').change(() => { paused = !paused; });
$('#show_gc').change(() => {
show_gc = !show_gc;
updatePlots();
});
$('#select_timerange').click(() => {
const val = parseInt($("#select_timerange option:selected").val(), 10);
timerange = val;
updatePlots();
});
initDone = true;
return;
}
stats.pushData(data);
if (paused) {
return
}
updatePlots(PlotsDef.events);
}
}
前端具体是如何根据数据作图的呢?这个涉及比较多的前端知识,相关文件存放在/internal/static
目录下,后面有时间再做深入的探究。