mirror of
https://github.com/zhufuyi/sponge.git
synced 2025-09-26 12:41:11 +08:00
feat: add performance test command
This commit is contained in:
@@ -102,7 +102,7 @@ func chat(asst *assistantParams) error {
|
||||
}
|
||||
if input == "r" || input == "R" {
|
||||
client.RefreshContext()
|
||||
fmt.Println(color.HiBlackString("Finished refreshing context.") + "\n\n")
|
||||
fmt.Println(color.HiBlackString("Start a new conversation.") + "\n\n")
|
||||
continue
|
||||
}
|
||||
|
||||
|
29
cmd/sponge/commands/perftest.go
Normal file
29
cmd/sponge/commands/perftest.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/http"
|
||||
"github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/websocket"
|
||||
)
|
||||
|
||||
// PerftestCommand command entry
|
||||
func PerftestCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "perftest",
|
||||
Short: "Performance testing for HTTP/1.1, HTTP/2, HTTP/3, and websocket",
|
||||
Long: `Perftest is a performance testing tool that supports HTTP/1.1, HTTP/2, HTTP/3, and WebSocket protocols.
|
||||
It also allows real-time statistics to be pushed to a custom HTTP server or Prometheus.`,
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
http.PerfTestHTTPCMD(),
|
||||
http.PerfTestHTTP2CMD(),
|
||||
http.PerfTestHTTP3CMD(),
|
||||
websocket.PerfTestWebsocketCMD(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
194
cmd/sponge/commands/perftest/README.md
Normal file
194
cmd/sponge/commands/perftest/README.md
Normal file
@@ -0,0 +1,194 @@
|
||||
## English | [简体中文](readme-cn.md)
|
||||
|
||||
## PerfTest
|
||||
|
||||
`perftest` is a lightweight and high-performance testing tool that supports **HTTP/1.1, HTTP/2, HTTP/3, and WebSocket** protocols.
|
||||
It can execute high-concurrency requests efficiently and push real-time statistics to a custom HTTP server or **Prometheus** for monitoring and analysis.
|
||||
|
||||
<br>
|
||||
|
||||
### ✨ Features
|
||||
|
||||
* ✅ Support for **HTTP/1.1, HTTP/2, HTTP/3, WebSocket**
|
||||
* ✅ Two modes: **fixed total requests** or **fixed duration**
|
||||
* ✅ Configurable **workers (concurrency)**
|
||||
* ✅ Support for **GET, POST, custom request bodies**
|
||||
* ✅ **Real-time statistics push** (HTTP server / Prometheus)
|
||||
* ✅ **Detailed performance reports** (QPS, latency distribution, data transfer, status codes, etc.)
|
||||
* ✅ **WebSocket message performance test** with custom message payload and send interval
|
||||
|
||||
<br>
|
||||
|
||||
### 📦 Installation
|
||||
|
||||
```bash
|
||||
go install github.com/go-dev-frame/sponge/cmd/perftest@latest
|
||||
```
|
||||
|
||||
After installation, run `perftest -h` to see usage.
|
||||
|
||||
<br>
|
||||
|
||||
### 🚀 Usage Examples
|
||||
|
||||
#### 1. HTTP/1.1 Performance Test
|
||||
|
||||
```bash
|
||||
# Default mode: worker=CPU*3, 5000 requests, GET request
|
||||
sponge perftest http --url=http://localhost:8080/user/1
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests
|
||||
sponge perftest http --worker=50 --total=500000 --url=http://localhost:8080/user/1
|
||||
|
||||
# Fixed number of requests: POST with JSON body
|
||||
sponge perftest http --worker=50 --total=500000 --method=POST \
|
||||
--url=http://localhost:8080/user \
|
||||
--body='{"name":"Alice","age":25}'
|
||||
|
||||
# Fixed duration: 50 workers, run for 10s
|
||||
sponge perftest http --worker=50 --duration=10s --url=http://localhost:8080/user/1
|
||||
|
||||
# Push statistics to custom HTTP server every 1s
|
||||
sponge perftest http --worker=50 --total=500000 \
|
||||
--url=http://localhost:8080/user/1 \
|
||||
--push-url=http://localhost:9090/report
|
||||
|
||||
# Push statistics to Prometheus (job=xxx)
|
||||
sponge perftest http --worker=50 --duration=10s \
|
||||
--url=http://localhost:8080/user/1 \
|
||||
--push-url=http://localhost:9091/metrics \
|
||||
--prometheus-job-name=perftest-http
|
||||
```
|
||||
|
||||
**Report Example:**
|
||||
|
||||
```
|
||||
500000 / 500000 [==================================================] 100.00% 8.85s
|
||||
|
||||
========== HTTP/1.1 Performance Test Report ==========
|
||||
|
||||
[Requests]
|
||||
• Total Requests: 500000
|
||||
• Successful: 500000 (100%)
|
||||
• Failed: 0
|
||||
• Total Duration: 8.85s
|
||||
• Throughput (QPS): 56489.26 req/sec
|
||||
|
||||
[Latency]
|
||||
• Average: 0.88 ms
|
||||
• Minimum: 0.00 ms
|
||||
• Maximum: 21.56 ms
|
||||
• P25: 0.00 ms
|
||||
• P50: 1.01 ms
|
||||
• P95: 2.34 ms
|
||||
|
||||
[Data Transfer]
|
||||
• Sent: 12.5 MB
|
||||
• Received: 24.5 MB
|
||||
|
||||
[Status Codes]
|
||||
• 200: 500000
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
#### 2. HTTP/2 Performance Test
|
||||
|
||||
Usage is the same as HTTP/1.1, just replace `http` with `http2`:
|
||||
|
||||
```bash
|
||||
sponge perftest http2 --worker=50 --total=500000 --url=http2://localhost:8080/user/1
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
#### 3. HTTP/3 Performance Test
|
||||
|
||||
Usage is the same as HTTP/1.1, just replace `http` with `http3`:
|
||||
|
||||
```bash
|
||||
sponge perftest http3 --worker=50 --total=500000 --url=http3://localhost:8080/user/1
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
#### 4. WebSocket Performance Test
|
||||
|
||||
```bash
|
||||
# Default: 10 workers, 10s duration, random(10) string message
|
||||
sponge perftest websocket --url=ws://localhost:8080/ws
|
||||
|
||||
# Send fixed string messages, interval=10ms
|
||||
sponge perftest websocket --worker=100 --duration=1m \
|
||||
--send-interval=10ms \
|
||||
--body-string=abcdefghijklmnopqrstuvwxyz \
|
||||
--url=ws://localhost:8080/ws
|
||||
|
||||
# Send JSON messages, default no interval
|
||||
sponge perftest websocket --worker=10 --duration=10s \
|
||||
--body='{"name":"Alice","age":25}' \
|
||||
--url=ws://localhost:8080/ws
|
||||
|
||||
# Send JSON messages, interval=10ms
|
||||
sponge perftest websocket --worker=100 --duration=1m \
|
||||
--send-interval=10ms \
|
||||
--body='{"name":"Alice","age":25}' \
|
||||
--url=ws://localhost:8080/ws
|
||||
```
|
||||
|
||||
**Report Example:**
|
||||
|
||||
```
|
||||
5.0s / 5.0s [==================================================] 100.00%
|
||||
|
||||
========== WebSocket Performance Test Report ==========
|
||||
|
||||
[Connections]
|
||||
• Total: 10
|
||||
• Successful: 10 (100%)
|
||||
• Failed: 0
|
||||
• Latency: min: 14.80 ms, avg: 14.80 ms, max: 14.80 ms
|
||||
|
||||
[Messages Sent]
|
||||
• Total Messages: 2954089
|
||||
• Total Bytes: 295408900
|
||||
• Throughput (QPS): 590817.80 msgs/sec
|
||||
|
||||
[Messages Received]
|
||||
• Total Messages: 2954089
|
||||
• Total Bytes: 295408900
|
||||
• Throughput (QPS): 590817.80 msgs/sec
|
||||
```
|
||||
|
||||
<br>
|
||||
|
||||
### ⚙️ Common Parameters
|
||||
|
||||
| Parameter | Description | Example |
|
||||
| ----------------------- |-------------------------------------------------------------------------------|-------------------------------------------|
|
||||
| `--url`, `-u` | Request URL (http/https/ws) | `--url=http://localhost:8080/user/1` |
|
||||
| `--worker`, `-w` | Number of concurrent workers (default = CPU\*3) | `--worker=50` |
|
||||
| `--total`, `-t` | Total number of requests (mutually exclusive with `--duration`, `--duration` higher priority) | `--total=500000` |
|
||||
| `--duration`, `-d` | Duration of the test (mutually exclusive with `--total`) | `--duration=10s` |
|
||||
| `--method`, `-m` | HTTP method | `--method=POST` |
|
||||
| `--body`, `-b` | Request body (JSON supported) | `--body='{"name":"Alice"}'` |
|
||||
| `--body-string`,`-s` | WebSocket message string body | `--body-string=hello` |
|
||||
| `--send-interval`, `-i` | Interval between WebSocket messages | `--send-interval=10ms` |
|
||||
| `--push-url`, `-p` | URL to push statistics | `--push-url=http://localhost:9090/report` |
|
||||
| `--prometheus-job-name`, `-j` | Job name for Prometheus metrics | `--prometheus-job-name=perftest-http` |
|
||||
|
||||
<br>
|
||||
|
||||
### 📊 Typical Use Cases
|
||||
|
||||
* ✅ Performance testing **Web APIs (HTTP/1.1/2/3)**
|
||||
* ✅ Comparing performance differences across HTTP versions
|
||||
* ✅ Stress-testing **real-time WebSocket services**
|
||||
* ✅ **CI/CD integration** with Prometheus monitoring
|
||||
|
||||
<br>
|
||||
|
||||
### 📝 Summary
|
||||
|
||||
`perftest` is a simple yet powerful performance testing tool.
|
||||
It’s suitable for quick performance verification as well as integration into **CI/CD pipelines** or **real-time monitoring systems** with Prometheus and Grafana.
|
55
cmd/sponge/commands/perftest/common/common.go
Normal file
55
cmd/sponge/commands/perftest/common/common.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckBodyParam checks if the body parameter is provided in JSON or file format.
|
||||
func CheckBodyParam(bodyJSON string, bodyFile string) (string, error) {
|
||||
var body []byte
|
||||
if bodyJSON != "" {
|
||||
bodyJSON = strings.Trim(bodyJSON, "'")
|
||||
body = []byte(bodyJSON)
|
||||
} else {
|
||||
if bodyFile != "" {
|
||||
data, err := os.ReadFile(bodyFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
body = data
|
||||
}
|
||||
}
|
||||
|
||||
if len(body) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
ok, err := isValidJSON(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid JSON format")
|
||||
}
|
||||
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func isValidJSON(data []byte) (bool, error) {
|
||||
var js interface{}
|
||||
if err := json.Unmarshal(data, &js); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// NewID generates a new ID for each request.
|
||||
func NewID() int64 {
|
||||
ns := time.Now().UnixMilli() * 1000000
|
||||
return ns + rand.Int63n(1000000)
|
||||
}
|
232
cmd/sponge/commands/perftest/common/progress_bar.go
Normal file
232
cmd/sponge/commands/perftest/common/progress_bar.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
// Bar represents a thread-safe progress bar.
|
||||
type Bar struct {
|
||||
total int64 // total items
|
||||
current int64 // current progress
|
||||
startTime time.Time
|
||||
barWidth int // display width in terminal
|
||||
graph string // symbol for completed portion
|
||||
arrow string // symbol for current progress
|
||||
space string // symbol for remaining portion
|
||||
|
||||
lastDrawNano atomic.Int64
|
||||
updateInterval time.Duration // refresh interval to avoid frequent I/O
|
||||
}
|
||||
|
||||
// NewBar returns a new progress bar with the given total count.
|
||||
func NewBar(total int64, t time.Time) *Bar {
|
||||
b := &Bar{
|
||||
total: total,
|
||||
startTime: t,
|
||||
barWidth: 50,
|
||||
graph: "=",
|
||||
arrow: ">",
|
||||
space: " ",
|
||||
updateInterval: 500 * time.Millisecond,
|
||||
}
|
||||
|
||||
b.lastDrawNano.Store(0)
|
||||
return b
|
||||
}
|
||||
|
||||
// Increment advances the progress by 1 and redraws the bar if needed.
|
||||
func (b *Bar) Increment() {
|
||||
atomic.AddInt64(&b.current, 1)
|
||||
b.draw()
|
||||
}
|
||||
|
||||
// Finish marks the bar as complete and prints the final state.
|
||||
func (b *Bar) Finish() {
|
||||
atomic.StoreInt64(&b.current, b.total)
|
||||
b.draw()
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// shouldDraw reports whether a redraw should occur using a CAS timestamp.
|
||||
// Ensures only one goroutine wins the right to draw within the interval.
|
||||
func (b *Bar) shouldDraw() bool {
|
||||
now := time.Now().UnixNano()
|
||||
intervalNano := b.updateInterval.Nanoseconds()
|
||||
|
||||
//for {
|
||||
lastNano := b.lastDrawNano.Load()
|
||||
if now-lastNano < intervalNano {
|
||||
// Too close to the last draw, skip
|
||||
return false
|
||||
}
|
||||
|
||||
// Attempt to update the timestamp
|
||||
if b.lastDrawNano.CompareAndSwap(lastNano, now) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
//}
|
||||
}
|
||||
|
||||
// draw renders the bar in the terminal.
|
||||
// Uses shouldDraw to avoid excessive refreshes.
|
||||
func (b *Bar) draw() {
|
||||
current := atomic.LoadInt64(&b.current)
|
||||
|
||||
// Redraw only when reaching refresh interval or on completion.
|
||||
// Finish() always forces a final draw.
|
||||
if current < b.total && !b.shouldDraw() {
|
||||
return
|
||||
}
|
||||
|
||||
percent := float64(current) / float64(b.total)
|
||||
if percent > 1.0 {
|
||||
percent = 1.0
|
||||
}
|
||||
filledLength := int(float64(b.barWidth) * percent)
|
||||
|
||||
// Build the visual bar
|
||||
var barBuilder strings.Builder
|
||||
barBuilder.Grow(b.barWidth + 2)
|
||||
barBuilder.WriteString("[")
|
||||
barBuilder.WriteString(strings.Repeat(b.graph, filledLength))
|
||||
|
||||
// Show arrow only when not finished
|
||||
if current < b.total {
|
||||
if filledLength < b.barWidth {
|
||||
barBuilder.WriteString(b.arrow)
|
||||
barBuilder.WriteString(strings.Repeat(b.space, b.barWidth-filledLength-1))
|
||||
} else {
|
||||
barBuilder.WriteString(strings.Repeat(b.space, b.barWidth-filledLength))
|
||||
}
|
||||
} else {
|
||||
barBuilder.WriteString(strings.Repeat(b.space, b.barWidth-filledLength))
|
||||
}
|
||||
barBuilder.WriteString("]")
|
||||
|
||||
elapsed := time.Since(b.startTime).Seconds()
|
||||
|
||||
str := fmt.Sprintf("%8d / %-8d %s %6.2f%% %.2fs", current, b.total, barBuilder.String(), percent*100, elapsed)
|
||||
fmt.Printf("\r%s", color.HiBlackString(str))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// TimeBar represents a thread-safe time-based progress bar.
|
||||
type TimeBar struct {
|
||||
totalDuration time.Duration // total duration
|
||||
startTime time.Time // start time
|
||||
barWidth int // display width in terminal
|
||||
graph string // symbol for completed portion
|
||||
arrow string // symbol for current progress
|
||||
space string // symbol for remaining portion
|
||||
|
||||
// Background goroutine control
|
||||
wg sync.WaitGroup
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewTimeBar returns a new time-based progress bar with the given duration.
|
||||
func NewTimeBar(totalDuration time.Duration) *TimeBar {
|
||||
return &TimeBar{
|
||||
totalDuration: totalDuration,
|
||||
barWidth: 50,
|
||||
graph: "=",
|
||||
arrow: ">",
|
||||
space: " ",
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start begins automatic updates in a background goroutine.
|
||||
func (b *TimeBar) Start() {
|
||||
b.startTime = time.Now()
|
||||
b.wg.Add(1)
|
||||
go b.run()
|
||||
}
|
||||
|
||||
// Finish stops the progress bar and ensures the final state is drawn.
|
||||
func (b *TimeBar) Finish() {
|
||||
select {
|
||||
case <-b.done:
|
||||
return
|
||||
default:
|
||||
close(b.done)
|
||||
}
|
||||
b.wg.Wait()
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// run periodically refreshes the bar in the background.
|
||||
func (b *TimeBar) run() {
|
||||
defer b.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-b.done:
|
||||
b.draw(true)
|
||||
return
|
||||
case <-ticker.C:
|
||||
if time.Since(b.startTime) >= b.totalDuration {
|
||||
b.draw(true)
|
||||
return
|
||||
}
|
||||
b.draw(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// draw renders the bar in the terminal.
|
||||
// isFinal indicates whether this is the last draw.
|
||||
func (b *TimeBar) draw(isFinal bool) {
|
||||
elapsed := time.Since(b.startTime)
|
||||
percent := elapsed.Seconds() / b.totalDuration.Seconds()
|
||||
|
||||
// Handle final state and overflow
|
||||
if isFinal || percent >= 1.0 {
|
||||
percent = 1.0
|
||||
elapsed = b.totalDuration
|
||||
}
|
||||
|
||||
filledLength := int(float64(b.barWidth) * percent)
|
||||
|
||||
// Build the visual bar
|
||||
var barBuilder strings.Builder
|
||||
barBuilder.Grow(b.barWidth + 2)
|
||||
barBuilder.WriteString("[")
|
||||
barBuilder.WriteString(strings.Repeat(b.graph, filledLength))
|
||||
|
||||
// Show arrow if not complete
|
||||
if percent < 1.0 {
|
||||
if filledLength < b.barWidth {
|
||||
barBuilder.WriteString(b.arrow)
|
||||
barBuilder.WriteString(strings.Repeat(b.space, b.barWidth-filledLength-1))
|
||||
}
|
||||
}
|
||||
|
||||
remainingSpace := b.barWidth - barBuilder.Len() + 1 // +1 for '['
|
||||
if remainingSpace > 0 {
|
||||
barBuilder.WriteString(strings.Repeat(b.space, remainingSpace))
|
||||
}
|
||||
|
||||
barBuilder.WriteString("]")
|
||||
|
||||
// Print with carriage return for alignment.
|
||||
// Format times with one decimal place for consistency.
|
||||
str := fmt.Sprintf("%.1fs / %.1fs %s %.2f%%",
|
||||
elapsed.Seconds(),
|
||||
b.totalDuration.Seconds(),
|
||||
barBuilder.String(),
|
||||
percent*100,
|
||||
)
|
||||
fmt.Printf("\r%s", color.HiBlackString(str))
|
||||
}
|
139
cmd/sponge/commands/perftest/http/http1.go
Normal file
139
cmd/sponge/commands/perftest/http/http1.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common"
|
||||
)
|
||||
|
||||
// PerfTestHTTPCMD creates a new cobra.Command for HTTP/1.1 performance test.
|
||||
func PerfTestHTTPCMD() *cobra.Command {
|
||||
var (
|
||||
targetURL string
|
||||
method string
|
||||
body string
|
||||
bodyFile string
|
||||
headers []string
|
||||
|
||||
worker int
|
||||
total uint64
|
||||
duration time.Duration
|
||||
|
||||
out string
|
||||
pushURL string
|
||||
prometheusJobName string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "http",
|
||||
Short: "Run performance test for HTTP/1.1 API",
|
||||
Long: "Run performance test for HTTP/1.1 API.",
|
||||
Example: color.HiBlackString(` # Default mode: worker=CPU*3, 5000 requests, GET request
|
||||
sponge perftest http --url=http://localhost:8080/user/1
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, GET request
|
||||
sponge perftest http --worker=50 --total=500000 --url=http://localhost:8080/user/1
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, POST request with JSON body
|
||||
sponge perftest http --worker=50 --total=500000 --url=http://localhost:8080/user --method=POST --body={\"name\":\"Alice\",\"age\":25}
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, GET request
|
||||
sponge perftest http --worker=50 --duration=10s --url=http://localhost:8080/user/1
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, POST request with JSON body
|
||||
sponge perftest http --worker=50 --duration=10s --url=http://localhost:8080/user --method=POST --body={\"name\":\"Alice\",\"age\":25}
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, GET request, push statistics to custom HTTP server every 1s
|
||||
sponge perftest http --worker=50 --total=500000 --url=http://localhost:8080/user/1 --push-url=http://localhost:7070/report
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, get request, push statistics to Prometheus (job=xxx)
|
||||
sponge perftest http --worker=50 --duration=10s --url=http://localhost:8080/user/1 --push-url=http://localhost:9091/metrics --prometheus-job-name=perftest-http`),
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
bodyData, err := common.CheckBodyParam(body, bodyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := &HTTPReqParams{
|
||||
URL: targetURL,
|
||||
Method: method,
|
||||
Headers: headers,
|
||||
Body: bodyData,
|
||||
version: "HTTP/1.1",
|
||||
}
|
||||
|
||||
b := PerfTestHTTP{
|
||||
ID: common.NewID(),
|
||||
Client: newHTTPClient(),
|
||||
Params: params,
|
||||
Worker: worker,
|
||||
TotalRequests: total,
|
||||
Duration: duration,
|
||||
PushURL: pushURL,
|
||||
PrometheusJobName: prometheusJobName,
|
||||
}
|
||||
if err = b.checkParams(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var stats *Statistics
|
||||
if duration > 0 {
|
||||
stats, err = b.RunWithFixedDuration()
|
||||
} else {
|
||||
stats, err = b.RunWithFixedRequestsNum()
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out != "" && stats != nil {
|
||||
err = stats.Save(out)
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("failed to save statistics to file: %s", err)
|
||||
}
|
||||
fmt.Printf("\nsave statistics to '%s' successfully\n", out)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&targetURL, "url", "u", "", "request URL")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
cmd.Flags().StringVarP(&method, "method", "m", "GET", "request method")
|
||||
cmd.Flags().StringSliceVarP(&headers, "header", "e", nil, "request headers")
|
||||
cmd.Flags().StringVarP(&body, "body", "b", "", "request body (priority higher than --body-file)")
|
||||
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "request body file")
|
||||
|
||||
cmd.Flags().IntVarP(&worker, "worker", "w", runtime.NumCPU()*3, "number of workers concurrently processing requests")
|
||||
cmd.Flags().Uint64VarP(&total, "total", "t", 5000, "total requests")
|
||||
cmd.Flags().DurationVarP(&duration, "duration", "d", 0, "duration of the test, e.g., 10s, 1m (priority higher than --total)")
|
||||
|
||||
cmd.Flags().StringVarP(&out, "out", "o", "", "save statistics to JSON file")
|
||||
cmd.Flags().StringVarP(&pushURL, "push-url", "p", "", "push statistics to target URL once per second ")
|
||||
cmd.Flags().StringVarP(&prometheusJobName, "prometheus-job-name", "j", "", "if not empty, the --push-url parameter value indicates prometheus url")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHTTPClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Skip certificate validation
|
||||
MaxIdleConns: 1000,
|
||||
MaxIdleConnsPerHost: 1000,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
}
|
139
cmd/sponge/commands/perftest/http/http2.go
Normal file
139
cmd/sponge/commands/perftest/http/http2.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common"
|
||||
)
|
||||
|
||||
// PerfTestHTTP2CMD creates a new cobra.Command for HTTP/2 performance test.
|
||||
func PerfTestHTTP2CMD() *cobra.Command {
|
||||
var (
|
||||
targetURL string
|
||||
method string
|
||||
body string
|
||||
bodyFile string
|
||||
headers []string
|
||||
|
||||
worker int
|
||||
total uint64
|
||||
duration time.Duration
|
||||
|
||||
out string
|
||||
pushURL string
|
||||
prometheusJobName string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "http2",
|
||||
Short: "Run performance test for HTTP/2 API",
|
||||
Long: "Run performance test for HTTP/2 API.",
|
||||
Example: color.HiBlackString(` # Default mode: worker=CPU*3, 5000 requests, GET request
|
||||
sponge perftest http2 --url=https://localhost:6443/user/1
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, GET request
|
||||
sponge perftest http2 --worker=50 --total=500000 --url=https://localhost:6443/user/1
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, POST request with JSON body
|
||||
sponge perftest http2 --worker=50 --total=500000 --url=https://localhost:6443/user --method=POST --body={\"name\":\"Alice\",\"age\":25}
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, GET request
|
||||
sponge perftest http2 --worker=50 --duration=10s --url=https://localhost:6443/user/1
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, POST request with JSON body
|
||||
sponge perftest http2 --worker=50 --duration=10s --url=https://localhost:6443/user --method=POST --body={\"name\":\"Alice\",\"age\":25}
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, GET request, push statistics to custom HTTP server every 1s
|
||||
sponge perftest http2 --worker=50 --total=500000 --url=https://localhost:6443/user/1 --push-url=http://localhost:7070/report
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, get request, push statistics to Prometheus (job=xxx)
|
||||
sponge perftest http2 --worker=50 --duration=10s --url=https://localhost:6443/user/1 --push-url=http://localhost:9091/metrics --prometheus-job-name=perftest-http2`),
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
bodyData, err := common.CheckBodyParam(body, bodyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := &HTTPReqParams{
|
||||
URL: targetURL,
|
||||
Method: method,
|
||||
Headers: headers,
|
||||
Body: bodyData,
|
||||
version: "HTTP/2",
|
||||
}
|
||||
|
||||
b := PerfTestHTTP{
|
||||
ID: common.NewID(),
|
||||
Client: newHTTP2Client(),
|
||||
Params: params,
|
||||
Worker: worker,
|
||||
TotalRequests: total,
|
||||
Duration: duration,
|
||||
PushURL: pushURL,
|
||||
PrometheusJobName: prometheusJobName,
|
||||
}
|
||||
if err = b.checkParams(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var stats *Statistics
|
||||
if duration > 0 {
|
||||
stats, err = b.RunWithFixedDuration()
|
||||
} else {
|
||||
stats, err = b.RunWithFixedRequestsNum()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out != "" && stats != nil {
|
||||
err = stats.Save(out)
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("failed to save statistics to file: %s", err)
|
||||
}
|
||||
fmt.Printf("\nsave statistics to '%s' successfully\n", out)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&targetURL, "url", "u", "", "request URL")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
cmd.Flags().StringVarP(&method, "method", "m", "GET", "request method")
|
||||
cmd.Flags().StringSliceVarP(&headers, "header", "e", nil, "request headers")
|
||||
cmd.Flags().StringVarP(&body, "body", "b", "", "request body (priority higher than --body-file)")
|
||||
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "request body file")
|
||||
|
||||
cmd.Flags().IntVarP(&worker, "worker", "w", runtime.NumCPU()*3, "number of workers concurrently processing requests")
|
||||
cmd.Flags().Uint64VarP(&total, "total", "t", 5000, "total requests")
|
||||
cmd.Flags().DurationVarP(&duration, "duration", "d", 0, "duration of the test, e.g., 10s, 1m (priority higher than --total)")
|
||||
|
||||
cmd.Flags().StringVarP(&out, "out", "o", "", "save statistics to JSON file")
|
||||
cmd.Flags().StringVarP(&pushURL, "push-url", "p", "", "push statistics to target URL once per second ")
|
||||
cmd.Flags().StringVarP(&prometheusJobName, "prometheus-job-name", "j", "", "if not empty, the push-url parameter value indicates prometheus url")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHTTP2Client() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Skip certificate validation
|
||||
MaxIdleConns: 1000,
|
||||
MaxIdleConnsPerHost: 1000,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
ForceAttemptHTTP2: true,
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
}
|
150
cmd/sponge/commands/perftest/http/http3.go
Normal file
150
cmd/sponge/commands/perftest/http/http3.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/quic-go/quic-go"
|
||||
"github.com/quic-go/quic-go/http3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common"
|
||||
)
|
||||
|
||||
// PerfTestHTTP3CMD creates a new cobra.Command for HTTP/3 performance test.
|
||||
func PerfTestHTTP3CMD() *cobra.Command {
|
||||
var (
|
||||
targetURL string
|
||||
method string
|
||||
body string
|
||||
bodyFile string
|
||||
headers []string
|
||||
|
||||
worker int
|
||||
total uint64
|
||||
duration time.Duration
|
||||
|
||||
out string
|
||||
pushURL string
|
||||
prometheusJobName string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "http3",
|
||||
Short: "Run performance test for HTTP/3 API",
|
||||
Long: "Run performance test for HTTP/3 API.",
|
||||
Example: color.HiBlackString(` # Default mode: worker=CPU*3, 5000 requests, GET request
|
||||
sponge perftest http3 --url=https://localhost:8443/user/1
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, GET request
|
||||
sponge perftest http3 --worker=50 --total=500000 --url=https://localhost:8443/user/1
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, POST request with JSON body
|
||||
sponge perftest http3 --worker=50 --total=500000 --url=https://localhost:8443/user --method=POST --body={\"name\":\"Alice\",\"age\":25}
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, GET request
|
||||
sponge perftest http3 --worker=50 --duration=10s --url=https://localhost:8443/user/1
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, POST request with JSON body
|
||||
sponge perftest http3 --worker=50 --duration=10s --url=https://localhost:8443/user --method=POST --body={\"name\":\"Alice\",\"age\":25}
|
||||
|
||||
# Fixed number of requests: 50 workers, 500k requests, GET request, push statistics to custom HTTP server every 1s
|
||||
sponge perftest http3 --worker=50 --total=500000 --url=https://localhost:8443/user/1 --push-url=http://localhost:7070/report
|
||||
|
||||
# Fixed duration: 50 workers, duration 10s, get request, push statistics to Prometheus (job=xxx)
|
||||
sponge perftest http3 --worker=50 --duration=10s --url=https://localhost:8443/user/1 --push-url=http://localhost:9091/metrics --prometheus-job-name=perftest-http3`),
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
bodyData, err := common.CheckBodyParam(body, bodyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
params := &HTTPReqParams{
|
||||
URL: targetURL,
|
||||
Method: method,
|
||||
Headers: headers,
|
||||
Body: bodyData,
|
||||
version: "HTTP/3",
|
||||
}
|
||||
|
||||
b := PerfTestHTTP{
|
||||
ID: common.NewID(),
|
||||
Client: newHTTP3Client(),
|
||||
Params: params,
|
||||
Worker: worker,
|
||||
TotalRequests: total,
|
||||
Duration: duration,
|
||||
PushURL: pushURL,
|
||||
PrometheusJobName: prometheusJobName,
|
||||
}
|
||||
if err = b.checkParams(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var stats *Statistics
|
||||
if duration > 0 {
|
||||
stats, err = b.RunWithFixedDuration()
|
||||
} else {
|
||||
stats, err = b.RunWithFixedRequestsNum()
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if out != "" && stats != nil {
|
||||
err = stats.Save(out)
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("failed to save statistics to file: %s", err)
|
||||
}
|
||||
fmt.Printf("\nsave statistics to '%s' successfully\n", out)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&targetURL, "url", "u", "", "request URL")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
cmd.Flags().StringVarP(&method, "method", "m", "GET", "request method")
|
||||
cmd.Flags().StringSliceVarP(&headers, "header", "e", nil, "request headers")
|
||||
cmd.Flags().StringVarP(&body, "body", "b", "", "request body (priority higher than --body-file)")
|
||||
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "request body file")
|
||||
|
||||
cmd.Flags().IntVarP(&worker, "worker", "w", runtime.NumCPU()*3, "number of workers concurrently processing requests")
|
||||
cmd.Flags().Uint64VarP(&total, "total", "t", 5000, "total requests")
|
||||
cmd.Flags().DurationVarP(&duration, "duration", "d", 0, "duration of the test, e.g., 10s, 1m (priority higher than --total)")
|
||||
|
||||
cmd.Flags().StringVarP(&out, "out", "o", "", "save statistics to JSON file")
|
||||
cmd.Flags().StringVarP(&pushURL, "push-url", "p", "", "push statistics to target URL once per second ")
|
||||
cmd.Flags().StringVarP(&prometheusJobName, "prometheus-job-name", "j", "", "if not empty, the push-url parameter value indicates prometheus url")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newHTTP3Client() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: &http3.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // Skip certificate validation
|
||||
|
||||
// quic.Config provides fine control over the underlying QUIC connections
|
||||
QUICConfig: &quic.Config{
|
||||
MaxIdleTimeout: 10 * time.Second,
|
||||
KeepAlivePeriod: 5 * time.Second,
|
||||
InitialStreamReceiveWindow: 6 * 1024 * 1024,
|
||||
InitialConnectionReceiveWindow: 15 * 1024 * 1024,
|
||||
MaxStreamReceiveWindow: 6 * 1024 * 1024,
|
||||
MaxConnectionReceiveWindow: 15 * 1024 * 1024,
|
||||
MaxIncomingStreams: 1000,
|
||||
MaxIncomingUniStreams: 1000,
|
||||
HandshakeIdleTimeout: 5 * time.Second,
|
||||
},
|
||||
},
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
}
|
347
cmd/sponge/commands/perftest/http/run.go
Normal file
347
cmd/sponge/commands/perftest/http/run.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
|
||||
"github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common"
|
||||
)
|
||||
|
||||
// PerfTestHTTP performance test parameters for HTTP
|
||||
type PerfTestHTTP struct {
|
||||
ID int64 // performance test ID
|
||||
|
||||
Client *http.Client
|
||||
Params *HTTPReqParams
|
||||
|
||||
Worker int
|
||||
TotalRequests uint64
|
||||
Duration time.Duration
|
||||
|
||||
PushURL string
|
||||
PrometheusJobName string
|
||||
}
|
||||
|
||||
func (p *PerfTestHTTP) checkParams() error {
|
||||
if p.Worker == 0 {
|
||||
return fmt.Errorf("'--worker' number must be greater than 0")
|
||||
}
|
||||
|
||||
if p.TotalRequests == 0 && p.Duration == 0 {
|
||||
return errors.New("'--duration' and '--total' must be set one of them")
|
||||
}
|
||||
|
||||
if p.PrometheusJobName != "" && p.PushURL == "" {
|
||||
return errors.New("'--prometheus-job-name' has already been set, '--push-url' must be set")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RunWithFixedRequestsNum implements performance with a fixed number of requests.
|
||||
func (p *PerfTestHTTP) RunWithFixedRequestsNum() (*Statistics, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
cancel()
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
jobs := make(chan struct{}, p.Worker)
|
||||
resultCh := make(chan Result, p.Worker*3)
|
||||
statsDone := make(chan struct{})
|
||||
bar := &common.Bar{}
|
||||
|
||||
collector := &statsCollector{
|
||||
durations: make([]float64, 0, p.TotalRequests),
|
||||
}
|
||||
var spc *statsPrometheusCollector
|
||||
var start time.Time
|
||||
|
||||
// The collector counts the results of each request, closes the statsDone
|
||||
// channel, and notifies the main thread of the end
|
||||
if p.PushURL == "" {
|
||||
go collector.collect(resultCh, statsDone)
|
||||
} else {
|
||||
if p.PrometheusJobName == "" {
|
||||
spc = &statsPrometheusCollector{}
|
||||
} else {
|
||||
spc = newStatsPrometheusCollector()
|
||||
}
|
||||
//go collector.collectAndPush(ctx, resultCh, statsDone, spc, b.PushURL, b.PrometheusJobName, b.Params, start)
|
||||
go collector.collectAndPush(ctx, resultCh, statsDone, spc, p, start)
|
||||
}
|
||||
|
||||
for i := 0; i < p.Worker; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for range jobs {
|
||||
requestOnce(p.Client, p.Params, resultCh)
|
||||
bar.Increment()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
start = time.Now()
|
||||
bar = common.NewBar(int64(p.TotalRequests), start)
|
||||
// Distribute tasks and listen for context cancellation events
|
||||
loop:
|
||||
for i := uint64(0); i < p.TotalRequests; i++ {
|
||||
select {
|
||||
case jobs <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
break loop
|
||||
}
|
||||
}
|
||||
|
||||
close(jobs)
|
||||
|
||||
wg.Wait()
|
||||
close(resultCh)
|
||||
|
||||
<-statsDone
|
||||
|
||||
totalTime := time.Since(start)
|
||||
if ctx.Err() == nil {
|
||||
bar.Finish()
|
||||
} else {
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
statistics, err := collector.printReport(totalTime, p.TotalRequests, p.Params)
|
||||
|
||||
if p.PushURL != "" {
|
||||
spc.copyStatsCollector(collector)
|
||||
pushStatistics(spc, p, totalTime)
|
||||
}
|
||||
|
||||
return statistics, err
|
||||
}
|
||||
|
||||
// RunWithFixedDuration implements performance with a fixed duration.
|
||||
func (p *PerfTestHTTP) RunWithFixedDuration() (*Statistics, error) {
|
||||
// Create a context that will be canceled when the duration is over
|
||||
ctx, cancel := context.WithTimeout(context.Background(), p.Duration)
|
||||
defer cancel()
|
||||
|
||||
// Handle manual interruption (Ctrl+C)
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
cancel()
|
||||
}()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
resultCh := make(chan Result, p.Worker*3)
|
||||
statsDone := make(chan struct{})
|
||||
|
||||
// Since the total number of requests is unknown, we initialize with a reasonable capacity to reduce reallocation's.
|
||||
collector := &statsCollector{
|
||||
durations: make([]float64, 0, 100000),
|
||||
}
|
||||
var spc *statsPrometheusCollector
|
||||
var start time.Time
|
||||
|
||||
if p.PushURL == "" {
|
||||
go collector.collect(resultCh, statsDone)
|
||||
} else {
|
||||
if p.PrometheusJobName == "" {
|
||||
spc = &statsPrometheusCollector{}
|
||||
} else {
|
||||
spc = newStatsPrometheusCollector()
|
||||
}
|
||||
go collector.collectAndPush(ctx, resultCh, statsDone, spc, p, start)
|
||||
}
|
||||
|
||||
// Start workers
|
||||
for i := 0; i < p.Worker; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Keep sending requests until the context is canceled
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return // Exit goroutine when context is canceled
|
||||
default:
|
||||
requestOnce(p.Client, p.Params, resultCh)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
start = time.Now()
|
||||
bar := common.NewTimeBar(p.Duration)
|
||||
bar.Start()
|
||||
|
||||
<-ctx.Done() // Wait for the timeout or a signal
|
||||
|
||||
totalTime := time.Since(start)
|
||||
|
||||
// Wait for all workers to finish their current request
|
||||
wg.Wait()
|
||||
// Close the result channel to signal the collector that no more results will be sent
|
||||
close(resultCh)
|
||||
// Wait for the collector to process all the results in the channel
|
||||
<-statsDone
|
||||
|
||||
if errors.Is(ctx.Err(), context.Canceled) {
|
||||
fmt.Println()
|
||||
} else {
|
||||
bar.Finish()
|
||||
}
|
||||
|
||||
// The total number of requests is the count of collected results
|
||||
totalRequests := collector.successCount + collector.errorCount
|
||||
statistics, err := collector.printReport(totalTime, totalRequests, p.Params)
|
||||
|
||||
if p.PushURL != "" {
|
||||
spc.copyStatsCollector(collector)
|
||||
pushStatistics(spc, p, totalTime)
|
||||
}
|
||||
|
||||
return statistics, err
|
||||
}
|
||||
|
||||
func pushStatistics(spc *statsPrometheusCollector, b *PerfTestHTTP, totalTime time.Duration) {
|
||||
var err error
|
||||
ctx, _ := context.WithTimeout(context.Background(), time.Second*3) //nolint
|
||||
if b.PrometheusJobName == "" {
|
||||
err = spc.PushToServer(ctx, b.PushURL, totalTime, b.Params, b.ID)
|
||||
} else {
|
||||
err = spc.PushToPrometheus(ctx, b.PushURL, b.PrometheusJobName, totalTime)
|
||||
}
|
||||
_, _ = color.New(color.Bold).Println("[Push Statistics]")
|
||||
var result = color.GreenString("ok")
|
||||
if err != nil {
|
||||
result = color.RedString("%v", err)
|
||||
}
|
||||
fmt.Printf(" • %s\n", result)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
// Result record the results of the request
|
||||
type Result struct {
|
||||
Duration time.Duration
|
||||
ReqSize int64
|
||||
RespSize int64
|
||||
StatusCode int
|
||||
Err error
|
||||
}
|
||||
|
||||
type HTTPReqParams struct {
|
||||
URL string
|
||||
Method string
|
||||
Headers []string
|
||||
Body string
|
||||
|
||||
version string
|
||||
}
|
||||
|
||||
func buildRequest(params *HTTPReqParams) (*http.Request, error) {
|
||||
var req *http.Request
|
||||
var err error
|
||||
|
||||
reqMethod := strings.ToUpper(params.Method)
|
||||
if reqMethod == "POST" || reqMethod == "PUT" || reqMethod == "PATCH" || reqMethod == "DELETE" {
|
||||
body := bytes.NewReader([]byte(params.Body))
|
||||
req, err = http.NewRequest(reqMethod, params.URL, body)
|
||||
if err == nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
} else {
|
||||
req, err = http.NewRequest(reqMethod, params.URL, nil)
|
||||
}
|
||||
|
||||
for _, h := range params.Headers {
|
||||
kvs := strings.SplitN(h, ":", 2)
|
||||
if len(kvs) == 2 {
|
||||
req.Header.Set(strings.TrimSpace(kvs[0]), strings.TrimSpace(kvs[1]))
|
||||
}
|
||||
}
|
||||
|
||||
return req, err
|
||||
}
|
||||
|
||||
func requestOnce(client *http.Client, params *HTTPReqParams, ch chan<- Result) {
|
||||
req, err := buildRequest(params)
|
||||
if err != nil {
|
||||
ch <- Result{Err: err}
|
||||
return
|
||||
}
|
||||
|
||||
var reqSize int64
|
||||
if req.Body != nil {
|
||||
reqSize = req.ContentLength
|
||||
if seeker, ok := req.Body.(io.ReadSeeker); ok {
|
||||
_, _ = seeker.Seek(0, io.SeekStart)
|
||||
}
|
||||
}
|
||||
|
||||
begin := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil { // Check for request-level errors (e.g. timeout, DNS resolution failure)
|
||||
duration := time.Since(begin)
|
||||
ch <- Result{
|
||||
Duration: duration,
|
||||
ReqSize: reqSize,
|
||||
RespSize: 0,
|
||||
Err: err,
|
||||
}
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 { // Check if the response status code is not 2xx
|
||||
respSize, _ := io.Copy(io.Discard, resp.Body)
|
||||
duration := time.Since(begin)
|
||||
ch <- Result{
|
||||
Duration: duration,
|
||||
ReqSize: reqSize,
|
||||
RespSize: respSize,
|
||||
StatusCode: resp.StatusCode,
|
||||
Err: fmt.Errorf("%s, [%s] %s", http.StatusText(resp.StatusCode), req.Method, req.URL.String()),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
respSize, err := io.Copy(io.Discard, resp.Body)
|
||||
if err != nil {
|
||||
duration := time.Since(begin)
|
||||
ch <- Result{
|
||||
Duration: duration,
|
||||
ReqSize: reqSize,
|
||||
RespSize: respSize,
|
||||
StatusCode: resp.StatusCode,
|
||||
Err: err,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
duration := time.Since(begin)
|
||||
ch <- Result{
|
||||
Duration: duration,
|
||||
ReqSize: reqSize,
|
||||
RespSize: respSize,
|
||||
StatusCode: resp.StatusCode,
|
||||
}
|
||||
}
|
525
cmd/sponge/commands/perftest/http/stats.go
Normal file
525
cmd/sponge/commands/perftest/http/stats.go
Normal file
@@ -0,0 +1,525 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/push"
|
||||
)
|
||||
|
||||
// collector of statistical results
|
||||
type statsCollector struct {
|
||||
durations []float64
|
||||
totalReqBytes int64
|
||||
totalRespBytes int64
|
||||
successCount uint64
|
||||
errorCount uint64
|
||||
errSet map[string]struct{}
|
||||
statusCodeSet map[int]int64
|
||||
}
|
||||
|
||||
func (c *statsCollector) collect(results <-chan Result, done chan<- struct{}) {
|
||||
errSet := make(map[string]struct{})
|
||||
statusCodes := make(map[int]int64)
|
||||
|
||||
for r := range results {
|
||||
if r.Err == nil {
|
||||
c.successCount++
|
||||
} else {
|
||||
c.errorCount++
|
||||
if _, ok := errSet[r.Err.Error()]; !ok {
|
||||
errSet[r.Err.Error()] = struct{}{}
|
||||
}
|
||||
}
|
||||
if _, ok := statusCodes[r.StatusCode]; !ok {
|
||||
statusCodes[r.StatusCode] = 1
|
||||
} else {
|
||||
//statusCodes[r.StatusCode] += 1
|
||||
statusCodes[r.StatusCode]++
|
||||
}
|
||||
|
||||
c.durations = append(c.durations, float64(r.Duration))
|
||||
c.totalReqBytes += r.ReqSize
|
||||
c.totalRespBytes += r.RespSize
|
||||
}
|
||||
c.errSet = errSet
|
||||
c.statusCodeSet = statusCodes
|
||||
|
||||
close(done)
|
||||
}
|
||||
|
||||
// nolint
|
||||
func (c *statsCollector) collectAndPush(ctx context.Context, results <-chan Result, done chan<- struct{},
|
||||
spc *statsPrometheusCollector, p *PerfTestHTTP, start time.Time) {
|
||||
errSet := make(map[string]struct{})
|
||||
statusCodes := make(map[int]int64)
|
||||
|
||||
pushTicker := time.NewTicker(time.Second)
|
||||
defer pushTicker.Stop()
|
||||
start = time.Now()
|
||||
|
||||
for r := range results {
|
||||
if r.Err == nil {
|
||||
c.successCount++
|
||||
} else {
|
||||
c.errorCount++
|
||||
if _, ok := errSet[r.Err.Error()]; !ok {
|
||||
errSet[r.Err.Error()] = struct{}{}
|
||||
}
|
||||
}
|
||||
if _, ok := statusCodes[r.StatusCode]; !ok {
|
||||
statusCodes[r.StatusCode] = 1
|
||||
} else {
|
||||
//statusCodes[r.StatusCode] += 1
|
||||
statusCodes[r.StatusCode]++
|
||||
}
|
||||
|
||||
c.durations = append(c.durations, float64(r.Duration))
|
||||
c.totalReqBytes += r.ReqSize
|
||||
c.totalRespBytes += r.RespSize
|
||||
c.errSet = errSet
|
||||
c.statusCodeSet = statusCodes
|
||||
select {
|
||||
case <-pushTicker.C:
|
||||
spc.copyStatsCollector(c)
|
||||
if p.PrometheusJobName != "" {
|
||||
spc.PushToPrometheusAsync(ctx, p.PushURL, p.PrometheusJobName, time.Since(start))
|
||||
} else {
|
||||
spc.PushToServerAsync(ctx, p.PushURL, time.Since(start), p.Params, p.ID)
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (c *statsCollector) toStatistics(totalTime time.Duration, totalRequests uint64, params *HTTPReqParams) *Statistics {
|
||||
sort.Float64s(c.durations)
|
||||
|
||||
var totalDuration float64
|
||||
for _, d := range c.durations {
|
||||
totalDuration += d
|
||||
}
|
||||
|
||||
var avg, minLatency, maxLatency float64
|
||||
var p25, p50, p95 float64
|
||||
|
||||
if c.successCount > 0 {
|
||||
avg = totalDuration / float64(c.successCount)
|
||||
minLatency = c.durations[0]
|
||||
maxLatency = c.durations[c.successCount-1]
|
||||
percentile := func(p float64) float64 {
|
||||
index := int(float64(c.successCount-1) * p)
|
||||
return c.durations[index]
|
||||
}
|
||||
p25 = percentile(0.25)
|
||||
p50 = percentile(0.50)
|
||||
p95 = percentile(0.95)
|
||||
}
|
||||
|
||||
var errors []string
|
||||
for errStr := range c.errSet {
|
||||
errors = append(errors, errStr)
|
||||
}
|
||||
|
||||
body := params.Body
|
||||
if len(body) > 300 {
|
||||
body = body[:300] + "..."
|
||||
}
|
||||
|
||||
return &Statistics{
|
||||
URL: params.URL,
|
||||
Method: params.Method,
|
||||
Body: body,
|
||||
|
||||
TotalRequests: totalRequests,
|
||||
Errors: errors,
|
||||
SuccessCount: c.successCount,
|
||||
ErrorCount: c.errorCount,
|
||||
TotalTime: totalTime.Seconds(),
|
||||
QPS: float64(c.successCount) / totalTime.Seconds(),
|
||||
|
||||
AvgLatency: convertToMilliseconds(avg),
|
||||
P25Latency: convertToMilliseconds(p25),
|
||||
P50Latency: convertToMilliseconds(p50),
|
||||
P95Latency: convertToMilliseconds(p95),
|
||||
MinLatency: convertToMilliseconds(minLatency),
|
||||
MaxLatency: convertToMilliseconds(maxLatency),
|
||||
|
||||
TotalSent: float64(c.totalReqBytes),
|
||||
TotalReceived: float64(c.totalRespBytes),
|
||||
StatusCodes: c.statusCodeSet,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *statsCollector) printReport(totalDuration time.Duration, totalRequests uint64, params *HTTPReqParams) (*Statistics, error) {
|
||||
fmt.Printf("\n========== %s Performance Test Report ==========\n\n", params.version)
|
||||
if c.successCount == 0 {
|
||||
_, _ = color.New(color.Bold).Println("[Requests]")
|
||||
fmt.Printf(" • %-19s%d\n", "Total Requests:", totalRequests)
|
||||
fmt.Printf(" • %-19s%d%s\n", "Successful:", 0, color.RedString(" (0%)"))
|
||||
fmt.Printf(" • %-19s%d%s\n", "Failed:", c.errorCount, color.RedString(" ✗"))
|
||||
fmt.Printf(" • %-19s%.2fs\n\n", "Total Duration:", totalDuration.Seconds())
|
||||
|
||||
if len(c.statusCodeSet) > 0 {
|
||||
printStatusCodeSet(c.statusCodeSet)
|
||||
}
|
||||
|
||||
if len(c.errSet) > 0 {
|
||||
printErrorSet(c.errSet)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
st := c.toStatistics(totalDuration, totalRequests, params)
|
||||
|
||||
_, _ = color.New(color.Bold).Println("[Requests]")
|
||||
fmt.Printf(" • %-19s%d\n", "Total Requests:", st.TotalRequests)
|
||||
successStr := fmt.Sprintf(" • %-19s%d", "Successful:", st.SuccessCount)
|
||||
failureStr := fmt.Sprintf(" • %-19s%d", "Failed:", st.ErrorCount)
|
||||
if st.TotalRequests > 0 {
|
||||
if totalRequests == st.SuccessCount {
|
||||
successStr += color.GreenString(" (100%)")
|
||||
} else if st.ErrorCount > 0 {
|
||||
if st.SuccessCount == 0 {
|
||||
successStr += color.RedString(" (0%)")
|
||||
} else {
|
||||
successStr += color.YellowString(" (%d%%)", int(float64(st.SuccessCount)/float64(st.TotalRequests)*100))
|
||||
}
|
||||
failureStr += color.RedString(" ✗")
|
||||
}
|
||||
}
|
||||
fmt.Println(successStr)
|
||||
fmt.Println(failureStr)
|
||||
fmt.Printf(" • %-19s%.2fs\n", "Total Duration:", st.TotalTime)
|
||||
fmt.Printf(" • %-19s%.2f req/sec\n\n", "Throughput (QPS):", st.QPS)
|
||||
|
||||
_, _ = color.New(color.Bold).Println("[Latency]")
|
||||
fmt.Printf(" • %-19s%.2f ms\n", "Average:", st.AvgLatency)
|
||||
fmt.Printf(" • %-19s%.2f ms\n", "Minimum:", st.MinLatency)
|
||||
fmt.Printf(" • %-19s%.2f ms\n", "Maximum:", st.MaxLatency)
|
||||
fmt.Printf(" • %-19s%.2f ms\n", "P25:", st.P25Latency)
|
||||
fmt.Printf(" • %-19s%.2f ms\n", "P50:", st.P50Latency)
|
||||
fmt.Printf(" • %-19s%.2f ms\n\n", "P95:", st.P95Latency)
|
||||
|
||||
_, _ = color.New(color.Bold).Println("[Data Transfer]")
|
||||
fmt.Printf(" • %-19s%.0f Bytes\n", "Sent:", st.TotalSent)
|
||||
fmt.Printf(" • %-19s%.0f Bytes\n\n", "Received:", st.TotalReceived)
|
||||
|
||||
if len(c.statusCodeSet) > 0 {
|
||||
printStatusCodeSet(st.StatusCodes)
|
||||
}
|
||||
|
||||
if len(c.errSet) > 0 {
|
||||
printErrorSet(c.errSet)
|
||||
}
|
||||
|
||||
return st, nil
|
||||
}
|
||||
|
||||
func printStatusCodeSet(statusCodeSet map[int]int64) {
|
||||
codes := make([]int, 0, len(statusCodeSet))
|
||||
for code := range statusCodeSet {
|
||||
codes = append(codes, code)
|
||||
}
|
||||
sort.Ints(codes)
|
||||
|
||||
_, _ = color.New(color.Bold).Println("[Status Codes]")
|
||||
for _, code := range codes {
|
||||
fmt.Printf(" • %-19s%d\n", fmt.Sprintf("%d:", code), statusCodeSet[code])
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
func printErrorSet(errSet map[string]struct{}) {
|
||||
_, _ = color.New(color.Bold).Println("[Error Details]")
|
||||
for errStr := range errSet {
|
||||
fmt.Printf(" • %s\n", color.RedString(errStr))
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------------
|
||||
|
||||
// Statistics statistical data
|
||||
type Statistics struct {
|
||||
PerfTestID int64 `json:"perf_test_id"` // Performance Test ID
|
||||
|
||||
URL string `json:"url"` // performed request URL
|
||||
Method string `json:"method"` // request method
|
||||
Body string `json:"body"` // request body (JSON)
|
||||
|
||||
TotalRequests uint64 `json:"total_requests"` // total requests
|
||||
TotalTime float64 `json:"total_time"` // seconds
|
||||
SuccessCount uint64 `json:"success_count"` // successful requests (status code 2xx)
|
||||
ErrorCount uint64 `json:"error_count"` // failed requests (status code not 2xx)
|
||||
Errors []string `json:"errors"` // error details
|
||||
|
||||
QPS float64 `json:"qps"` // requests per second (Throughput)
|
||||
AvgLatency float64 `json:"avg_latency"` // average latency (ms)
|
||||
P25Latency float64 `json:"p25_latency"` // 25th percentile latency (ms)
|
||||
P50Latency float64 `json:"p50_latency"` // 50th percentile latency (ms)
|
||||
P95Latency float64 `json:"p95_latency"` // 95th percentile latency (ms)
|
||||
MinLatency float64 `json:"min_latency"` // minimum latency (ms)
|
||||
MaxLatency float64 `json:"max_latency"` // maximum latency (ms)
|
||||
|
||||
TotalSent float64 `json:"total_sent"` // total sent (bytes)
|
||||
TotalReceived float64 `json:"total_received"` // total received (bytes)
|
||||
|
||||
StatusCodes map[int]int64 `json:"status_codes"` // status code distribution (count)
|
||||
}
|
||||
|
||||
// Save saves the statistics data to a JSON file.
|
||||
func (s *Statistics) Save(filePath string) error {
|
||||
err := ensureFileExists(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(filePath, data, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureFileExists(filePath string) error {
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create directories: %w", err)
|
||||
}
|
||||
_, err := os.Stat(filePath)
|
||||
if os.IsNotExist(err) {
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertToMilliseconds(f float64) float64 {
|
||||
if f <= 0.0 {
|
||||
return 0
|
||||
}
|
||||
return math.Round((f/1e6)*100) / 100
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
type statsPrometheusCollector struct {
|
||||
statsCollector *statsCollector
|
||||
|
||||
// prometheus metrics
|
||||
totalRequestsGauge prometheus.Gauge
|
||||
successGauge prometheus.Gauge
|
||||
errorGauge prometheus.Gauge
|
||||
totalTimeGauge prometheus.Gauge
|
||||
qpsGauge prometheus.Gauge
|
||||
avgLatencyGauge prometheus.Gauge
|
||||
p25LatencyGauge prometheus.Gauge
|
||||
p50LatencyGauge prometheus.Gauge
|
||||
p95LatencyGauge prometheus.Gauge
|
||||
minLatencyGauge prometheus.Gauge
|
||||
maxLatencyGauge prometheus.Gauge
|
||||
totalSentGauge prometheus.Gauge
|
||||
totalRecvGauge prometheus.Gauge
|
||||
statusCodeGaugeVec *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
func newStatsPrometheusCollector() *statsPrometheusCollector {
|
||||
return &statsPrometheusCollector{
|
||||
totalRequestsGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_total_requests", Help: "Total requests"}),
|
||||
successGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_success_count", Help: "Successful requests"}),
|
||||
errorGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_error_count", Help: "Failed requests"}),
|
||||
totalTimeGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_total_time_seconds", Help: "Total time elapsed"}),
|
||||
qpsGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_qps", Help: "Queries per second"}),
|
||||
avgLatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_avg_latency_ms", Help: "Average latency (ms)"}),
|
||||
p25LatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_p25_latency_ms", Help: "P25 latency (ms)"}),
|
||||
p50LatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_p50_latency_ms", Help: "P50 latency (ms)"}),
|
||||
p95LatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_p95_latency_ms", Help: "P95 latency (ms)"}),
|
||||
minLatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_min_latency_ms", Help: "Minimum latency (ms)"}),
|
||||
maxLatencyGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_max_latency_ms", Help: "Maximum latency (ms)"}),
|
||||
totalSentGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_total_sent_bytes", Help: "Total bytes sent"}),
|
||||
totalRecvGauge: prometheus.NewGauge(prometheus.GaugeOpts{Name: "performance_test_total_received_bytes", Help: "Total bytes received"}),
|
||||
statusCodeGaugeVec: prometheus.NewGaugeVec(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "performance_test_status_code_count",
|
||||
Help: "Count of responses by HTTP status code",
|
||||
},
|
||||
[]string{"status_code"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
func (spc *statsPrometheusCollector) copyStatsCollector(s *statsCollector) {
|
||||
var durations = make([]float64, 0, 100000)
|
||||
if s.durations != nil {
|
||||
durations = make([]float64, len(s.durations))
|
||||
copy(durations, s.durations)
|
||||
}
|
||||
|
||||
var errSet = make(map[string]struct{})
|
||||
if s.errSet != nil {
|
||||
errSet = make(map[string]struct{}, len(s.errSet))
|
||||
for k, v := range s.errSet {
|
||||
errSet[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
var statusCodeSet = make(map[int]int64)
|
||||
if s.statusCodeSet != nil {
|
||||
statusCodeSet = make(map[int]int64, len(s.statusCodeSet))
|
||||
for k, v := range s.statusCodeSet {
|
||||
statusCodeSet[k] = v
|
||||
}
|
||||
}
|
||||
spc.statsCollector = &statsCollector{
|
||||
durations: durations,
|
||||
errSet: errSet,
|
||||
statusCodeSet: statusCodeSet,
|
||||
totalReqBytes: s.totalReqBytes,
|
||||
totalRespBytes: s.totalRespBytes,
|
||||
successCount: s.successCount,
|
||||
errorCount: s.errorCount,
|
||||
}
|
||||
}
|
||||
|
||||
func percentile(sorted []float64, p float64) float64 {
|
||||
if len(sorted) == 0 {
|
||||
return 0
|
||||
}
|
||||
idx := int(math.Round((float64(len(sorted)) - 1) * p))
|
||||
if idx < 0 {
|
||||
idx = 0
|
||||
} else if idx >= len(sorted) {
|
||||
idx = len(sorted) - 1
|
||||
}
|
||||
return sorted[idx]
|
||||
}
|
||||
|
||||
// PushToPrometheus pushes the statistics to a Prometheus.
|
||||
func (spc *statsPrometheusCollector) PushToPrometheus(ctx context.Context, pushGatewayURL, jobName string, elapsed time.Duration) error {
|
||||
totalReq := spc.statsCollector.successCount + spc.statsCollector.errorCount
|
||||
qps := 0.0
|
||||
if elapsed.Seconds() > 0 {
|
||||
qps = float64(spc.statsCollector.successCount) / elapsed.Seconds()
|
||||
}
|
||||
|
||||
d := append([]float64(nil), spc.statsCollector.durations...)
|
||||
sort.Float64s(d)
|
||||
avg, minVal, maxVal := 0.0, 0.0, 0.0
|
||||
if len(d) > 0 {
|
||||
sum := 0.0
|
||||
minVal = d[0]
|
||||
maxVal = d[len(d)-1]
|
||||
for _, v := range d {
|
||||
sum += v
|
||||
}
|
||||
avg = sum / float64(len(d))
|
||||
}
|
||||
|
||||
// set gauges
|
||||
spc.totalRequestsGauge.Set(float64(totalReq))
|
||||
spc.successGauge.Set(float64(spc.statsCollector.successCount))
|
||||
spc.errorGauge.Set(float64(spc.statsCollector.errorCount))
|
||||
spc.totalTimeGauge.Set(elapsed.Seconds())
|
||||
spc.qpsGauge.Set(qps)
|
||||
spc.avgLatencyGauge.Set(avg * 1000)
|
||||
spc.p25LatencyGauge.Set(percentile(d, 0.25) * 1000)
|
||||
spc.p50LatencyGauge.Set(percentile(d, 0.50) * 1000)
|
||||
spc.p95LatencyGauge.Set(percentile(d, 0.95) * 1000)
|
||||
spc.minLatencyGauge.Set(minVal * 1000)
|
||||
spc.maxLatencyGauge.Set(maxVal * 1000)
|
||||
spc.totalSentGauge.Set(float64(spc.statsCollector.totalReqBytes))
|
||||
spc.totalRecvGauge.Set(float64(spc.statsCollector.totalRespBytes))
|
||||
|
||||
for code, count := range spc.statsCollector.statusCodeSet {
|
||||
spc.statusCodeGaugeVec.WithLabelValues(fmt.Sprintf("%d", code)).Set(float64(count))
|
||||
}
|
||||
|
||||
pusher := push.New(pushGatewayURL, jobName).
|
||||
Collector(spc.totalRequestsGauge).
|
||||
Collector(spc.successGauge).
|
||||
Collector(spc.errorGauge).
|
||||
Collector(spc.totalTimeGauge).
|
||||
Collector(spc.qpsGauge).
|
||||
Collector(spc.avgLatencyGauge).
|
||||
Collector(spc.p25LatencyGauge).
|
||||
Collector(spc.p50LatencyGauge).
|
||||
Collector(spc.p95LatencyGauge).
|
||||
Collector(spc.minLatencyGauge).
|
||||
Collector(spc.maxLatencyGauge).
|
||||
Collector(spc.totalSentGauge).
|
||||
Collector(spc.totalRecvGauge).
|
||||
Collector(spc.statusCodeGaugeVec)
|
||||
|
||||
return pusher.PushContext(ctx)
|
||||
}
|
||||
|
||||
// PushToPrometheusAsync pushes the statistics to a Prometheus asynchronously
|
||||
func (spc *statsPrometheusCollector) PushToPrometheusAsync(ctx context.Context, pushGatewayURL, jobName string, elapsed time.Duration) {
|
||||
go func() {
|
||||
_ = spc.PushToPrometheus(ctx, pushGatewayURL, jobName, elapsed)
|
||||
}()
|
||||
}
|
||||
|
||||
// PushToServer pushes the statistics data to a custom server
|
||||
// body is the JSON data of Statistics struct
|
||||
func (spc *statsPrometheusCollector) PushToServer(ctx context.Context, pushURL string, elapsed time.Duration, httpReqParams *HTTPReqParams, id int64) error {
|
||||
statistics := spc.statsCollector.toStatistics(elapsed, spc.statsCollector.successCount+spc.statsCollector.errorCount, httpReqParams)
|
||||
statistics.PerfTestID = id
|
||||
|
||||
_, err := postWithContext(ctx, pushURL, statistics)
|
||||
return err
|
||||
}
|
||||
|
||||
// PushToServerAsync pushes the statistics data to a custom server asynchronously
|
||||
func (spc *statsPrometheusCollector) PushToServerAsync(ctx context.Context, pushURL string, elapsed time.Duration, httpReqParams *HTTPReqParams, id int64) {
|
||||
go func() {
|
||||
_ = spc.PushToServer(ctx, pushURL, elapsed, httpReqParams, id)
|
||||
}()
|
||||
}
|
||||
|
||||
func postWithContext(ctx context.Context, url string, data *Statistics) (*http.Response, error) {
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal data: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 400 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return resp, fmt.Errorf(`post "%s" failed with status code %d`, url, resp.StatusCode)
|
||||
}
|
170
cmd/sponge/commands/perftest/websocket/client.go
Normal file
170
cmd/sponge/commands/perftest/websocket/client.go
Normal file
@@ -0,0 +1,170 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
// Client represents a single WebSocket client worker.
|
||||
type Client struct {
|
||||
id int
|
||||
conn *websocket.Conn
|
||||
stats *statsCollector
|
||||
url string
|
||||
|
||||
isJSON bool
|
||||
//sendPayloadTemplate map[string]any // isJSON=true, for JSON data
|
||||
sendPayloadBytes []byte // if isJSON = true, sendPayloadBytes is the JSON data, otherwise, it is the binary data
|
||||
sendTicker *time.Ticker
|
||||
}
|
||||
|
||||
// NewClient creates a new WebSocket client worker.
|
||||
func NewClient(id int, url string, stats *statsCollector, sendInterval time.Duration, payloadData []byte, isJSON bool) *Client {
|
||||
var ticker *time.Ticker
|
||||
if sendInterval > 0 {
|
||||
ticker = time.NewTicker(sendInterval)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
id: id,
|
||||
stats: stats,
|
||||
url: url,
|
||||
isJSON: isJSON,
|
||||
sendPayloadBytes: payloadData,
|
||||
sendTicker: ticker,
|
||||
}
|
||||
}
|
||||
|
||||
// Dial establishes a WebSocket connection to the server.
|
||||
func (c *Client) Dial(ctx context.Context) error {
|
||||
customDialer := websocket.Dialer{
|
||||
HandshakeTimeout: 5 * time.Second,
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
|
||||
dialStartTime := time.Now()
|
||||
conn, _, err := customDialer.DialContext(ctx, c.url, nil)
|
||||
connectTime := time.Since(dialStartTime)
|
||||
if err != nil {
|
||||
c.stats.AddConnectFailure()
|
||||
c.stats.errSet.Add(err.Error())
|
||||
return err
|
||||
}
|
||||
c.stats.RecordConnectTime(connectTime)
|
||||
c.stats.AddConnectSuccess()
|
||||
c.conn = conn
|
||||
return nil
|
||||
}
|
||||
|
||||
// Run starts the client worker.
|
||||
func (c *Client) Run(ctx context.Context, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
err := c.Dial(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if c.conn == nil {
|
||||
return
|
||||
}
|
||||
defer c.conn.Close()
|
||||
defer c.stats.AddDisconnect()
|
||||
|
||||
var loopWg sync.WaitGroup
|
||||
loopWg.Add(2)
|
||||
|
||||
go c.writeLoop(ctx, &loopWg)
|
||||
go c.readLoop(ctx, &loopWg)
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
// Wait for loops to finish with a timeout
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
loopWg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-shutdownCtx.Done():
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) sendData(ctx context.Context) error {
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, c.sendPayloadBytes); err != nil {
|
||||
c.stats.AddError()
|
||||
return err
|
||||
}
|
||||
c.stats.AddMessageSent()
|
||||
c.stats.AddSentBytes(uint64(len(c.sendPayloadBytes)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeLoop handles sending messages with sequence numbers.
|
||||
func (c *Client) writeLoop(ctx context.Context, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
if c.sendTicker != nil {
|
||||
defer c.sendTicker.Stop()
|
||||
}
|
||||
|
||||
if c.sendTicker != nil {
|
||||
for {
|
||||
select {
|
||||
case <-c.sendTicker.C:
|
||||
if err := c.sendData(ctx); err != nil {
|
||||
c.stats.AddError()
|
||||
c.stats.errSet.Add(err.Error())
|
||||
return
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
if err := c.sendData(ctx); err != nil {
|
||||
c.stats.AddError()
|
||||
c.stats.errSet.Add(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readLoop handles receiving messages and checking for latency and order.
|
||||
func (c *Client) readLoop(ctx context.Context, wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
for {
|
||||
//_ = c.conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||
msgType, p, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
||||
c.stats.errSet.Add(err.Error())
|
||||
c.stats.AddError()
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if msgType == websocket.TextMessage {
|
||||
c.stats.AddMessageRecv()
|
||||
c.stats.AddRecvBytes(uint64(len(p)))
|
||||
}
|
||||
}
|
||||
}
|
281
cmd/sponge/commands/perftest/websocket/stats.go
Normal file
281
cmd/sponge/commands/perftest/websocket/stats.go
Normal file
@@ -0,0 +1,281 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
)
|
||||
|
||||
type ErrSet struct {
|
||||
m sync.Map
|
||||
}
|
||||
|
||||
func NewErrSet() *ErrSet {
|
||||
return &ErrSet{
|
||||
m: sync.Map{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ErrSet) Add(key string) {
|
||||
s.m.Store(key, struct{}{})
|
||||
}
|
||||
|
||||
func (s *ErrSet) List() []string {
|
||||
var result []string
|
||||
|
||||
s.m.Range(func(key, value interface{}) bool {
|
||||
if str, ok := key.(string); ok {
|
||||
result = append(result, str)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
type statsCollector struct {
|
||||
// Connection
|
||||
activeConns int64
|
||||
connectSuccessCount uint64
|
||||
connectFailureCount uint64
|
||||
totalConnectTime uint64 // ns
|
||||
minConnectTime uint64 // ns
|
||||
maxConnectTime uint64 // ns
|
||||
|
||||
// Message
|
||||
messageSentCount uint64
|
||||
messageRecvCount uint64
|
||||
sentBytesCount uint64 // sent total bytes
|
||||
recvBytesCount uint64 // received total bytes
|
||||
errorCount uint64
|
||||
|
||||
errSet *ErrSet // set of errors
|
||||
}
|
||||
|
||||
type Statistics struct {
|
||||
URL string `json:"url"` // performed request URL
|
||||
Duration float64 `json:"duration"` // seconds
|
||||
|
||||
// Connections
|
||||
TotalConnections uint64 `json:"total_connections"`
|
||||
SuccessConnections uint64 `json:"success_connections"`
|
||||
FailedConnections uint64 `json:"failed_connections"`
|
||||
AvgConnectLatency float64 `json:"avg_connect_latency"` // ms
|
||||
MinConnectLatency float64 `json:"min_connect_latency"` // ms
|
||||
MaxConnectLatency float64 `json:"max_connect_latency"` // ms
|
||||
|
||||
// Messages
|
||||
TotalMessagesSent uint64 `json:"total_messages_sent"`
|
||||
TotalMessagesReceived uint64 `json:"total_messages_received"`
|
||||
SentMessageQPS float64 `json:"sent_message_qps"` // sent messages per second
|
||||
ReceivedMessageQPS float64 `json:"received_message_qps"` // received messages per second
|
||||
TotalBytesSent uint64 `json:"total_bytes_sent"` // bytes
|
||||
TotalBytesReceived uint64 `json:"total_bytes_received"` // bytes
|
||||
|
||||
ErrorCount uint64 `json:"error_count"` // total errors
|
||||
Errors []string `json:"errors"` // list of errors
|
||||
}
|
||||
|
||||
func (s *Statistics) Save(filePath string) error {
|
||||
err := ensureFileExists(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(s, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(filePath, data, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Atomic operations for thread safety ---
|
||||
|
||||
func (s *statsCollector) AddConnectSuccess() {
|
||||
atomic.AddUint64(&s.connectSuccessCount, 1)
|
||||
atomic.AddInt64(&s.activeConns, 1)
|
||||
}
|
||||
|
||||
func (s *statsCollector) AddConnectFailure() {
|
||||
atomic.AddUint64(&s.connectFailureCount, 1)
|
||||
}
|
||||
|
||||
func (s *statsCollector) AddDisconnect() {
|
||||
atomic.AddInt64(&s.activeConns, -1)
|
||||
}
|
||||
|
||||
func (s *statsCollector) RecordConnectTime(d time.Duration) {
|
||||
ns := uint64(d.Nanoseconds())
|
||||
atomic.AddUint64(&s.totalConnectTime, ns)
|
||||
|
||||
minTime := atomic.LoadUint64(&s.minConnectTime)
|
||||
if minTime == 0 {
|
||||
atomic.CompareAndSwapUint64(&s.minConnectTime, minTime, ns)
|
||||
} else if ns < minTime {
|
||||
atomic.CompareAndSwapUint64(&s.maxConnectTime, minTime, ns)
|
||||
}
|
||||
maxTime := atomic.LoadUint64(&s.maxConnectTime)
|
||||
if ns > maxTime {
|
||||
atomic.CompareAndSwapUint64(&s.maxConnectTime, maxTime, ns)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *statsCollector) AddMessageSent() {
|
||||
atomic.AddUint64(&s.messageSentCount, 1)
|
||||
}
|
||||
|
||||
func (s *statsCollector) AddMessageRecv() {
|
||||
atomic.AddUint64(&s.messageRecvCount, 1)
|
||||
}
|
||||
|
||||
func (s *statsCollector) AddSentBytes(bytes uint64) {
|
||||
atomic.AddUint64(&s.sentBytesCount, bytes)
|
||||
}
|
||||
|
||||
func (s *statsCollector) AddRecvBytes(bytes uint64) {
|
||||
atomic.AddUint64(&s.recvBytesCount, bytes)
|
||||
}
|
||||
|
||||
func (s *statsCollector) AddError() {
|
||||
atomic.AddUint64(&s.errorCount, 1)
|
||||
}
|
||||
|
||||
// Snapshot creates a read-only copy of the current stats.
|
||||
func (s *statsCollector) Snapshot() statsCollector {
|
||||
return statsCollector{
|
||||
connectSuccessCount: atomic.LoadUint64(&s.connectSuccessCount),
|
||||
connectFailureCount: atomic.LoadUint64(&s.connectFailureCount),
|
||||
activeConns: atomic.LoadInt64(&s.activeConns),
|
||||
totalConnectTime: atomic.LoadUint64(&s.totalConnectTime),
|
||||
minConnectTime: atomic.LoadUint64(&s.minConnectTime),
|
||||
maxConnectTime: atomic.LoadUint64(&s.maxConnectTime),
|
||||
messageSentCount: atomic.LoadUint64(&s.messageSentCount),
|
||||
messageRecvCount: atomic.LoadUint64(&s.messageRecvCount),
|
||||
sentBytesCount: atomic.LoadUint64(&s.sentBytesCount),
|
||||
recvBytesCount: atomic.LoadUint64(&s.recvBytesCount),
|
||||
errorCount: atomic.LoadUint64(&s.errorCount),
|
||||
}
|
||||
}
|
||||
|
||||
// PrintReport prints a formatted report of the current stats.
|
||||
func (s *statsCollector) PrintReport(duration time.Duration, targetURL string) *Statistics {
|
||||
snapshot := s.Snapshot()
|
||||
|
||||
totalConnections := snapshot.connectSuccessCount + snapshot.connectFailureCount
|
||||
avgConnectTimeMs := 0.0
|
||||
if snapshot.connectSuccessCount > 0 {
|
||||
avgConnectTimeMs = float64(snapshot.totalConnectTime) / float64(snapshot.connectSuccessCount) / 1e6
|
||||
}
|
||||
minConnTimeMs := float64(snapshot.minConnectTime) / 1e6
|
||||
maxConnTimeMs := float64(snapshot.maxConnectTime) / 1e6
|
||||
errSet := s.errSet.List()
|
||||
|
||||
fmt.Printf("\n========== WebSocket Performance Test Report ==========\n\n")
|
||||
if snapshot.connectSuccessCount == 0 {
|
||||
_, _ = color.New(color.Bold).Println("[Connections]")
|
||||
fmt.Printf(" • %-20s%d\n", "Total:", totalConnections)
|
||||
fmt.Printf(" • %-20s%d%s\n", "Successful:", 0, color.RedString(" (0%)"))
|
||||
fmt.Printf(" • %-20s%d%s\n", "Failed:", snapshot.connectFailureCount, color.RedString(" ✗"))
|
||||
fmt.Printf(" • %-20smin: %.2f ms, avg: %.2f ms, max: %.2f ms\n\n", "Latency:", minConnTimeMs, avgConnectTimeMs, maxConnTimeMs)
|
||||
if len(errSet) > 0 {
|
||||
_, _ = color.New(color.Bold).Println("[Error Details]")
|
||||
for _, errStr := range errSet {
|
||||
fmt.Printf(" • %s\n", color.RedString(errStr))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
sentThroughput := 0.0
|
||||
recvThroughput := 0.0
|
||||
if seconds := duration.Seconds(); seconds > 0 {
|
||||
sentThroughput = float64(snapshot.messageSentCount) / seconds
|
||||
recvThroughput = float64(snapshot.messageRecvCount) / seconds
|
||||
}
|
||||
|
||||
st := &Statistics{
|
||||
URL: targetURL,
|
||||
Duration: duration.Seconds(),
|
||||
|
||||
TotalConnections: totalConnections,
|
||||
SuccessConnections: snapshot.connectSuccessCount,
|
||||
FailedConnections: snapshot.connectFailureCount,
|
||||
AvgConnectLatency: avgConnectTimeMs,
|
||||
MinConnectLatency: minConnTimeMs,
|
||||
MaxConnectLatency: maxConnTimeMs,
|
||||
|
||||
SentMessageQPS: sentThroughput,
|
||||
ReceivedMessageQPS: recvThroughput,
|
||||
TotalMessagesSent: snapshot.messageSentCount,
|
||||
TotalMessagesReceived: snapshot.messageRecvCount,
|
||||
TotalBytesSent: snapshot.sentBytesCount,
|
||||
TotalBytesReceived: snapshot.recvBytesCount,
|
||||
|
||||
ErrorCount: snapshot.errorCount,
|
||||
Errors: errSet,
|
||||
}
|
||||
|
||||
_, _ = color.New(color.Bold).Println("[Connections]")
|
||||
fmt.Printf(" • %-20s%d\n", "Total:", st.TotalConnections)
|
||||
successStr := fmt.Sprintf(" • %-20s%d", "Successful:", st.SuccessConnections)
|
||||
failureStr := fmt.Sprintf(" • %-20s%d", "Failed:", st.FailedConnections)
|
||||
if st.TotalConnections > 0 {
|
||||
if st.TotalConnections == st.SuccessConnections {
|
||||
successStr += color.GreenString(" (100%)")
|
||||
} else if st.FailedConnections > 0 {
|
||||
if st.SuccessConnections == 0 {
|
||||
successStr += color.RedString(" (0%)")
|
||||
} else {
|
||||
successStr += color.YellowString(" (%d%%)", int(float64(st.SuccessConnections)/float64(st.TotalConnections)*100))
|
||||
}
|
||||
failureStr += color.RedString(" ✗")
|
||||
}
|
||||
}
|
||||
fmt.Println(successStr)
|
||||
fmt.Println(failureStr)
|
||||
fmt.Printf(" • %-20smin: %.2f ms, avg: %.2f ms, max: %.2f ms\n\n", "Latency:", st.MinConnectLatency, st.AvgConnectLatency, st.MaxConnectLatency)
|
||||
|
||||
_, _ = color.New(color.Bold).Println("[Messages Sent]")
|
||||
fmt.Printf(" • %-20s%d\n", "Total Messages:", st.TotalMessagesSent)
|
||||
fmt.Printf(" • %-20s%d\n", "Total Bytes:", st.TotalBytesSent)
|
||||
fmt.Printf(" • %-20s%.2f msgs/sec\n\n", "Throughput (QPS):", st.SentMessageQPS)
|
||||
|
||||
_, _ = color.New(color.Bold).Println("[Messages Received]")
|
||||
fmt.Printf(" • %-20s%d\n", "Total Messages:", st.TotalMessagesReceived)
|
||||
fmt.Printf(" • %-20s%d\n", "Total Bytes:", st.TotalBytesReceived)
|
||||
fmt.Printf(" • %-20s%.2f msgs/sec\n\n", "Throughput (QPS):", st.ReceivedMessageQPS)
|
||||
|
||||
if len(errSet) > 0 {
|
||||
_, _ = color.New(color.Bold).Println("[Error Details]")
|
||||
for _, errStr := range errSet {
|
||||
fmt.Printf(" • %s\n", color.RedString(errStr))
|
||||
}
|
||||
}
|
||||
|
||||
return st
|
||||
}
|
||||
|
||||
func ensureFileExists(filePath string) error {
|
||||
dir := filepath.Dir(filePath)
|
||||
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
|
||||
return fmt.Errorf("failed to create directories: %w", err)
|
||||
}
|
||||
_, err := os.Stat(filePath)
|
||||
if os.IsNotExist(err) {
|
||||
file, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
178
cmd/sponge/commands/perftest/websocket/websocket.go
Normal file
178
cmd/sponge/commands/perftest/websocket/websocket.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/go-dev-frame/sponge/cmd/sponge/commands/perftest/common"
|
||||
"github.com/go-dev-frame/sponge/pkg/krand"
|
||||
)
|
||||
|
||||
// PerfTestWebsocketCMD creates a cobra command for websocket performance test
|
||||
func PerfTestWebsocketCMD() *cobra.Command {
|
||||
var (
|
||||
targetURL string
|
||||
worker int
|
||||
duration time.Duration
|
||||
sendInterval time.Duration
|
||||
rampUp time.Duration
|
||||
|
||||
bodyString string
|
||||
bodyJSON string
|
||||
bodyFile string
|
||||
|
||||
out string
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "websocket",
|
||||
Short: "Run performance test for websocket",
|
||||
Long: "Run performance test for websocket.",
|
||||
Example: color.HiBlackString(` # Default: 10 workers, 10s duration, random(10) string message
|
||||
sponge perftest websocket --url=ws://localhost:8080/ws
|
||||
|
||||
# Send fixed string messages, 100 workers, 1m duration, each worker sends messages every 10ms
|
||||
sponge perftest websocket --worker=100 --duration=1m --send-interval=10ms --body-string=abcdefghijklmnopqrstuvwxyz --url=ws://localhost:8080/ws
|
||||
|
||||
# Send JSON messages, 10 workers, 10s duration
|
||||
sponge perftest websocket --worker=10 --duration=10s --body={\"name\":\"Alice\",\"age\":25} --url=ws://localhost:8080/ws
|
||||
|
||||
# Send JSON messages, 100 workers, 1m duration, each worker sends messages every 10ms
|
||||
sponge perftest websocket --worker=100 --duration=1m --send-interval=10ms --body={\"name\":\"Alice\",\"age\":25} --url=ws://localhost:8080/ws`),
|
||||
SilenceErrors: true,
|
||||
SilenceUsage: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
body, err := common.CheckBodyParam(bodyJSON, bodyFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var payloadData []byte
|
||||
isJSON := false
|
||||
if body != "" {
|
||||
payloadData = []byte(body)
|
||||
isJSON = true
|
||||
} else {
|
||||
if bodyString != "" {
|
||||
payloadData = []byte(bodyString)
|
||||
} else {
|
||||
payloadData = krand.Bytes(krand.R_All, 10) // default payload data random(10)
|
||||
}
|
||||
}
|
||||
|
||||
p := &perfTestParams{
|
||||
targetURL: targetURL,
|
||||
worker: worker,
|
||||
duration: duration,
|
||||
sendInterval: sendInterval,
|
||||
rampUp: rampUp,
|
||||
payloadData: payloadData,
|
||||
isJSON: isJSON,
|
||||
out: out,
|
||||
}
|
||||
|
||||
return p.run()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&targetURL, "url", "u", "", "request URL")
|
||||
_ = cmd.MarkFlagRequired("url")
|
||||
cmd.Flags().IntVarP(&worker, "worker", "c", 10, "number of concurrent websocket clients")
|
||||
cmd.Flags().DurationVarP(&duration, "duration", "d", time.Second*10, "duration of the test, e.g., 10s, 1m")
|
||||
cmd.Flags().DurationVarP(&sendInterval, "send-interval", "i", 0, "interval for sending messages per client")
|
||||
cmd.Flags().DurationVarP(&rampUp, "ramp-up", "r", 0, "time to ramp up all connections (e.g., 10s)")
|
||||
|
||||
cmd.Flags().StringVarP(&bodyJSON, "body", "b", "", "request body (JSON String, priority higher than --body-file, --body-string)")
|
||||
cmd.Flags().StringVarP(&bodyFile, "body-file", "f", "", "request body file")
|
||||
cmd.Flags().StringVarP(&bodyString, "body-string", "s", "", "request body (String)")
|
||||
|
||||
cmd.Flags().StringVarP(&out, "out", "o", "", "save statistics to JSON file")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type perfTestParams struct {
|
||||
targetURL string
|
||||
worker int
|
||||
duration time.Duration
|
||||
sendInterval time.Duration
|
||||
rampUp time.Duration
|
||||
|
||||
payloadData []byte
|
||||
isJSON bool
|
||||
|
||||
out string
|
||||
}
|
||||
|
||||
func (p *perfTestParams) run() error {
|
||||
mainCtx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Test duration timer
|
||||
go func() {
|
||||
<-time.After(p.duration)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// OS interrupt signal
|
||||
interrupt := make(chan os.Signal, 1)
|
||||
signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-interrupt
|
||||
cancel()
|
||||
}()
|
||||
|
||||
// Calculate ramp-up delay
|
||||
var rampUpDelay time.Duration
|
||||
if p.worker > 0 && p.rampUp > 0 {
|
||||
rampUpDelay = p.rampUp / time.Duration(p.worker)
|
||||
}
|
||||
|
||||
stats := &statsCollector{errSet: NewErrSet()}
|
||||
bar := common.NewTimeBar(p.duration)
|
||||
bar.Start()
|
||||
|
||||
// Start all client workers with ramp-up
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < p.worker; i++ {
|
||||
if mainCtx.Err() != nil {
|
||||
break
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
client := NewClient(i+1, p.targetURL, stats, p.sendInterval, p.payloadData, p.isJSON)
|
||||
go client.Run(mainCtx, &wg)
|
||||
|
||||
if rampUpDelay > 0 {
|
||||
time.Sleep(rampUpDelay)
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all workers to finish
|
||||
wg.Wait()
|
||||
if !errors.Is(mainCtx.Err(), context.Canceled) {
|
||||
bar.Finish()
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
st := stats.PrintReport(p.duration, p.targetURL)
|
||||
if p.out != "" && st != nil {
|
||||
err := st.Save(p.out)
|
||||
if err != nil {
|
||||
fmt.Println()
|
||||
return fmt.Errorf("failed to save statistics to file: %v", err)
|
||||
}
|
||||
fmt.Printf("\nsave statistics to '%s' successfully\n", p.out)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
@@ -24,6 +24,7 @@ var pluginNames = []string{
|
||||
"protoc-gen-go-gin",
|
||||
"protoc-gen-go-rpc-tmpl",
|
||||
"protoc-gen-json-field",
|
||||
"perftest",
|
||||
"protoc-gen-openapiv2",
|
||||
"protoc-gen-doc",
|
||||
"swag",
|
||||
@@ -41,6 +42,7 @@ var installPluginCommands = map[string]string{
|
||||
"protoc-gen-go-gin": "github.com/go-dev-frame/sponge/cmd/protoc-gen-go-gin@latest",
|
||||
"protoc-gen-go-rpc-tmpl": "github.com/go-dev-frame/sponge/cmd/protoc-gen-go-rpc-tmpl@latest",
|
||||
"protoc-gen-json-field": "github.com/go-dev-frame/sponge/cmd/protoc-gen-json-field@latest",
|
||||
"perftest": "github.com/go-dev-frame/sponge/cmd/perftest@latest",
|
||||
"protoc-gen-openapiv2": "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2@latest",
|
||||
"protoc-gen-doc": "github.com/pseudomuto/protoc-gen-doc/cmd/protoc-gen-doc@latest",
|
||||
"swag": "github.com/swaggo/swag/cmd/swag@v1.8.12",
|
||||
@@ -178,7 +180,8 @@ func installPlugins(lackNames []string) {
|
||||
}
|
||||
|
||||
func adaptInternalCommand(name string, pkgAddr string) string {
|
||||
if name == "protoc-gen-go-gin" || name == "protoc-gen-go-rpc-tmpl" || name == "protoc-gen-json-field" {
|
||||
if name == "protoc-gen-go-gin" || name == "protoc-gen-go-rpc-tmpl" ||
|
||||
name == "protoc-gen-json-field" || name == "perftest" {
|
||||
if version != "v0.0.0" {
|
||||
return strings.ReplaceAll(pkgAddr, "@latest", "@"+version)
|
||||
}
|
||||
|
@@ -45,6 +45,7 @@ Docs: %s`,
|
||||
GenGraphCommand(),
|
||||
TemplateCommand(),
|
||||
AssistantCommand(),
|
||||
PerftestCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
|
@@ -93,7 +93,6 @@ func ListLLM(c *gin.Context) {
|
||||
{Label: "gpt-4.1-mini", Value: "gpt-4.1-mini"},
|
||||
{Label: "gpt-4o", Value: "gpt-4o"},
|
||||
{Label: "gpt-4o-mini", Value: "gpt-4o-mini"},
|
||||
{Label: "o4-mini-high", Value: "o4-mini-high"},
|
||||
},
|
||||
"gemini": {
|
||||
{Label: "gemini-2.5-flash", Value: "gemini-2.5-flash"},
|
||||
@@ -332,6 +331,78 @@ func HandleAssistant(c *gin.Context) {
|
||||
response.Success(c, resultInfo)
|
||||
}
|
||||
|
||||
// HandlePerformanceTest handle performance test
|
||||
func HandlePerformanceTest(c *gin.Context) {
|
||||
form := &GenerateCodeForm{}
|
||||
err := c.ShouldBindJSON(form)
|
||||
if err != nil {
|
||||
responseErr(c, err, errcode.InvalidParams)
|
||||
return
|
||||
}
|
||||
|
||||
args := strings.Split(form.Arg, " ")
|
||||
params := parseCommandArgs(args)
|
||||
if len(args) > 2 {
|
||||
params.Protocol = args[1]
|
||||
}
|
||||
if params.TotalRequests > 0 {
|
||||
params.TestType = "requests"
|
||||
} else if params.Duration != "" {
|
||||
params.TestType = "duration"
|
||||
}
|
||||
if params.JobName != "" && params.PushURL != "" {
|
||||
params.PushType = "prometheus"
|
||||
params.PrometheusURL = params.PushURL
|
||||
params.PushURL = ""
|
||||
} else if params.PushURL != "" {
|
||||
params.PushType = "custom"
|
||||
}
|
||||
|
||||
ctx, _ := context.WithTimeout(context.Background(), time.Minute*30) // nolint
|
||||
result := gobash.Run(ctx, "sponge", args...)
|
||||
resultInfo := ""
|
||||
count := 0
|
||||
for v := range result.StdOut {
|
||||
count++
|
||||
if count == 1 { // first line is the command
|
||||
continue
|
||||
}
|
||||
if strings.Contains(v, "Waiting for assistant responses") {
|
||||
continue
|
||||
}
|
||||
resultInfo += v
|
||||
}
|
||||
if result.Err != nil {
|
||||
responseErr(c, result.Err, errcode.InternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
recordObj().set(c.ClientIP(), form.Path, params)
|
||||
|
||||
resultInfo = splitString(resultInfo, "Performance Test Report ==========")
|
||||
|
||||
response.Success(c, resultInfo)
|
||||
}
|
||||
|
||||
func splitString(str string, sep string) string {
|
||||
lines := strings.Split(str, "\n")
|
||||
startIndex := 0
|
||||
isFound := false
|
||||
|
||||
for i, line := range lines {
|
||||
if strings.Contains(line, sep) {
|
||||
isFound = true
|
||||
startIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if isFound {
|
||||
return strings.Join(lines[startIndex:], "\n")
|
||||
}
|
||||
return str
|
||||
}
|
||||
|
||||
// GetRecord generate run command record
|
||||
func GetRecord(c *gin.Context) {
|
||||
pathParam := c.Param("path")
|
||||
|
@@ -58,6 +58,7 @@ func NewRouter(spongeAddr string, isLog bool) *gin.Engine {
|
||||
apiV1.POST("/generate", GenerateCode)
|
||||
apiV1.POST("/getTemplateInfo", GetTemplateInfo)
|
||||
apiV1.POST("/assistant", HandleAssistant)
|
||||
apiV1.POST("/performanceTest", HandlePerformanceTest)
|
||||
apiV1.POST("/uploadFiles", UploadFiles)
|
||||
apiV1.POST("/listTables", ListTables)
|
||||
apiV1.GET("/listDrivers", ListDbDrivers)
|
||||
|
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -44,6 +45,20 @@ type parameters struct {
|
||||
GoFile string `json:"goFile"`
|
||||
IsSpecifiedGoFile bool `json:"isSpecifiedGoFile"`
|
||||
|
||||
Protocol string `json:"protocol"`
|
||||
URL string `json:"url"`
|
||||
Method string `json:"method"`
|
||||
Body string `json:"body"`
|
||||
Headers []string `json:"headers"`
|
||||
Worker int `json:"worker"`
|
||||
TestType string `json:"testType"`
|
||||
TotalRequests uint64 `json:"totalRequests"`
|
||||
Duration string `json:"duration"`
|
||||
PushType string `json:"pushType"`
|
||||
PushURL string `json:"pushUrl"`
|
||||
PrometheusURL string `json:"prometheusUrl"`
|
||||
JobName string `json:"jobName"`
|
||||
|
||||
SuitedMonoRepo bool `json:"suitedMonoRepo"`
|
||||
}
|
||||
|
||||
@@ -186,6 +201,41 @@ func parseCommandArgs(args []string) *parameters {
|
||||
case "--file":
|
||||
params.GoFile = val
|
||||
params.IsSpecifiedGoFile = true
|
||||
|
||||
case "--url":
|
||||
params.URL = val
|
||||
case "--method":
|
||||
params.Method = val
|
||||
case "--body":
|
||||
params.Body = val
|
||||
case "--header":
|
||||
if val != "" {
|
||||
params.Headers = append(params.Headers, val)
|
||||
}
|
||||
case "--worker":
|
||||
vl, _ := strconv.Atoi(val)
|
||||
if vl > 0 {
|
||||
params.Worker = vl
|
||||
}
|
||||
case "--total":
|
||||
vl, _ := strconv.ParseUint(val, 10, 64)
|
||||
if vl > 0 {
|
||||
params.TotalRequests = vl
|
||||
}
|
||||
case "--duration":
|
||||
val = strings.TrimSuffix(val, "s")
|
||||
vl, _ := strconv.Atoi(val)
|
||||
if vl > 0 {
|
||||
params.Duration = val
|
||||
}
|
||||
case "--push-url":
|
||||
if val != "" {
|
||||
params.PushURL = val
|
||||
}
|
||||
case "--prometheus-job-name":
|
||||
if val != "" {
|
||||
params.JobName = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
15
go.mod
15
go.mod
@@ -6,7 +6,7 @@ require (
|
||||
github.com/DATA-DOG/go-sqlmock v1.5.0
|
||||
github.com/IBM/sarama v1.43.2
|
||||
github.com/alicebob/miniredis/v2 v2.23.0
|
||||
github.com/bojand/ghz v0.117.0
|
||||
github.com/bojand/ghz v0.120.0
|
||||
github.com/dgraph-io/ristretto v0.2.0
|
||||
github.com/fatih/color v1.13.0
|
||||
github.com/felixge/fgprof v0.9.3
|
||||
@@ -32,7 +32,8 @@ require (
|
||||
github.com/nacos-group/nacos-sdk-go/v2 v2.2.7
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/prometheus/client_golang v1.14.0
|
||||
github.com/prometheus/client_golang v1.19.1
|
||||
github.com/quic-go/quic-go v0.54.0
|
||||
github.com/rabbitmq/amqp091-go v1.9.0
|
||||
github.com/redis/go-redis/extra/redisotel/v9 v9.7.0
|
||||
github.com/redis/go-redis/v9 v9.7.0
|
||||
@@ -177,7 +178,6 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.12 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.17 // indirect
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
|
||||
github.com/mitchellh/copystructure v1.0.0 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
@@ -196,8 +196,9 @@ require (
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/prometheus/client_model v0.6.0 // indirect
|
||||
github.com/prometheus/common v0.37.0 // indirect
|
||||
github.com/prometheus/procfs v0.8.0 // indirect
|
||||
github.com/prometheus/common v0.53.0 // indirect
|
||||
github.com/prometheus/procfs v0.12.0 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect
|
||||
github.com/redis/go-redis/extra/rediscmd/v9 v9.7.0 // indirect
|
||||
github.com/shopspring/decimal v1.2.0 // indirect
|
||||
@@ -223,14 +224,16 @@ require (
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.51.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.26.0 // indirect
|
||||
go.uber.org/atomic v1.7.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/mod v0.18.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/oauth2 v0.27.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/time v0.8.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
golang.org/x/tools v0.22.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect
|
||||
gopkg.in/ini.v1 v1.66.4 // indirect
|
||||
|
65
go.sum
65
go.sum
@@ -77,7 +77,6 @@ github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafo
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
|
||||
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68 h1:NqugFkGxx1TXSh/pBcU00Y6bljgDPaFdh5MUSeJ7e50=
|
||||
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
|
||||
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
|
||||
@@ -108,8 +107,8 @@ github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+Ce
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs=
|
||||
github.com/bojand/ghz v0.117.0 h1:dTMxg+tUcLMw8BYi7vQPjXsrM2DJ20ns53hz1am1SbQ=
|
||||
github.com/bojand/ghz v0.117.0/go.mod h1:MXspmKdJie7NAS0IHzqG9X5h6zO3tIRGQ6Tkt8sAwa4=
|
||||
github.com/bojand/ghz v0.120.0 h1:6F4wsmZVwFg5UnD+/R+IABWk6sKE/0OKIBdUQUZnOdo=
|
||||
github.com/bojand/ghz v0.120.0/go.mod h1:HfECuBZj1v02XObGnRuoZgyB1PR24/25dIYiJIMjJnE=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
@@ -126,7 +125,6 @@ github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1 h1:iKLQ0xPNFxR/2hzXZMrBo8f1j86j5WHzznCCQxV/b8g=
|
||||
github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
@@ -213,12 +211,8 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
|
||||
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -463,12 +457,10 @@ github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af h1:pmfjZENx5i
|
||||
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.5/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
@@ -477,7 +469,6 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV
|
||||
github.com/juju/errors v1.0.0 h1:yiq7kjCLll1BiaRuNY53MGI0+EQ3rF6GB+wvboZDefM=
|
||||
github.com/juju/errors v1.0.0/go.mod h1:B5x9thDqx0wIMH3+aLIMP9HjItInYWObRovoCFM5Qe8=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU=
|
||||
@@ -487,7 +478,6 @@ github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuV
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -528,7 +518,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
|
||||
github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY=
|
||||
@@ -559,7 +548,6 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwd
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe h1:iruDEfMl2E6fbMZ9s0scYfZQ84/6SPL6zC8ACM2oIL0=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nacos-group/nacos-sdk-go/v2 v2.2.7 h1:wCC1f3/VzIR1WD30YKeJGZAOchYCK/35mLC8qWt6Q6o=
|
||||
github.com/nacos-group/nacos-sdk-go/v2 v2.2.7/go.mod h1:VYlyDPlQchPC31PmfBustu81vsOkdpCuO5k0dRdQcFc=
|
||||
github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM=
|
||||
@@ -603,11 +591,8 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:Om
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
|
||||
github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY=
|
||||
github.com/prometheus/client_golang v1.14.0 h1:nJdhIvne2eSX/XRAFV9PcvFFRbrjbcTUj0VP62TMhnw=
|
||||
github.com/prometheus/client_golang v1.14.0/go.mod h1:8vpkKitgIVNcqrRBWh1C4TIUQgYNtG/XQE4E/Zae36Y=
|
||||
github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE=
|
||||
github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
@@ -616,19 +601,17 @@ github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZ
|
||||
github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8=
|
||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
|
||||
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
|
||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
|
||||
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
|
||||
github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls=
|
||||
github.com/prometheus/common v0.37.0 h1:ccBbHCgIiT9uSoFY0vX8H3zsNR5eLt17/RQLUvn8pXE=
|
||||
github.com/prometheus/common v0.37.0/go.mod h1:phzohg0JFMnBEFGxTDbfu3QyL5GI8gTQJFhYO5B3mfA=
|
||||
github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE=
|
||||
github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U=
|
||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.8.0 h1:ODq8ZFEaYeCaZOJlZZdJA2AbQR98dSHSM1KW/You5mo=
|
||||
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
||||
github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo=
|
||||
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc3Aoo=
|
||||
github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5XpJzTSTfLsJV/mx9Q9g7kxmchpfZyxgzM=
|
||||
@@ -651,8 +634,6 @@ github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/sashabaranov/go-openai v1.36.1 h1:EVfRXwIlW2rUzpx6vR+aeIKCK/xylSrVYAx1TMTSX3g=
|
||||
github.com/sashabaranov/go-openai v1.36.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sashabaranov/go-openai v1.41.1 h1:zf5tM+GuxpyiyD9XZg8nCqu52eYFQg9OOew0gnIuDy4=
|
||||
github.com/sashabaranov/go-openai v1.41.1/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
|
||||
@@ -664,7 +645,6 @@ github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/spf13/afero v1.10.0 h1:EaGW2JJh15aKOejeuJ+wpFSHnbd7GE6Wvp3TsNhb6LY=
|
||||
@@ -789,6 +769,8 @@ go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
|
||||
@@ -847,8 +829,8 @@ golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
|
||||
golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -887,11 +869,8 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8=
|
||||
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
|
||||
golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
@@ -908,8 +887,6 @@ golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -949,7 +926,6 @@ golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -964,8 +940,6 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -974,7 +948,6 @@ golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
@@ -982,7 +955,6 @@ golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -990,7 +962,6 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -1075,8 +1046,8 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
|
||||
golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
Reference in New Issue
Block a user