Skip to content

Objective 2: Propose a better solution #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 31 additions & 49 deletions main.go
Original file line number Diff line number Diff line change
@@ -1,29 +1,41 @@
package main

import (
"bufio"
"flag"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
)

const (
minWorkers = 1
maxWorkers = 100
defaultWorkers = 10

defaultTimeout = 30 * time.Second
)

type Result struct {
Url string
Status int
Err error
Latency time.Duration
}

// main is the entry point to the program.
func main() {
if len(os.Args) < 2 {
// Command line flags declaration and parsing.
numWorkers := flag.Int("workers", defaultWorkers, fmt.Sprintf("number of workers (min: %d, max: %d)", minWorkers, maxWorkers))

flag.Parse()

if flag.NArg() < 1 {
fmt.Fprintln(os.Stderr, "missing file argument")
os.Exit(1)
}

path := os.Args[1]
// Opening file argument and checking for errors.
path := flag.Arg(0)
fmt.Printf("Opening %s\n", path)

f, err := os.Open(path)
Expand All @@ -33,51 +45,21 @@ func main() {
}
defer f.Close()

services := GetServices(f)
results := HealthCheck(services)
for _, res := range results {
if res.Err != nil {
fmt.Printf("Url: %s; Error: %s\n", res.Url, res.Err)
continue
}
fmt.Printf("Url: %s; Status: %d; Latency: %s\n", res.Url, res.Status, res.Latency.Round(time.Millisecond))
}
}

// HealthCheck report if a list of web service is up and running.
func HealthCheck(urls []string) []Result {
results := make([]Result, 0, len(urls))

var wg sync.WaitGroup
wg.Add(len(urls))
for _, url := range urls {
go func() {
defer wg.Done()
var result Result
start := time.Now()
resp, err := http.Get(url)
if err != nil {
result.Err = err
} else {
result.Status = resp.StatusCode
result.Url = url
result.Latency = time.Since(start)
}
results = append(results, result)
}()
// Create and run new healthcheck worker pool with specific options.
wp, err := NewWorkerPool(WithNumWorkers(*numWorkers))
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

wg.Wait()
return results
}
results := wp.HealthCheck(f)

// GetServices read each line of the input reader and return a list of url.
func GetServices(r io.Reader) []string {
urls := make([]string, 0)
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
urls = append(urls, scanner.Text())
// Range over the results channel to process them as soon as they come in.
for result := range results {
if result.Err != nil {
fmt.Printf("Url: %s; Error: %s\n", result.Url, result.Err)
} else {
fmt.Printf("Url: %s; Status: %d; Latency: %s\n", result.Url, result.Status, result.Latency.Round(time.Millisecond))
}
}
return urls
}
83 changes: 61 additions & 22 deletions main_test.go
Original file line number Diff line number Diff line change
@@ -1,35 +1,74 @@
package main

import (
"golang.org/x/exp/slices"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
"time"

var services = `https://stackoverflow.com
https://www.google.com
https://go.dev
https://www.docker.com
https://kubernetes.io
https://www.finconsgroup.com
`
"golang.org/x/exp/slices"
)

func TestHealthCheck(t *testing.T) {
panic("TODO implements me")
}
var urls []string
statusCodes := []int{http.StatusOK, http.StatusPermanentRedirect, http.StatusNotFound, http.StatusInternalServerError}

// Create a mock server for each status code
for _, statusCode := range statusCodes {
status := statusCode
latency := time.Duration(status) * time.Millisecond

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
time.Sleep(latency)
w.WriteHeader(status)
}))
t.Cleanup(server.Close)

