Vegeta as a library
* Slicker API * Privatises a bunch of stuff * More tests * More documentation
This commit is contained in:
26
README.md
26
README.md
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Vegeta is a versatile HTTP load testing tool built out of need to drill
|
Vegeta is a versatile HTTP load testing tool built out of need to drill
|
||||||
HTTP services with a constant request rate.
|
HTTP services with a constant request rate.
|
||||||
|
It can be used both as a command line utility and a library.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -12,7 +13,7 @@ command:
|
|||||||
$ go install github.com/tsenart/vegeta
|
$ go install github.com/tsenart/vegeta
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage (CLI)
|
||||||
```shell
|
```shell
|
||||||
$ vegeta -h
|
$ vegeta -h
|
||||||
Usage of vegeta:
|
Usage of vegeta:
|
||||||
@@ -59,6 +60,28 @@ HEAD http://goku:9090/path/to/success
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Usage (Library)
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
vegeta "github.com/tsenart/vegeta/lib"
|
||||||
|
"time"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
targets := vegeta.NewTargets([]string{"GET http://localhost:9100/"})
|
||||||
|
rate := uint64(100) // per second
|
||||||
|
duration := 4 * time.Second
|
||||||
|
reporter := vegeta.NewTextReporter()
|
||||||
|
|
||||||
|
vegeta.Attack(targets, rate, duration, reporter)
|
||||||
|
|
||||||
|
reporter.Report(os.Stdout)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
#### Limitations
|
#### Limitations
|
||||||
There will be an upper bound of the supported `rate` which varies on the
|
There will be an upper bound of the supported `rate` which varies on the
|
||||||
machine being used.
|
machine being used.
|
||||||
@@ -79,7 +102,6 @@ Just pass a new number as the argument to change it.
|
|||||||
* Add timeout options to the requests
|
* Add timeout options to the requests
|
||||||
* Graphical reporters
|
* Graphical reporters
|
||||||
* Cluster mode (to overcome single machine limits)
|
* Cluster mode (to overcome single machine limits)
|
||||||
* More tests
|
|
||||||
* HTTPS
|
* HTTPS
|
||||||
|
|
||||||
## Licence
|
## Licence
|
||||||
|
|||||||
52
client.go
52
client.go
@@ -1,52 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Client is an http.Client with rate limiting
|
|
||||||
// TODO: Add timeouts
|
|
||||||
type Client struct{ http.Client }
|
|
||||||
|
|
||||||
// Response represents the metrics we want out of an http.Response
|
|
||||||
type Response struct {
|
|
||||||
code uint64
|
|
||||||
timestamp time.Time
|
|
||||||
timing time.Duration
|
|
||||||
bytesOut uint64
|
|
||||||
bytesIn uint64
|
|
||||||
err error
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drill loops over the passed reqs channel and executes each request.
|
|
||||||
// It is throttled to the rate specified
|
|
||||||
func (c *Client) Drill(rate uint64, reqs chan *http.Request, res chan *Response) {
|
|
||||||
throttle := time.Tick(time.Duration(1e9 / rate))
|
|
||||||
for req := range reqs {
|
|
||||||
<-throttle
|
|
||||||
go c.Do(req, res)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Do executes the passed http.Request and puts a generated *Response into res.
|
|
||||||
func (c *Client) Do(req *http.Request, res chan *Response) {
|
|
||||||
began := time.Now()
|
|
||||||
r, err := c.Client.Do(req)
|
|
||||||
resp := &Response{
|
|
||||||
timestamp: began,
|
|
||||||
timing: time.Since(began),
|
|
||||||
bytesOut: uint64(req.ContentLength),
|
|
||||||
err: err,
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
resp.bytesIn, resp.code = uint64(r.ContentLength), uint64(r.StatusCode)
|
|
||||||
if body, err := ioutil.ReadAll(r.Body); err != nil && resp.code < 200 || resp.code >= 300 {
|
|
||||||
resp.err = errors.New(string(body))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res <- resp
|
|
||||||
}
|
|
||||||
68
lib/attack.go
Normal file
68
lib/attack.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package vegeta
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/ioutil"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Attack hits the passed Targets (http.Requests) at the rate specified for
|
||||||
|
// duration time and then waits for all the requests to come back.
|
||||||
|
// The results of the attack are put into the rep Reporter.
|
||||||
|
func Attack(targets Targets, rate uint64, duration time.Duration, rep Reporter) {
|
||||||
|
hits := make(chan *http.Request, rate*uint64((duration).Seconds()))
|
||||||
|
defer close(hits)
|
||||||
|
responses := make(chan *result, cap(hits))
|
||||||
|
defer close(responses)
|
||||||
|
go drill(rate, hits, responses) // Attack!
|
||||||
|
for i := 0; i < cap(hits); i++ {
|
||||||
|
hits <- targets[i%len(targets)]
|
||||||
|
}
|
||||||
|
// Wait for all requests to finish
|
||||||
|
for i := 0; i < cap(responses); i++ {
|
||||||
|
rep.add(<-responses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// result represents the metrics we want out of an http.Response
|
||||||
|
type result struct {
|
||||||
|
code uint64
|
||||||
|
timestamp time.Time
|
||||||
|
timing time.Duration
|
||||||
|
bytesOut uint64
|
||||||
|
bytesIn uint64
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// drill loops over the passed reqs channel and executes each request.
|
||||||
|
// It is throttled to the rate specified.
|
||||||
|
func drill(rate uint64, reqs chan *http.Request, res chan *result) {
|
||||||
|
throttle := time.Tick(time.Duration(1e9 / rate))
|
||||||
|
for req := range reqs {
|
||||||
|
<-throttle
|
||||||
|
go hit(req, res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hit executes the passed http.Request and puts a generated *result into res.
|
||||||
|
// Both transport errors and unsucessfull requests (non {2xx,3xx}) are
|
||||||
|
// considered errors which are set in the Response.
|
||||||
|
func hit(req *http.Request, res chan *result) {
|
||||||
|
began := time.Now()
|
||||||
|
r, err := http.DefaultClient.Do(req)
|
||||||
|
result := &result{
|
||||||
|
timestamp: began,
|
||||||
|
timing: time.Since(began),
|
||||||
|
bytesOut: uint64(req.ContentLength),
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
if err == nil {
|
||||||
|
result.bytesIn, result.code = uint64(r.ContentLength), uint64(r.StatusCode)
|
||||||
|
if body, err := ioutil.ReadAll(r.Body); err != nil && result.code < 200 || result.code >= 300 {
|
||||||
|
result.err = errors.New(string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res <- result
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package main
|
package vegeta
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,13 +16,10 @@ func TestAttackRate(t *testing.T) {
|
|||||||
atomic.AddUint64(&hitCount, 1)
|
atomic.AddUint64(&hitCount, 1)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
targets, err := NewTargets(bytes.NewBufferString("GET " + server.URL + "\n"))
|
request, _ := http.NewRequest("GET", server.URL, nil)
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
rate := uint64(5000)
|
rate := uint64(5000)
|
||||||
rep := NewTextReporter()
|
rep := NewTextReporter()
|
||||||
attack(targets, "random", rate, 1*time.Second, rep)
|
Attack(Targets{request}, rate, 1*time.Second, rep)
|
||||||
if hits := atomic.LoadUint64(&hitCount); hits != rate {
|
if hits := atomic.LoadUint64(&hitCount); hits != rate {
|
||||||
rep.Report(os.Stdout)
|
rep.Report(os.Stdout)
|
||||||
t.Fatalf("Wrong number of hits: want %d, got %d\n", rate, hits)
|
t.Fatalf("Wrong number of hits: want %d, got %d\n", rate, hits)
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package vegeta
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,23 +9,20 @@ import (
|
|||||||
|
|
||||||
// Reporter represents any reporter of the results of the test
|
// Reporter represents any reporter of the results of the test
|
||||||
type Reporter interface {
|
type Reporter interface {
|
||||||
Add(res *Response)
|
|
||||||
Report(io.Writer) error
|
Report(io.Writer) error
|
||||||
|
add(res *result)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TextReporter prints the test results as text
|
||||||
|
// Metrics incude avg time per request, success ratio,
|
||||||
|
// total number of request, avg bytes in and avg bytes out
|
||||||
type TextReporter struct {
|
type TextReporter struct {
|
||||||
responses []*Response
|
responses []*result
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTextReporter initializes a TextReporter with n responses
|
// NewTextReporter initializes a TextReporter with n responses
|
||||||
func NewTextReporter() *TextReporter {
|
func NewTextReporter() *TextReporter {
|
||||||
return &TextReporter{responses: make([]*Response, 0)}
|
return &TextReporter{responses: make([]*result, 0)}
|
||||||
}
|
|
||||||
|
|
||||||
// Add adds a response to be used in the report
|
|
||||||
// Order of arrival is not relevant for this reporter
|
|
||||||
func (r *TextReporter) Add(res *Response) {
|
|
||||||
r.responses = append(r.responses, res)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Report computes and writes the report to out.
|
// Report computes and writes the report to out.
|
||||||
@@ -77,3 +74,9 @@ func (r *TextReporter) Report(out io.Writer) error {
|
|||||||
|
|
||||||
return w.Flush()
|
return w.Flush()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// add adds a response to be used in the report
|
||||||
|
// Order of arrival is not relevant for this reporter
|
||||||
|
func (r *TextReporter) add(res *result) {
|
||||||
|
r.responses = append(r.responses, res)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package vegeta
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
@@ -10,26 +10,41 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Targets represents the http.Requests which will be issued during the test
|
||||||
type Targets []*http.Request
|
type Targets []*http.Request
|
||||||
|
|
||||||
|
// NewTargetsFromFile reads and parses targets from a text file
|
||||||
func NewTargetsFromFile(filename string) (Targets, error) {
|
func NewTargetsFromFile(filename string) (Targets, error) {
|
||||||
file, err := os.Open(filename)
|
file, err := os.Open(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Targets{}, err
|
return Targets{}, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
return NewTargets(file)
|
return readTargets(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTargets(source io.Reader) (Targets, error) {
|
// readTargets reads targets out of a line separated source skipping empty lines
|
||||||
targets := make([]*http.Request, 0)
|
func readTargets(source io.Reader) (Targets, error) {
|
||||||
scanner := bufio.NewScanner(source)
|
scanner := bufio.NewScanner(source)
|
||||||
|
lines := make([]string, 0)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
if line = strings.TrimSpace(line); line == "" { // Empty line
|
if line = strings.TrimSpace(line); line == "" { // Empty line
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return Targets{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewTargets(lines)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTargets instantiates Targets from a slice of strings
|
||||||
|
func NewTargets(lines []string) (Targets, error) {
|
||||||
|
targets := make([]*http.Request, 0)
|
||||||
|
for _, line := range lines {
|
||||||
parts := strings.SplitN(line, " ", 2)
|
parts := strings.SplitN(line, " ", 2)
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
return targets, fmt.Errorf("Invalid request format: `%s`", line)
|
return targets, fmt.Errorf("Invalid request format: `%s`", line)
|
||||||
@@ -41,12 +56,10 @@ func NewTargets(source io.Reader) (Targets, error) {
|
|||||||
}
|
}
|
||||||
targets = append(targets, req)
|
targets = append(targets, req)
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return targets, err
|
|
||||||
}
|
|
||||||
return targets, nil
|
return targets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Shuffle randomly alters the order of Targets with the provided seed
|
||||||
func (t Targets) Shuffle(seed int64) {
|
func (t Targets) Shuffle(seed int64) {
|
||||||
rand.Seed(seed)
|
rand.Seed(seed)
|
||||||
for i, rnd := range rand.Perm(len(t)) {
|
for i, rnd := range rand.Perm(len(t)) {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
package main
|
package vegeta
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
@@ -6,9 +6,24 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestReadTargets(t *testing.T) {
|
||||||
|
lines := bytes.NewBufferString("GET http://lolcathost:9999/\n\nHEAD http://lolcathost:9999/\n")
|
||||||
|
targets, err := readTargets(lines)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Couldn't parse valid source: %s", err)
|
||||||
|
}
|
||||||
|
for i, method := range []string{"GET", "HEAD"} {
|
||||||
|
if targets[i].Method != method ||
|
||||||
|
targets[i].URL.String() != "http://lolcathost:9999/" {
|
||||||
|
t.Fatalf("Request was parsed incorrectly. Got: %s %s",
|
||||||
|
targets[i].Method, targets[i].URL.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewTargets(t *testing.T) {
|
func TestNewTargets(t *testing.T) {
|
||||||
text := "GET http://lolcathost:9999/\n\nHEAD http://lolcathost:9999/\n"
|
lines := []string{"GET http://lolcathost:9999/", "HEAD http://lolcathost:9999/"}
|
||||||
targets, err := NewTargets(bytes.NewBufferString(text))
|
targets, err := NewTargets(lines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Couldn't parse valid source: %s", err)
|
t.Fatalf("Couldn't parse valid source: %s", err)
|
||||||
}
|
}
|
||||||
28
main.go
28
main.go
@@ -2,9 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
"flag"
|
||||||
|
vegeta "github.com/tsenart/vegeta/lib"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
@@ -34,7 +34,7 @@ func main() {
|
|||||||
log.Fatal("rate can't be zero")
|
log.Fatal("rate can't be zero")
|
||||||
}
|
}
|
||||||
|
|
||||||
targets, err := NewTargetsFromFile(*targetsf)
|
targets, err := vegeta.NewTargetsFromFile(*targetsf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -52,13 +52,13 @@ func main() {
|
|||||||
log.Fatal("Duration provided is invalid")
|
log.Fatal("Duration provided is invalid")
|
||||||
}
|
}
|
||||||
|
|
||||||
var rep Reporter
|
var rep vegeta.Reporter
|
||||||
switch *reporter {
|
switch *reporter {
|
||||||
case "text":
|
case "text":
|
||||||
rep = NewTextReporter()
|
rep = vegeta.NewTextReporter()
|
||||||
default:
|
default:
|
||||||
log.Println("reporter provided is not supported. using text")
|
log.Println("reporter provided is not supported. using text")
|
||||||
rep = NewTextReporter()
|
rep = vegeta.NewTextReporter()
|
||||||
}
|
}
|
||||||
|
|
||||||
var out io.Writer
|
var out io.Writer
|
||||||
@@ -75,7 +75,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Vegeta is attacking %d targets in %s order for %s...\n", len(targets), *ordering, *duration)
|
log.Printf("Vegeta is attacking %d targets in %s order for %s...\n", len(targets), *ordering, *duration)
|
||||||
attack(targets, *ordering, *rate, *duration, rep)
|
vegeta.Attack(targets, *rate, *duration, rep)
|
||||||
log.Println("Done!")
|
log.Println("Done!")
|
||||||
|
|
||||||
log.Printf("Writing report to '%s'...", *output)
|
log.Printf("Writing report to '%s'...", *output)
|
||||||
@@ -83,19 +83,3 @@ func main() {
|
|||||||
log.Println("Failed to report!")
|
log.Println("Failed to report!")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func attack(targets Targets, ordering string, rate uint64, duration time.Duration, rep Reporter) {
|
|
||||||
hits := make(chan *http.Request, rate*uint64((duration).Seconds()))
|
|
||||||
defer close(hits)
|
|
||||||
responses := make(chan *Response, cap(hits))
|
|
||||||
defer close(responses)
|
|
||||||
client := Client{}
|
|
||||||
go client.Drill(rate, hits, responses) // Attack!
|
|
||||||
for i := 0; i < cap(hits); i++ {
|
|
||||||
hits <- targets[i%len(targets)]
|
|
||||||
}
|
|
||||||
// Wait for all requests to finish
|
|
||||||
for i := 0; i < cap(responses); i++ {
|
|
||||||
rep.Add(<-responses)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user