├── .gitignore ├── Gomfile ├── link_project_in_workspace.sh ├── files ├── file.go ├── files_suite_test.go ├── reading_writing.go ├── endless_file.go ├── stream.go ├── file_system.go ├── result_writer.go ├── endless_file_test.go ├── result_writer_test.go ├── stream_test.go ├── reading_writing_test.go └── fake_file_system_test.go ├── signals ├── waiting.go ├── signals_suite_test.go └── waiting_test.go ├── config ├── config_suite_test.go ├── options.go ├── usage_help.go ├── parser.go └── parser_test.go ├── request ├── request_suite_test.go ├── get.go └── get_test.go ├── CHANGELOG.md ├── goony.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS* 2 | _vendor 3 | goony 4 | -------------------------------------------------------------------------------- /Gomfile: -------------------------------------------------------------------------------- 1 | group :test do 2 | gom 'github.com/onsi/ginkgo/ginkgo' 3 | gom 'github.com/onsi/gomega' 4 | end 5 | -------------------------------------------------------------------------------- /link_project_in_workspace.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ln -s ${PWD} $GOPATH/src/github.com/christophgockel/goony 4 | -------------------------------------------------------------------------------- /files/file.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "io" 4 | 5 | type File interface { 6 | io.Reader 7 | io.Writer 8 | io.Seeker 9 | io.Closer 10 | } 11 | -------------------------------------------------------------------------------- /signals/waiting.go: -------------------------------------------------------------------------------- 1 | package signals 2 | 3 | import "os" 4 | 5 | func WaitForSignal(signals chan os.Signal, trues chan bool) { 6 | <-signals 7 | trues <- true 8 | } 9 | -------------------------------------------------------------------------------- /config/config_suite_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestFiles(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Config Suite") 13 | } 14 | -------------------------------------------------------------------------------- /files/files_suite_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | 7 | "testing" 8 | ) 9 | 10 | func TestFiles(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Files Suite") 13 | } 14 | -------------------------------------------------------------------------------- /request/request_suite_test.go: -------------------------------------------------------------------------------- 1 | package request_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | "testing" 7 | ) 8 | 9 | func TestFiles(t *testing.T) { 10 | RegisterFailHandler(Fail) 11 | RunSpecs(t, "Request Suite") 12 | } 13 | -------------------------------------------------------------------------------- /signals/signals_suite_test.go: -------------------------------------------------------------------------------- 1 | package signals_test 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | "testing" 7 | ) 8 | 9 | func TestFiles(t *testing.T) { 10 | RegisterFailHandler(Fail) 11 | RunSpecs(t, "Signals Suite") 12 | } 13 | -------------------------------------------------------------------------------- /files/reading_writing.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "os" 4 | 5 | func OpenForReading(name string) (File, error) { 6 | return Filesystem.Open(name) 7 | } 8 | 9 | func OpenForWriting(name string) (File, error) { 10 | if name == "" { 11 | return os.Stdout, nil 12 | } 13 | 14 | return Filesystem.Create(name) 15 | } 16 | -------------------------------------------------------------------------------- /files/endless_file.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "io" 4 | 5 | type EndlessFile struct { 6 | File 7 | } 8 | 9 | func (file EndlessFile) Read(b []byte) (int, error) { 10 | data, err := file.File.Read(b) 11 | 12 | if err == io.EOF { 13 | file.File.Seek(0, 0) 14 | data, err = file.File.Read(b) 15 | } 16 | 17 | return data, err 18 | } 19 | -------------------------------------------------------------------------------- /files/stream.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "bufio" 4 | 5 | func StreamContent(file File, lines chan string, stop chan bool) { 6 | scanner := bufio.NewScanner(file) 7 | 8 | for { 9 | select { 10 | case <-stop: 11 | close(lines) 12 | return 13 | default: 14 | if scanner.Scan() { 15 | lines <- scanner.Text() 16 | } else { 17 | stop <- true 18 | } 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /files/file_system.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import "os" 4 | 5 | var Filesystem TheFileSystem = realFilesystem{} 6 | 7 | type TheFileSystem interface { 8 | Create(name string) (File, error) 9 | Open(name string) (File, error) 10 | } 11 | 12 | type realFilesystem struct{} 13 | 14 | func (realFilesystem) Open(name string) (File, error) { 15 | return os.Open(name) 16 | } 17 | 18 | func (realFilesystem) Create(name string) (File, error) { 19 | return os.Create(name) 20 | } 21 | -------------------------------------------------------------------------------- /config/options.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const DEFAULT_NUMBER_OF_ROUTINES = 10 4 | const DEFAULT_HOST = "http://localhost" 5 | 6 | type Options struct { 7 | UsageHelp bool 8 | 9 | NumberOfRoutines int 10 | Host string 11 | File string 12 | OutputFilename string 13 | RunEndless bool 14 | } 15 | 16 | func newDefaultOptions() Options { 17 | return Options{ 18 | NumberOfRoutines: DEFAULT_NUMBER_OF_ROUTINES, 19 | Host: DEFAULT_HOST, 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /signals/waiting_test.go: -------------------------------------------------------------------------------- 1 | package signals_test 2 | 3 | import ( 4 | "github.com/christophgockel/goony/signals" 5 | . "github.com/onsi/ginkgo" 6 | . "github.com/onsi/gomega" 7 | "os" 8 | ) 9 | 10 | var _ = Describe("Waiting for a signal", func() { 11 | It("emits a true value when a signal is read", func() { 12 | signalChannel := make(chan os.Signal) 13 | outputChannel := make(chan bool) 14 | 15 | go signals.WaitForSignal(signalChannel, outputChannel) 16 | signalChannel <- os.Interrupt 17 | 18 | Expect(<-outputChannel).To(BeTrue()) 19 | }) 20 | }) 21 | -------------------------------------------------------------------------------- /files/result_writer.go: -------------------------------------------------------------------------------- 1 | package files 2 | 3 | import ( 4 | "fmt" 5 | "github.com/christophgockel/goony/request" 6 | "io" 7 | ) 8 | 9 | func Print(result request.Result, output io.Writer) { 10 | startDate := result.StartTime.Format("2006-01-02") 11 | startTime := result.StartTime.Format("15:04:05.000000000") 12 | endDate := result.EndTime.Format("2006-01-02") 13 | endTime := result.EndTime.Format("15:04:05.000000000") 14 | 15 | line := fmt.Sprintf("%s,%s,%s,%s,%d,%d,%s,%s,%s\n", result.Status, startDate, startTime, result.Url, result.HttpStatus, result.Nanoseconds, endDate, endTime, result.StatusMessage) 16 | 17 | io.WriteString(output, line) 18 | } 19 | -------------------------------------------------------------------------------- /files/endless_file_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "bufio" 5 | "github.com/christophgockel/goony/files" 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("Endless File", func() { 11 | var scanner *bufio.Scanner 12 | 13 | readLine := func() string { 14 | scanner.Scan() 15 | return scanner.Text() 16 | } 17 | 18 | It("continously reads file contents", func() { 19 | file := fakeFile{} 20 | file.AddLine("line 1") 21 | file.AddLine("line 2") 22 | 23 | endlessFile := files.EndlessFile{file} 24 | scanner = bufio.NewScanner(endlessFile) 25 | 26 | Expect(readLine()).To(Equal("line 1")) 27 | Expect(readLine()).To(Equal("line 2")) 28 | Expect(readLine()).To(Equal("line 1")) 29 | Expect(readLine()).To(Equal("line 2")) 30 | }) 31 | }) 32 | -------------------------------------------------------------------------------- /files/result_writer_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "bytes" 5 | "github.com/christophgockel/goony/files" 6 | "github.com/christophgockel/goony/request" 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | "time" 10 | ) 11 | 12 | var _ = Describe("Result writer", func() { 13 | It("prints a Result structure as a line of CSV", func() { 14 | result := request.Result{ 15 | Status: request.SUCCESS, 16 | StartTime: time.Date(2015, 11, 13, 15, 06, 01, 123456789, time.Local), 17 | Url: "http://host/route/endpoint", 18 | HttpStatus: 200, 19 | Nanoseconds: 1522643, 20 | EndTime: time.Date(2015, 11, 13, 15, 06, 02, 123456789, time.Local), 21 | StatusMessage: "", 22 | } 23 | 24 | output := new(bytes.Buffer) 25 | 26 | files.Print(result, output) 27 | 28 | Expect(output.String()).To(Equal("S,2015-11-13,15:06:01.123456789,http://host/route/endpoint,200,1522643,2015-11-13,15:06:02.123456789,\n")) 29 | }) 30 | }) 31 | -------------------------------------------------------------------------------- /config/usage_help.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "fmt" 4 | 5 | func UsageHelp() string { 6 | return fmt.Sprintf("Usage: goony [OPTIONS] ROUTES-FILE\n"+ 7 | "\n"+ 8 | "Examples:\n"+ 9 | " goony routes.txt\n"+ 10 | " goony --threads 100 routes.txt\n"+ 11 | " goony --host http://localhost:8080 routes.txt\n"+ 12 | " goony --endless --host http://example.org routes.txt\n"+ 13 | "\n"+ 14 | "Configuration Options:\n"+ 15 | " -h, --host specify the target host (and optional port)\n"+ 16 | " (default: %s)\n"+ 17 | " -t, --threads define number of parallel threads\n"+ 18 | " (default: %d)\n"+ 19 | " -o, --out FILE specify target FILE to write results to\n"+ 20 | " -e, --endless continuously repeat content of FILE\n"+ 21 | " (needs to be stopped with Ctrl+C)\n"+ 22 | " --help show this usage text", DEFAULT_HOST, DEFAULT_NUMBER_OF_ROUTINES) 23 | } 24 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## [Unreleased] 6 | 7 | 8 | ## [1.2.0] - 2016-01-22 9 | 10 | ### Added 11 | - Content of route files can be repeated endlessly via the `--endless` flag. 12 | - Added usage help for the executable via `--help`. 13 | 14 | ## [1.1.0] - 2015-12-18 15 | 16 | ### Added 17 | - CSV output can be written to a file directly instead of stdout. 18 | 19 | ### Fixed 20 | - Trying to read an unknown or inaccessible file yields an error message. 21 | - Main executable returns proper error codes on error conditions (e.g. unknown file). 22 | 23 | 24 | ## [1.0.0] - 2015-11-25 25 | 26 | ### Changed 27 | - Command line options are flags now instead of positional arguments. 28 | 29 | 30 | ## 0.0.1 - 2015-11-16 31 | 32 | ### Added 33 | - Initial release. 34 | - Reading a text file with routes. 35 | - Every line of the file will be a request executed in a separate goroutine. 36 | - Results are consolidated as CSV content printed to stdout. 37 | - The target host and number of separate threads are configurable. 38 | 39 | 40 | [Unreleased]: https://github.com/christophgockel/goony/compare/1.2.0...HEAD 41 | [1.2.0]: https://github.com/christophgockel/goony/compare/1.1.0...1.2.0 42 | [1.1.0]: https://github.com/christophgockel/goony/compare/1.0.0...1.1.0 43 | [1.0.0]: https://github.com/christophgockel/goony/compare/0.0.1...1.0.0 44 | 45 | -------------------------------------------------------------------------------- /files/stream_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "github.com/christophgockel/goony/files" 5 | . "github.com/onsi/ginkgo" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("StreamContent", func() { 10 | var file fakeFile 11 | var lineChannel chan string 12 | var stopChannel chan bool 13 | 14 | BeforeEach(func() { 15 | lineChannel = make(chan string) 16 | stopChannel = make(chan bool, 1) 17 | 18 | file = fakeFile{} 19 | }) 20 | 21 | It("reads lines of a files into a channel", func() { 22 | file.AddLine("first line") 23 | file.AddLine("second line") 24 | file.AddLine("third line") 25 | 26 | go files.StreamContent(file, lineChannel, stopChannel) 27 | 28 | Expect(<-lineChannel).To(Equal("first line")) 29 | Expect(<-lineChannel).To(Equal("second line")) 30 | Expect(<-lineChannel).To(Equal("third line")) 31 | }) 32 | 33 | It("closes the channel when done reading", func() { 34 | file.AddLine("the only line") 35 | 36 | go files.StreamContent(file, lineChannel, stopChannel) 37 | 38 | DrainRemainingMessages(lineChannel) 39 | ExpectToBeClosed(lineChannel) 40 | }) 41 | 42 | It("reads file contents until told to stop", func() { 43 | file.AddLine("first line") 44 | file.AddLine("second line") 45 | 46 | go files.StreamContent(file, lineChannel, stopChannel) 47 | stopChannel <- true 48 | 49 | DrainRemainingMessages(lineChannel) 50 | ExpectToBeClosed(lineChannel) 51 | }) 52 | }) 53 | 54 | func DrainRemainingMessages(channel chan string) { 55 | <-channel 56 | } 57 | 58 | func ExpectToBeClosed(channel chan string) { 59 | _, valueCouldBeRead := <-channel 60 | 61 | Expect(valueCouldBeRead).To(BeFalse()) 62 | } 63 | -------------------------------------------------------------------------------- /request/get.go: -------------------------------------------------------------------------------- 1 | package request 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | type Status int 9 | 10 | const ( 11 | SUCCESS Status = 0 + iota 12 | FAILURE Status = 0 + iota 13 | ) 14 | 15 | type Result struct { 16 | Status Status 17 | StartTime time.Time 18 | Url string 19 | HttpStatus int 20 | Nanoseconds int64 21 | EndTime time.Time 22 | StatusMessage string 23 | } 24 | 25 | func Get(path string, host string, client *http.Client) Result { 26 | url := host + path 27 | 28 | start := time.Now() 29 | response, err := client.Get(url) 30 | end := time.Now() 31 | requestDuration := end.Sub(start).Nanoseconds() 32 | 33 | if err != nil { 34 | return newFailureResult(err, start, end, url, requestDuration) 35 | } 36 | defer response.Body.Close() 37 | 38 | return newSuccessResult(start, end, url, response.StatusCode, requestDuration) 39 | } 40 | 41 | func newFailureResult(err error, startTime time.Time, endTime time.Time, url string, nanoseconds int64) Result { 42 | return Result{ 43 | Status: FAILURE, 44 | StartTime: startTime, 45 | Url: url, 46 | StatusMessage: err.Error(), 47 | Nanoseconds: nanoseconds, 48 | EndTime: endTime, 49 | } 50 | } 51 | 52 | func newSuccessResult(startTime time.Time, endTime time.Time, url string, httpStatus int, nanoseconds int64) Result { 53 | return Result{ 54 | Status: SUCCESS, 55 | StartTime: startTime, 56 | Url: url, 57 | HttpStatus: httpStatus, 58 | Nanoseconds: nanoseconds, 59 | EndTime: endTime, 60 | } 61 | } 62 | 63 | var statusStrings = [...]string{"S", "F"} 64 | 65 | func (status Status) String() string { 66 | return statusStrings[status] 67 | } 68 | -------------------------------------------------------------------------------- /files/reading_writing_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "github.com/christophgockel/goony/files" 5 | . "github.com/onsi/ginkgo" 6 | . "github.com/onsi/gomega" 7 | "os" 8 | ) 9 | 10 | var _ = Describe("Files - Reading and Writing", func() { 11 | Context("OpenForReading()", func() { 12 | It("returns a readable file", func() { 13 | prepareFilesystemWithAccessibleFile() 14 | 15 | file, err := files.OpenForReading("existing-file") 16 | 17 | Expect(file).To(Not(BeNil())) 18 | Expect(err).To(Not(HaveOccurred())) 19 | Expect(fileIsReadable()).To(Equal(true)) 20 | }) 21 | 22 | It("returns an error if the file does not exist", func() { 23 | prepareFilesystemWithUnexistingFile() 24 | 25 | _, err := files.OpenForReading("unknown-file") 26 | 27 | Expect(err).To(HaveOccurred()) 28 | Expect(err.Error()).To(ContainSubstring("does not exist")) 29 | }) 30 | 31 | It("returns an error if the file is not accessible", func() { 32 | prepareFilesystemWithUnaccessibleFile() 33 | 34 | _, err := files.OpenForReading("unaccessible-file") 35 | 36 | Expect(err).To(HaveOccurred()) 37 | Expect(err.Error()).To(ContainSubstring("permission denied")) 38 | }) 39 | }) 40 | 41 | Context("OpenForWriting()", func() { 42 | It("returns a writable file", func() { 43 | prepareFilesystemWithAccessibleFile() 44 | 45 | file, err := files.OpenForWriting("the-file") 46 | 47 | Expect(file).To(Not(BeNil())) 48 | Expect(err).To(Not(HaveOccurred())) 49 | Expect(fileIsWritable()).To(Equal(true)) 50 | }) 51 | 52 | It("returns stdout if no filename is given", func() { 53 | file, _ := files.OpenForWriting("") 54 | 55 | Expect(file).To(Equal(os.Stdout)) 56 | }) 57 | 58 | It("returns an error if the file is not writable", func() { 59 | prepareFilesystemWithUnaccessibleFile() 60 | 61 | _, err := files.OpenForWriting("unaccessible-file") 62 | 63 | Expect(err).To(HaveOccurred()) 64 | }) 65 | }) 66 | }) 67 | -------------------------------------------------------------------------------- /files/fake_file_system_test.go: -------------------------------------------------------------------------------- 1 | package files_test 2 | 3 | import ( 4 | "bytes" 5 | "github.com/christophgockel/goony/files" 6 | "os" 7 | ) 8 | 9 | var fakeFilesystem aFakeFileSystem = aFakeFileSystem{} 10 | 11 | type aFakeFileSystem struct { 12 | files.TheFileSystem 13 | 14 | fileExists bool 15 | fileHasCorrectPermissions bool 16 | 17 | openHasBeenCalled bool 18 | createHasBeenCalled bool 19 | } 20 | 21 | func (fs *aFakeFileSystem) Open(name string) (files.File, error) { 22 | fs.openHasBeenCalled = true 23 | 24 | return fs.fakeFileBehavior(name) 25 | } 26 | 27 | func (fs *aFakeFileSystem) Create(name string) (files.File, error) { 28 | fs.createHasBeenCalled = true 29 | 30 | return fs.fakeFileBehavior(name) 31 | } 32 | 33 | func (fs aFakeFileSystem) fakeFileBehavior(name string) (files.File, error) { 34 | if fs.fileExists == false { 35 | return nil, os.ErrNotExist 36 | } 37 | 38 | if fs.fileHasCorrectPermissions == false { 39 | return nil, os.ErrPermission 40 | } 41 | 42 | return fakeFile{}, nil 43 | } 44 | 45 | func prepareFilesystemWithAccessibleFile() { 46 | files.Filesystem = &fakeFilesystem 47 | 48 | fakeFilesystem.fileExists = true 49 | fakeFilesystem.fileHasCorrectPermissions = true 50 | } 51 | 52 | func prepareFilesystemWithUnexistingFile() { 53 | files.Filesystem = &fakeFilesystem 54 | 55 | fakeFilesystem.fileExists = false 56 | fakeFilesystem.fileHasCorrectPermissions = true 57 | } 58 | 59 | func prepareFilesystemWithUnaccessibleFile() { 60 | files.Filesystem = &fakeFilesystem 61 | 62 | fakeFilesystem.fileExists = true 63 | fakeFilesystem.fileHasCorrectPermissions = false 64 | } 65 | 66 | func fileIsWritable() bool { 67 | return fakeFilesystem.createHasBeenCalled 68 | } 69 | 70 | func fileIsReadable() bool { 71 | return fakeFilesystem.openHasBeenCalled 72 | } 73 | 74 | type fakeFile struct { 75 | files.File 76 | 77 | lines []string 78 | content *bytes.Buffer 79 | } 80 | 81 | func (file fakeFile) Write(p []byte) (n int, err error) { 82 | return 0, nil 83 | } 84 | 85 | func (file fakeFile) Read(p []byte) (n int, err error) { 86 | return file.content.Read(p) 87 | } 88 | 89 | func (file fakeFile) Seek(offset int64, whence int) (int64, error) { 90 | for _, line := range file.lines { 91 | file.AddLine(line) 92 | } 93 | 94 | return 0, nil 95 | } 96 | 97 | func (file *fakeFile) AddLine(line string) { 98 | if file.content == nil { 99 | file.content = new(bytes.Buffer) 100 | } 101 | file.lines = append(file.lines, line) 102 | file.content.WriteString(line + "\n") 103 | } 104 | -------------------------------------------------------------------------------- /request/get_test.go: -------------------------------------------------------------------------------- 1 | package request_test 2 | 3 | import ( 4 | "github.com/christophgockel/goony/request" 5 | . "github.com/onsi/ginkgo" 6 | . "github.com/onsi/gomega" 7 | "net/http" 8 | "net/http/httptest" 9 | "net/url" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | var server *httptest.Server 15 | var client *http.Client 16 | 17 | var _ = Describe("Request Get", func() { 18 | AfterEach(func() { 19 | server.Close() 20 | }) 21 | 22 | It("returns the status of a request", func() { 23 | startServerWithResponseCode(200) 24 | 25 | result := request.Get("/route", "http://host", client) 26 | 27 | Expect(result.Status).To(Equal(request.SUCCESS)) 28 | }) 29 | 30 | It("tracks result data of a request", func() { 31 | startServerWithResponseCode(200) 32 | 33 | result := request.Get("/route", "http://host", client) 34 | 35 | Expect(result.Status).To(Equal(request.SUCCESS)) 36 | Expect(result.StartTime.Second()).To(Equal(time.Now().Second())) 37 | Expect(result.HttpStatus).To(Equal(200)) 38 | Expect(result.Url).To(Equal("http://host/route")) 39 | Expect(result.Nanoseconds).To(BeNumerically(">", 0)) 40 | Expect(result.EndTime.Second()).To(BeNumerically(">", 0)) 41 | Expect(result.StatusMessage).To(Equal("")) 42 | }) 43 | 44 | It("returns failure results when the request times out", func() { 45 | startTimingOutServer() 46 | 47 | result := request.Get("/route", "http://host", client) 48 | 49 | Expect(result.Status).To(Equal(request.FAILURE)) 50 | Expect(result.StartTime.Second()).To(Equal(time.Now().Second())) 51 | Expect(result.Url).To(Equal("http://host/route")) 52 | Expect(result.Nanoseconds).To(BeNumerically(">", 0)) 53 | Expect(result.EndTime.Second()).To(BeNumerically(">", 0)) 54 | Expect(strings.ToLower(result.StatusMessage)).To(ContainSubstring("timeout")) 55 | }) 56 | 57 | Context("Status Type", func() { 58 | It("has string representations", func() { 59 | Expect(request.SUCCESS.String()).To(Equal("S")) 60 | Expect(request.FAILURE.String()).To(Equal("F")) 61 | }) 62 | }) 63 | }) 64 | 65 | func startServerWithResponseCode(code int) { 66 | startServerWith(func(response http.ResponseWriter, request *http.Request) { 67 | response.WriteHeader(code) 68 | }) 69 | } 70 | 71 | func startTimingOutServer() { 72 | startServerWith(func(response http.ResponseWriter, request *http.Request) { 73 | time.Sleep(5) 74 | }) 75 | 76 | client.Timeout = 1 77 | } 78 | 79 | func startServerWith(handler http.HandlerFunc) { 80 | server = httptest.NewServer(handler) 81 | 82 | transport := &http.Transport{ 83 | Proxy: func(request *http.Request) (*url.URL, error) { 84 | return url.Parse(server.URL) 85 | }, 86 | } 87 | 88 | client = &http.Client{Transport: transport} 89 | } 90 | -------------------------------------------------------------------------------- /goony.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/christophgockel/goony/config" 6 | "github.com/christophgockel/goony/files" 7 | "github.com/christophgockel/goony/request" 8 | "github.com/christophgockel/goony/signals" 9 | "net/http" 10 | "os" 11 | "os/signal" 12 | ) 13 | 14 | func main() { 15 | options := options() 16 | routesFile := routesFile(options.File, options.RunEndless) 17 | outputFile := outputFile(options.OutputFilename) 18 | 19 | defer routesFile.Close() 20 | defer outputFile.Close() 21 | 22 | linesChannel := make(chan string) 23 | done := make(chan bool) 24 | resultsChannel := make(chan request.Result, 10000) 25 | 26 | startContentStream(options, routesFile, linesChannel) 27 | 28 | for i := 0; i < options.NumberOfRoutines; i++ { 29 | go func() { 30 | for line := range linesChannel { 31 | resultsChannel <- request.Get(line, options.Host, http.DefaultClient) 32 | } 33 | 34 | done <- true 35 | }() 36 | } 37 | 38 | go func() { 39 | for result := range resultsChannel { 40 | files.Print(result, outputFile) 41 | } 42 | done <- true 43 | }() 44 | 45 | for i := 0; i < options.NumberOfRoutines; i++ { 46 | <-done 47 | } 48 | 49 | close(resultsChannel) 50 | <-done 51 | } 52 | 53 | func options() config.Options { 54 | options, err := config.Parse(os.Args[1:]...) 55 | 56 | if err != nil { 57 | fmt.Fprintln(os.Stderr, err.Error()) 58 | usageHelp() 59 | os.Exit(1) 60 | } 61 | 62 | if options.UsageHelp { 63 | usageHelp() 64 | os.Exit(99) 65 | } 66 | 67 | return options 68 | } 69 | 70 | func usageHelp() { 71 | fmt.Fprintln(os.Stdout, config.UsageHelp()) 72 | } 73 | 74 | func routesFile(filename string, endless bool) files.File { 75 | file, err := files.OpenForReading(filename) 76 | 77 | if err != nil { 78 | fmt.Fprintln(os.Stderr, err.Error()) 79 | os.Exit(2) 80 | } 81 | 82 | if endless { 83 | return files.EndlessFile{file} 84 | } else { 85 | return file 86 | } 87 | } 88 | 89 | func outputFile(filename string) files.File { 90 | file, err := files.OpenForWriting(filename) 91 | 92 | if err != nil { 93 | fmt.Fprintln(os.Stderr, err.Error()) 94 | os.Exit(3) 95 | } 96 | 97 | return file 98 | } 99 | 100 | func startContentStream(options config.Options, file files.File, linesChannel chan string) { 101 | stopChannel := make(chan bool, 1) 102 | catchCtrlC(stopChannel) 103 | go files.StreamContent(file, linesChannel, stopChannel) 104 | } 105 | 106 | func catchCtrlC(output chan bool) { 107 | signalChannel := make(chan os.Signal) 108 | signal.Notify(signalChannel, os.Interrupt) 109 | 110 | go signals.WaitForSignal(signalChannel, output) 111 | } 112 | -------------------------------------------------------------------------------- /config/parser.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "strconv" 6 | "strings" 7 | ) 8 | 9 | func Parse(arguments ...string) (Options, error) { 10 | var err error 11 | options := newDefaultOptions() 12 | 13 | options, err = parseArguments(options, arguments...) 14 | 15 | if err != nil || options.UsageHelp { 16 | return options, err 17 | } 18 | 19 | if options.File == "" { 20 | return options, errors.New("Filename is missing") 21 | } 22 | 23 | return options, err 24 | } 25 | 26 | func parseArguments(options Options, arguments ...string) (Options, error) { 27 | if len(arguments) == 0 { 28 | return options, nil 29 | } 30 | 31 | nextArgument := arguments[0] 32 | 33 | if isFlag(nextArgument) { 34 | return parseFlag(options, arguments...) 35 | } 36 | 37 | return parseNonFlagArgument(options, arguments...) 38 | } 39 | 40 | func parseFlag(options Options, arguments ...string) (Options, error) { 41 | flag := arguments[0] 42 | 43 | if isThreadsFlag(flag) { 44 | return parseThreadArgument(options, arguments...) 45 | } else if isHostFlag(flag) { 46 | return parseHostnameArgument(options, arguments...) 47 | } else if isOutFlag(flag) { 48 | return parseOutputFileArgument(options, arguments...) 49 | } else if isEndlessFlag(flag) { 50 | return parseEndlessArgument(options, arguments...) 51 | } else if isHelpFlag(flag) { 52 | return parseHelpArgument(options, arguments...) 53 | } 54 | 55 | return options, errors.New("Invalid argument: " + flag) 56 | } 57 | 58 | func parseNonFlagArgument(options Options, arguments ...string) (Options, error) { 59 | if fileArgumentIsAllowed(options) { 60 | options.File = arguments[0] 61 | return parseArguments(options, arguments[1:]...) 62 | } 63 | 64 | return options, errors.New("Too many arguments") 65 | } 66 | 67 | func isFlag(argument string) bool { 68 | return strings.HasPrefix(argument, "-") 69 | } 70 | 71 | func isThreadsFlag(argument string) bool { 72 | return argument == "-t" || argument == "--threads" 73 | } 74 | 75 | func isHostFlag(argument string) bool { 76 | return argument == "-h" || argument == "--host" 77 | } 78 | 79 | func isOutFlag(argument string) bool { 80 | return argument == "-o" || argument == "--out" 81 | } 82 | 83 | func isEndlessFlag(argument string) bool { 84 | return argument == "-e" || argument == "--endless" 85 | } 86 | 87 | func isHelpFlag(argument string) bool { 88 | return argument == "--help" 89 | } 90 | 91 | func fileArgumentIsAllowed(options Options) bool { 92 | return options.File == "" 93 | } 94 | 95 | func parseThreadArgument(options Options, arguments ...string) (Options, error) { 96 | if len(arguments) < 2 { 97 | return options, errors.New("Missing thread count") 98 | } 99 | 100 | number, err := strconv.Atoi(arguments[1]) 101 | 102 | if err != nil { 103 | return options, errors.New("Invalid thread count: " + arguments[1]) 104 | } 105 | 106 | options.NumberOfRoutines = number 107 | 108 | return parseArguments(options, arguments[2:]...) 109 | } 110 | 111 | func parseHostnameArgument(options Options, arguments ...string) (Options, error) { 112 | if len(arguments) < 2 { 113 | return options, errors.New("Missing hostname") 114 | } 115 | 116 | options.Host = hostWithScheme(arguments[1]) 117 | 118 | return parseArguments(options, arguments[2:]...) 119 | } 120 | 121 | func hostWithScheme(host string) string { 122 | if strings.Contains(host, "//") { 123 | return host 124 | } 125 | 126 | return "http://" + host 127 | } 128 | 129 | func parseOutputFileArgument(options Options, arguments ...string) (Options, error) { 130 | if len(arguments) < 2 { 131 | return options, errors.New("Missing output filename") 132 | } 133 | 134 | options.OutputFilename = arguments[1] 135 | 136 | return parseArguments(options, arguments[2:]...) 137 | } 138 | 139 | func parseEndlessArgument(options Options, arguments ...string) (Options, error) { 140 | options.RunEndless = true 141 | 142 | return parseArguments(options, arguments[1:]...) 143 | } 144 | 145 | func parseHelpArgument(options Options, arguments ...string) (Options, error) { 146 | resettedOptions := newDefaultOptions() 147 | resettedOptions.UsageHelp = true 148 | 149 | return parseArguments(resettedOptions, arguments[0:0]...) 150 | } 151 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Goony 2 | 3 | – Simple Load Testing 4 | 5 | 6 | ## Premise 7 | 8 | Load testing a server should be a straightforward process. 9 | Goony uses a text-file that contains all routes/endpoints you want to request on a given host. 10 | 11 | 12 | ## Install 13 | 14 | The easiest way at the moment: `go get github.com/christophgockel/goony` 15 | 16 | 17 | ## Usage 18 | 19 | > `goony [-t|--threads n] [-h|--host http://target-host] [-o|--out filename] [-e|--endless] routes-file` 20 | 21 | Goony has to be called with at least the route file as its argument. 22 | Additionally to that, there are flags to configure the number of threads (goroutines), or specify the target host, etc. 23 | 24 | - `goony routes.txt` 25 | - Uses `routes.txt` to be used as the routes to be requested. 26 | - Uses the default host `http://localhost` and number of threads (10). 27 | - `goony --host http://hostname.dev:8080 routes.txt` 28 | - Uses `routes.txt` to be used as the routes to be requested. 29 | - Uses the host `http://hostname.dev:8080` and the default number of threads (10). 30 | - `goony -t 100 -h http://hostname.dev:8080 routes.txt` 31 | - Uses `routes.txt` to be used as the routes to be requested. 32 | - Uses the host `http://hostname.dev:8080`. 33 | - Uses 100 threads to execute the requests. 34 | - If the file has less routes in total, excessive threads will do no work. 35 | - `goony --out report.csv routes.txt` 36 | - Uses `routes.txt` to be used as the routes to be requested. 37 | - Writes CSV output to file `report.csv` 38 | - `goony --endless routes.txt` 39 | - Re-runs all routes from `routes.txt` continuously until aborted with Ctrl+C. 40 | - `goony --help` 41 | - Prints a usage help with examples. 42 | 43 | ### File Format 44 | 45 | The expected format of the log file to be used is as follows: 46 | 47 | ``` 48 | / 49 | /an/endpoint 50 | /another/endpoint 51 | /yet/another/endpoint?with=query&strings=possible 52 | ``` 53 | 54 | The routes in this file will be appended to the hostname specified as a command-line argument. 55 | 56 | 57 | ### Output Format 58 | 59 | Goony's output is a list of comma separated values. 60 | The idea is that it can be piped to another process for further processing. 61 | 62 | ``` 63 | ,,,,,,,, 64 | ``` 65 | 66 | A successful request looks like this: 67 | 68 | ``` 69 | S,2015-11-17,09:10:42.655774898,http://localhost:8080/some/endpoint,200,2353789705,2015-11-17,11:10:45.009564603, 70 | ``` 71 | 72 | In case of a connection error, a line looks like this: 73 | 74 | ``` 75 | F,2015-11-17,09:00:34.782749629,http://localhost:8081/some/endpoint,0,4845,2015-11-17,09:00:34.782754474,Get http://http:localhost:8081/some/endpoint: dial tcp: dial tcp [::1]:8081: getsockopt: connection refused 76 | ``` 77 | 78 | 79 | ## Contribute 80 | 81 | I prefer having my development projects outside of my Go workspace. 82 | The file `link_project_in_workspace.sh` has been added for that. 83 | It will create a symlink in your `$GOPATH` to the directory you cloned this repository into. 84 | This way all import statements will work as expected. 85 | 86 | ### Running the Tests 87 | 88 | Goony uses [gom](https://github.com/mattn/gom) for its dependencies. 89 | Make sure to run `gom -test install` before running the tests. 90 | 91 | Execute `gom exec ginkgo -r` to run the test suite. 92 | 93 | 94 | ## Goony? 95 | 96 | The name “goony” was chosen because at the time of writing it, it felt like a relatively _goony_ problem we tried to solve. 97 | 98 | All we wanted was to replay a given access-log file from a webserver. 99 | 100 | JMeter and httperf seemed to be the tool of choice but weren't capable of just replaying it with a certain amount of threads. 101 | We ran into the issue that while you can specify the number of threads in JMeter, it will actually only replay this amount of requests. 102 | So when we said _run the log file with 100 threads_ JMeter ran only the first 100 lines of the log file. 103 | Adding another iteration in JMeter just replayed the same first 100 lines again. 104 | 105 | I'm open to suggestions, and in case anyone knows how to replay a given access-log file with ~450k lines in JMeter, please [open an issue](https://github.com/christophgockel/goony/issues) or get in touch with me. Thank you! 106 | -------------------------------------------------------------------------------- /config/parser_test.go: -------------------------------------------------------------------------------- 1 | package config_test 2 | 3 | import ( 4 | "github.com/christophgockel/goony/config" 5 | . "github.com/onsi/ginkgo" 6 | . "github.com/onsi/gomega" 7 | ) 8 | 9 | var _ = Describe("Config - Parser", func() { 10 | It("returns an error if no arguments are given", func() { 11 | _, err := config.Parse() 12 | 13 | Expect(err).To(HaveOccurred()) 14 | Expect(err.Error()).To(Equal("Filename is missing")) 15 | }) 16 | 17 | It("returns an error for mistyped flag", func() { 18 | _, err := config.Parse("-t800") 19 | 20 | Expect(err).To(HaveOccurred()) 21 | Expect(err.Error()).To(Equal("Invalid argument: -t800")) 22 | }) 23 | 24 | Context("filename argument", func() { 25 | It("returns an error if no file has been specified", func() { 26 | _, err := config.Parse("-h", "host.name") 27 | 28 | Expect(err).To(HaveOccurred()) 29 | Expect(err.Error()).To(Equal("Filename is missing")) 30 | }) 31 | 32 | It("parses the filename", func() { 33 | options, _ := config.Parse("filename") 34 | 35 | Expect(options.File).To(Equal("filename")) 36 | }) 37 | }) 38 | 39 | Context("--threads flag", func() { 40 | It("parses threads (short flag)", func() { 41 | options, _ := config.Parse("-t", "1000", "filename") 42 | 43 | Expect(options.NumberOfRoutines).To(Equal(1000)) 44 | }) 45 | 46 | It("parses threads (long flag)", func() { 47 | options, _ := config.Parse("--threads", "1000", "filename") 48 | 49 | Expect(options.NumberOfRoutines).To(Equal(1000)) 50 | }) 51 | 52 | It("returns an error for invalid thread count", func() { 53 | _, err := config.Parse("-t", "one", "filename") 54 | 55 | Expect(err).To(HaveOccurred()) 56 | Expect(err.Error()).To(Equal("Invalid thread count: one")) 57 | }) 58 | }) 59 | 60 | Context("--host flag", func() { 61 | It("returns an error if filename can't be distinguished", func() { 62 | _, err := config.Parse("1000", "filename") 63 | 64 | Expect(err).To(HaveOccurred()) 65 | Expect(err.Error()).To(Equal("Too many arguments")) 66 | }) 67 | 68 | It("parses the host (short flag)", func() { 69 | options, _ := config.Parse("-h", "http://hostname", "filename") 70 | 71 | Expect(options.Host).To(Equal("http://hostname")) 72 | }) 73 | 74 | It("parses the host (long flag)", func() { 75 | options, _ := config.Parse("--host", "http://hostname", "filename") 76 | 77 | Expect(options.Host).To(Equal("http://hostname")) 78 | }) 79 | 80 | It("adds HTTP as the default scheme, if not given", func() { 81 | options, _ := config.Parse("--host", "hostname", "filename") 82 | 83 | Expect(options.Host).To(Equal("http://hostname")) 84 | }) 85 | 86 | It("returns an error for missing hostname", func() { 87 | _, err := config.Parse("-h") 88 | 89 | Expect(err).To(HaveOccurred()) 90 | Expect(err.Error()).To(Equal("Missing hostname")) 91 | }) 92 | }) 93 | 94 | Context("--out flag", func() { 95 | It("parses the filename (short flag)", func() { 96 | options, _ := config.Parse("-o", "output-filename") 97 | 98 | Expect(options.OutputFilename).To(Equal("output-filename")) 99 | }) 100 | 101 | It("parses the filename (long flag)", func() { 102 | options, _ := config.Parse("--out", "output-filename") 103 | 104 | Expect(options.OutputFilename).To(Equal("output-filename")) 105 | }) 106 | 107 | It("returns an error for missing filename", func() { 108 | _, err := config.Parse("--out") 109 | 110 | Expect(err).To(HaveOccurred()) 111 | Expect(err.Error()).To(Equal("Missing output filename")) 112 | }) 113 | }) 114 | 115 | Context("--endless flag", func() { 116 | It("parses the short flag", func() { 117 | options, _ := config.Parse("-e") 118 | 119 | Expect(options.RunEndless).To(BeTrue()) 120 | }) 121 | 122 | It("parses the long flag", func() { 123 | options, _ := config.Parse("--endless") 124 | 125 | Expect(options.RunEndless).To(BeTrue()) 126 | }) 127 | }) 128 | 129 | Context("--help flag", func() { 130 | It("parses the long flag", func() { 131 | options, _ := config.Parse("--help") 132 | 133 | Expect(options.UsageHelp).To(BeTrue()) 134 | }) 135 | 136 | It("ignores any other flags", func() { 137 | options, err := config.Parse("--endless", "--help", "--threads", "42") 138 | 139 | Expect(err).ToNot(HaveOccurred()) 140 | Expect(options.UsageHelp).To(BeTrue()) 141 | Expect(options.RunEndless).To(BeFalse()) 142 | Expect(options.NumberOfRoutines).ToNot(Equal(42)) 143 | }) 144 | }) 145 | 146 | Context("all arguments", func() { 147 | It("parses all options", func() { 148 | options, err := config.Parse("-t", "42", "-h", "http://hostname", "-o", "output", "filename", "-e") 149 | 150 | Expect(err).ToNot(HaveOccurred()) 151 | Expect(options.Host).To(Equal("http://hostname")) 152 | Expect(options.NumberOfRoutines).To(Equal(42)) 153 | Expect(options.File).To(Equal("filename")) 154 | Expect(options.OutputFilename).To(Equal("output")) 155 | Expect(options.RunEndless).To(BeTrue()) 156 | }) 157 | 158 | It("doesn't care about the order of the filename and flags", func() { 159 | options, err := config.Parse("-e", "filename", "-t", "42", "-h", "http://hostname", "-o", "output") 160 | 161 | Expect(err).ToNot(HaveOccurred()) 162 | Expect(options.Host).To(Equal("http://hostname")) 163 | Expect(options.NumberOfRoutines).To(Equal(42)) 164 | Expect(options.File).To(Equal("filename")) 165 | Expect(options.OutputFilename).To(Equal("output")) 166 | Expect(options.RunEndless).To(BeTrue()) 167 | }) 168 | }) 169 | }) 170 | --------------------------------------------------------------------------------