289 lines
8.4 KiB
Go
289 lines
8.4 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io/ioutil"
|
|
"net"
|
|
"net/http"
|
|
_ "net/http/pprof"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"golang.org/x/time/rate"
|
|
|
|
"gopkg.in/alecthomas/kingpin.v3-unstable"
|
|
)
|
|
|
|
var (
|
|
concurrency = kingpin.Flag("concurrency", "Number of connections to run concurrently").Short('c').Default("1").Int()
|
|
reqRate = rateFlag(kingpin.Flag("rate", "Number of requests per time unit, examples: --rate 50 --rate 10/ms").Default("infinity"))
|
|
requests = kingpin.Flag("requests", "Number of requests to run").Short('n').Default("-1").Int64()
|
|
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()
|
|
method = kingpin.Flag("method", "HTTP method").Default("GET").Short('m').String()
|
|
headers = kingpin.Flag("header", "Custom HTTP headers").Short('H').PlaceHolder("K:V").Strings()
|
|
host = kingpin.Flag("host", "Host header").String()
|
|
contentType = kingpin.Flag("content", "Content-Type header").Short('T').String()
|
|
cert = kingpin.Flag("cert", "Path to the client's TLS Certificate").ExistingFile()
|
|
key = kingpin.Flag("key", "Path to the client's TLS Certificate Private Key").ExistingFile()
|
|
insecure = kingpin.Flag("insecure", "Controls whether a client verifies the server's certificate chain and host name").Short('k').Bool()
|
|
|
|
chartsListenAddr = kingpin.Flag("listen", "Listen addr to serve Web UI").Default(":18888").String()
|
|
timeout = kingpin.Flag("timeout", "Timeout for each http request").PlaceHolder("DURATION").Duration()
|
|
dialTimeout = kingpin.Flag("dial-timeout", "Timeout for dial addr").PlaceHolder("DURATION").Duration()
|
|
reqWriteTimeout = kingpin.Flag("req-timeout", "Timeout for full request writing").PlaceHolder("DURATION").Duration()
|
|
respReadTimeout = kingpin.Flag("resp-timeout", "Timeout for full response reading").PlaceHolder("DURATION").Duration()
|
|
socks5 = kingpin.Flag("socks5", "Socks5 proxy").PlaceHolder("ip:port").String()
|
|
|
|
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").Bool()
|
|
pprofAddr = kingpin.Flag("pprof", "Enable pprof at special address").Hidden().String()
|
|
url = kingpin.Arg("url", "request url").Required().String()
|
|
)
|
|
|
|
func errAndExit(msg string) {
|
|
fmt.Fprintln(os.Stderr, "plow: "+msg)
|
|
os.Exit(1)
|
|
}
|
|
|
|
var CompactUsageTemplate = `{{define "FormatCommand" -}}
|
|
{{if .FlagSummary}} {{.FlagSummary}}{{end -}}
|
|
{{range .Args}} {{if not .Required}}[{{end}}<{{.Name}}>{{if .Value|IsCumulative}} ...{{end}}{{if not .Required}}]{{end}}{{end -}}
|
|
{{end -}}
|
|
|
|
{{define "FormatCommandList" -}}
|
|
{{range . -}}
|
|
{{if not .Hidden -}}
|
|
{{.Depth|Indent}}{{.Name}}{{if .Default}}*{{end}}{{template "FormatCommand" .}}
|
|
{{end -}}
|
|
{{template "FormatCommandList" .Commands -}}
|
|
{{end -}}
|
|
{{end -}}
|
|
|
|
{{define "FormatUsage" -}}
|
|
{{template "FormatCommand" .}}{{if .Commands}} <command> [<args> ...]{{end}}
|
|
{{if .Help}}
|
|
{{.Help|Wrap 0 -}}
|
|
{{end -}}
|
|
|
|
{{end -}}
|
|
|
|
{{if .Context.SelectedCommand -}}
|
|
{{T "usage:"}} {{.App.Name}} {{template "FormatUsage" .Context.SelectedCommand}}
|
|
{{else -}}
|
|
{{T "usage:"}} {{.App.Name}}{{template "FormatUsage" .App}}
|
|
{{end -}}
|
|
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
|
|
|
|
{{if .Context.Flags -}}
|
|
{{T "Flags:"}}
|
|
{{.Context.Flags|FlagsToTwoColumns|FormatTwoColumns}}
|
|
Flags default values also read from env PLOW_SOME_FLAG, such as PLOW_TIMEOUT=5s equals to --timeout=5s
|
|
|
|
{{end -}}
|
|
{{if .Context.Args -}}
|
|
{{T "Args:"}}
|
|
{{.Context.Args|ArgsToTwoColumns|FormatTwoColumns}}
|
|
{{end -}}
|
|
{{if .Context.SelectedCommand -}}
|
|
{{if .Context.SelectedCommand.Commands -}}
|
|
{{T "Commands:"}}
|
|
{{.Context.SelectedCommand}}
|
|
{{.Context.SelectedCommand.Commands|CommandsToTwoColumns|FormatTwoColumns}}
|
|
{{end -}}
|
|
{{else if .App.Commands -}}
|
|
{{T "Commands:"}}
|
|
{{.App.Commands|CommandsToTwoColumns|FormatTwoColumns}}
|
|
{{end -}}
|
|
`
|
|
|
|
type rateFlagValue struct {
|
|
infinity bool
|
|
limit rate.Limit
|
|
v string
|
|
}
|
|
|
|
func (f *rateFlagValue) Set(v string) error {
|
|
if v == "infinity" {
|
|
f.infinity = true
|
|
return nil
|
|
}
|
|
|
|
retErr := fmt.Errorf("--rate format %q doesn't match the \"freq/duration\" (i.e. 50/1s)", v)
|
|
ps := strings.SplitN(v, "/", 2)
|
|
switch len(ps) {
|
|
case 1:
|
|
ps = append(ps, "1s")
|
|
case 0:
|
|
return retErr
|
|
}
|
|
|
|
freq, err := strconv.Atoi(ps[0])
|
|
if err != nil {
|
|
return retErr
|
|
}
|
|
if freq == 0 {
|
|
f.infinity = true
|
|
return nil
|
|
}
|
|
|
|
switch ps[1] {
|
|
case "ns", "us", "µs", "ms", "s", "m", "h":
|
|
ps[1] = "1" + ps[1]
|
|
}
|
|
|
|
per, err := time.ParseDuration(ps[1])
|
|
if err != nil {
|
|
return retErr
|
|
}
|
|
|
|
f.limit = rate.Limit(float64(freq) / per.Seconds())
|
|
f.v = v
|
|
return nil
|
|
}
|
|
|
|
func (f *rateFlagValue) Limit() *rate.Limit {
|
|
if f.infinity {
|
|
return nil
|
|
}
|
|
return &f.limit
|
|
}
|
|
|
|
func (f *rateFlagValue) String() string {
|
|
return f.v
|
|
}
|
|
|
|
func rateFlag(c *kingpin.Clause) (target *rateFlagValue) {
|
|
target = new(rateFlagValue)
|
|
c.SetValue(target)
|
|
return
|
|
}
|
|
|
|
func main() {
|
|
kingpin.UsageTemplate(CompactUsageTemplate).
|
|
Version("1.3.1").
|
|
Author("six-ddc@github").
|
|
Resolver(kingpin.PrefixedEnvarResolver("PLOW_", ";")).
|
|
Help = `A high-performance HTTP benchmarking tool with real-time web UI and terminal displaying`
|
|
kingpin.Parse()
|
|
|
|
if *requests >= 0 && *requests < int64(*concurrency) {
|
|
errAndExit("requests must greater than or equal concurrency")
|
|
return
|
|
}
|
|
if (*cert != "" && *key == "") || (*cert == "" && *key != "") {
|
|
errAndExit("must specify cert and key at the same time")
|
|
return
|
|
}
|
|
|
|
if *pprofAddr != "" {
|
|
go http.ListenAndServe(*pprofAddr, nil)
|
|
}
|
|
|
|
var err error
|
|
var bodyBytes []byte
|
|
var bodyFile string
|
|
if strings.HasPrefix(*body, "@") {
|
|
fileName := (*body)[1:]
|
|
if _, err = os.Stat(fileName); err != nil {
|
|
errAndExit(err.Error())
|
|
return
|
|
}
|
|
if *stream {
|
|
bodyFile = fileName
|
|
} else {
|
|
bodyBytes, err = ioutil.ReadFile(fileName)
|
|
if err != nil {
|
|
errAndExit(err.Error())
|
|
return
|
|
}
|
|
}
|
|
} else if *body != "" {
|
|
bodyBytes = []byte(*body)
|
|
}
|
|
|
|
clientOpt := ClientOpt{
|
|
url: *url,
|
|
method: *method,
|
|
headers: *headers,
|
|
bodyBytes: bodyBytes,
|
|
bodyFile: bodyFile,
|
|
|
|
certPath: *cert,
|
|
keyPath: *key,
|
|
insecure: *insecure,
|
|
|
|
maxConns: *concurrency,
|
|
doTimeout: *timeout,
|
|
readTimeout: *respReadTimeout,
|
|
writeTimeout: *reqWriteTimeout,
|
|
dialTimeout: *dialTimeout,
|
|
|
|
socks5Proxy: *socks5,
|
|
contentType: *contentType,
|
|
host: *host,
|
|
}
|
|
|
|
requester, err := NewRequester(*concurrency, *requests, *duration, reqRate.Limit(), &clientOpt)
|
|
if err != nil {
|
|
errAndExit(err.Error())
|
|
return
|
|
}
|
|
|
|
// description
|
|
var desc string
|
|
desc = fmt.Sprintf("Benchmarking %s", *url)
|
|
if *requests > 0 {
|
|
desc += fmt.Sprintf(" with %d request(s)", *requests)
|
|
}
|
|
if *duration > 0 {
|
|
desc += fmt.Sprintf(" for %s", duration.String())
|
|
}
|
|
desc += fmt.Sprintf(" using %d connection(s).", *concurrency)
|
|
fmt.Fprintln(os.Stderr, desc)
|
|
|
|
// charts listener
|
|
var ln net.Listener
|
|
if *chartsListenAddr != "" {
|
|
ln, err = net.Listen("tcp", *chartsListenAddr)
|
|
if err != nil {
|
|
errAndExit(err.Error())
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, "@ Real-time charts is listening on http://%s\n", ln.Addr().String())
|
|
}
|
|
fmt.Fprintln(os.Stderr, "")
|
|
|
|
// do request
|
|
go requester.Run()
|
|
|
|
// metrics collection
|
|
report := NewStreamReport()
|
|
go report.Collect(requester.RecordChan())
|
|
|
|
if ln != nil {
|
|
// serve charts data
|
|
charts, err := NewCharts(ln, report.Charts, desc)
|
|
if err != nil {
|
|
errAndExit(err.Error())
|
|
return
|
|
}
|
|
go charts.Serve(*autoOpenBrowser)
|
|
}
|
|
|
|
// terminal printer
|
|
printer := NewPrinter(*requests, *duration, !*clean, *summary)
|
|
printer.PrintLoop(report.Snapshot, *interval, *seconds, *jsonFormat, report.Done())
|
|
}
|