├── .gcloudignore ├── .gitignore ├── Gopkg.lock ├── Gopkg.toml ├── LICENSE ├── Makefile ├── README.md ├── app.yaml ├── bin └── .gitkeep ├── cmd └── fast │ ├── download.go │ ├── lap.go │ ├── main.go │ ├── recorder.go │ └── upload.go ├── internal ├── adapter │ ├── adapter.go │ └── zap.go ├── config │ └── config.go ├── logger │ ├── logger.go │ └── logger_test.go └── server │ ├── handler.go │ └── server.go └── main.go /.gcloudignore: -------------------------------------------------------------------------------- 1 | # This file specifies files that are *not* uploaded to Google Cloud Platform 2 | # using gcloud. It follows the same syntax as .gitignore, with the addition of 3 | # "#!include" directives (which insert the entries of the given .gitignore-style 4 | # file at that point). 5 | # 6 | # For more information, run: 7 | # $ gcloud topic gcloudignore 8 | # 9 | .gcloudignore 10 | # If you would like to upload your .git directory, .gitignore file or files 11 | # from your .gitignore file, remove the corresponding line 12 | # below: 13 | .git 14 | .gitignore 15 | 16 | # Binaries for programs and plugins 17 | *.exe 18 | *.exe~ 19 | *.dll 20 | *.so 21 | *.dylib 22 | # Test binary, build with `go test -c` 23 | *.test 24 | # Output of the go coverage tool, specifically when used with LiteIDE 25 | *.out 26 | 27 | cmd/fast/fast 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cmd/fast/fast 2 | vendor/ 3 | bin/* 4 | !bin/.gitkeep 5 | -------------------------------------------------------------------------------- /Gopkg.lock: -------------------------------------------------------------------------------- 1 | # This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'. 2 | 3 | 4 | [[projects]] 5 | digest = "1:d666a7b48e2878c5c0de95b2b3027b458692dc6a242b0cc8e4ed2c802e5e7006" 6 | name = "github.com/blendle/zapdriver" 7 | packages = ["."] 8 | pruneopts = "UT" 9 | revision = "63327ce6a5a7d5cc97dbbeab5cdc771cfc62d1e2" 10 | version = "v1.1.6" 11 | 12 | [[projects]] 13 | digest = "1:6f9339c912bbdda81302633ad7e99a28dfa5a639c864061f1929510a9a64aa74" 14 | name = "github.com/dustin/go-humanize" 15 | packages = ["."] 16 | pruneopts = "UT" 17 | revision = "9f541cc9db5d55bce703bd99987c9d5cb8eea45e" 18 | version = "v1.0.0" 19 | 20 | [[projects]] 21 | digest = "1:edbef42561faa44c19129b68d1e109fbc1647f63239250391eadc8d0e7c9f669" 22 | name = "github.com/kelseyhightower/envconfig" 23 | packages = ["."] 24 | pruneopts = "UT" 25 | revision = "f611eb38b3875cc3bd991ca91c51d06446afa14c" 26 | version = "v1.3.0" 27 | 28 | [[projects]] 29 | digest = "1:2e76a73cb51f42d63a2a1a85b3dc5731fd4faf6821b434bd0ef2c099186031d6" 30 | name = "github.com/rs/xid" 31 | packages = ["."] 32 | pruneopts = "UT" 33 | revision = "15d26544def341f036c5f8dca987a4cbe575032c" 34 | version = "v1.2.1" 35 | 36 | [[projects]] 37 | digest = "1:3c1a69cdae3501bf75e76d0d86dc6f2b0a7421bc205c0cb7b96b19eed464a34d" 38 | name = "go.uber.org/atomic" 39 | packages = ["."] 40 | pruneopts = "UT" 41 | revision = "1ea20fb1cbb1cc08cbd0d913a96dead89aa18289" 42 | version = "v1.3.2" 43 | 44 | [[projects]] 45 | digest = "1:60bf2a5e347af463c42ed31a493d817f8a72f102543060ed992754e689805d1a" 46 | name = "go.uber.org/multierr" 47 | packages = ["."] 48 | pruneopts = "UT" 49 | revision = "3c4937480c32f4c13a875a1829af76c98ca3d40a" 50 | version = "v1.1.0" 51 | 52 | [[projects]] 53 | digest = "1:c52caf7bd44f92e54627a31b85baf06a68333a196b3d8d241480a774733dcf8b" 54 | name = "go.uber.org/zap" 55 | packages = [ 56 | ".", 57 | "buffer", 58 | "internal/bufferpool", 59 | "internal/color", 60 | "internal/exit", 61 | "zapcore", 62 | ] 63 | pruneopts = "UT" 64 | revision = "ff33455a0e382e8a81d14dd7c922020b6b5e7982" 65 | version = "v1.9.1" 66 | 67 | [[projects]] 68 | branch = "master" 69 | digest = "1:b521f10a2d8fa85c04a8ef4e62f2d1e14d303599a55d64dabf9f5a02f84d35eb" 70 | name = "golang.org/x/sync" 71 | packages = ["errgroup"] 72 | pruneopts = "UT" 73 | revision = "e225da77a7e68af35c70ccbf71af2b83e6acac3c" 74 | 75 | [solve-meta] 76 | analyzer-name = "dep" 77 | analyzer-version = 1 78 | input-imports = [ 79 | "github.com/blendle/zapdriver", 80 | "github.com/dustin/go-humanize", 81 | "github.com/kelseyhightower/envconfig", 82 | "github.com/rs/xid", 83 | "go.uber.org/zap", 84 | "go.uber.org/zap/zapcore", 85 | "golang.org/x/sync/errgroup", 86 | ] 87 | solver-name = "gps-cdcl" 88 | solver-version = 1 89 | -------------------------------------------------------------------------------- /Gopkg.toml: -------------------------------------------------------------------------------- 1 | # Gopkg.toml example 2 | # 3 | # Refer to https://golang.github.io/dep/docs/Gopkg.toml.html 4 | # for detailed Gopkg.toml documentation. 5 | # 6 | # required = ["github.com/user/thing/cmd/thing"] 7 | # ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"] 8 | # 9 | # [[constraint]] 10 | # name = "github.com/user/project" 11 | # version = "1.0.0" 12 | # 13 | # [[constraint]] 14 | # name = "github.com/user/project2" 15 | # branch = "dev" 16 | # source = "github.com/myfork/project2" 17 | # 18 | # [[override]] 19 | # name = "github.com/x/y" 20 | # version = "2.4.0" 21 | # 22 | # [prune] 23 | # non-go = false 24 | # go-tests = true 25 | # unused-packages = true 26 | 27 | 28 | [[constraint]] 29 | name = "github.com/kelseyhightower/envconfig" 30 | version = "1.3.0" 31 | 32 | [prune] 33 | go-tests = true 34 | unused-packages = true 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Kei Kamikawa 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build 2 | build: build/server build/cli 3 | build/server: 4 | @echo "+ $@" 5 | CGO_ENABLED=0 go build -o bin/speedtest-server \ 6 | -ldflags "-w -s" \ 7 | github.com/Code-Hex/fast-service 8 | build/cli: 9 | @echo "+ $@" 10 | CGO_ENABLED=0 go build -o bin/speedtest-client \ 11 | -ldflags "-w -s" \ 12 | github.com/Code-Hex/fast-service/cmd/fast 13 | 14 | 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fast-service is opensource speedtest service written in Go 2 | 3 |

