项目介绍

地址: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 提供的默认ServeMuxServeMux类型是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格式,实际数据样例如下。其中,除了lastgctimestamp之外,每个字段对应页面上的一张图表。

{"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
}

其他结构体还有:liveObjectsliveBytesmspanMcachegoroutinessizeClassesgcpausesrunnableTimeschedEventscgogcStackSize。这些所有的图表专业的结构体均实现了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中,然后额外增加了两个字段lastgctimestamp,最后通过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目录下,后面有时间再做深入的探究。

本文参与了思否技术征文,欢迎正在阅读的你也加入。

mumingv
24 声望1 粉丝

持之以恒,方得始终