func TestGetServices(t *testing.T) {
want := []string{
"https://stackoverflow.com",
"https://www.google.com",
"https://go.dev",
"https://www.docker.com",
"https://kubernetes.io",
"https://www.finconsgroup.com",
// Add each mock server URL to the list of URLs.
urls = append(urls, server.URL)
}

got := GetServices(strings.NewReader(services))
if slices.Compare(want, got) != 0 {
t.Errorf("want: %v; got: %v", want, got)
// Add an invalid URL test case.
invalidUrl := "http:/invalid-url"
urls = append(urls, invalidUrl)

wp, err := NewWorkerPool(WithNumWorkers(3))
if err != nil {
t.Errorf("failed to create worker pool: %v", err)
}

resultsCh := wp.HealthCheck(strings.NewReader(strings.Join(urls, "\n")))

// Get all the results in a slice.
var results []Result
for res := range resultsCh {
results = append(results, res)
}

if len(results) != len(urls) {
t.Errorf("want: %d results, got %d results", len(urls), len(results))
}
for _, result := range results {
i := slices.Index(urls, result.Url)
if i == -1 {
// Check the invalid URL
if result.Err == nil {
t.Errorf("[URL index %d] want: non-nil error, got: error <nil>", len(urls)-1)
}
} else {
// Check the other valid URLs
expectedStatus := statusCodes[i]
expectedLatency := time.Duration(expectedStatus) * time.Millisecond
if result.Err != nil {
t.Errorf("[URL index %d][err] want: <nil>, got: %v", i, result.Err)
}
if result.Status != expectedStatus {
t.Errorf("[URL index %d][status] want: %v, got: %v", i, expectedStatus, result.Status)
}
if result.Latency < expectedLatency {
t.Errorf("[URL index %d][latency] want: >= %v, got: %v", i, expectedLatency, result.Latency)
}
}
}
}
31 changes: 31 additions & 0 deletions options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package main

import (
"fmt"
"net/http"
)

// WorkerPoolOptionFunc can be used to customize a new WorkerPool.
type WorkerPoolOptionFunc func(*WorkerPool) error

// WithNumWorkers can be used to configure the number of workers.
func WithNumWorkers(numWorkers int) WorkerPoolOptionFunc {
return func(wp *WorkerPool) error {
if numWorkers < minWorkers {
return fmt.Errorf("numWorkers must be at least %d", minWorkers)
}
if numWorkers > maxWorkers {
return fmt.Errorf("numWorkers must be at most %d", maxWorkers)
}
wp.numWorkers = numWorkers
return nil
}
}

// WithHTTPClient can be used to configure a custom HTTP client.
func WithHTTPClient(httpClient *http.Client) WorkerPoolOptionFunc {
return func(wp *WorkerPool) error {
wp.client = httpClient
return nil
}
}
101 changes: 101 additions & 0 deletions worker_pool.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package main

import (
"bufio"
"io"
"net/http"
"sync"
"time"
)

type WorkerPool struct {
numWorkers int
urls chan string
results chan Result
wg *sync.WaitGroup

client *http.Client
}

// NewWorkerPool creates a new WorkerPool with the given options.
func NewWorkerPool(options ...WorkerPoolOptionFunc) (*WorkerPool, error) {
wp := &WorkerPool{
numWorkers: defaultWorkers,
urls: make(chan string),
results: make(chan Result),
wg: &sync.WaitGroup{},

client: &http.Client{
Timeout: defaultTimeout,
},
}

// Apply any given client options.
for _, fn := range options {
if fn == nil {
continue
}
if err := fn(wp); err != nil {
return nil, err
}
}

return wp, nil
}

// HealthCheck implements a worker pool to perform concurrent health checks on a list of URLs from a file.
func (wp *WorkerPool) HealthCheck(r io.Reader) <-chan Result {
// Start workers goroutines. Add to a WaitGroup for each one, so we can wait for all of them to finish.
for i := 0; i < wp.numWorkers; i++ {
wp.wg.Add(1)
go wp.worker()
}

// Start a goroutine to close the results channel after all workers are done.
go func() {
wp.wg.Wait()
close(wp.results)
}()

// Start a goroutine to read the file line by line and send them to the urls channel.
go func() {
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
wp.urls <- scanner.Text()
}
close(wp.urls)
}()

return wp.results
}

// worker reads URLs from the urls channel, performs the GET request, and sends the result to the results channel.
func (wp *WorkerPool) worker() {
defer wp.wg.Done()
for url := range wp.urls {
result := wp.doGetRequest(url)
wp.results <- result
}
}

// doGetRequest performs the GET request and returns a Result.
func (wp *WorkerPool) doGetRequest(url string) Result {
start := time.Now()
resp, err := wp.client.Get(url)
if err != nil {
return Result{Err: err}
}

result := Result{
Status: resp.StatusCode,
Url: url,
Latency: time.Since(start),
}
// Close the response body as recommended in the http.Get documentation.
defer resp.Body.Close()
// We don't need to read the body, so we drain up to 4KB of it to ensure connection reuse.
io.CopyN(io.Discard, resp.Body, 4096)

return result
}