4 | 5 |

6 | 7 | ## Environment variables 8 | 9 | Environment variables is [here](https://github.com/Code-Hex/fast-service/blob/9e3a385f34985237c655efd9aedddbf05ef3ae45/internal/config/config.go#L12-L24) 10 | 11 | ## How to try this contents 12 | 13 | We are necessary [dep](https://github.com/golang/dep#installation) to install dependency packages. 14 | 15 | dep ensure -vendor-only 16 | make build 17 | 18 | ### How to run server 19 | 20 | ENV=development ./bin/speedtest-server 21 | 22 | ### How to run client 23 | 24 | If you want to run client on another port (default is 8000), you should rewrite [`cmd/fast/main.go:10`](https://github.com/Code-Hex/fast-service/blob/8c70fbfef8c6efcbd7e6a75e459ec8cf83dde6b5/cmd/fast/main.go#L10) before build client code. 25 | 26 | And build it like this. 27 | 28 | ./bin/speedtest-client 29 | -------------------------------------------------------------------------------- /app.yaml: -------------------------------------------------------------------------------- 1 | runtime: go111 2 | 3 | env_variables: 4 | ENV: production -------------------------------------------------------------------------------- /bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Code-Hex/fast-service/612eec2ad74cd8a670ca022ffb0d911a4b31b85f/bin/.gitkeep -------------------------------------------------------------------------------- /cmd/fast/download.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "golang.org/x/sync/errgroup" 8 | ) 9 | 10 | var DownloadTimeout = 15 * time.Second 11 | 12 | const downloadURL = api + "/download" 13 | 14 | func DownloadTest(ctx context.Context, cb IntervalCallback) error { 15 | ctx, cancel := context.WithTimeout(ctx, DownloadTimeout) 16 | defer cancel() 17 | eg, ctx := errgroup.WithContext(ctx) 18 | 19 | r := newRecorder(time.Now(), maxConnections) 20 | 21 | go func() { 22 | for { 23 | select { 24 | case lap := <-r.Lap(): 25 | cb(&lap) 26 | case <-ctx.Done(): 27 | return 28 | } 29 | } 30 | }() 31 | 32 | semaphore := make(chan struct{}, maxConnections) 33 | loop: 34 | for i := 0; i < tryCount; i++ { 35 | for _, size := range payloadSizes { 36 | select { 37 | case <-ctx.Done(): 38 | break loop 39 | case semaphore <- struct{}{}: 40 | time.Sleep(250 * time.Millisecond) 41 | } 42 | eg.Go(func() error { 43 | defer func() { <-semaphore }() 44 | if err := r.download(ctx, downloadURL, size); err != nil { 45 | return err 46 | } 47 | return nil 48 | }) 49 | } 50 | } 51 | // waiting 52 | select { 53 | case <-ctx.Done(): 54 | case semaphore <- struct{}{}: 55 | cancel() 56 | } 57 | return errorCheck(eg.Wait()) 58 | } 59 | -------------------------------------------------------------------------------- /cmd/fast/lap.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | humanize "github.com/dustin/go-humanize" 7 | ) 8 | 9 | // Lap represents lap used in stopwatch. 10 | type Lap struct { 11 | Bytes int64 12 | Bps float64 13 | PrettyBps float64 14 | Units string 15 | 16 | delta float64 17 | } 18 | 19 | func newLap(byteLen int64, delta float64) Lap { 20 | var bytes float64 21 | if delta > 0 { 22 | bytes = float64(byteLen) / delta 23 | } 24 | bps := bytes * 8 25 | prettyBps, unit := humanize.ComputeSI(bps) 26 | return Lap{ 27 | Bytes: byteLen, 28 | Bps: bps, 29 | PrettyBps: prettyBps, 30 | Units: unit + "bps", 31 | 32 | delta: delta, 33 | } 34 | } 35 | 36 | func (l *Lap) String() string { 37 | return fmt.Sprintf("%7.2f %s", l.PrettyBps, l.Units) 38 | } 39 | -------------------------------------------------------------------------------- /cmd/fast/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "runtime" 8 | ) 9 | 10 | const api = "http://localhost:8000" 11 | const tryCount = 3 12 | 13 | var loading = []rune{ 14 | '⠏', 15 | '⠛', 16 | '⠹', 17 | '⠼', 18 | '⠶', 19 | '⠧', 20 | } 21 | 22 | var ( 23 | maxConnections = runtime.NumCPU() 24 | payloadSizes = []int{ 25 | 1562500, // 1.5625MB 26 | 6250000, // 6.25MB 27 | 12500000, // 12.5MB 28 | 26214400, // 25MB 29 | } 30 | ) 31 | 32 | func main() { 33 | ctx := context.Background() 34 | 35 | var ( 36 | lastDown string 37 | downBytes int64 38 | lastUp string 39 | upBytes int64 40 | ) 41 | fmt.Println() 42 | 43 | var i int 44 | err := DownloadTest(ctx, func(result *Lap) error { 45 | lastDown = result.String() 46 | downBytes = result.Bytes 47 | fmt.Printf("%c%s, size: %d ↓ - %s bps, size: %d ↑\r", loading[i%len(loading)], lastDown, downBytes, "", 0) 48 | i++ 49 | return nil 50 | }) 51 | if err != nil { 52 | log.Fatal(err) 53 | } 54 | err = UploadTest(ctx, func(result *Lap) error { 55 | lastUp = result.String() 56 | upBytes = result.Bytes 57 | fmt.Printf("%c%s, size: %d ↓ - %s, size: %d ↑\r", loading[i%len(loading)], lastDown, downBytes, lastUp, result.Bytes) 58 | i++ 59 | return nil 60 | }) 61 | if err != nil { 62 | log.Fatal(err) 63 | } 64 | fmt.Printf("%s, size: %d ↓ - %s, size: %d ↑\n", lastDown, downBytes, lastUp, upBytes) 65 | } 66 | -------------------------------------------------------------------------------- /cmd/fast/recorder.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | "math/rand" 10 | "net/http" 11 | "sync/atomic" 12 | "time" 13 | ) 14 | 15 | type recorder struct { 16 | byteLen int64 17 | start time.Time 18 | lapch chan Lap 19 | } 20 | 21 | func newRecorder(start time.Time, cpun int) *recorder { 22 | return &recorder{ 23 | start: start, 24 | lapch: make(chan Lap, cpun), 25 | } 26 | } 27 | 28 | func (r *recorder) Lap() <-chan Lap { 29 | return r.lapch 30 | } 31 | 32 | var src = rand.NewSource(0) 33 | 34 | func (r *recorder) download(ctx context.Context, url string, size int) error { 35 | url = fmt.Sprintf("%s?size=%d", url, size) 36 | req, err := http.NewRequest("GET", url, nil) 37 | if err != nil { 38 | return err 39 | } 40 | req = req.WithContext(ctx) 41 | resp, err := http.DefaultClient.Do(req) 42 | if err != nil { 43 | return err 44 | } 45 | defer resp.Body.Close() 46 | 47 | // status check 48 | if resp.StatusCode != http.StatusOK { 49 | return errors.New(resp.Status) 50 | } 51 | 52 | // start measure 53 | proxy := r.newMeasureProxy(ctx, resp.Body) 54 | 55 | if _, err := io.Copy(ioutil.Discard, proxy); err != nil { 56 | return err 57 | } 58 | return nil 59 | } 60 | 61 | func (r *recorder) upload(ctx context.Context, url string, size int) error { 62 | // start measure 63 | proxy := r.newMeasureProxy(ctx, rand.New(rand.NewSource(0))) 64 | req, err := http.NewRequest("POST", url, proxy) 65 | if err != nil { 66 | return err 67 | } 68 | req.ContentLength = int64(size) 69 | req.Header.Set("Content-Type", "application/octet-stream") 70 | req = req.WithContext(ctx) 71 | resp, err := http.DefaultClient.Do(req) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | // status check 77 | if resp.StatusCode != http.StatusOK { 78 | return errors.New(resp.Status) 79 | } 80 | 81 | return nil 82 | } 83 | 84 | type measureProxy struct { 85 | io.Reader 86 | *recorder 87 | } 88 | 89 | func (r *recorder) newMeasureProxy(ctx context.Context, reader io.Reader) io.Reader { 90 | rp := &measureProxy{ 91 | Reader: reader, 92 | recorder: r, 93 | } 94 | go rp.Watch(ctx, r.lapch) 95 | return rp 96 | } 97 | 98 | func (m *measureProxy) Watch(ctx context.Context, send chan<- Lap) { 99 | t := time.NewTicker(150 * time.Millisecond) 100 | for { 101 | select { 102 | case <-t.C: 103 | byteLen := atomic.LoadInt64(&m.byteLen) 104 | delta := time.Now().Sub(m.start).Seconds() 105 | send <- newLap(byteLen, delta) 106 | case <-ctx.Done(): 107 | return 108 | } 109 | } 110 | } 111 | 112 | func (m *measureProxy) Read(p []byte) (n int, err error) { 113 | n, err = m.Reader.Read(p) 114 | atomic.AddInt64(&m.byteLen, int64(n)) 115 | return 116 | } 117 | -------------------------------------------------------------------------------- /cmd/fast/upload.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | "time" 7 | 8 | "golang.org/x/sync/errgroup" 9 | ) 10 | 11 | var UploadTimeout = 15 * time.Second 12 | 13 | const uploadURL = api + "/upload" 14 | 15 | type IntervalCallback func(current *Lap) error 16 | 17 | func UploadTest(ctx context.Context, cb IntervalCallback) error { 18 | ctx, cancel := context.WithTimeout(ctx, UploadTimeout) 19 | defer cancel() 20 | eg, ctx := errgroup.WithContext(ctx) 21 | 22 | r := newRecorder(time.Now(), maxConnections) 23 | 24 | go func() { 25 | for { 26 | select { 27 | case lap := <-r.Lap(): 28 | cb(&lap) 29 | case <-ctx.Done(): 30 | return 31 | } 32 | } 33 | }() 34 | 35 | semaphore := make(chan struct{}, maxConnections) 36 | loop: 37 | for i := 0; i < tryCount; i++ { 38 | for _, size := range payloadSizes { 39 | select { 40 | case <-ctx.Done(): 41 | break loop 42 | case semaphore <- struct{}{}: 43 | time.Sleep(250 * time.Millisecond) 44 | } 45 | eg.Go(func() error { 46 | defer func() { <-semaphore }() 47 | if err := r.upload(ctx, uploadURL, size); err != nil { 48 | return err 49 | } 50 | return nil 51 | }) 52 | } 53 | } 54 | // waiting 55 | select { 56 | case <-ctx.Done(): 57 | case semaphore <- struct{}{}: 58 | cancel() 59 | } 60 | return errorCheck(eg.Wait()) 61 | } 62 | 63 | func errorCheck(err error) error { 64 | if err == context.Canceled || err == context.DeadlineExceeded { 65 | return nil 66 | } 67 | if v, ok := err.(*url.Error); ok { 68 | err = v.Err 69 | return errorCheck(err) 70 | } 71 | return err 72 | } 73 | -------------------------------------------------------------------------------- /internal/adapter/adapter.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | // https://medium.com/@matryer/writing-middleware-in-golang-and-how-go-makes-it-so-much-fun-4375c1246e81 4 | import "net/http" 5 | 6 | // Adapter represents middleware between request and main handler 7 | type Adapter func(http.Handler) http.Handler 8 | 9 | // Adapt adapts datapters to giving handler. 10 | func Adapt(h http.Handler, adapters ...Adapter) http.Handler { 11 | // To process from left to right, iterate from the last one. 12 | for i := len(adapters) - 1; i >= 0; i-- { 13 | h = adapters[i](h) 14 | } 15 | return h 16 | } 17 | -------------------------------------------------------------------------------- /internal/adapter/zap.go: -------------------------------------------------------------------------------- 1 | package adapter 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | 7 | "github.com/Code-Hex/fast-service/internal/logger" 8 | "github.com/rs/xid" 9 | "go.uber.org/zap" 10 | ) 11 | 12 | type delegator struct { 13 | http.ResponseWriter 14 | Status int 15 | } 16 | 17 | // ZapAdapter returns Adapter which adapt logging middleware. 18 | func ZapAdapter(l *zap.Logger) Adapter { 19 | return func(next http.Handler) http.Handler { 20 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | start := time.Now() 22 | 23 | ctx := logger.ToContext( 24 | r.Context(), 25 | l.With(zap.Stringer("request_id", xid.New())), 26 | ) 27 | 28 | d := &delegator{ResponseWriter: w} 29 | next.ServeHTTP(d, r.WithContext(ctx)) 30 | 31 | logger.Info(ctx, "request", 32 | zap.String("host", r.Host), 33 | zap.String("path", r.URL.Path), 34 | zap.Int("status", d.Status), 35 | zap.Duration("duration", time.Now().Sub(start)), 36 | zap.String("method", r.Method), 37 | zap.String("user_agent", r.UserAgent()), 38 | ) 39 | }) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/kelseyhightower/envconfig" 5 | ) 6 | 7 | const ( 8 | envDevelopment = "development" 9 | envProduction = "production" 10 | ) 11 | 12 | // Env stores configuration settings extract from enviromental variables 13 | // by using https://github.com/kelseyhightower/envconfig 14 | type Env struct { 15 | // LogLevel is INFO or DEBUG. Default is "INFO". 16 | LogLevel string `envconfig:"LOG_LEVEL" default:"INFO"` 17 | 18 | // Env is environment where application is running The value must be 19 | // "development" or "production". 20 | Env string `envconfig:"ENV" required:"true"` 21 | 22 | // Port is http serve port. 23 | Port int `envconfig:"PORT" default:"8000"` 24 | } 25 | 26 | // ReadFromEnv reads configuration from environmental variables 27 | // defined by Env struct. 28 | func ReadFromEnv() (*Env, error) { 29 | var env Env 30 | if err := envconfig.Process("", &env); err != nil { 31 | return nil, err 32 | } 33 | return &env, nil 34 | } 35 | -------------------------------------------------------------------------------- /internal/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/blendle/zapdriver" 9 | "go.uber.org/zap" 10 | "go.uber.org/zap/zapcore" 11 | ) 12 | 13 | type loggerKey struct{} 14 | 15 | var contextKey = &loggerKey{} 16 | 17 | // Extract takes the call-scoped Logger from context. 18 | func Extract(ctx context.Context) *zap.Logger { 19 | l, ok := ctx.Value(contextKey).(*zap.Logger) 20 | if !ok || l == nil { 21 | return NewDiscard() 22 | } 23 | return l 24 | } 25 | 26 | // ToContext adds the zap.Logger into context. 27 | func ToContext(ctx context.Context, l *zap.Logger) context.Context { 28 | return context.WithValue(ctx, contextKey, l) 29 | } 30 | 31 | // Debug logs a message at DebugLevel. 32 | func Debug(ctx context.Context, msg string, fields ...zap.Field) { 33 | Extract(ctx).Debug(msg, fields...) 34 | } 35 | 36 | // Info logs a message at InfoLevel. 37 | func Info(ctx context.Context, msg string, fields ...zap.Field) { 38 | Extract(ctx).Info(msg, fields...) 39 | } 40 | 41 | // Warn logs a message at WarnLevel. 42 | func Warn(ctx context.Context, msg string, fields ...zap.Field) { 43 | Extract(ctx).Warn(msg, fields...) 44 | } 45 | 46 | // Error logs a message at ErrorLevel. 47 | func Error(ctx context.Context, msg string, fields ...zap.Field) { 48 | Extract(ctx).Error(msg, fields...) 49 | } 50 | 51 | // New creates a new zap logger with the given log level. 52 | func New(level string) (*zap.Logger, error) { 53 | l, err := logLevel(level) 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | config := zapdriver.NewProductionConfig() 59 | config.Level = zap.NewAtomicLevelAt(l) 60 | config.DisableStacktrace = true 61 | return config.Build() 62 | } 63 | 64 | // NewDiscard creates logger which output to ioutil.Discard. 65 | // This can be used for testing. 66 | func NewDiscard() *zap.Logger { 67 | return zap.NewNop() 68 | } 69 | 70 | func logLevel(level string) (zapcore.Level, error) { 71 | level = strings.ToUpper(level) 72 | var l zapcore.Level 73 | switch level { 74 | case "DEBUG": 75 | l = zapcore.DebugLevel 76 | case "INFO": 77 | l = zapcore.InfoLevel 78 | case "ERROR": 79 | l = zapcore.ErrorLevel 80 | default: 81 | return l, fmt.Errorf("invalid loglevel: %s", level) 82 | } 83 | return l, nil 84 | } 85 | -------------------------------------------------------------------------------- /internal/logger/logger_test.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "testing" 5 | 6 | "go.uber.org/zap/zapcore" 7 | ) 8 | 9 | func TestLogLevel(t *testing.T) { 10 | cases := []struct { 11 | level string 12 | success bool 13 | want zapcore.Level 14 | }{ 15 | { 16 | "info", 17 | true, 18 | zapcore.InfoLevel, 19 | }, 20 | { 21 | "DEBUG", 22 | true, 23 | zapcore.DebugLevel, 24 | }, 25 | { 26 | "Error", 27 | true, 28 | zapcore.ErrorLevel, 29 | }, 30 | { 31 | "FATAL", // not supported (debug or info is enough) 32 | false, 33 | zapcore.Level(0), 34 | }, 35 | } 36 | 37 | for _, tc := range cases { 38 | got, err := logLevel(tc.level) 39 | if err != nil { 40 | if tc.success { 41 | t.Fatalf("expect to success: %s", err) 42 | } 43 | continue 44 | } 45 | 46 | if !tc.success { 47 | t.Fatal("expect to be failed") 48 | } 49 | 50 | if got != tc.want { 51 | t.Fatalf("got %v, want %v", got, tc.want) 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/server/handler.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/Code-Hex/fast-service/internal/adapter" 7 | "go.uber.org/zap" 8 | ) 9 | 10 | // Mux manages handlers. 11 | type Mux struct { 12 | mux *http.ServeMux 13 | adapters []adapter.Adapter 14 | } 15 | 16 | // NewMux creates new Mux http.Handler. 17 | func NewMux(logger *zap.Logger) *Mux { 18 | return &Mux{ 19 | mux: http.NewServeMux(), 20 | // if set adapter like []Adapter{A(), B()} 21 | // we will access A() -> B() -> main -> B() -> A() 22 | adapters: []adapter.Adapter{ 23 | adapter.ZapAdapter(logger), 24 | }, 25 | } 26 | } 27 | 28 | // Handle registers the handler for the given pattern. 29 | func (m *Mux) Handle(pattern string, h http.Handler) { 30 | m.mux.Handle(pattern, adapter.Adapt(h, m.adapters...)) 31 | } 32 | -------------------------------------------------------------------------------- /internal/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | ) 8 | 9 | // Server represents an HTTP server. 10 | type Server struct { 11 | server *http.Server 12 | } 13 | 14 | // NewServer creates new Server. 15 | func New(h http.Handler) *Server { 16 | return &Server{ 17 | server: &http.Server{ 18 | Handler: h, 19 | }, 20 | } 21 | } 22 | 23 | // Serve starts accept requests from the given listener. If any returns error. 24 | func (s *Server) Serve(ln net.Listener) error { 25 | // ErrServerClosed is returned by the Server's Serve 26 | // after a call to Shutdown or Close, we can ignore it. 27 | if err := s.server.Serve(ln); err != nil && err != http.ErrServerClosed { 28 | return err 29 | } 30 | return nil 31 | } 32 | 33 | // Shutdown gracefully shutdown the server without interrupting any 34 | // active connections. If any returns error. 35 | func (s *Server) Shutdown(ctx context.Context) error { 36 | return s.server.Shutdown(ctx) 37 | } 38 | 39 | // ServeHTTP for represents http.Handler 40 | func (m *Mux) ServeHTTP(w http.ResponseWriter, r *http.Request) { 41 | m.mux.ServeHTTP(w, r) 42 | } 43 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "math/rand" 10 | "net" 11 | "net/http" 12 | "os" 13 | "os/signal" 14 | "strconv" 15 | "syscall" 16 | 17 | "go.uber.org/zap" 18 | 19 | "github.com/Code-Hex/fast-service/internal/config" 20 | "github.com/Code-Hex/fast-service/internal/logger" 21 | "github.com/Code-Hex/fast-service/internal/server" 22 | ) 23 | 24 | const maxSize = 26214400 // 25MB 25 | 26 | func main() { 27 | // Read configurations from environmental variables. 28 | env, err := config.ReadFromEnv() 29 | if err != nil { 30 | log.Fatalf("failed to read environment variables: %s", err) 31 | } 32 | 33 | // Setup new zap logger. This logger should be used for all logging in this service. 34 | // The log level can be updated via environment variables. 35 | l, err := logger.New(env.LogLevel) 36 | if err != nil { 37 | log.Fatalf("failed to prepare logger: %s", err) 38 | } 39 | 40 | if err := _main(env, l); err != nil { 41 | log.Fatalf("failed to serve: %s", err) 42 | } 43 | } 44 | 45 | func _main(env *config.Env, l *zap.Logger) error { 46 | mux := server.NewMux(l) 47 | mux.Handle("/download", downloadHandler()) 48 | mux.Handle("/upload", uploadHandler()) 49 | 50 | srv := server.New(mux) 51 | 52 | addr := fmt.Sprintf(":%d", env.Port) 53 | ln, err := net.Listen("tcp", addr) 54 | if err != nil { 55 | return fmt.Errorf("failed to listen port: %s", err) 56 | } 57 | 58 | ctx, cancel := context.WithCancel(context.Background()) 59 | defer cancel() 60 | 61 | go func() { 62 | if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed { 63 | log.Printf("serve err: %s", err) 64 | return 65 | } 66 | }() 67 | 68 | // waiting for SIGTERM or Interrupt signal. 69 | sigCh := make(chan os.Signal, 1) 70 | signal.Notify(sigCh, syscall.SIGTERM, os.Interrupt) 71 | select { 72 | case <-sigCh: 73 | log.Printf("received SIGTERM, exiting server gracefully") 74 | case <-ctx.Done(): 75 | } 76 | log.Printf("shutdown servers") 77 | 78 | if err := srv.Shutdown(ctx); err != nil { 79 | return fmt.Errorf("failed to gracefully shutdown HTTP server: %s", err) 80 | } 81 | return nil 82 | } 83 | 84 | func downloadHandler() http.HandlerFunc { 85 | src := rand.NewSource(0) 86 | return func(w http.ResponseWriter, r *http.Request) { 87 | ctx := r.Context() 88 | if r.Method != http.MethodGet { 89 | w.WriteHeader(http.StatusBadRequest) 90 | return 91 | } 92 | queries := r.URL.Query() 93 | size := queries.Get("size") 94 | max, err := strconv.Atoi(size) 95 | if err != nil { 96 | max = maxSize 97 | } 98 | if _, err := io.CopyN(w, rand.New(src), int64(max)); err != nil { 99 | logger.Error(ctx, "failed to write random file: %s", zap.Error(err)) 100 | return 101 | } 102 | } 103 | } 104 | 105 | func uploadHandler() http.HandlerFunc { 106 | return func(w http.ResponseWriter, r *http.Request) { 107 | ctx := r.Context() 108 | if r.Method != http.MethodPost { 109 | w.WriteHeader(http.StatusBadRequest) 110 | return 111 | } 112 | contentType := r.Header.Get("Content-Type") 113 | if contentType != "application/octet-stream" { 114 | logger.Warn(ctx, "invalid content type", zap.String("Content-Type", contentType)) 115 | w.WriteHeader(http.StatusBadRequest) 116 | return 117 | } 118 | contentLength := r.ContentLength 119 | if contentLength > maxSize { 120 | contentLength = maxSize 121 | } 122 | if _, err := io.CopyN(ioutil.Discard, r.Body, contentLength); err != nil { 123 | logger.Warn(ctx, "failed to write body", zap.Error(err)) 124 | return 125 | } 126 | } 127 | } 128 | --------------------------------------------------------------------------------