From cf5e6eef855e390375fc4a06a795a97d2015996f Mon Sep 17 00:00:00 2001 From: duncandu Date: Fri, 1 Jul 2022 23:19:55 +0800 Subject: [PATCH] #46 support JSON snapshot --- README.md | 5 +- charts.go | 6 +- main.go | 26 +++++---- print.go | 167 +++++++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 174 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 22fc17f..6f3da30 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ A high-performance HTTP benchmarking tool with real-time web UI and terminal dis Examples: plow http://127.0.0.1:8080/ -c 20 -n 100000 - plow https://httpbin.org/post -c 20 -d 5m --body @file.json -T 'application/json' -m POST + plow https://httpbin.org/post -c 20 -d 5m --body @file.jsonFormat -T 'application/jsonFormat' -m POST Flags: --help Show context-sensitive help. @@ -109,6 +109,7 @@ Flags: -d, --duration=DURATION Duration of test, examples: -d 10s -d 3m -i, --interval=200ms Print snapshot result every interval, use 0 to print once at the end --seconds Use seconds as time unit to print + --json Print snapshot result as JSON -b, --body=BODY HTTP request body, if start the body with @, the rest should be a filename to read --stream Specify whether to stream file specified by '--body @file' using chunked encoding or to read into memory -m, --method="GET" HTTP method @@ -126,7 +127,7 @@ Flags: --socks5=ip:port Socks5 proxy --auto-open-browser Specify whether auto open browser to show Web charts --[no-]clean Clean the histogram bar once its finished. Default is true - --[no-]summary Only print the summary without realtime reports + --summary Only print the summary without realtime reports --version Show application version. Flags default values also read from env PLOW_SOME_FLAG, such as PLOW_TIMEOUT=5s equals to --timeout=5s diff --git a/charts.go b/charts.go index f90ed99..d18a3f6 100644 --- a/charts.go +++ b/charts.go @@ -42,7 +42,7 @@ function {{ .ViewID }}_sync() { $.ajax({ type: "GET", url: "{{ .APIPath }}{{ .Route }}", - dataType: "json", + dataType: "jsonFormat", success: function (result) { let opt = goecharts_{{ .ViewID }}.getOption(); let x = opt.xAxis[0].data; @@ -141,8 +141,8 @@ func (c *Charts) newRPSView() components.Charter { } type Metrics struct { - Values []interface{} `json:"values"` - Time string `json:"time"` + Values []interface{} `jsonFormat:"values"` + Time string `jsonFormat:"time"` } type Charts struct { diff --git a/main.go b/main.go index 94ec1f6..2dc7818 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,8 @@ import ( "golang.org/x/time/rate" "io/ioutil" "net" + "net/http" + _ "net/http/pprof" "os" "strconv" "strings" @@ -20,6 +22,7 @@ var ( duration = kingpin.Flag("duration", "Duration of test, examples: -d 10s -d 3m").Short('d').PlaceHolder("DURATION").Duration() interval = kingpin.Flag("interval", "Print snapshot result every interval, use 0 to print once at the end").Short('i').Default("200ms").Duration() seconds = kingpin.Flag("seconds", "Use seconds as time unit to print").Bool() + jsonFormat = kingpin.Flag("json", "Print snapshot result as JSON").Bool() body = kingpin.Flag("body", "HTTP request body, if start the body with @, the rest should be a filename to read").Short('b').String() stream = kingpin.Flag("stream", "Specify whether to stream file specified by '--body @file' using chunked encoding or to read into memory").Default("false").Bool() @@ -40,7 +43,8 @@ var ( autoOpenBrowser = kingpin.Flag("auto-open-browser", "Specify whether auto open browser to show Web charts").Bool() clean = kingpin.Flag("clean", "Clean the histogram bar once its finished. Default is true").Default("true").NegatableBool() - summary = kingpin.Flag("summary", "Only print the summary without realtime reports").Default("false").NegatableBool() + summary = kingpin.Flag("summary", "Only print the summary without realtime reports").Default("false").Bool() + pprofAddr = kingpin.Flag("pprof", "Enable pprof at special address").Hidden().String() url = kingpin.Arg("url", "request url").Required().String() ) @@ -79,7 +83,7 @@ var CompactUsageTemplate = `{{define "FormatCommand" -}} Examples: plow http://127.0.0.1:8080/ -c 20 -n 100000 - plow https://httpbin.org/post -c 20 -d 5m --body @file.json -T 'application/json' -m POST + plow https://httpbin.org/post -c 20 -d 5m --body @file.jsonFormat -T 'application/jsonFormat' -m POST {{if .Context.Flags -}} {{T "Flags:"}} @@ -182,6 +186,10 @@ func main() { return } + if *pprofAddr != "" { + go http.ListenAndServe(*pprofAddr, nil) + } + var err error var bodyBytes []byte var bodyFile string @@ -232,11 +240,6 @@ func main() { return } - outStream := os.Stdout - if *summary { - outStream = os.Stderr - isTerminal = false - } // description var desc string desc = fmt.Sprintf("Benchmarking %s", *url) @@ -247,7 +250,7 @@ func main() { desc += fmt.Sprintf(" for %s", duration.String()) } desc += fmt.Sprintf(" using %d connection(s).", *concurrency) - fmt.Fprintln(outStream, desc) + fmt.Fprintln(os.Stderr, desc) // charts listener var ln net.Listener @@ -257,9 +260,9 @@ func main() { errAndExit(err.Error()) return } - fmt.Fprintf(outStream, "@ Real-time charts is listening on http://%s\n", ln.Addr().String()) + fmt.Fprintf(os.Stderr, "@ Real-time charts is listening on http://%s\n", ln.Addr().String()) } - fmt.Fprintln(outStream, "") + fmt.Fprintln(os.Stderr, "") // do request go requester.Run() @@ -280,6 +283,5 @@ func main() { // terminal printer printer := NewPrinter(*requests, *duration, !*clean, *summary) - printer.PrintLoop(report.Snapshot, *interval, *seconds, report.Done()) - + printer.PrintLoop(report.Snapshot, *interval, *seconds, *jsonFormat, report.Done()) } diff --git a/print.go b/print.go index 9fa94da..c427e03 100644 --- a/print.go +++ b/print.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "fmt" "github.com/mattn/go-isatty" "github.com/mattn/go-runewidth" @@ -63,12 +64,12 @@ func (p *Printer) updateProgressValue(rs *SnapshotReport) { } } -func (p *Printer) PrintLoop(snapshot func() *SnapshotReport, interval time.Duration, useSeconds bool, doneChan <-chan struct{}) { +func (p *Printer) PrintLoop(snapshot func() *SnapshotReport, interval time.Duration, useSeconds bool, json bool, doneChan <-chan struct{}) { var buf bytes.Buffer var backCursor string cl := clearLine - if p.summary || interval == 0 { + if p.summary || interval == 0 || !isTerminal { cl = nil } echo := func(isFinal bool) { @@ -76,7 +77,11 @@ func (p *Printer) PrintLoop(snapshot func() *SnapshotReport, interval time.Durat p.updateProgressValue(report) os.Stdout.WriteString(backCursor) buf.Reset() - p.formatTableReports(&buf, report, isFinal, useSeconds) + if json { + p.formatJSONReports(&buf, report, isFinal, useSeconds) + } else { + p.formatTableReports(&buf, report, isFinal, useSeconds) + } result := buf.Bytes() n := 0 for { @@ -93,7 +98,9 @@ func (p *Printer) PrintLoop(snapshot func() *SnapshotReport, interval time.Durat result = result[i+1:] } os.Stdout.Sync() - backCursor = fmt.Sprintf("\033[%dA", n) + if isTerminal { + backCursor = fmt.Sprintf("\033[%dA", n) + } } if interval > 0 { @@ -185,6 +192,24 @@ func formatFloat64(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) } +func (p *Printer) formatJSONReports(writer *bytes.Buffer, snapshot *SnapshotReport, isFinal bool, useSeconds bool) { + indent := 0 + writer.WriteString("{\n") + indent++ + p.buildJSONSummary(writer, snapshot, indent) + if len(snapshot.Errors) != 0 { + writer.WriteString(",\n") + p.buildJSONErrors(writer, snapshot, indent) + } + writer.WriteString(",\n") + p.buildJSONStats(writer, snapshot, useSeconds, indent) + writer.WriteString(",\n") + p.buildJSONPercentile(writer, snapshot, useSeconds, indent) + writer.WriteString(",\n") + p.buildJSONHistogram(writer, snapshot, useSeconds, indent) + writer.WriteString("\n}\n") +} + func (p *Printer) formatTableReports(writer *bytes.Buffer, snapshot *SnapshotReport, isFinal bool, useSeconds bool) { summaryBulk := p.buildSummary(snapshot, isFinal) errorsBulks := p.buildErrors(snapshot) @@ -213,6 +238,30 @@ func (p *Printer) formatTableReports(writer *bytes.Buffer, snapshot *SnapshotRep writeBulk(writer, hisBulk) } +func (p *Printer) buildJSONHistogram(writer *bytes.Buffer, snapshot *SnapshotReport, useSeconds bool, indent int) { + tab0 := strings.Repeat(" ", indent) + writer.WriteString(tab0 + "\"Histograms\": [\n") + tab1 := strings.Repeat(" ", indent+1) + + maxCount := 0 + hisSum := 0 + for _, bin := range snapshot.Histograms { + if maxCount < bin.Count { + maxCount = bin.Count + } + hisSum += bin.Count + } + for i, bin := range snapshot.Histograms { + writer.WriteString(fmt.Sprintf(`%s[ "%s", %d ]`, tab1, + durationToString(bin.Mean, useSeconds), bin.Count)) + if i != len(snapshot.Histograms)-1 { + writer.WriteString(",") + } + writer.WriteString("\n") + } + writer.WriteString(tab0 + "]") +} + func (p *Printer) buildHistogram(snapshot *SnapshotReport, useSeconds bool, isFinal bool) [][]string { hisBulk := make([][]string, 0, 8) maxCount := 0 @@ -245,6 +294,22 @@ func (p *Printer) buildHistogram(snapshot *SnapshotReport, useSeconds bool, isFi return hisBulk } +func (p *Printer) buildJSONPercentile(writer *bytes.Buffer, snapshot *SnapshotReport, useSeconds bool, indent int) { + tab0 := strings.Repeat(" ", indent) + writer.WriteString(tab0 + "\"Percentiles\": {\n") + tab1 := strings.Repeat(" ", indent+1) + for i, percentile := range snapshot.Percentiles { + perc := formatFloat64(percentile.Percentile * 100) + writer.WriteString(fmt.Sprintf(`%s"%s": "%s"`, tab1, "P"+perc, + durationToString(percentile.Latency, useSeconds))) + if i != len(snapshot.Percentiles)-1 { + writer.WriteString(",") + } + writer.WriteString("\n") + } + writer.WriteString(tab0 + "}") +} + func (p *Printer) buildPercentile(snapshot *SnapshotReport, useSeconds bool) [][]string { percBulk := make([][]string, 2) percAligns := make([]int, 0, len(snapshot.Percentiles)) @@ -259,6 +324,30 @@ func (p *Printer) buildPercentile(snapshot *SnapshotReport, useSeconds bool) [][ return percBulk } +func (p *Printer) buildJSONStats(writer *bytes.Buffer, snapshot *SnapshotReport, useSeconds bool, indent int) { + tab0 := strings.Repeat(" ", indent) + writer.WriteString(tab0 + "\"Statistics\": {\n") + tab1 := strings.Repeat(" ", indent+1) + writer.WriteString(fmt.Sprintf(`%s"Latency": { "Min": "%s", "Mean": "%s", "StdDev": "%s", "Max": "%s" }`, + tab1, + durationToString(snapshot.Stats.Min, useSeconds), + durationToString(snapshot.Stats.Mean, useSeconds), + durationToString(snapshot.Stats.StdDev, useSeconds), + durationToString(snapshot.Stats.Max, useSeconds), + )) + if snapshot.RpsStats != nil { + writer.WriteString(",\n") + writer.WriteString(fmt.Sprintf(`%s"RPS": { "Min": %s, "Mean": %s, "StdDev": %s, "Max": %s }`, + tab1, + formatFloat64(math.Trunc(snapshot.RpsStats.Min*100)/100.0), + formatFloat64(math.Trunc(snapshot.RpsStats.Mean*100)/100.0), + formatFloat64(math.Trunc(snapshot.RpsStats.StdDev*100)/100.0), + formatFloat64(math.Trunc(snapshot.RpsStats.Max*100)/100.0), + )) + } + writer.WriteString("\n" + tab0 + "}") +} + func (p *Printer) buildStats(snapshot *SnapshotReport, useSeconds bool) [][]string { var statsBulk [][]string statsBulk = append(statsBulk, @@ -286,6 +375,23 @@ func (p *Printer) buildStats(snapshot *SnapshotReport, useSeconds bool) [][]stri return statsBulk } +func (p *Printer) buildJSONErrors(writer *bytes.Buffer, snapshot *SnapshotReport, indent int) { + tab0 := strings.Repeat(" ", indent) + writer.WriteString(tab0 + "\"Error\": {\n") + tab1 := strings.Repeat(" ", indent+1) + errors := sortMapStrInt(snapshot.Errors) + for i, v := range errors { + v[1] = colorize(v[1], FgRedColor) + vb, _ := json.Marshal(v[0]) + writer.WriteString(fmt.Sprintf(`%s%s: %s`, tab1, vb, v[1])) + if i != len(errors)-1 { + writer.WriteString(",") + } + writer.WriteString("\n") + } + writer.WriteString(tab0 + "}") +} + func (p *Printer) buildErrors(snapshot *SnapshotReport) [][]string { var errorsBulks [][]string for k, v := range snapshot.Errors { @@ -299,9 +405,47 @@ func (p *Printer) buildErrors(snapshot *SnapshotReport) [][]string { return errorsBulks } +func sortMapStrInt(m map[string]int64) (ret [][]string) { + for k, v := range m { + ret = append(ret, []string{k, strconv.FormatInt(v, 10)}) + } + sort.Slice(ret, func(i, j int) bool { return ret[i][0] < ret[j][0] }) + return +} + +func (p *Printer) buildJSONSummary(writer *bytes.Buffer, snapshot *SnapshotReport, indent int) { + tab0 := strings.Repeat(" ", indent) + writer.WriteString(tab0 + "\"Summary\": {\n") + { + tab1 := strings.Repeat(" ", indent+1) + writer.WriteString(fmt.Sprintf("%s\"Elapsed\": \"%s\",\n", tab1, snapshot.Elapsed.Truncate(100*time.Millisecond).String())) + writer.WriteString(fmt.Sprintf("%s\"Count\": %d,\n", tab1, snapshot.Count)) + writer.WriteString(fmt.Sprintf("%s\"Counts\": {\n", tab1)) + i := 0 + tab2 := strings.Repeat(" ", indent+2) + codes := sortMapStrInt(snapshot.Codes) + for _, v := range codes { + i++ + if v[0] != "2xx" { + v[1] = colorize(v[1], FgMagentaColor) + } + writer.WriteString(fmt.Sprintf(`%s"%s": %s`, tab2, v[0], v[1])) + if i != len(snapshot.Codes) { + writer.WriteString(",") + } + writer.WriteString("\n") + } + writer.WriteString(tab1 + "},\n") + writer.WriteString(fmt.Sprintf("%s\"RPS\": %.3f,\n", tab1, snapshot.RPS)) + writer.WriteString(fmt.Sprintf("%s\"Reads\": \"%.3fMB/s\",\n", tab1, snapshot.ReadThroughput)) + writer.WriteString(fmt.Sprintf("%s\"Writes\": \"%.3fMB/s\"\n", tab1, snapshot.WriteThroughput)) + } + writer.WriteString(tab0 + "}") +} + func (p *Printer) buildSummary(snapshot *SnapshotReport, isFinal bool) [][]string { summarybulk := make([][]string, 0, 8) - elapsedLine := []string{"Elapsed", snapshot.Elapsed.Truncate(time.Millisecond).String()} + elapsedLine := []string{"Elapsed", snapshot.Elapsed.Truncate(100 * time.Millisecond).String()} if p.maxDuration > 0 && !isFinal { elapsedLine = append(elapsedLine, p.pbDurStr) } @@ -315,16 +459,13 @@ func (p *Printer) buildSummary(snapshot *SnapshotReport, isFinal bool) [][]strin countLine, ) - codesBulks := make([][]string, 0, len(snapshot.Codes)) - for k, v := range snapshot.Codes { - vs := strconv.FormatInt(v, 10) - if k != "2xx" { - vs = colorize(vs, FgMagentaColor) + codes := sortMapStrInt(snapshot.Codes) + for _, v := range codes { + if v[0] != "2xx" { + v[1] = colorize(v[1], FgMagentaColor) } - codesBulks = append(codesBulks, []string{" " + k, vs}) + summarybulk = append(summarybulk, []string{" " + v[0], v[1]}) } - sort.Slice(codesBulks, func(i, j int) bool { return codesBulks[i][0] < codesBulks[j][0] }) - summarybulk = append(summarybulk, codesBulks...) summarybulk = append(summarybulk, []string{"RPS", fmt.Sprintf("%.3f", snapshot.RPS)}, []string{"Reads", fmt.Sprintf("%.3fMB/s", snapshot.ReadThroughput)},