From fa730323072afb68dd5b415ae415ae3a3aea84fa Mon Sep 17 00:00:00 2001 From: stsm Date: Mon, 18 Mar 2024 14:49:28 +0400 Subject: [PATCH] Iter9 (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * рефакторинг агента по итогам встречи с ментором * рефакторинг сервера по результам встречи с ментором * реализовал инкремент 6 * исправления по результатам работы статического анализатора * реализовал iter7 * pool->poll * обновление в chi * исправление по результатам статического анализатора * ещё исправления по статическому анализатору * поправил обновление метрик * убрал лишние проверки, из-за которых не проходили значения спорядком (типа 1.8e+7) * перевел агента на json * реализовал компрессию на сервере при передаче ответов * incr8 * поправил названия * обновил чтение конфигурации для сервера * добавил методы сохранения и восстановления хранилища * наработки * наработки (не собирается) * рефакторинг сервера, тесты пройдены * наработки по инкременту 9 * небольшой комментарий * ещё рефакторинг * наработки по инкременту 9 * изменения по результам работы статического анализатора + доп вопросы + немного комментариев * накидал идею по архитектуре --- .gitattributes | 1 + Arch_Idea.png | 3 + Questions.md | 17 + cmd/agent/agent.go | 31 +- cmd/server/server.go | 72 ++- go.mod | 17 +- go.sum | 19 + internal/agent/agent.go | 101 ++-- internal/agent/api.go | 15 - internal/agent/domain_model.go | 15 + internal/agent/http_result_sender.go | 74 ++- ...runtime_metrics.go => memstats_storage.go} | 44 +- internal/agent/memstats_storage_test.go | 70 +++ internal/agent/runtime_metrics_test.go | 53 -- internal/common.go | 43 -- .../configuration.go => config/agent.go} | 8 +- internal/config/server.go | 59 +++ internal/server/adapter/fs/formatter/json.go | 90 ++++ .../server/adapter/fs/formatter/json_test.go | 89 ++++ .../server/adapter/http/handler/handler.go | 384 ++++++++++++++ .../adapter/http/handler/handler_test.go | 501 ++++++++++++++++++ .../server/adapter/http/middleware/common.go | 12 + .../compress_gzip_buffer_response_mw.go | 62 +++ .../compress_gzip_buffer_response_mw_test.go | 119 +++++ .../compress/compress_gzip_response_mw.go | 57 ++ .../compress_gzip_response_mw_test.go | 119 +++++ .../compress/compress_utils_test.go | 46 ++ .../compress/uncompress_gzip_request_mw.go | 41 ++ .../uncompress_gzip_request_mw_test.go | 73 +++ .../logging/logging_request_info_mw.go | 30 ++ .../logging/logging_response_info_mw.go | 53 ++ internal/server/configuration.go | 41 -- internal/server/controller.go | 76 --- internal/server/domain/common_types.go | 5 + internal/server/domain/entities.go | 15 + internal/server/domain/errors.go | 8 + internal/server/domain/usefull.go | 9 + internal/server/handlers.go | 120 ----- internal/server/middleware.go | 62 --- internal/server/middleware_test.go | 170 ------ internal/server/server.go | 140 +++-- internal/server/server_test.go | 112 ---- internal/server/storage/memory/storage.go | 141 +++++ .../server/storage/memory/storage_test.go | 174 ++++++ internal/server/usecase/backup.go | 56 ++ internal/server/usecase/metrics.go | 145 +++++ internal/server/usecase/metrics_test.go | 145 +++++ internal/server/utils.go | 40 -- internal/server/utils_test.go | 292 ---------- internal/storage/api.go | 12 - internal/storage/mem_storage.go | 53 -- internal/storage/mem_storage_test.go | 46 -- 52 files changed, 2905 insertions(+), 1275 deletions(-) create mode 100644 .gitattributes create mode 100644 Arch_Idea.png create mode 100644 Questions.md delete mode 100644 internal/agent/api.go create mode 100644 internal/agent/domain_model.go rename internal/agent/{runtime_metrics.go => memstats_storage.go} (65%) create mode 100644 internal/agent/memstats_storage_test.go delete mode 100644 internal/agent/runtime_metrics_test.go delete mode 100644 internal/common.go rename internal/{agent/configuration.go => config/agent.go} (82%) create mode 100644 internal/config/server.go create mode 100644 internal/server/adapter/fs/formatter/json.go create mode 100644 internal/server/adapter/fs/formatter/json_test.go create mode 100644 internal/server/adapter/http/handler/handler.go create mode 100644 internal/server/adapter/http/handler/handler_test.go create mode 100644 internal/server/adapter/http/middleware/common.go create mode 100644 internal/server/adapter/http/middleware/compress/compress_gzip_buffer_response_mw.go create mode 100644 internal/server/adapter/http/middleware/compress/compress_gzip_buffer_response_mw_test.go create mode 100644 internal/server/adapter/http/middleware/compress/compress_gzip_response_mw.go create mode 100644 internal/server/adapter/http/middleware/compress/compress_gzip_response_mw_test.go create mode 100644 internal/server/adapter/http/middleware/compress/compress_utils_test.go create mode 100644 internal/server/adapter/http/middleware/compress/uncompress_gzip_request_mw.go create mode 100644 internal/server/adapter/http/middleware/compress/uncompress_gzip_request_mw_test.go create mode 100644 internal/server/adapter/http/middleware/logging/logging_request_info_mw.go create mode 100644 internal/server/adapter/http/middleware/logging/logging_response_info_mw.go delete mode 100644 internal/server/configuration.go delete mode 100644 internal/server/controller.go create mode 100644 internal/server/domain/common_types.go create mode 100644 internal/server/domain/entities.go create mode 100644 internal/server/domain/errors.go create mode 100644 internal/server/domain/usefull.go delete mode 100644 internal/server/handlers.go delete mode 100644 internal/server/middleware.go delete mode 100644 internal/server/middleware_test.go create mode 100644 internal/server/storage/memory/storage.go create mode 100644 internal/server/storage/memory/storage_test.go create mode 100644 internal/server/usecase/backup.go create mode 100644 internal/server/usecase/metrics.go create mode 100644 internal/server/usecase/metrics_test.go delete mode 100644 internal/server/utils.go delete mode 100644 internal/server/utils_test.go delete mode 100644 internal/storage/api.go delete mode 100644 internal/storage/mem_storage.go delete mode 100644 internal/storage/mem_storage_test.go diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..24a8e87 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.png filter=lfs diff=lfs merge=lfs -text diff --git a/Arch_Idea.png b/Arch_Idea.png new file mode 100644 index 0000000..fd5438f --- /dev/null +++ b/Arch_Idea.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f30243b2c9b7168a9fde6cd0732a365e9c8d0313dec612b54657bcb6edbc54a3 +size 397679 diff --git a/Questions.md b/Questions.md new file mode 100644 index 0000000..e1d134d --- /dev/null +++ b/Questions.md @@ -0,0 +1,17 @@ +- АРХИТЕКТУРА!!! + +- chi.Use хочет handler - можно ли подставить в chi http.HandlerFunc (с handlerFunc работать удобней, чем с обычным http.Handler; см compress_utils_test.go); есть ли преобразование HandlerFunc в Handler (ссылка на пример, если есть) + +- Куда спрятать доп.функции для тестирования (compress_utils_test.go) + +- Формат логов - что писать, где писть, как писать, КАК ПРОВЕРЯТЬ что разработчики не косячат и как автоматически поддерживать + +- w.WriteHeader(http.StatusOK) - где писать, надо ли вообще писать? (судя по всему не надо; мало ли какие мидлы дальше будут работать) + +- Загрузка конфигурации (flag, env) (см internal/config/server.go) - можно ли покрыть тестами (ссылка на пример, если есть; надо ли вообще? :) ) + +- Передача logger и инициализация в тестах log := logger.Sugar() (нормальна ли завязка на конкрентый логгер; есть ли какой-то вариант из zap для использования в тестах с упрощенной инициализацией см handler_test/TestPostUpdate.go) + +- BoolVar - работает хитро - значение по-умолчанию "true" ????!!! (сделал через flag.Var - не усложняю ли) + +- Вопрос по api - "net/http".Server.Shutdown(ctx context.Context) для чего передается контекст? для возможности cancel контекста? (например по по таймауту) diff --git a/cmd/agent/agent.go b/cmd/agent/agent.go index b4ad5f6..6d98ba3 100644 --- a/cmd/agent/agent.go +++ b/cmd/agent/agent.go @@ -8,25 +8,38 @@ import ( "syscall" "github.com/StasMerzlyakov/go-metrics/internal/agent" + "github.com/StasMerzlyakov/go-metrics/internal/config" ) +type Agent interface { + Start(ctx context.Context) + Wait() +} + func main() { - agentCfg, err := agent.LoadConfig() + agentCfg, err := config.LoadAgentConfig() if err != nil { log.Fatal(err) } + metricStorage := agent.NewMemStatsStorage() + resultSender := agent.NewHTTPResultSender(agentCfg.ServerAddr) + + var agnt Agent = agent.Create(agentCfg, + resultSender, + metricStorage, + ) + // Взято отсюда: "Реализация Graceful Shutdown в Go"(https://habr.com/ru/articles/771626/) // Сейчас выглядит избыточным - оставил как задел на будущее для сервера - ctx, cancel := context.WithCancel(context.Background()) + ctx, cancelFn := context.WithCancel(context.Background()) exit := make(chan os.Signal, 1) signal.Notify(exit, os.Interrupt, syscall.SIGTERM) - if agent, err := agent.CreateAgent(ctx, agentCfg); err != nil { - panic(err) - } else { - <-exit - cancel() - agent.Wait() // ожидаение завершения go-рутин в агенте - } + agnt.Start(ctx) + defer func() { + cancelFn() + agnt.Wait() + }() + <-exit } diff --git a/cmd/server/server.go b/cmd/server/server.go index 9ff5d1c..6590cf7 100644 --- a/cmd/server/server.go +++ b/cmd/server/server.go @@ -1,19 +1,81 @@ package main import ( - "log" + "context" + "net/http" + "os" + "os/signal" + "syscall" + "github.com/StasMerzlyakov/go-metrics/internal/config" "github.com/StasMerzlyakov/go-metrics/internal/server" + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/fs/formatter" + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/handler" + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware/compress" + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware/logging" + "github.com/StasMerzlyakov/go-metrics/internal/server/storage/memory" + "github.com/StasMerzlyakov/go-metrics/internal/server/usecase" + "go.uber.org/zap" ) +type Server interface { + Start(ctx context.Context) + WaitDone() +} + +func createMiddleWareList(log *zap.SugaredLogger) []func(http.Handler) http.Handler { + return []func(http.Handler) http.Handler{ + logging.NewLoggingResponseMW(log), + compress.NewCompressGZIPResponseMW(log), //compress.NewCompressGZIPBufferResponseMW(log), + compress.NewUncompressGZIPRequestMW(log), + logging.NewLoggingRequestMW(log), + } +} + func main() { - srvConf, err := server.LoadConfig() + // Конфигурация + srvConf, err := config.LoadServerConfig() if err != nil { - log.Fatal(err) + panic(err) } - if err := server.CreateServer(srvConf); err != nil { - panic(err) + // Создаем логгер + logger, err := zap.NewDevelopment() + if err != nil { + // вызываем панику, если ошибка + panic("cannot initialize zap") } + defer logger.Sync() + + sugarLog := logger.Sugar() + + // Сборка сервера + storage := memory.NewStorage() + + backupFomratter := formatter.NewJSON(sugarLog, srvConf.FileStoragePath) + backup := usecase.NewBackup(sugarLog, storage, backupFomratter) + + metrics := usecase.NewMetrics(storage) + + mwList := createMiddleWareList(sugarLog) + httpHandler := handler.NewHTTP(metrics, sugarLog, mwList...) + + var server Server = server.NewMetricsServer(srvConf, + sugarLog, + httpHandler, + metrics, + backup) + + // Запуск сервера + ctx, cancelFn := context.WithCancel(context.Background()) + exit := make(chan os.Signal, 1) + signal.Notify(exit, os.Interrupt, syscall.SIGTERM) + + server.Start(ctx) + defer func() { + cancelFn() + server.WaitDone() + }() + <-exit } diff --git a/go.mod b/go.mod index cf4a363..d2835d2 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,21 @@ module github.com/StasMerzlyakov/go-metrics go 1.19 require ( - github.com/caarlos0/env v3.5.0+incompatible // indirect + github.com/caarlos0/env v3.5.0+incompatible + github.com/go-chi/chi/v5 v5.0.11 + github.com/go-resty/resty/v2 v2.11.0 + github.com/pkg/errors v0.9.1 + github.com/sirupsen/logrus v1.9.3 + github.com/stretchr/testify v1.8.4 + github.com/ungerik/go-pool v0.0.0-20140720100922-d102a2c7872a + go.uber.org/zap v1.27.0 +) + +require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/go-chi/chi/v5 v5.0.11 // indirect - github.com/go-resty/resty/v2 v2.11.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - github.com/stretchr/testify v1.8.4 // indirect + go.uber.org/multierr v1.10.0 // indirect golang.org/x/net v0.17.0 // indirect + golang.org/x/sys v0.13.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 844aa83..7f22f50 100644 --- a/go.sum +++ b/go.sum @@ -1,16 +1,30 @@ github.com/caarlos0/env v3.5.0+incompatible h1:Yy0UN8o9Wtr/jGHZDpCBLpNrzcFLLM2yixi/rBrKyJs= github.com/caarlos0/env v3.5.0+incompatible/go.mod h1:tdCsowwCzMLdkqRYDlHpZCp2UooDD3MspDBjZ2AD02Y= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-resty/resty/v2 v2.11.0 h1:i7jMfNOJYMp69lq7qozJP+bjgzfAzeOhuGlyDrqxT/8= github.com/go-resty/resty/v2 v2.11.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/ungerik/go-pool v0.0.0-20140720100922-d102a2c7872a h1:Lfu+d2ZpWyfH6HyuQrGI3om39+yNuvk2OWT0UptrFAM= +github.com/ungerik/go-pool v0.0.0-20140720100922-d102a2c7872a/go.mod h1:DcjcKDwgMN/tbplnqJjxJNonhx4QvgPsv5W3N+h6sFU= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= @@ -30,9 +44,11 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -45,12 +61,15 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index f413ff9..9783dc3 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -2,88 +2,89 @@ package agent import ( "context" - "fmt" + "log" "sync" "time" - "github.com/StasMerzlyakov/go-metrics/internal/storage" + "github.com/StasMerzlyakov/go-metrics/internal/config" + "github.com/sirupsen/logrus" ) -type Agent interface { - Wait() +type ResultSender interface { + SendMetrics(metrics []Metrics) error } -func CreateAgent(ctx context.Context, config *Configuration) (Agent, error) { +type MetricStorage interface { + Refresh() error + GetMetrics() []Metrics +} + +func Create(config *config.AgentConfiguration, + resultSender ResultSender, + metricStorage MetricStorage, +) *agent { agent := &agent{ - metricsSource: NewRuntimeMetricsSource(), - resultSender: NewHTTPResultSender(config.ServerAddr), - gaugeStorage: storage.NewMemoryFloat64Storage(), - poolCounter: 0, + metricStorage: metricStorage, + resultSender: resultSender, + pollIntervalSec: config.PollInterval, + reportIntervalSec: config.ReportInterval, } - go agent.PoolMetrics(ctx, config.PollInterval) - go agent.ReportMetrics(ctx, config.ReportInterval) - agent.wg.Add(2) - return agent, nil + + return agent } type agent struct { - metricsSource MetricsSource - resultSender ResultSender - gaugeStorage storage.MetricsStorage[float64] - poolCounter int64 - wg sync.WaitGroup + metricStorage MetricStorage + resultSender ResultSender + pollIntervalSec int + reportIntervalSec int + wg sync.WaitGroup } func (a *agent) Wait() { a.wg.Wait() } -func (a *agent) PoolMetrics(ctx context.Context, pollIntervalSec int) { - counter := 0 +func (a *agent) Start(ctx context.Context) { + go a.pollMetrics(ctx) + go a.reportMetrics(ctx) + a.wg.Add(2) +} + +func (a *agent) pollMetrics(ctx context.Context) { + pollInterval := time.Duration(a.pollIntervalSec) * time.Second + defer a.wg.Done() for { select { case <-ctx.Done(): - fmt.Printf("[%v] PoolMetrics DONE\n", time.Now()) - a.wg.Done() + logrus.Info("PollMetrics DONE") return - default: - time.Sleep(1 * time.Second) // Будем просыпаться каждую секунду для проверки ctx - counter++ - if counter == pollIntervalSec { - counter = 0 - for k, v := range a.metricsSource.PollMetrics() { - a.gaugeStorage.Set(k, v) - } - a.poolCounter = a.metricsSource.PollCount() - fmt.Printf("[%v] PoolMetrics [SUCCESS] (poolCounter %v)\n", time.Now(), a.poolCounter) + + case <-time.After(pollInterval): + if err := a.metricStorage.Refresh(); err != nil { + logrus.Fatalf("PollMetrics metrics error: %v", err) } + logrus.Info("PollMetrics SUCCESS") } } } -func (a *agent) ReportMetrics(ctx context.Context, reportIntervalSec int) { - counter := 0 -MAIN: +func (a *agent) reportMetrics(ctx context.Context) { + reportInterval := time.Duration(a.reportIntervalSec) * time.Second + for { select { case <-ctx.Done(): - fmt.Printf("[%v] ReportMetrics DONE\n", time.Now()) + log.Println("ReportMetrics DONE") a.wg.Done() return - default: - time.Sleep(1 * time.Second) // Будем просыпаться каждую секунду для проверки ctx - counter++ - if counter == reportIntervalSec { - counter = 0 - for _, key := range a.gaugeStorage.Keys() { - val, _ := a.gaugeStorage.Get(key) - if err := a.resultSender.SendGauge(key, val); err != nil { - fmt.Printf("[%v] ReportMetrics [ERROR] (poolCounter %v)\n", time.Now(), err) - continue MAIN - } - } - _ = a.resultSender.SendCounter("PoolCount", a.poolCounter) - fmt.Printf("[%v] ReportMetrics [SUCCESS] (poolCounter %v)\n", time.Now(), a.poolCounter) + case <-time.After(reportInterval): + metrics := a.metricStorage.GetMetrics() + err := a.resultSender.SendMetrics(metrics) + if err != nil { + log.Printf("ReportMetrics ERROR: %v\n", err) + } else { + logrus.Info("ReportMetrics SUCCESS") } } } diff --git a/internal/agent/api.go b/internal/agent/api.go deleted file mode 100644 index d2cf8ff..0000000 --- a/internal/agent/api.go +++ /dev/null @@ -1,15 +0,0 @@ -package agent - -import "errors" - -type MetricsSource interface { - PollMetrics() map[string]float64 - PollCount() int64 -} - -var ErrServerInteraction = errors.New("server interaction error") - -type ResultSender interface { - SendGauge(name string, value float64) error - SendCounter(name string, value int64) error -} diff --git a/internal/agent/domain_model.go b/internal/agent/domain_model.go new file mode 100644 index 0000000..26f326c --- /dev/null +++ b/internal/agent/domain_model.go @@ -0,0 +1,15 @@ +package agent + +type MetricType string + +type Metrics struct { + ID string `json:"id"` // имя метрики + MType MetricType `json:"type"` // параметр, принимающий значение gauge или counter + Delta *int64 `json:"delta,omitempty"` // значение метрики в случае передачи counter + Value *float64 `json:"value,omitempty"` // значение метрики в случае передачи gauge +} + +const ( + GaugeType MetricType = "gauge" + CounterType MetricType = "counter" +) diff --git a/internal/agent/http_result_sender.go b/internal/agent/http_result_sender.go index a7d52e0..6e7c80b 100644 --- a/internal/agent/http_result_sender.go +++ b/internal/agent/http_result_sender.go @@ -1,61 +1,81 @@ package agent import ( + "bytes" + "compress/gzip" + "encoding/json" "errors" "fmt" + "net/http" "strings" - "sync" "github.com/go-resty/resty/v2" + "github.com/sirupsen/logrus" ) -func NewHTTPResultSender(serverAdd string) ResultSender { +func NewHTTPResultSender(serverAdd string) *httpResultSender { if !strings.HasPrefix(serverAdd, "http") { serverAdd = "http://" + serverAdd } serverAdd = strings.TrimSuffix(serverAdd, "/") return &httpResultSender{ serverAdd: serverAdd, + client: resty.New(). + // Иногда возникает ошибка EOF или http: server closed idle connection; добавим Retry + SetRetryCount(3), } } type httpResultSender struct { serverAdd string client *resty.Client - sm sync.Mutex } -func (h *httpResultSender) initIfNecessary() { - h.sm.Lock() - defer h.sm.Unlock() - if h.client == nil { - h.client = resty.New(). - // Иногда возникает ошибка EOF или http: server closed idle connection; добавим Retry - SetRetryCount(3) +func (h *httpResultSender) SendMetrics(metrics []Metrics) error { + for _, metric := range metrics { + err := h.store(metric) + if err != nil { + return err + } } + return nil } -func (h *httpResultSender) store(metricType string, metricName string, value string) error { - h.initIfNecessary() - _, err := h.client.R(). - SetHeader("Content-Type", "text/plain; charset=UTF-8"). - SetPathParams(map[string]string{ - "metricType": metricType, - "metricName": metricName, - "value": value, - }).Post(h.serverAdd + "/update/{metricType}/{metricName}/{value}") +func (h *httpResultSender) store(metric Metrics) error { + var buf bytes.Buffer + w, err := gzip.NewWriterLevel(&buf, gzip.BestCompression) if err != nil { - fmt.Printf("%v\n", errors.Unwrap(err)) + logrus.Errorf("gzip.NewWriterLevel error: %v", err) + return err + } + if err := json.NewEncoder(w).Encode(metric); err != nil { + logrus.Errorf("json encode error: %v", err) + return err } - return err -} + err = w.Close() + if err != nil { + logrus.Errorf("gzip close error: %v", err) + } -func (h *httpResultSender) SendGauge(name string, value float64) error { - return h.store("gauge", name, fmt.Sprintf("%v", value)) -} + resp, err := h.client.R(). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetHeader("Content-Encoding", "gzip"). + SetBody(buf.Bytes()).Post(h.serverAdd + "/update/") + if err != nil { + logrus.Errorf("server communication error: %v", err) + } -func (h *httpResultSender) SendCounter(name string, value int64) error { - return h.store("counter", name, fmt.Sprintf("%v", value)) + if resp.StatusCode() != http.StatusOK { + errStr := fmt.Sprintf("unexpected server http response code: %v", resp.StatusCode()) + logrus.Errorf(errStr) + return errors.New(errStr) + } + + /*_, err := h.client.R(). + SetHeader("Content-Type", "application/json; charset=UTF-8"). + SetBody(metric).Post(h.serverAdd + "/update/") */ + + return err } diff --git a/internal/agent/runtime_metrics.go b/internal/agent/memstats_storage.go similarity index 65% rename from internal/agent/runtime_metrics.go rename to internal/agent/memstats_storage.go index ad0f3ff..9b05d91 100644 --- a/internal/agent/runtime_metrics.go +++ b/internal/agent/memstats_storage.go @@ -6,22 +6,23 @@ import ( "sync/atomic" ) -func NewRuntimeMetricsSource() MetricsSource { - return &runtimeMetrics{} -} - -type runtimeMetrics struct { - counter int64 +func NewMemStatsStorage() *memStatsSource { + return &memStatsSource{ + poolCounter: 0, + memStatStorage: nil, + } } -func (rm *runtimeMetrics) PollCount() int64 { - return rm.counter +type memStatsSource struct { + poolCounter int64 + memStatStorage map[string]float64 } -func (rm *runtimeMetrics) PollMetrics() map[string]float64 { - defer atomic.AddInt64(&rm.counter, 1) +func (m *memStatsSource) Refresh() error { + defer atomic.AddInt64(&m.poolCounter, 1) var memStats runtime.MemStats - return map[string]float64{ + runtime.ReadMemStats(&memStats) + m.memStatStorage = map[string]float64{ "Alloc": float64(memStats.Alloc), "BuckHashSys": float64(memStats.BuckHashSys), "Frees": float64(memStats.Frees), @@ -51,4 +52,25 @@ func (rm *runtimeMetrics) PollMetrics() map[string]float64 { "TotalAlloc": float64(memStats.TotalAlloc), "RandomValue": rand.Float64(), } + return nil +} + +func (m *memStatsSource) GetMetrics() []Metrics { + var metrics []Metrics + for k, v := range m.memStatStorage { + value := v + metrics = append(metrics, Metrics{ + ID: k, + MType: GaugeType, + Value: &value, + }) + } + + poolCount := m.poolCounter + metrics = append(metrics, Metrics{ + ID: "PollCount", + MType: CounterType, + Delta: &poolCount, + }) + return metrics } diff --git a/internal/agent/memstats_storage_test.go b/internal/agent/memstats_storage_test.go new file mode 100644 index 0000000..405a746 --- /dev/null +++ b/internal/agent/memstats_storage_test.go @@ -0,0 +1,70 @@ +package agent + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMemStatsSource(t *testing.T) { + mm := &memStatsSource{} + expectedKeys := map[string]MetricType{ + "Alloc": GaugeType, + "BuckHashSys": GaugeType, + "Frees": GaugeType, + "GCCPUFraction": GaugeType, + "GCSys": GaugeType, + "HeapAlloc": GaugeType, + "HeapIdle": GaugeType, + "HeapInuse": GaugeType, + "HeapObjects": GaugeType, + "HeapReleased": GaugeType, + "HeapSys": GaugeType, + "LastGC": GaugeType, + "Lookups": GaugeType, + "MCacheInuse": GaugeType, + "MCacheSys": GaugeType, + "MSpanInuse": GaugeType, + "MSpanSys": GaugeType, + "Mallocs": GaugeType, + "NextGC": GaugeType, + "NumForcedGC": GaugeType, + "NumGC": GaugeType, + "OtherSys": GaugeType, + "PauseTotalNs": GaugeType, + "StackInuse": GaugeType, + "StackSys": GaugeType, + "Sys": GaugeType, + "TotalAlloc": GaugeType, + "RandomValue": GaugeType, + "PollCount": CounterType, + } + err := mm.Refresh() + require.NoError(t, err) + err = mm.Refresh() + require.NoError(t, err) + err = mm.Refresh() + require.NoError(t, err) + err = mm.Refresh() + require.NoError(t, err) + + metrics := mm.GetMetrics() + + require.Equal(t, len(expectedKeys), len(metrics)) + + var pollCount int64 + + for _, metric := range metrics { + mType, ok := expectedKeys[metric.ID] + require.Truef(t, ok, "pollMetrics doesn't contain key %v", metric.ID) + require.Equalf(t, mType, metric.MType, "pollMetrics contain key %v with different type", metric.ID) + + if metric.ID == "PollCount" { + pollCount = *metric.Delta + } + + } + + assert.Equal(t, int64(4), pollCount) +} diff --git a/internal/agent/runtime_metrics_test.go b/internal/agent/runtime_metrics_test.go deleted file mode 100644 index 4c983b7..0000000 --- a/internal/agent/runtime_metrics_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package agent - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestRuntimeMetrics(t *testing.T) { - rm := &runtimeMetrics{} - expectedKeys := []string{ - "Alloc", - "BuckHashSys", - "Frees", - "GCCPUFraction", - "GCSys", - "HeapAlloc", - "HeapIdle", - "HeapInuse", - "HeapObjects", - "HeapReleased", - "HeapSys", - "LastGC", - "Lookups", - "MCacheInuse", - "MCacheSys", - "MSpanInuse", - "MSpanSys", - "Mallocs", - "NextGC", - "NumForcedGC", - "NumGC", - "OtherSys", - "PauseTotalNs", - "StackInuse", - "StackSys", - "Sys", - "TotalAlloc", - "RandomValue", - } - result := rm.PollMetrics() - assert.Equal(t, len(expectedKeys), len(result)) - for _, expectedKey := range expectedKeys { - _, ok := result[expectedKey] - require.Truef(t, ok, "pollMetrics doesn't contain key %v", expectedKey) - } - - rm.PollMetrics() - rm.PollMetrics() - rm.PollMetrics() - assert.Equal(t, int64(4), rm.PollCount()) -} diff --git a/internal/common.go b/internal/common.go deleted file mode 100644 index f33e319..0000000 --- a/internal/common.go +++ /dev/null @@ -1,43 +0,0 @@ -package internal - -import ( - "fmt" - "net/http" -) - -func BadRequestHandler(w http.ResponseWriter, req *http.Request) { - http.Error(w, "BadRequest", http.StatusBadRequest) -} - -func StatusMethodNotAllowedHandler(w http.ResponseWriter, req *http.Request) { - http.Error(w, "StatusMethodNotAllowed", http.StatusMethodNotAllowed) -} -func StatusNotImplemented(w http.ResponseWriter, req *http.Request) { - http.Error(w, "StatusMethodNotAllowed", http.StatusNotImplemented) -} - -func StatusNotFound(w http.ResponseWriter, req *http.Request) { - http.Error(w, "StatusNotFound", http.StatusNotFound) -} - -func TodoResponse(res http.ResponseWriter, message string) { - res.Header().Set("Content-Type", "application/json") - res.WriteHeader(http.StatusNotImplemented) - fmt.Fprintf(res, ` - { - "response": { - "text": "%v" - }, - "version": "1.0" - } - `, message) -} - -type Middleware func(http.HandlerFunc) http.HandlerFunc - -func Conveyor(h http.HandlerFunc, middlewares ...Middleware) http.HandlerFunc { - for _, middleware := range middlewares { - h = middleware(h) - } - return h -} diff --git a/internal/agent/configuration.go b/internal/config/agent.go similarity index 82% rename from internal/agent/configuration.go rename to internal/config/agent.go index 89f4560..db3f2b2 100644 --- a/internal/agent/configuration.go +++ b/internal/config/agent.go @@ -1,4 +1,4 @@ -package agent +package config import ( "flag" @@ -8,15 +8,15 @@ import ( "github.com/caarlos0/env" ) -type Configuration struct { +type AgentConfiguration struct { ServerAddr string `env:"ADDRESS"` PollInterval int `env:"POLL_INTERVAL"` ReportInterval int `env:"REPORT_INTERVAL"` } -func LoadConfig() (*Configuration, error) { +func LoadAgentConfig() (*AgentConfiguration, error) { - agentCfg := &Configuration{} + agentCfg := &AgentConfiguration{} flag.StringVar(&agentCfg.ServerAddr, "a", "localhost:8080", "serverAddress") flag.IntVar(&agentCfg.PollInterval, "p", 2, "poolInterval in seconds") diff --git a/internal/config/server.go b/internal/config/server.go new file mode 100644 index 0000000..40b5265 --- /dev/null +++ b/internal/config/server.go @@ -0,0 +1,59 @@ +package config + +import ( + "flag" + "fmt" + "os" + "strconv" + + "github.com/caarlos0/env" +) + +type ServerConfiguration struct { + URL string `env:"ADDRESS"` + StoreInterval uint `env:"STORE_INTERVAL"` + FileStoragePath string `env:"FILE_STORAGE_PATH"` + Restore bool `env:"RESTORE"` +} + +type RestoreConfiguration struct { + Restore bool +} + +func (r *RestoreConfiguration) String() string { + return fmt.Sprintf("%v", r.Restore) +} + +func (r *RestoreConfiguration) Set(s string) (err error) { + r.Restore, err = strconv.ParseBool(s) + return +} + +var _ flag.Value = (*RestoreConfiguration)(nil) + +func LoadServerConfig() (*ServerConfiguration, error) { + srvConf := &ServerConfiguration{} + + flag.StringVar(&srvConf.URL, "a", ":8080", "server address (format \":PORT\")") + flag.UintVar(&srvConf.StoreInterval, "i", 300, "Backup store interval in seconds") + flag.StringVar(&srvConf.FileStoragePath, "f", "/tmp/metrics-db.json", "Backup file path") + + // Шаманстрва из-за того, что Go хитро обрабатывает Bool-флаги(проверяет просто наличие флага в коммандной строке) + restoreConf := &RestoreConfiguration{ + Restore: true, // Значение по-умолчанию + } + flag.Var(restoreConf, "r", "is backap restore need") + flag.Usage = func() { + fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) + flag.PrintDefaults() + } + flag.Parse() + + srvConf.Restore = restoreConf.Restore + + err := env.Parse(srvConf) + if err != nil { + return nil, err + } + return srvConf, nil +} diff --git a/internal/server/adapter/fs/formatter/json.go b/internal/server/adapter/fs/formatter/json.go new file mode 100644 index 0000000..9f8a3b1 --- /dev/null +++ b/internal/server/adapter/fs/formatter/json.go @@ -0,0 +1,90 @@ +package formatter + +import ( + "encoding/json" + "errors" + "fmt" + "os" + + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" + "go.uber.org/zap" +) + +const ( + tempFileTemplate = "metrics_backup_*.json.tmp" +) + +func NewJSON(log *zap.SugaredLogger, fileStoragePath string) *jsonFormatter { + jsonFormatter := &jsonFormatter{ + fileStoragePath: fileStoragePath, + logger: log, + } + return jsonFormatter +} + +type jsonFormatter struct { + fileStoragePath string + logger *zap.SugaredLogger +} + +func (jf *jsonFormatter) Write(metricses []domain.Metrics) error { + if jf.fileStoragePath == "" { + jf.logger.Errorw("Write", "status", "error", "msg", "fileStoragePath is not specified") + return os.ErrNotExist + } + + file, err := os.OpenFile(jf.fileStoragePath, os.O_CREATE|os.O_WRONLY, 0660) + if err != nil { + return err + } + + defer file.Close() + + // Пишем во временный файл + tmpDir := os.TempDir() + file, err = os.CreateTemp(tmpDir, tempFileTemplate) + + if err != nil { + jf.logger.Infow("Write", "status", "ok", "error", "can't create temp file") + return err + } + + defer os.Rename(file.Name(), jf.fileStoragePath) // Просто переименовываем временный файл + + err = json.NewEncoder(file).Encode(metricses) + if err != nil { + jf.logger.Errorw("Write", "status", "error", "msg", err.Error()) + return err + } + + jf.logger.Infow("Write", "status", "ok", "msg", fmt.Sprintf("metrics stored into file %v", jf.fileStoragePath)) + return nil +} +func (jf *jsonFormatter) Read() ([]domain.Metrics, error) { + + var result []domain.Metrics + + if jf.fileStoragePath == "" { + jf.logger.Errorw("Read", "status", "error", "msg", "fileStoragePath is not specified") + return result, os.ErrNotExist + } + + file, err := os.Open(jf.fileStoragePath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + jf.logger.Infow("Read", "status", "ok", "msg", fmt.Sprintf("dump file %v not exists", jf.fileStoragePath)) + return result, os.ErrNotExist + } + return nil, err + } + + defer file.Close() + + err = json.NewDecoder(file).Decode(&result) + if err != nil { + jf.logger.Infow("Read", "status", "error", "msg", fmt.Sprintf("can't restore backup from file %v", jf.fileStoragePath)) + return nil, err + } + + return result, nil +} diff --git a/internal/server/adapter/fs/formatter/json_test.go b/internal/server/adapter/fs/formatter/json_test.go new file mode 100644 index 0000000..4f3f646 --- /dev/null +++ b/internal/server/adapter/fs/formatter/json_test.go @@ -0,0 +1,89 @@ +package formatter_test + +import ( + "os" + "reflect" + "testing" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/fs/formatter" + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func getLogger() *zap.SugaredLogger { + logger, err := zap.NewDevelopment() + if err != nil { + // вызываем панику, если ошибка + panic("cannot initialize zap") + } + + return logger.Sugar() +} + +var toWrite = []domain.Metrics{ + {MType: domain.CounterType, ID: "PollCount", Delta: domain.DeltaPtr(1)}, + {MType: domain.GaugeType, ID: "RandomValue", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Alloc", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "BuckHashSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Frees", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "GCCPUFraction", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "GCSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapAlloc", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapIdle", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapInuse", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapObjects", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapReleased", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "LastGC", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Lookups", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "MCacheInuse", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "MCacheSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "MSpanInuse", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "MSpanSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Mallocs", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "NextGC", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "NumForcedGC", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "NumGC", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "OtherSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "PauseTotalNs", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "StackInuse", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "StackSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Sys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "TotalAlloc", Value: domain.ValuePtr(1.123)}, +} + +func TestJsonFormatter(t *testing.T) { + + logger := getLogger() + + // Файл не указан + jF := formatter.NewJSON(logger, "") + restored, err := jF.Read() + require.True(t, len(restored) == 0) + require.ErrorIs(t, os.ErrNotExist, err) + + err = jF.Write(nil) + require.ErrorIs(t, os.ErrNotExist, err) + + // Проверяем сохранение и восстановление + tmpDir := os.TempDir() + file, err := os.CreateTemp(tmpDir, "json_formatter_test*") + + require.NoError(t, err) + + defer os.Remove(file.Name()) + + fileName := file.Name() + jF = formatter.NewJSON(logger, fileName) + restored, err = jF.Read() + require.Error(t, err) // EOF + require.True(t, len(restored) == 0) + + err = jF.Write(toWrite) + require.NoError(t, err) // EOF + + restored, err = jF.Read() + require.NoError(t, err) + require.True(t, reflect.DeepEqual(toWrite, restored)) +} diff --git a/internal/server/adapter/http/handler/handler.go b/internal/server/adapter/http/handler/handler.go new file mode 100644 index 0000000..54d5eb8 --- /dev/null +++ b/internal/server/adapter/http/handler/handler.go @@ -0,0 +1,384 @@ +package handler + +import ( + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "net/http" + "strconv" + "strings" + + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" + "github.com/go-chi/chi/v5" + "go.uber.org/zap" +) + +type MetricUseCase interface { + GetAllMetrics() ([]domain.Metrics, error) + GetCounter(name string) (*domain.Metrics, error) + GetGauge(name string) (*domain.Metrics, error) + AddCounter(m *domain.Metrics) error + SetGauge(m *domain.Metrics) error +} + +func NewHTTP(metricUseCase MetricUseCase, log *zap.SugaredLogger, middlewares ...func(http.Handler) http.Handler) http.Handler { + return createHTTPHandler(&httpAdapter{ + metricController: metricUseCase, + logger: log, + }, middlewares...) +} + +func createHTTPHandler(httpAdapter *httpAdapter, middlewares ...func(http.Handler) http.Handler) http.Handler { + r := chi.NewRouter() + + r.Use(middlewares...) + + r.Get("/", httpAdapter.AllMetrics) + + r.Route("/update", func(r chi.Router) { + r.Post("/", httpAdapter.PostMetrics) + r.Post("/gauge/{name}/{value}", httpAdapter.PostGauge) + r.Post("/gauge/{name}", StatusNotFound) + r.Post("/counter/{name}/{value}", httpAdapter.PostCounter) + r.Post("/counter/{name}", StatusNotFound) + r.Post("/{type}/{name}/{value}", StatusNotImplemented) + }) + + r.Route("/value", func(r chi.Router) { + r.Post("/", httpAdapter.ValueMetrics) + r.Get("/gauge/{name}", httpAdapter.GetGauge) + r.Get("/counter/{name}", httpAdapter.GetCounter) + }) + return r +} + +// TODO много повторяющегося кода +type httpAdapter struct { + metricController MetricUseCase + logger *zap.SugaredLogger +} + +func (h *httpAdapter) PostGauge(w http.ResponseWriter, req *http.Request) { + _, _ = io.ReadAll(req.Body) + // Проверка content-type + contentType := req.Header.Get("Content-Type") + if contentType != "" && !strings.HasPrefix(contentType, TextPlain) { + h.logger.Infow("PostGauge", "status", "error", "msg", fmt.Sprintf("unexpected content-type: %v", contentType)) + http.Error(w, "only 'text/plain' supported", http.StatusUnsupportedMediaType) + return + } + + // Извлекаем имя + name := chi.URLParam(req, "name") + if name == "" { + err := errors.New("name is not set") + h.logger.Infow("PostGauge", "status", "error", "msg", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Проверка допустимости значения параметра + valueStr := chi.URLParam(req, "value") + value, err := ExtractFloat64(valueStr) + if err != nil { + h.logger.Infow("PostGauge", "status", "error", "msg", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + metrics := &domain.Metrics{ + ID: name, + MType: domain.GaugeType, + Value: domain.ValuePtr(value), + } + + if err := h.metricController.SetGauge(metrics); err != nil { + h.handlerStorageError("PostGauge", err, w) + return + } + h.logger.Infow("PostGauge", "name", name, "status", "ok") + w.WriteHeader(http.StatusOK) +} + +func (h *httpAdapter) GetGauge(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", TextPlain) + // Извлекаем имя + name := chi.URLParam(req, "name") + if name == "" { + err := errors.New("name is not set") + h.logger.Infow("GetGauge", "status", "error", "msg", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + m, err := h.metricController.GetGauge(name) + if err != nil { + h.handlerStorageError("GetGauge", err, w) + return + } + + if m == nil { + h.logger.Infow("GetGauge", "status", "error", "msg", fmt.Sprintf("can't find gague by name '%v'", name)) + w.WriteHeader(http.StatusNotFound) + } else { + h.logger.Infow("GetGauge", "name", name, "status", "ok") + w.Write([]byte(fmt.Sprintf("%v", *m.Value))) + } +} + +func (h *httpAdapter) PostCounter(w http.ResponseWriter, req *http.Request) { + _, _ = io.ReadAll(req.Body) + + // Проверка content-type + contentType := req.Header.Get("Content-Type") + if contentType != "" && !strings.HasPrefix(contentType, TextPlain) { + h.logger.Infow("PostCounter", "status", "error", "msg", fmt.Sprintf("unexpected content-type: %v", contentType)) + http.Error(w, "only 'text/plain' supported", http.StatusUnsupportedMediaType) + return + } + + // Извлекаем имя + name := chi.URLParam(req, "name") + if name == "" { + err := errors.New("name is not set") + h.logger.Infow("PostGauge", "status", "error", "msg", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Проверка допустимости значения + valueStr := chi.URLParam(req, "value") + + value, err := ExtractInt64(valueStr) + if err != nil { + h.logger.Infow("PostCounter", "status", "error", "msg", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + metrics := &domain.Metrics{ + ID: name, + MType: domain.CounterType, + Delta: domain.DeltaPtr(value), + } + + if err := h.metricController.AddCounter(metrics); err != nil { + h.handlerStorageError("PostCounter", err, w) + return + } + + h.logger.Infow("PostCounter", "name", name, "status", "ok") +} + +func (h *httpAdapter) GetCounter(w http.ResponseWriter, req *http.Request) { + w.Header().Set("Content-Type", TextPlain) + // Извлекаем имя + name := chi.URLParam(req, "name") + if name == "" { + err := errors.New("name is not set") + h.logger.Infow("GetCounter", "status", "error", "msg", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + m, err := h.metricController.GetCounter(name) + if err != nil { + h.handlerStorageError("GetCounter", err, w) + return + } + + if m == nil { + h.logger.Infow("GetCounter", "status", "error", "msg", fmt.Sprintf("can't find counter by name '%v'", name)) + w.WriteHeader(http.StatusNotFound) + } else { + h.logger.Infow("GetCounter", "name", name, "status", "ok") + w.Write([]byte(fmt.Sprintf("%v", *m.Delta))) + } +} + +func (h *httpAdapter) AllMetrics(w http.ResponseWriter, request *http.Request) { + metricses, err := h.metricController.GetAllMetrics() + if err != nil { + h.handlerStorageError("AllMetrics", err, w) + return + } + + w.Header().Set("Content-Type", TextHTML) + + allMetricsViewTmpl.Execute(w, metricses) + h.logger.Infow("AllMetrics", "status", "ok") +} + +var allMetricsViewTmpl, _ = template.New("allMetrics").Parse(` + + + + + + + + + {{ range .}} + + + {{if .Delta}}{{else}}{{end}} + {{ end}} +
TypeNameValue
{{ .MType }}{{ .ID }}{{ .Delta }}{{ .Value }}
+ + +`) + +func (h *httpAdapter) PostMetrics(w http.ResponseWriter, req *http.Request) { + + if metrics, ok := h.checkRequestBody("PostMetrics", w, req); ok { + var err error + if metrics.MType == "gauge" { + err = h.metricController.SetGauge(metrics) + } + if metrics.MType == "counter" { + err = h.metricController.AddCounter(metrics) + } + + if err != nil { + h.handlerStorageError("PostMetrics", err, w) + return + } + if err := h.sendMetrics("PostMetrics", w, metrics); err == nil { + h.logger.Infow("PostMetrics", "name", metrics.ID, "status", "ok") + } + } +} + +func (h *httpAdapter) ValueMetrics(w http.ResponseWriter, req *http.Request) { + if metrics, ok := h.checkRequestBody("ValueMetrics", w, req); ok { + var err error + var metricsFound *domain.Metrics + + if metrics.MType == "gauge" { + metricsFound, err = h.metricController.GetGauge(metrics.ID) + } + + if metrics.MType == "counter" { + metricsFound, err = h.metricController.GetCounter(metrics.ID) + } + + if err != nil { + h.handlerStorageError("ValueMetrics", err, w) + return + } + + if metricsFound == nil { + h.logger.Infow("ValueMetrics", "status", "error", "msg", + fmt.Sprintf("can't find metrics by ID '%v' and MType '%v'", metrics.ID, metrics.MType)) + w.WriteHeader(http.StatusNotFound) + } else { + if err := h.sendMetrics("ValueMetrics", w, metricsFound); err == nil { + h.logger.Infow("ValueMetrics", "name", metrics.ID, "status", "ok") + } + } + } +} + +func (h *httpAdapter) handlerStorageError(action string, err error, w http.ResponseWriter) { + if errors.Is(err, domain.ErrDataFormat) { + h.logger.Infow(action, "status", "error", "msg", "err", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } else { + h.logger.Infow(action, "status", "error", "msg", "err", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (h *httpAdapter) checkRequestBody(action string, w http.ResponseWriter, req *http.Request) (*domain.Metrics, bool) { + // Проверка content-type + contentType := req.Header.Get("Content-Type") + if contentType != "" && !strings.HasPrefix(contentType, ApplicationJSON) { + h.logger.Infow(action, "status", "error", "msg", fmt.Sprintf("unexpected content-type: %v", contentType)) + http.Error(w, "only 'text/plain' supported", http.StatusUnsupportedMediaType) + return nil, false + } + + // Декодируем входные данные + var metrics domain.Metrics + if err := json.NewDecoder(req.Body).Decode(&metrics); err != nil { + h.logger.Infow(action, "status", "error", "msg", fmt.Sprintf("json decode error: %v", err)) + http.Error(w, err.Error(), http.StatusBadRequest) + return nil, false + } + + // Проверка типа + if metrics.MType != "counter" && metrics.MType != "gauge" { + errMess := fmt.Sprintf("unexpected MType %v, expected 'counter' and 'gauge'", metrics.MType) + h.logger.Infow(action, "status", "error", "msg", errMess) + http.Error(w, errMess, http.StatusBadRequest) + return nil, false + } + + return &metrics, true +} + +func (h *httpAdapter) sendMetrics(action string, w http.ResponseWriter, metrics *domain.Metrics) error { + w.Header().Set("Content-Type", ApplicationJSON) + resp, err := json.Marshal(metrics) + if err != nil { + h.logger.Infow(action, "status", "error", "msg", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return err + } + w.Write(resp) + return nil +} + +func ExtractFloat64(valueStr string) (float64, error) { + value, err := strconv.ParseFloat(valueStr, 64) + if err != nil { + return -1, err + } + return value, nil +} + +func ExtractInt64(valueStr string) (int64, error) { + value, err := strconv.ParseInt(valueStr, 10, 64) + if err != nil { + return -1, err + } + return value, nil +} + +// TODO дублируется с internal/server/server.go +const ( + ApplicationJSON = "application/json" + TextPlain = "text/plain" + TextHTML = "text/html" +) + +func BadRequestHandler(w http.ResponseWriter, req *http.Request) { + http.Error(w, "BadRequest", http.StatusBadRequest) +} + +func StatusMethodNotAllowedHandler(w http.ResponseWriter, req *http.Request) { + http.Error(w, "StatusMethodNotAllowed", http.StatusMethodNotAllowed) +} +func StatusNotImplemented(w http.ResponseWriter, req *http.Request) { + http.Error(w, "StatusMethodNotAllowed", http.StatusNotImplemented) +} + +func StatusNotFound(w http.ResponseWriter, req *http.Request) { + http.Error(w, "StatusNotFound", http.StatusNotFound) +} + +func TodoResponse(res http.ResponseWriter, message string) { + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusNotImplemented) + fmt.Fprintf(res, ` + { + "response": { + "text": "%v" + }, + "version": "1.0" + } + `, message) +} diff --git a/internal/server/adapter/http/handler/handler_test.go b/internal/server/adapter/http/handler/handler_test.go new file mode 100644 index 0000000..75051ba --- /dev/null +++ b/internal/server/adapter/http/handler/handler_test.go @@ -0,0 +1,501 @@ +package handler_test + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/handler" + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestExtractFloat64(t *testing.T) { + type extractFloat64Result struct { + value float64 + isSuccessExpected bool + } + tests := []struct { + name string + input string + result extractFloat64Result + }{ + { + "good value", + "123.5", + extractFloat64Result{ + 123.5, + true, + }, + }, + { + "good value 2", + "123", + extractFloat64Result{ + 123, + true, + }, + }, + { + "bad value", + "123.F", + extractFloat64Result{ + -1, + false, + }, + }, + { + "good value", + "1.8070544e+07", + extractFloat64Result{ + 18070544, + true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value, err := handler.ExtractFloat64(tt.input) + assert.Equal(t, tt.result.value, value) + assert.Equal(t, tt.result.isSuccessExpected, err == nil) + }) + } +} + +func TestExtractInt64(t *testing.T) { + + type extractInt64Result struct { + value int64 + isSuccessExpected bool + } + + tests := []struct { + name string + input string + result extractInt64Result + }{ + { + "good value", + "123", + extractInt64Result{ + 123, + true, + }, + }, + { + "bad value", + "123F", + extractInt64Result{ + -1, + false, + }, + }, + { + "bad value 2", + "123.5", + extractInt64Result{ + -1, + false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + value, err := handler.ExtractInt64(tt.input) + assert.Equal(t, tt.result.value, value) + assert.Equal(t, tt.result.isSuccessExpected, err == nil) + }) + } +} + +type mockMetricUse struct { + counterVal int64 + gaugeVal float64 +} + +func (*mockMetricUse) SetAllMetrics(in []domain.Metrics) error { + return nil +} +func (*mockMetricUse) GetAllMetrics() ([]domain.Metrics, error) { + return []domain.Metrics{ + { + ID: "PoolCount", + MType: domain.CounterType, + Delta: domain.DeltaPtr(1), + }, + { + ID: "RandomValue", + MType: domain.GaugeType, + Value: domain.ValuePtr(1.1), + }, + }, nil +} +func (m *mockMetricUse) GetCounter(name string) (*domain.Metrics, error) { + return &domain.Metrics{ + ID: name, + MType: domain.CounterType, + Delta: domain.DeltaPtr(m.counterVal), + }, nil +} +func (m *mockMetricUse) GetGauge(name string) (*domain.Metrics, error) { + return &domain.Metrics{ + ID: name, + MType: domain.GaugeType, + Value: domain.ValuePtr(m.gaugeVal), + }, nil +} +func (*mockMetricUse) AddCounter(m *domain.Metrics) error { + return nil +} +func (*mockMetricUse) SetGauge(m *domain.Metrics) error { + return nil +} + +func TestPostUpdate(t *testing.T) { + logger, err := zap.NewDevelopment() + if err != nil { + // вызываем панику, если ошибка + panic("cannot initialize zap") + } + defer logger.Sync() + + log := logger.Sugar() + + hHandler := handler.NewHTTP(&mockMetricUse{}, log) + + srv := httptest.NewServer(hHandler) + defer srv.Close() + + req := resty.New().R() + req.Method = http.MethodPost + + req.URL = srv.URL + "/update/" + req.Header.Add("Content-Type", handler.ApplicationJSON) + _, err = req.Send() + require.Nil(t, err) + + req.URL = srv.URL + "/value/" + req.Header.Add("Content-Type", handler.ApplicationJSON) + _, err = req.Send() + assert.Nil(t, err) +} + +func TestCounterValueHandler(t *testing.T) { + testValue := 123 + testValueStr := fmt.Sprintf("%v", testValue) + + logger, err := zap.NewDevelopment() + if err != nil { + // вызываем панику, если ошибка + panic("cannot initialize zap") + } + defer logger.Sync() + + log := logger.Sugar() + + hHandler := handler.NewHTTP(&mockMetricUse{ + counterVal: int64(testValue), + }, log) + + srv := httptest.NewServer(hHandler) + defer srv.Close() + + req := resty.New().R() + req.Method = http.MethodPost + + req.URL = srv.URL + "/update/counter/TestCounter/" + testValueStr + req.Header.Add("Content-Type", handler.TextPlain) + _, err = req.Send() + require.Nil(t, err) + + req = resty.New().R() + req.Method = http.MethodGet + req.URL = srv.URL + "/value/counter/TestCounter" + req.Header.Add("Content-Type", handler.TextPlain) + + resp, err := req.Send() + require.Nil(t, err) + respBody := string(resp.Body()) + require.Equal(t, testValueStr, respBody) + assert.Equal(t, http.StatusOK, resp.StatusCode()) +} + +func TestGaugeValueHandler(t *testing.T) { + + logger, err := zap.NewDevelopment() + if err != nil { + // вызываем панику, если ошибка + panic("cannot initialize zap") + } + defer logger.Sync() + + log := logger.Sugar() + + testValue := 234.123 + testValueStr := fmt.Sprintf("%v", testValue) + + hHandler := handler.NewHTTP(&mockMetricUse{ + gaugeVal: float64(testValue), + }, log) + + srv := httptest.NewServer(hHandler) + defer srv.Close() + + req := resty.New().R() + req.Method = http.MethodPost + req.URL = srv.URL + "/update/gauge/TestCounter/" + testValueStr + req.Header.Add("Content-Type", handler.TextPlain) + _, err = req.Send() + require.Nil(t, err) + + req = resty.New().R() + req.Method = http.MethodGet + req.URL = srv.URL + "/value/gauge/TestCounter" + req.Header.Add("Content-Type", handler.TextPlain) + resp, err := req.Send() + require.Nil(t, err) + respBody := string(resp.Body()) + require.Equal(t, testValueStr, respBody) // Не попасть бы на потерю точности string -> float64 -> string + assert.Equal(t, http.StatusOK, resp.StatusCode()) +} + +func TestGetAll(t *testing.T) { + logger, err := zap.NewDevelopment() + if err != nil { + // вызываем панику, если ошибка + panic("cannot initialize zap") + } + defer logger.Sync() + + log := logger.Sugar() + + hHandler := handler.NewHTTP(&mockMetricUse{}, log) + + srv := httptest.NewServer(hHandler) + defer srv.Close() + + req := resty.New().R() + req.Method = http.MethodGet + + req.URL = srv.URL + + resp, err := req.Send() + require.Nil(t, err) + + assert.Equal(t, http.StatusOK, resp.StatusCode()) +} + +type mockMetricUse2 struct { + metrics *domain.Metrics +} + +func (*mockMetricUse2) SetAllMetrics(in []domain.Metrics) error { + return nil +} +func (m2 *mockMetricUse2) GetAllMetrics() ([]domain.Metrics, error) { + var out []domain.Metrics + if m2.metrics != nil { + out = append(out, *m2.metrics) + } + return out, nil + +} +func (m2 *mockMetricUse2) GetCounter(name string) (*domain.Metrics, error) { + if m2.metrics != nil { + if m2.metrics.ID == name && m2.metrics.MType == domain.CounterType { + return m2.metrics, nil + } + } + return nil, nil +} +func (m2 *mockMetricUse2) GetGauge(name string) (*domain.Metrics, error) { + if m2.metrics != nil { + if m2.metrics.ID == name && m2.metrics.MType == domain.GaugeType { + return m2.metrics, nil + } + } + return nil, nil +} +func (m2 *mockMetricUse2) AddCounter(m *domain.Metrics) error { + if m.MType == domain.CounterType { + m2.metrics = m + } else { + return fmt.Errorf("unexpected input") + } + return nil +} +func (m2 *mockMetricUse2) SetGauge(m *domain.Metrics) error { + if m.MType == domain.GaugeType { + m2.metrics = m + } else { + return fmt.Errorf("unexpected input") + } + return nil +} + +func TestCounterPostAndValue(t *testing.T) { + logger, err := zap.NewDevelopment() + if err != nil { + // вызываем панику, если ошибка + panic("cannot initialize zap") + } + defer logger.Sync() + + log := logger.Sugar() + + hHandler := handler.NewHTTP(&mockMetricUse2{}, log) + + srv := httptest.NewServer(hHandler) + defer srv.Close() + + // Метрик нет + req := resty.New().R() + req.Method = http.MethodGet + req.URL = srv.URL + + resp, err := req.Send() + require.Nil(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) + + req.Method = http.MethodPost + req.Header.Add("Content-Type", handler.ApplicationJSON) + req.URL = srv.URL + "/value" + metrics := domain.Metrics{ + ID: "PoolCount", + MType: domain.CounterType, + } + + req.SetBody(metrics) + + resp, err = req.Send() + require.Nil(t, err) + require.Equal(t, resp.StatusCode(), http.StatusNotFound) + + // Добавляем метрику + + req.Header.Add("Content-Type", handler.ApplicationJSON) + req.URL = srv.URL + "/update" + + metricsReq := domain.Metrics{ + ID: "PoolCount", + MType: domain.CounterType, + Delta: domain.DeltaPtr(2), + } + + req.SetBody(metricsReq) + + resp, err = req.Send() + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + // Проверяем метрику + req.Method = http.MethodPost + req.Header.Add("Content-Type", handler.ApplicationJSON) + req.URL = srv.URL + "/value" + metrics = domain.Metrics{ + ID: metricsReq.ID, + MType: metricsReq.MType, + } + + req.SetBody(metrics) + + resp, err = req.Send() + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + var respMetrics domain.Metrics + err = json.Unmarshal(resp.Body(), &respMetrics) + require.Nil(t, err) + + require.Equal(t, metricsReq.ID, respMetrics.ID) + require.Equal(t, metricsReq.MType, respMetrics.MType) + require.NotNil(t, respMetrics.Delta) + require.Nil(t, respMetrics.Value) + require.Equal(t, *metricsReq.Delta, *respMetrics.Delta) +} + +func TestGaguePostAndValue(t *testing.T) { + logger, err := zap.NewDevelopment() + if err != nil { + // вызываем панику, если ошибка + panic("cannot initialize zap") + } + defer logger.Sync() + + log := logger.Sugar() + + hHandler := handler.NewHTTP(&mockMetricUse2{}, log) + + srv := httptest.NewServer(hHandler) + defer srv.Close() + + // Метрик нет + req := resty.New().R() + req.Method = http.MethodGet + req.URL = srv.URL + + resp, err := req.Send() + require.Nil(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) + + req.Method = http.MethodPost + req.Header.Add("Content-Type", handler.ApplicationJSON) + req.URL = srv.URL + "/value" + metrics := domain.Metrics{ + ID: "RandomValue", + MType: domain.GaugeType, + } + + req.SetBody(metrics) + + resp, err = req.Send() + require.Nil(t, err) + require.Equal(t, resp.StatusCode(), http.StatusNotFound) + + // Добавляем метрику + + req.Header.Add("Content-Type", handler.ApplicationJSON) + req.URL = srv.URL + "/update" + + metricsReq := domain.Metrics{ + ID: "RandomValue", + MType: domain.GaugeType, + Value: domain.ValuePtr(2), + } + + req.SetBody(metricsReq) + + resp, err = req.Send() + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + // Проверяем метрику + req.Method = http.MethodPost + req.Header.Add("Content-Type", handler.ApplicationJSON) + req.URL = srv.URL + "/value" + metrics = domain.Metrics{ + ID: metricsReq.ID, + MType: metricsReq.MType, + } + + req.SetBody(metrics) + + resp, err = req.Send() + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + + var respMetrics domain.Metrics + err = json.Unmarshal(resp.Body(), &respMetrics) + require.Nil(t, err) + + require.Equal(t, metricsReq.ID, respMetrics.ID) + require.Equal(t, metricsReq.MType, respMetrics.MType) + require.Nil(t, respMetrics.Delta) + require.NotNil(t, respMetrics.Value) + require.Equal(t, *metricsReq.Value, *respMetrics.Value) +} diff --git a/internal/server/adapter/http/middleware/common.go b/internal/server/adapter/http/middleware/common.go new file mode 100644 index 0000000..13107ca --- /dev/null +++ b/internal/server/adapter/http/middleware/common.go @@ -0,0 +1,12 @@ +package middleware + +import "net/http" + +type Middleware func(http.Handler) http.Handler + +func Conveyor(h http.Handler, middlewares ...Middleware) http.Handler { + for _, middleware := range middlewares { + h = middleware(h) + } + return h +} diff --git a/internal/server/adapter/http/middleware/compress/compress_gzip_buffer_response_mw.go b/internal/server/adapter/http/middleware/compress/compress_gzip_buffer_response_mw.go new file mode 100644 index 0000000..b780eee --- /dev/null +++ b/internal/server/adapter/http/middleware/compress/compress_gzip_buffer_response_mw.go @@ -0,0 +1,62 @@ +package compress + +import ( + "bytes" + "compress/gzip" + "fmt" + "io" + "net/http" + "strings" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware" + "go.uber.org/zap" +) + +type bufferWriter struct { + http.ResponseWriter + Writer io.Writer +} + +func (w bufferWriter) Write(b []byte) (int, error) { + return w.Writer.Write(b) +} + +// Вариант мидлы через буфер. Можно оценить ответ. +func NewCompressGZIPBufferResponseMW(log *zap.SugaredLogger) middleware.Middleware { + return func(next http.Handler) http.Handler { + cmprFn := func(w http.ResponseWriter, r *http.Request) { + acceptEncodingReqHeader := r.Header.Get("Accept-Encoding") + if !strings.Contains(acceptEncodingReqHeader, "gzip") { + next.ServeHTTP(w, r) + } else { + var buff bytes.Buffer // Пишем в буфер + next.ServeHTTP(bufferWriter{ResponseWriter: w, Writer: &buff}, r) + + contentTypeRespHeader := w.Header().Get("Content-Type") + if strings.Contains(contentTypeRespHeader, "application/json") || + strings.Contains(contentTypeRespHeader, "text/html") { + w.Header().Add("Content-Encoding", "gzip") // добавляем заголовок + gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed) + if err != nil { + log.Errorln("writeGZIP", "desc", "can't initialize gzip", "err", err.Error()) + http.Error(w, "can't initialize gzip", http.StatusInternalServerError) + return + } + defer gz.Close() + _, err = gz.Write(buff.Bytes()) + if err != nil { + log.Errorln("writeGZIP", "status", "err", "msg", fmt.Sprintf("can't initialize gzip: %v", err.Error())) + http.Error(w, "can't write gzip", http.StatusInternalServerError) + return + } + + log.Infow("writeGZIP", "header", "Content-Type", "value", contentTypeRespHeader, "msg", "response will be zipped") + } else { + w.Write(buff.Bytes()) + } + + } + } + return http.HandlerFunc(cmprFn) + } +} diff --git a/internal/server/adapter/http/middleware/compress/compress_gzip_buffer_response_mw_test.go b/internal/server/adapter/http/middleware/compress/compress_gzip_buffer_response_mw_test.go new file mode 100644 index 0000000..26e07f2 --- /dev/null +++ b/internal/server/adapter/http/middleware/compress/compress_gzip_buffer_response_mw_test.go @@ -0,0 +1,119 @@ +package compress_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware" + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware/compress" + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestCompressGZIPBufferResponseMW(t *testing.T) { + + logger, err := zap.NewDevelopment() + require.NoError(t, err) + defer logger.Sync() + mux := http.NewServeMux() + + suga := logger.Sugar() + + compressMW := compress.NewCompressGZIPBufferResponseMW(suga) + + mux.Handle("/json", middleware.Conveyor(defaultJSONHandle{}, compressMW)) + mux.Handle("/html", middleware.Conveyor(defaultHTMLHandle{}, compressMW)) + mux.Handle("/text", middleware.Conveyor(defaultTextHandle{}, compressMW)) + + srv := httptest.NewServer(mux) + defer srv.Close() + + testCases := []struct { + Name string + Path string + AcceptEncoding string + ContentEncoding bool + }{ + { + "json_gzip", + "/json", + "gzip", + true, + }, + { + "json", + "/json", + "", + false, + }, + { + "json_deflate", + "/json", + "deflate", + false, + }, + { + "html_gzip", + "/html", + "gzip", + true, + }, + { + "html", + "/html", + "", + false, + }, + { + "html_deflate", + "/html", + "deflate", + false, + }, + { + "text_gzip", + "/text", + "gzip", + false, + }, + { + "text", + "/text", + "", + false, + }, + { + "text_deflate", + "/text", + "deflate", + false, + }, + } + + req := resty.New().R() + req.Method = http.MethodPost + + for _, tt := range testCases { + t.Run(tt.Name, func(t *testing.T) { + req.URL = srv.URL + tt.Path + if tt.AcceptEncoding == "" { + req.Header.Del("Accept-Encoding") + } else { + req.Header.Set("Accept-Encoding", tt.AcceptEncoding) + } + resp, err := req.Send() + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + if tt.ContentEncoding { + assert.True(t, strings.Contains(resp.Header().Get("Content-Encoding"), "gzip")) + } else { + assert.False(t, strings.Contains(resp.Header().Get("Content-Encoding"), "gzip")) + } + }) + } + +} diff --git a/internal/server/adapter/http/middleware/compress/compress_gzip_response_mw.go b/internal/server/adapter/http/middleware/compress/compress_gzip_response_mw.go new file mode 100644 index 0000000..b88e7ba --- /dev/null +++ b/internal/server/adapter/http/middleware/compress/compress_gzip_response_mw.go @@ -0,0 +1,57 @@ +package compress + +import ( + "io" + "net/http" + "strings" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware" + gPool "github.com/ungerik/go-pool" + "go.uber.org/zap" +) + +// Вариант мидлы без дополнительного буфера при обработке ответа. +func NewCompressGZIPResponseMW(log *zap.SugaredLogger) middleware.Middleware { + return func(next http.Handler) http.Handler { + cmprFn := func(w http.ResponseWriter, r *http.Request) { + acceptEncodingReqHeader := r.Header.Get("Accept-Encoding") + if !strings.Contains(acceptEncodingReqHeader, "gzip") { + next.ServeHTTP(w, r) + } else { + gz := gPool.Gzip.GetWriter(w) + defer gPool.Gzip.PutWriter(gz) + log.Infow("testAcceptEncoding", "AcceptEncoding", "exists", "value", acceptEncodingReqHeader) + next.ServeHTTP(gzipWriter{log: log, ResponseWriter: w, Writer: gz, IsGZIPHeaderRecorded: false, IsContentTypeVerified: false}, r) + } + } + return http.HandlerFunc(cmprFn) + } +} + +type gzipWriter struct { + log *zap.SugaredLogger + http.ResponseWriter + Writer io.Writer + IsGZIPHeaderRecorded bool + IsContentTypeVerified bool +} + +func (w gzipWriter) Write(b []byte) (int, error) { + // С помощью двух флагов определяем нужно ли записать заголовок и сжать + if !w.IsContentTypeVerified { + w.IsContentTypeVerified = true + contentTypeRespHeader := w.Header().Get("Content-Type") + if strings.Contains(contentTypeRespHeader, "application/json") || + strings.Contains(contentTypeRespHeader, "text/html") { + w.IsGZIPHeaderRecorded = true + w.Header().Add("Content-Encoding", "gzip") // добавляем заголовок + w.log.Infow("gzipResponse", "contentType", contentTypeRespHeader) + } + } + + if w.IsGZIPHeaderRecorded { + return w.Writer.Write(b) // пишем в gzip + } else { + return w.ResponseWriter.Write(b) // пишем в обычный поток + } +} diff --git a/internal/server/adapter/http/middleware/compress/compress_gzip_response_mw_test.go b/internal/server/adapter/http/middleware/compress/compress_gzip_response_mw_test.go new file mode 100644 index 0000000..b4314cf --- /dev/null +++ b/internal/server/adapter/http/middleware/compress/compress_gzip_response_mw_test.go @@ -0,0 +1,119 @@ +package compress_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware" + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware/compress" + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestCompressGZIPResponseMW(t *testing.T) { + + logger, err := zap.NewDevelopment() + require.NoError(t, err) + defer logger.Sync() + mux := http.NewServeMux() + + suga := logger.Sugar() + + compressMW := compress.NewCompressGZIPResponseMW(suga) + + mux.Handle("/json", middleware.Conveyor(defaultJSONHandle{}, compressMW)) + mux.Handle("/html", middleware.Conveyor(defaultHTMLHandle{}, compressMW)) + mux.Handle("/text", middleware.Conveyor(defaultTextHandle{}, compressMW)) + + srv := httptest.NewServer(mux) + defer srv.Close() + + testCases := []struct { + Name string + Path string + AcceptEncoding string + ContentEncoding bool + }{ + { + "json_gzip", + "/json", + "gzip", + true, + }, + { + "json", + "/json", + "", + false, + }, + { + "json_deflate", + "/json", + "deflate", + false, + }, + { + "html_gzip", + "/html", + "gzip", + true, + }, + { + "html", + "/html", + "", + false, + }, + { + "html_deflate", + "/html", + "deflate", + false, + }, + { + "text_gzip", + "/text", + "gzip", + false, + }, + { + "text", + "/text", + "", + false, + }, + { + "text_deflate", + "/text", + "deflate", + false, + }, + } + + req := resty.New().R() + req.Method = http.MethodPost + + for _, tt := range testCases { + t.Run(tt.Name, func(t *testing.T) { + req.URL = srv.URL + tt.Path + if tt.AcceptEncoding == "" { + req.Header.Del("Accept-Encoding") + } else { + req.Header.Set("Accept-Encoding", tt.AcceptEncoding) + } + resp, err := req.Send() + require.Nil(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode()) + if tt.ContentEncoding { + assert.True(t, strings.Contains(resp.Header().Get("Content-Encoding"), "gzip")) + } else { + assert.False(t, strings.Contains(resp.Header().Get("Content-Encoding"), "gzip")) + } + }) + } + +} diff --git a/internal/server/adapter/http/middleware/compress/compress_utils_test.go b/internal/server/adapter/http/middleware/compress/compress_utils_test.go new file mode 100644 index 0000000..3f8c46b --- /dev/null +++ b/internal/server/adapter/http/middleware/compress/compress_utils_test.go @@ -0,0 +1,46 @@ +package compress_test + +import ( + "bytes" + "io" + "net/http" + "strings" +) + +type checkBodyHandler struct { + expected []byte +} + +func (ch *checkBodyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + + if err != nil && err != io.EOF { + http.Error(w, "read body err", http.StatusInternalServerError) + } + + if !bytes.Equal(ch.expected, body) { + http.Error(w, "unexpected body err", http.StatusBadRequest) + } +} + +type defaultHTMLHandle struct{} + +func (defaultHTMLHandle) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + io.WriteString(w, ""+strings.Repeat("Hello, world
", 20)+"") +} + +type defaultTextHandle struct{} + +func (defaultTextHandle) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + io.WriteString(w, strings.Repeat("Hello, world\n", 20)) + +} + +type defaultJSONHandle struct{} + +func (defaultJSONHandle) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + io.WriteString(w, "{ "+strings.Repeat(`"msg":"Hello, world",`, 19)+`"msg":"Hello, world"`+"}") +} diff --git a/internal/server/adapter/http/middleware/compress/uncompress_gzip_request_mw.go b/internal/server/adapter/http/middleware/compress/uncompress_gzip_request_mw.go new file mode 100644 index 0000000..72f68b4 --- /dev/null +++ b/internal/server/adapter/http/middleware/compress/uncompress_gzip_request_mw.go @@ -0,0 +1,41 @@ +package compress + +import ( + "compress/gzip" + "io" + "net/http" + "strings" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware" + "go.uber.org/zap" +) + +type gzipreadCloser struct { + *gzip.Reader + io.Closer +} + +func (gz gzipreadCloser) Close() error { + return gz.Closer.Close() +} + +func NewUncompressGZIPRequestMW(log *zap.SugaredLogger) middleware.Middleware { + + return func(next http.Handler) http.Handler { + uncmprFn := func(w http.ResponseWriter, r *http.Request) { + contentEncodingHeader := r.Header.Get("Content-Encoding") + if strings.Contains(contentEncodingHeader, "gzip") { + zr, err := gzip.NewReader(r.Body) + if err != nil { + log.Infow("uncompress request error:", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + r.Body = gzipreadCloser{zr, r.Body} + log.Infow("readGZIP", "Content-Encoding", r.Header.Get("Content-Encoding"), "msq", "the request will be unzip") + } + next.ServeHTTP(w, r) + } + return http.HandlerFunc(uncmprFn) + } +} diff --git a/internal/server/adapter/http/middleware/compress/uncompress_gzip_request_mw_test.go b/internal/server/adapter/http/middleware/compress/uncompress_gzip_request_mw_test.go new file mode 100644 index 0000000..4c108d1 --- /dev/null +++ b/internal/server/adapter/http/middleware/compress/uncompress_gzip_request_mw_test.go @@ -0,0 +1,73 @@ +package compress_test + +import ( + "bytes" + "compress/gzip" + "net/http" + "net/http/httptest" + "testing" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware" + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware/compress" + "github.com/go-resty/resty/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestUncompressGZIPRequestMW(t *testing.T) { + + logger, err := zap.NewDevelopment() + require.NoError(t, err) + defer logger.Sync() + + suga := logger.Sugar() + + content := []byte("Hello World") + + handler := checkBodyHandler{ + expected: content, + } + + uncompressMW := compress.NewUncompressGZIPRequestMW(suga) + + srv := httptest.NewServer(middleware.Conveyor(&handler, uncompressMW)) + defer srv.Close() + + testCases := []struct { + Name string + Compress bool + }{ + { + Name: "compressed", + Compress: true, + }, + + { + Name: "uncompressed", + Compress: false, + }, + } + + for _, tt := range testCases { + t.Run(tt.Name, func(t *testing.T) { + req := resty.New().R(). + SetHeader("Content-Type", "text/plain; charset=UTF-8") + if tt.Compress { + req.SetHeader("Content-Encoding", "gzip") + var buff bytes.Buffer + gz, err := gzip.NewWriterLevel(&buff, gzip.BestCompression) + require.NoError(t, err) + gz.Write(content) + gz.Close() + req.SetBody(buff.Bytes()) + } else { + req.SetBody(content) + } + + resp, err := req.Post(srv.URL) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode()) + }) + } +} diff --git a/internal/server/adapter/http/middleware/logging/logging_request_info_mw.go b/internal/server/adapter/http/middleware/logging/logging_request_info_mw.go new file mode 100644 index 0000000..8dc5b7e --- /dev/null +++ b/internal/server/adapter/http/middleware/logging/logging_request_info_mw.go @@ -0,0 +1,30 @@ +package logging + +import ( + "net/http" + "time" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware" + "go.uber.org/zap" +) + +func NewLoggingRequestMW(log *zap.SugaredLogger) middleware.Middleware { + return func(next http.Handler) http.Handler { + logReqFn := func(w http.ResponseWriter, req *http.Request) { + start := time.Now() + uri := req.RequestURI + method := req.Method + + next.ServeHTTP(w, req) + + duration := time.Since(start) + + log.Infow("requestStatus", + "uri", uri, + "method", method, + "duration", duration, + ) + } + return http.HandlerFunc(logReqFn) + } +} diff --git a/internal/server/adapter/http/middleware/logging/logging_response_info_mw.go b/internal/server/adapter/http/middleware/logging/logging_response_info_mw.go new file mode 100644 index 0000000..d8db404 --- /dev/null +++ b/internal/server/adapter/http/middleware/logging/logging_response_info_mw.go @@ -0,0 +1,53 @@ +package logging + +import ( + "net/http" + + "github.com/StasMerzlyakov/go-metrics/internal/server/adapter/http/middleware" + "go.uber.org/zap" +) + +type responseData struct { + status int + size int +} + +type loggingResponseWriter struct { + http.ResponseWriter + responseData *responseData +} + +var _ http.ResponseWriter = (*loggingResponseWriter)(nil) + +func (lw *loggingResponseWriter) Header() http.Header { + return lw.ResponseWriter.Header() +} + +func (lw *loggingResponseWriter) Write(data []byte) (int, error) { + size, err := lw.ResponseWriter.Write(data) + lw.responseData.size += size + return size, err +} + +func (lw *loggingResponseWriter) WriteHeader(statusCode int) { + lw.ResponseWriter.WriteHeader(statusCode) + lw.responseData.status = statusCode +} + +func NewLoggingResponseMW(log *zap.SugaredLogger) middleware.Middleware { + return func(next http.Handler) http.Handler { + lrw := func(w http.ResponseWriter, r *http.Request) { + lw := &loggingResponseWriter{ + responseData: &responseData{ + status: 0, + size: 0, + }, + ResponseWriter: w, + } + + next.ServeHTTP(lw, r) + log.Infow("requestResult", "statusCode", lw.responseData.status, "size", lw.responseData.size) + } + return http.HandlerFunc(lrw) + } +} diff --git a/internal/server/configuration.go b/internal/server/configuration.go deleted file mode 100644 index 7d2c118..0000000 --- a/internal/server/configuration.go +++ /dev/null @@ -1,41 +0,0 @@ -package server - -import ( - "flag" - "fmt" - "os" -) - -type Configuration struct { - url string -} - -func (c *Configuration) String() string { - return c.url -} - -func (c *Configuration) Set(s string) error { - c.url = s - return nil -} - -var _ flag.Value = (*Configuration)(nil) - -func LoadConfig() (*Configuration, error) { - srvConf := &Configuration{} - srvConf.Set(":8080") // Значение по-умолчанию - - flag.Var(srvConf, "a", "serverAddress") - flag.Usage = func() { - fmt.Fprintf(flag.CommandLine.Output(), "Usage of %s:\n", os.Args[0]) - flag.PrintDefaults() - } - flag.Parse() - - addr, isExists := os.LookupEnv("ADDRESS") - if isExists { - srvConf.Set(addr) - } - - return srvConf, nil -} diff --git a/internal/server/controller.go b/internal/server/controller.go deleted file mode 100644 index 66ea8cf..0000000 --- a/internal/server/controller.go +++ /dev/null @@ -1,76 +0,0 @@ -package server - -import ( - "fmt" - - "github.com/StasMerzlyakov/go-metrics/internal/storage" -) - -type MetricController interface { - GetAllMetrics() MetricModel - GetCounter(name string) (int64, bool) - GetGaguge(name string) (float64, bool) - AddCounter(name string, value int64) - SetGauge(name string, value float64) -} - -func NewMetricController(counterStorage storage.MetricsStorage[int64], - gaugeStorage storage.MetricsStorage[float64]) MetricController { - return &metricController{ - counterStorage: counterStorage, - gaugeStorage: gaugeStorage, - } -} - -type MetricsData struct { - Type string - Name string - Value string -} - -type MetricModel struct { - Items []MetricsData -} - -type metricController struct { - counterStorage storage.MetricsStorage[int64] - gaugeStorage storage.MetricsStorage[float64] -} - -func (mc *metricController) GetAllMetrics() MetricModel { - items := MetricModel{} - for _, k := range mc.counterStorage.Keys() { - v, _ := mc.counterStorage.Get(k) - items.Items = append(items.Items, MetricsData{ - "counter", - k, - fmt.Sprintf("%v", v), - }) - } - - for _, k := range mc.gaugeStorage.Keys() { - v, _ := mc.gaugeStorage.Get(k) - items.Items = append(items.Items, MetricsData{ - "counter", - k, - fmt.Sprintf("%v", v), - }) - } - return items -} - -func (mc *metricController) GetCounter(name string) (int64, bool) { - return mc.counterStorage.Get(name) -} - -func (mc *metricController) GetGaguge(name string) (float64, bool) { - return mc.gaugeStorage.Get(name) -} - -func (mc *metricController) AddCounter(name string, value int64) { - mc.counterStorage.Add(name, value) -} - -func (mc *metricController) SetGauge(name string, value float64) { - mc.gaugeStorage.Set(name, value) -} diff --git a/internal/server/domain/common_types.go b/internal/server/domain/common_types.go new file mode 100644 index 0000000..c744987 --- /dev/null +++ b/internal/server/domain/common_types.go @@ -0,0 +1,5 @@ +package domain + +type ChangeListener interface { + Refresh(metrics *Metrics) error +} diff --git a/internal/server/domain/entities.go b/internal/server/domain/entities.go new file mode 100644 index 0000000..50621e6 --- /dev/null +++ b/internal/server/domain/entities.go @@ -0,0 +1,15 @@ +package domain + +type Metrics struct { + ID string `json:"id"` // имя метрики + MType MetricType `json:"type"` // параметр, принимающий значение gauge или counter + Delta *int64 `json:"delta,omitempty"` // значение метрики в случае передачи counter + Value *float64 `json:"value,omitempty"` // значение метрики в случае передачи gauge +} + +type MetricType string + +const ( + GaugeType MetricType = "gauge" + CounterType MetricType = "counter" +) diff --git a/internal/server/domain/errors.go b/internal/server/domain/errors.go new file mode 100644 index 0000000..ed8de99 --- /dev/null +++ b/internal/server/domain/errors.go @@ -0,0 +1,8 @@ +package domain + +import "errors" + +var ( + ErrServerInternal = errors.New("InternalError") // Ошибка на сервере + ErrDataFormat = errors.New("DataFormatError") // Ошибка в данных +) diff --git a/internal/server/domain/usefull.go b/internal/server/domain/usefull.go new file mode 100644 index 0000000..8188519 --- /dev/null +++ b/internal/server/domain/usefull.go @@ -0,0 +1,9 @@ +package domain + +func DeltaPtr(v int64) *int64 { + return &v +} + +func ValuePtr(v float64) *float64 { + return &v +} diff --git a/internal/server/handlers.go b/internal/server/handlers.go deleted file mode 100644 index 92f354c..0000000 --- a/internal/server/handlers.go +++ /dev/null @@ -1,120 +0,0 @@ -package server - -import ( - "fmt" - "html/template" - "io" - "net/http" - - "github.com/StasMerzlyakov/go-metrics/internal" - "github.com/go-chi/chi/v5" -) - -type BusinessHandler interface { - PostGauge(w http.ResponseWriter, req *http.Request) - GetGauge(w http.ResponseWriter, req *http.Request) - PostCounter(w http.ResponseWriter, req *http.Request) - GetCounter(w http.ResponseWriter, req *http.Request) - AllMetrics(w http.ResponseWriter, request *http.Request) -} - -func NewBusinessHandler(metricController MetricController) BusinessHandler { - return &handler{ - metricController: metricController, - } -} - -type handler struct { - metricController MetricController -} - -func (h *handler) PostGauge(w http.ResponseWriter, req *http.Request) { - _, _ = io.ReadAll(req.Body) - name := chi.URLParam(req, "name") - valueStr := chi.URLParam(req, "value") - value, err := ExtractFloat64(valueStr) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - h.metricController.SetGauge(name, value) -} - -func (h *handler) GetGauge(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - name := chi.URLParam(req, "name") - if v, ok := h.metricController.GetGaguge(name); !ok { - w.WriteHeader(http.StatusNotFound) - return - } else { - w.Write([]byte(fmt.Sprintf("%v", v))) - } -} - -func (h *handler) PostCounter(w http.ResponseWriter, req *http.Request) { - _, _ = io.ReadAll(req.Body) - name := chi.URLParam(req, "name") - valueStr := chi.URLParam(req, "value") - value, err := ExtractInt64(valueStr) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - h.metricController.AddCounter(name, value) -} - -func (h *handler) GetCounter(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "text/plain; charset=utf-8") - name := chi.URLParam(req, "name") - if v, ok := h.metricController.GetCounter(name); !ok { - w.WriteHeader(http.StatusNotFound) - } else { - w.Write([]byte(fmt.Sprintf("%v", v))) - } -} - -func (h *handler) AllMetrics(w http.ResponseWriter, request *http.Request) { - metrics := h.metricController.GetAllMetrics() - allMetricsViewTmpl.Execute(w, metrics) -} - -var allMetricsViewTmpl, _ = template.New("allMetrics").Parse(` - - - - - - - - - {{ range .Items}} - - - - - - {{ end}} -
TypeNameValue
{{ .Type }}{{ .Name }}{{ .Value }}
- - -`) - -func CreateFullPostCounterHandler(counterHandler http.HandlerFunc) http.HandlerFunc { - return internal.Conveyor( - counterHandler, - CheckIntegerMiddleware, - CheckMetricNameMiddleware, - CheckContentTypeMiddleware, - CheckMethodPostMiddleware, - ) -} - -func CreateFullPostGaugeHandler(gaugeHandler http.HandlerFunc) http.HandlerFunc { - return internal.Conveyor( - gaugeHandler, - CheckDigitalMiddleware, - CheckMetricNameMiddleware, - CheckContentTypeMiddleware, - CheckMethodPostMiddleware, - ) -} diff --git a/internal/server/middleware.go b/internal/server/middleware.go deleted file mode 100644 index 0a026e2..0000000 --- a/internal/server/middleware.go +++ /dev/null @@ -1,62 +0,0 @@ -package server - -import ( - "net/http" - "strings" - - "github.com/go-chi/chi/v5" -) - -func CheckMethodPostMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - if req.Method != http.MethodPost { - http.Error(w, "only post methods", http.StatusMethodNotAllowed) - return - } - next.ServeHTTP(w, req) - } -} - -func CheckContentTypeMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - contentType := req.Header.Get("Content-Type") - if contentType != "" && !strings.HasPrefix(contentType, "text/plain") { - http.Error(w, "only 'text/plain' supported", http.StatusUnsupportedMediaType) - return - } - next.ServeHTTP(w, req) - } -} - -func CheckDigitalMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - valueStr := chi.URLParam(req, "value") - if !CheckDecimal(valueStr) { - http.Error(w, "wrong decimal value", http.StatusBadRequest) - return - } - next.ServeHTTP(w, req) - } -} - -func CheckIntegerMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - valueStr := chi.URLParam(req, "value") - if !CheckInteger(valueStr) { - http.Error(w, "wrong integer value", http.StatusBadRequest) - return - } - next.ServeHTTP(w, req) - } -} - -func CheckMetricNameMiddleware(next http.HandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, req *http.Request) { - name := chi.URLParam(req, "name") - if !CheckName(name) { - http.Error(w, "wrong name value", http.StatusBadRequest) - return - } - next.ServeHTTP(w, req) - } -} diff --git a/internal/server/middleware_test.go b/internal/server/middleware_test.go deleted file mode 100644 index 430fa3e..0000000 --- a/internal/server/middleware_test.go +++ /dev/null @@ -1,170 +0,0 @@ -package server - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-resty/resty/v2" - "github.com/stretchr/testify/assert" -) - -const textPlaint = "text/plain; charset=utf-8" - -type mockBusinessHandler struct{} - -func (*mockBusinessHandler) PostGauge(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", textPlaint) - -} -func (*mockBusinessHandler) GetGauge(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", textPlaint) -} -func (*mockBusinessHandler) PostCounter(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", textPlaint) -} -func (*mockBusinessHandler) GetCounter(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", textPlaint) -} -func (*mockBusinessHandler) AllMetrics(w http.ResponseWriter, request *http.Request) { - w.Header().Set("Content-Type", textPlaint) -} - -func TestServerMiddlewareChain(t *testing.T) { - mockBusinessHandler := &mockBusinessHandler{} - serverHandler := CreateFullHTTPHandler(mockBusinessHandler) - - srv := httptest.NewServer(serverHandler) - defer srv.Close() - testCases := []struct { - name string - url string - method string - contentType string - want want - }{ - { - "unknown type", - "/update/unknown/testCounter/100", - http.MethodPost, - "text/plain", - want{ - http.StatusNotImplemented, - textPlaint, - }, - }, - { - "wrong content type", - "/update/gauge/m1/123", - http.MethodPost, - "application/json", - want{ - http.StatusUnsupportedMediaType, - textPlaint, - }, - }, - { - "wong metric value", - "/update/gauge/m1/123_", - http.MethodPost, - textPlaint, - want{ - http.StatusBadRequest, - textPlaint, - }, - }, - { - "float value", - "/update/gauge/m1/123.05", - http.MethodPost, - textPlaint, - want{ - http.StatusOK, - textPlaint, - }, - }, - { - "negative value", - "/update/gauge/m1/-123.05", - http.MethodPost, - textPlaint, - want{ - http.StatusOK, - textPlaint, - }, - }, - { - "float zero value", - "/update/gauge/m1/-0.0", - http.MethodPost, - textPlaint, - want{ - http.StatusOK, - textPlaint, - }, - }, - { - "int value", - "/update/counter/m1/123", - http.MethodPost, - textPlaint, - want{ - http.StatusOK, - textPlaint, - }, - }, - { - "negative int value", - "/update/counter/m1/-123", - http.MethodPost, - textPlaint, - want{ - http.StatusOK, - textPlaint, - }, - }, - { - "zero value", - "/update/counter/m1/0", - http.MethodPost, - textPlaint, - want{ - http.StatusOK, - textPlaint, - }, - }, - { - "request without metrics name", - "/update/counter/123", - http.MethodPost, - textPlaint, - want{ - http.StatusNotFound, - textPlaint, - }, - }, - { - "request without metrics name 2", - "/update/gauge/123", - http.MethodPost, - textPlaint, - want{ - http.StatusNotFound, - textPlaint, - }, - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - req := resty.New().R() - req.Method = test.method - req.URL = srv.URL + test.url - req.Header.Add("Content-Type", test.contentType) - resp, err := req.Send() - assert.Nil(t, err) - assert.Equal(t, test.want.code, resp.StatusCode()) - assert.Equal(t, test.want.contentType, resp.Header().Get("Content-Type")) - }) - } -} diff --git a/internal/server/server.go b/internal/server/server.go index f1b7a38..e30ab96 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -1,45 +1,125 @@ package server import ( + "context" "net/http" + "sync" + "time" - "github.com/StasMerzlyakov/go-metrics/internal" - "github.com/StasMerzlyakov/go-metrics/internal/storage" - "github.com/go-chi/chi/v5" + "github.com/StasMerzlyakov/go-metrics/internal/config" + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" + "go.uber.org/zap" ) -func CreateServer(config *Configuration) error { - serverHandler := CreateServerHandler() - server := &http.Server{Addr: config.url, Handler: serverHandler, ReadTimeout: 0, IdleTimeout: 0} - return server.ListenAndServe() +type Backuper interface { + RestoreBackUp() error + DoBackUp() error } -func CreateServerHandler() http.Handler { - counterStorage := storage.NewMemoryInt64Storage() - gaugeStorage := storage.NewMemoryFloat64Storage() - metricController := NewMetricController(counterStorage, gaugeStorage) - businessHandler := NewBusinessHandler(metricController) - return CreateFullHTTPHandler(businessHandler) +type ChangeListenerHolder interface { + AddListener(changeListener domain.ChangeListener) } -func CreateFullHTTPHandler(businessHandler BusinessHandler) http.Handler { +func NewMetricsServer( + config *config.ServerConfiguration, + sugar *zap.SugaredLogger, + httpHandler http.Handler, + holder ChangeListenerHolder, + backUper Backuper, +) *meterServer { - fullGaugeHandler := CreateFullPostGaugeHandler(businessHandler.PostGauge) - fullCounterHandler := CreateFullPostCounterHandler(businessHandler.PostCounter) - r := chi.NewRouter() - r.Get("/", businessHandler.AllMetrics) - r.Post("/update/gauge/{name}/{value}", fullGaugeHandler) + sugar.Infow("ServerConfig", "config", config) - r.Route("/update", func(r chi.Router) { - r.Post("/gauge/{name}/{value}", fullGaugeHandler) - r.Post("/gauge/{name}", internal.StatusNotFound) - r.Post("/counter/{name}/{value}", fullCounterHandler) - r.Post("/{type}/{name}/{value}", internal.StatusNotImplemented) - }) + // restore backup + if backUper != nil && config.Restore { + if err := backUper.RestoreBackUp(); err != nil { + panic(err) + } + } - r.Route("/value", func(r chi.Router) { - r.Get("/gauge/{name}", businessHandler.GetGauge) - r.Get("/counter/{name}", businessHandler.GetCounter) - }) - return r + // проверяем - нужен ли синхронный бэкап + doSyncBackup := config.StoreInterval == 0 + if doSyncBackup && backUper != nil { + sugar.Warnw("NewMetricsServer", "msg", "backup work in sync mode") + // синхронный бэкап реализован через мехинизм листенеров изменений + // (изменение данных может происходить и не только через http) + holder.AddListener(&backupSyncListener{ + backUper: backUper, + }) + } + + return &meterServer{ + srv: &http.Server{ + Addr: config.URL, + Handler: httpHandler, + ReadTimeout: 0, + IdleTimeout: 0, + }, + doSyncBackup: doSyncBackup, + backaupStoreIntervalSec: config.StoreInterval, + sugar: sugar, + backUper: backUper, + } +} + +type meterServer struct { + sugar *zap.SugaredLogger + srv *http.Server + wg sync.WaitGroup + backUper Backuper + startContext context.Context + backaupStoreIntervalSec uint + doSyncBackup bool +} + +func (s *meterServer) ServeBackup(ctx context.Context) error { + + storeInterval := time.Duration(s.backaupStoreIntervalSec) * time.Second + for { + select { + case <-ctx.Done(): + s.sugar.Infow("Run", "msg", "backup finished") + return nil + + case <-time.After(storeInterval): + if err := s.backUper.DoBackUp(); err != nil { + s.sugar.Fatalw("DoBackUp", "msg", err.Error()) + } + } + } +} + +func (s *meterServer) Start(startContext context.Context) { + s.startContext = startContext + s.wg.Add(2) + go func() { + defer s.wg.Done() + if err := s.srv.ListenAndServe(); err != http.ErrServerClosed { + s.sugar.Fatalw("srv.ListenAndServe", "msg", err.Error()) + } + }() + if s.backUper != nil && !s.doSyncBackup { + go func() { + defer s.wg.Done() + + if err := s.ServeBackup(startContext); err != nil { + s.sugar.Fatalw("ServeBackup", "msg", err.Error()) + } + }() + } + s.sugar.Infof("Server started") +} + +func (s *meterServer) WaitDone() { + s.srv.Shutdown(s.startContext) + s.wg.Wait() + s.sugar.Infof("WaitDone") +} + +type backupSyncListener struct { + backUper Backuper +} + +func (bsl *backupSyncListener) Refresh(*domain.Metrics) error { + return bsl.backUper.DoBackUp() } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 9791c11..abb4e43 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,113 +1 @@ package server - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-resty/resty/v2" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestCounterValueHandler(t *testing.T) { - // TODO использовать mock-storage - serverHandler := CreateServerHandler() - srv := httptest.NewServer(serverHandler) - defer srv.Close() - - req := resty.New().R() - req.Method = http.MethodPost - value1 := 123 - value1Str := fmt.Sprintf("%v", value1) - req.URL = srv.URL + "/update/counter/TestCounter/" + value1Str - req.Header.Add("Content-Type", textPlaint) - _, err := req.Send() - require.Nil(t, err) - - req = resty.New().R() - req.Method = http.MethodGet - req.URL = srv.URL + "/value/counter/TestCounter" - req.Header.Add("Content-Type", textPlaint) - - resp, err := req.Send() - require.Nil(t, err) - respBody := string(resp.Body()) - require.Equal(t, value1Str, respBody) - - req = resty.New().R() - req.Method = http.MethodPost - value2 := 234 - req.URL = srv.URL + "/update/counter/TestCounter/" + fmt.Sprintf("%v", value2) - req.Header.Add("Content-Type", textPlaint) - _, err = req.Send() - require.Nil(t, err) - - req = resty.New().R() - req.Method = http.MethodGet - req.URL = srv.URL + "/value/counter/TestCounter" - req.Header.Add("Content-Type", textPlaint) - - resp, err = req.Send() - require.Nil(t, err) - respBody = string(resp.Body()) - value3 := fmt.Sprintf("%v", value1+value2) - assert.Equal(t, value3, respBody) - -} - -func TestGaugeValueHandler(t *testing.T) { - // TODO использовать mock-storage - serverHandler := CreateServerHandler() - - srv := httptest.NewServer(serverHandler) - defer srv.Close() - - req := resty.New().R() - req.Method = http.MethodPost - value1 := 234.123 - value1Str := fmt.Sprintf("%v", value1) - req.URL = srv.URL + "/update/gauge/TestCounter/" + value1Str - req.Header.Add("Content-Type", textPlaint) - _, err := req.Send() - require.Nil(t, err) - - req = resty.New().R() - req.Method = http.MethodGet - req.URL = srv.URL + "/value/gauge/TestCounter" - req.Header.Add("Content-Type", textPlaint) - resp, err := req.Send() - require.Nil(t, err) - respBody := string(resp.Body()) - require.Equal(t, value1Str, respBody) // Не попасть бы на потерю точности string -> float64 -> string - - req = resty.New().R() - req.Method = http.MethodPost - value2 := 534.123 - value2Str := fmt.Sprintf("%v", value2) - req.URL = srv.URL + "/update/gauge/TestCounter/" + value2Str - req.Header.Add("Content-Type", textPlaint) - _, err = req.Send() - require.Nil(t, err) - - req = resty.New().R() - req.Method = http.MethodGet - req.URL = srv.URL + "/value/gauge/TestCounter" - req.Header.Add("Content-Type", textPlaint) - resp, err = req.Send() - require.Nil(t, err) - respBody = string(resp.Body()) - require.Equal(t, value2Str, respBody) // Не попасть бы на потерю точности string -> float64 -> string - -} - -var mockSuccessHandler = http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Header().Add("Content-Type", "text/plain; charset=utf-8") - w.WriteHeader(http.StatusOK) -}) - -type want struct { - code int - contentType string -} diff --git a/internal/server/storage/memory/storage.go b/internal/server/storage/memory/storage.go new file mode 100644 index 0000000..de829d4 --- /dev/null +++ b/internal/server/storage/memory/storage.go @@ -0,0 +1,141 @@ +package memory + +import ( + "fmt" + + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" +) + +func NewStorage() *storage { + return &storage{ + counterStorage: make(map[string]int64), + gaugeStorage: make(map[string]float64), + } +} + +type storage struct { + counterStorage map[string]int64 + gaugeStorage map[string]float64 +} + +func (st *storage) SetAllMetrics(in []domain.Metrics) error { + newCounterStorage := make(map[string]int64) + newGaugeStorage := make(map[string]float64) + + for _, m := range in { + switch m.MType { + case domain.CounterType: + delta := *m.Delta + newCounterStorage[m.ID] = delta + case domain.GaugeType: + value := *m.Value + newGaugeStorage[m.ID] = value + default: + return fmt.Errorf("unknown MType %v", m.MType) + } + } + + st.counterStorage = newCounterStorage + st.gaugeStorage = newGaugeStorage + return nil +} + +func (st *storage) GetAllMetrics() ([]domain.Metrics, error) { + + var out []domain.Metrics + + for k, v := range st.counterStorage { + delta := v + out = append(out, domain.Metrics{ + ID: k, + MType: domain.CounterType, + Delta: &delta, + }) + } + + for k, v := range st.gaugeStorage { + value := v + out = append(out, domain.Metrics{ + ID: k, + MType: domain.GaugeType, + Value: &value, + }) + } + + return out, nil +} + +func (st *storage) Set(m *domain.Metrics) error { + switch m.MType { + case domain.CounterType: + delta := *m.Delta + st.counterStorage[m.ID] = delta + case domain.GaugeType: + value := *m.Value + st.gaugeStorage[m.ID] = value + default: + return fmt.Errorf("unknown MType %v", m.MType) + } + return nil +} + +func (st *storage) Add(m *domain.Metrics) error { + switch m.MType { + case domain.CounterType: + delta := *m.Delta + curValue, ok := st.counterStorage[m.ID] + if ok { + delta += curValue + st.counterStorage[m.ID] = delta + // обновляем значение для входной переменной + m.Delta = &delta + } else { + st.counterStorage[m.ID] = delta + } + case domain.GaugeType: + value := *m.Value + st.gaugeStorage[m.ID] = value + curValue, ok := st.gaugeStorage[m.ID] + if ok { + curValue += value + st.gaugeStorage[m.ID] = curValue + // обновляем значение для входной переменной + m.Value = &curValue + } else { + st.gaugeStorage[m.ID] = value + } + default: + return fmt.Errorf("unknown MType %v", m.MType) + } + return nil +} +func (st *storage) Get(id string, mType domain.MetricType) (*domain.Metrics, error) { + switch mType { + case domain.CounterType: + curValue, ok := st.counterStorage[id] + delta := curValue + if ok { + return &domain.Metrics{ + ID: id, + MType: mType, + Delta: &delta, + }, nil + } else { + return nil, nil + } + case domain.GaugeType: + curValue, ok := st.gaugeStorage[id] + value := curValue + if ok { + return &domain.Metrics{ + ID: id, + MType: mType, + Value: &value, + }, nil + } else { + return nil, nil + } + default: + return nil, fmt.Errorf("unknown MType %v", mType) + } +} diff --git a/internal/server/storage/memory/storage_test.go b/internal/server/storage/memory/storage_test.go new file mode 100644 index 0000000..0f87351 --- /dev/null +++ b/internal/server/storage/memory/storage_test.go @@ -0,0 +1,174 @@ +package memory_test + +import ( + "testing" + + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" + "github.com/StasMerzlyakov/go-metrics/internal/server/storage/memory" + "github.com/stretchr/testify/require" +) + +func TestMemoryStorageStoreAndLoad(t *testing.T) { + + toLoad := []domain.Metrics{ + {MType: domain.CounterType, ID: "PollCount", Delta: domain.DeltaPtr(1)}, + {MType: domain.GaugeType, ID: "RandomValue", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Alloc", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "BuckHashSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Frees", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "GCCPUFraction", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "GCSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapAlloc", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapIdle", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapInuse", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapObjects", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapReleased", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "HeapSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "LastGC", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Lookups", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "MCacheInuse", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "MCacheSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "MSpanInuse", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "MSpanSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Mallocs", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "NextGC", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "NumForcedGC", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "NumGC", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "OtherSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "PauseTotalNs", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "StackInuse", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "StackSys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "Sys", Value: domain.ValuePtr(1.123)}, + {MType: domain.GaugeType, ID: "TotalAlloc", Value: domain.ValuePtr(1.123)}, + } + + storage := memory.NewStorage() + out, err := storage.GetAllMetrics() + require.NoError(t, err) + require.True(t, len(out) == 0) + + err = storage.SetAllMetrics(toLoad) + require.NoError(t, err) + + out, err = storage.GetAllMetrics() + require.NoError(t, err) + require.Equal(t, len(toLoad), len(out)) +} + +func TestMemoryStorageGaugeOperations(t *testing.T) { + + storage := memory.NewStorage() + + GagueID := "NumGC" + + mConst := &domain.Metrics{ + ID: GagueID, + MType: domain.GaugeType, + Value: domain.ValuePtr(2), + } + + ms, err := storage.Get(GagueID, domain.CounterType) + require.NoError(t, err) + require.Nil(t, ms) + + ms, err = storage.Get(GagueID, domain.GaugeType) + require.NoError(t, err) + require.Nil(t, ms) + + err = storage.Set(mConst) + require.NoError(t, err) + + ms, err = storage.Get(GagueID, domain.GaugeType) + require.NoError(t, err) + require.NotNil(t, ms) + require.Equal(t, ms.ID, GagueID) + require.Equal(t, ms.MType, domain.GaugeType) + require.NotNil(t, ms.Value) + require.Equal(t, float64(2), *ms.Value) + require.Nil(t, ms.Delta) + + ms, err = storage.Get(GagueID, domain.CounterType) + require.NoError(t, err) + require.Nil(t, ms) + + err = storage.Set(mConst) + require.NoError(t, err) + ms, err = storage.Get(GagueID, domain.GaugeType) + require.NoError(t, err) + require.NotNil(t, ms) + require.Equal(t, ms.ID, GagueID) + require.Equal(t, ms.MType, domain.GaugeType) + require.NotNil(t, ms.Value) + require.Equal(t, float64(2), *ms.Value) + require.Nil(t, ms.Delta) + + err = storage.Add(mConst) + require.NoError(t, err) + ms, err = storage.Get(GagueID, domain.GaugeType) + require.NoError(t, err) + require.NotNil(t, ms) + require.Equal(t, ms.ID, GagueID) + require.Equal(t, ms.MType, domain.GaugeType) + require.NotNil(t, ms.Value) + require.Equal(t, float64(4), *ms.Value) + require.Nil(t, ms.Delta) +} + +func TestMemoryStorageCounterOperations(t *testing.T) { + + storage := memory.NewStorage() + + CounterID := "PollCount" + + mConst := &domain.Metrics{ + ID: CounterID, + MType: domain.CounterType, + Delta: domain.DeltaPtr(2), + } + + ms, err := storage.Get(CounterID, domain.CounterType) + require.NoError(t, err) + require.Nil(t, ms) + + ms, err = storage.Get(CounterID, domain.GaugeType) + require.NoError(t, err) + require.Nil(t, ms) + + err = storage.Set(mConst) + require.NoError(t, err) + + ms, err = storage.Get(CounterID, domain.CounterType) + require.NoError(t, err) + require.NotNil(t, ms) + require.Equal(t, ms.ID, CounterID) + require.Equal(t, ms.MType, domain.CounterType) + require.NotNil(t, ms.Delta) + require.Equal(t, int64(2), *ms.Delta) + require.Nil(t, ms.Value) + + ms, err = storage.Get(CounterID, domain.GaugeType) + require.NoError(t, err) + require.Nil(t, ms) + + err = storage.Set(mConst) + require.NoError(t, err) + ms, err = storage.Get(CounterID, domain.CounterType) + require.NoError(t, err) + require.NotNil(t, ms) + require.Equal(t, ms.ID, CounterID) + require.Equal(t, ms.MType, domain.CounterType) + require.NotNil(t, ms.Delta) + require.Equal(t, int64(2), *ms.Delta) + require.Nil(t, ms.Value) + + err = storage.Add(mConst) + require.NoError(t, err) + ms, err = storage.Get(CounterID, domain.CounterType) + require.NoError(t, err) + require.NotNil(t, ms) + require.Equal(t, ms.ID, CounterID) + require.Equal(t, ms.MType, domain.CounterType) + require.NotNil(t, ms.Delta) + require.Equal(t, int64(4), *ms.Delta) + require.Nil(t, ms.Value) +} diff --git a/internal/server/usecase/backup.go b/internal/server/usecase/backup.go new file mode 100644 index 0000000..757e082 --- /dev/null +++ b/internal/server/usecase/backup.go @@ -0,0 +1,56 @@ +package usecase + +import ( + "errors" + "os" + + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" + "go.uber.org/zap" +) + +type AllMetricsStorage interface { + SetAllMetrics(in []domain.Metrics) error + GetAllMetrics() ([]domain.Metrics, error) +} + +type BackupFormatter interface { + Write([]domain.Metrics) error + Read() ([]domain.Metrics, error) +} + +func NewBackup(suga *zap.SugaredLogger, storage AllMetricsStorage, formatter BackupFormatter) *backUper { + return &backUper{ + suga: suga, + storage: storage, + formatter: formatter, + } +} + +type backUper struct { + suga *zap.SugaredLogger + storage AllMetricsStorage + formatter BackupFormatter +} + +func (bU *backUper) RestoreBackUp() error { + metrics, err := bU.formatter.Read() + if err != nil && !errors.Is(err, os.ErrNotExist) { + panic(err) + } + + return bU.storage.SetAllMetrics(metrics) +} + +func (bU *backUper) DoBackUp() error { + + metrics, err := bU.storage.GetAllMetrics() + if err != nil { + return err + } + err = bU.formatter.Write(metrics) + if err != nil { + return err + } + bU.suga.Infow("DoBackUp", "status", "ok", "msg", "backup is done") + return nil +} diff --git a/internal/server/usecase/metrics.go b/internal/server/usecase/metrics.go new file mode 100644 index 0000000..58bee36 --- /dev/null +++ b/internal/server/usecase/metrics.go @@ -0,0 +1,145 @@ +package usecase + +import ( + "fmt" + "regexp" + + "github.com/pkg/errors" + + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" +) + +type Storage interface { + SetAllMetrics(in []domain.Metrics) error + GetAllMetrics() ([]domain.Metrics, error) + Set(m *domain.Metrics) error + Add(m *domain.Metrics) error + Get(id string, mType domain.MetricType) (*domain.Metrics, error) +} + +type metricsUseCase struct { + storage Storage + changeListeners []domain.ChangeListener +} + +func NewMetrics(storage Storage) *metricsUseCase { + return &metricsUseCase{ + storage: storage, + } +} + +var nameRegexp = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_]*$") + +func (mc *metricsUseCase) AddListener(changeListener domain.ChangeListener) { + mc.changeListeners = append(mc.changeListeners, changeListener) +} + +func (mc *metricsUseCase) CheckName(name string) bool { + return nameRegexp.MatchString(name) +} + +func (mc *metricsUseCase) CheckMetrics(m *domain.Metrics) error { + if !mc.CheckName(m.ID) { + return errors.Wrap(domain.ErrDataFormat, fmt.Sprintf("wrong metric ID %v", m.ID)) + } + + if m.MType != domain.CounterType && m.MType != domain.GaugeType { + return errors.Wrap(domain.ErrDataFormat, fmt.Sprintf("metric ID: %v have type %v", m.ID, m.MType)) + } + + if m.MType == domain.CounterType { + if m.Delta == nil { + return errors.Wrap(domain.ErrDataFormat, fmt.Sprintf("metric ID: %v have MType %v, but delta is null", m.ID, m.MType)) + } + + if m.Value != nil { + return errors.Wrap(domain.ErrDataFormat, fmt.Sprintf("metric ID: %v have MType %v, but value is not null", m.ID, m.MType)) + } + } + + if m.MType == domain.GaugeType { + if m.Value == nil { + return errors.Wrap(domain.ErrDataFormat, fmt.Sprintf("metric ID: %v have MType %v, but value is null", m.ID, m.MType)) + } + + if m.Delta != nil { + return errors.Wrap(domain.ErrDataFormat, fmt.Sprintf("metric ID: %v have MType %v, but delta is not null", m.ID, m.MType)) + } + } + return nil +} + +func (mc *metricsUseCase) SetAllMetrics(in []domain.Metrics) error { + // Проверка данных + for _, m := range in { + err := mc.CheckMetrics(&m) + if err != nil { + return err + } + } + + return mc.storage.SetAllMetrics(in) +} + +func (mc *metricsUseCase) GetAllMetrics() ([]domain.Metrics, error) { + return mc.storage.GetAllMetrics() +} + +func (mc *metricsUseCase) GetCounter(name string) (*domain.Metrics, error) { + if !mc.CheckName(name) { + return nil, errors.Wrap(domain.ErrDataFormat, fmt.Sprintf("wrong metric ID %v", name)) + } + return mc.storage.Get(name, domain.CounterType) +} + +func (mc *metricsUseCase) GetGauge(name string) (*domain.Metrics, error) { + if !mc.CheckName(name) { + return nil, errors.Wrap(domain.ErrDataFormat, fmt.Sprintf("wrong metric ID %v", name)) + } + return mc.storage.Get(name, domain.GaugeType) +} + +func (mc *metricsUseCase) AddCounter(m *domain.Metrics) error { + if err := mc.CheckMetrics(m); err != nil { + return err + } + + if m.MType != domain.CounterType { + return fmt.Errorf("unexpected MType %v, expected %v", m.MType, domain.CounterType) + } + + if err := mc.storage.Add(m); err != nil { + return err + } + + if newValue, err := mc.storage.Get(m.ID, m.MType); err != nil { + return err + } else { + delta := *newValue.Delta + m.Delta = &delta + } + + for _, lst := range mc.changeListeners { + lst.Refresh(m) + } + + return nil +} + +func (mc *metricsUseCase) SetGauge(m *domain.Metrics) error { + if err := mc.CheckMetrics(m); err != nil { + return err + } + if m.MType != domain.GaugeType { + return fmt.Errorf("unexpected MType %v, expected %v", m.MType, domain.GaugeType) + } + if err := mc.storage.Set(m); err != nil { + return err + } + + for _, lst := range mc.changeListeners { + lst.Refresh(m) + } + + return nil +} diff --git a/internal/server/usecase/metrics_test.go b/internal/server/usecase/metrics_test.go new file mode 100644 index 0000000..3580567 --- /dev/null +++ b/internal/server/usecase/metrics_test.go @@ -0,0 +1,145 @@ +package usecase_test + +import ( + "errors" + "testing" + + "github.com/StasMerzlyakov/go-metrics/internal/server/domain" + "github.com/StasMerzlyakov/go-metrics/internal/server/usecase" + "github.com/stretchr/testify/assert" +) + +type mockStorage struct { +} + +func (*mockStorage) SetAllMetrics(in []domain.Metrics) error { + return nil +} +func (*mockStorage) GetAllMetrics() ([]domain.Metrics, error) { + return nil, nil +} +func (*mockStorage) Set(m *domain.Metrics) error { + return nil +} +func (*mockStorage) Add(m *domain.Metrics) error { + return nil +} +func (*mockStorage) Get(id string, mType domain.MetricType) (*domain.Metrics, error) { + return nil, nil +} + +func TestCheckName(t *testing.T) { + + mc := usecase.NewMetrics(&mockStorage{}) + + testCases := []struct { + name string + input string + result bool + }{ + { + "TestCheckName_1", + "a1s_asd1_1", + true, + }, + { + "TestCheckName_2", + "00.123", + false, + }, + { + "TestCheckName_3", + "0asd", + false, + }, + { + "TestCheckName_4", + "-A", + false, + }, + { + "TestCheckName_5", + "_asd", + false, + }, + { + "TestCheckName_6", + "A", + true, + }, + { + "TestCheckName_7", + "A0_123", + true, + }, + { + "TestCheckName_8", + "B123.1", + false, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.result, mc.CheckName(test.input)) + }) + } +} + +func TestCheckMetrics(t *testing.T) { + mc := usecase.NewMetrics(&mockStorage{}) + + testCases := []struct { + name string + input *domain.Metrics + isOk bool + }{ + { + "CheckMetrics_1", + &domain.Metrics{ + ID: "a1s_asd1_1", + MType: domain.CounterType, + Delta: domain.DeltaPtr(1), + }, + true, + }, + { + "CheckMetrics_2", + &domain.Metrics{ + ID: "a1s_asd1_1", + MType: domain.GaugeType, + Delta: domain.DeltaPtr(1), + }, + false, + }, + { + "CheckMetrics_3", + &domain.Metrics{ + ID: "0asd", + MType: domain.CounterType, + Delta: domain.DeltaPtr(1), + }, + false, + }, + { + "CheckMetrics_4", + &domain.Metrics{ + ID: "OK", + MType: domain.GaugeType, + Value: domain.ValuePtr(1), + }, + true, + }, + } + + for _, test := range testCases { + t.Run(test.name, func(t *testing.T) { + err := mc.CheckMetrics(test.input) + if test.isOk { + assert.NoError(t, err) + } else { + assert.True(t, errors.Is(err, domain.ErrDataFormat)) + } + }) + } +} diff --git a/internal/server/utils.go b/internal/server/utils.go deleted file mode 100644 index b7038d4..0000000 --- a/internal/server/utils.go +++ /dev/null @@ -1,40 +0,0 @@ -package server - -import ( - "regexp" - "strconv" -) - -var decimalRegexp = regexp.MustCompile("^[-+]?([1-9][0-9]*|0?)([.][0-9]*)?$") - -func CheckDecimal(value string) bool { - return decimalRegexp.MatchString(value) -} - -var integerRegexp = regexp.MustCompile("^[-+]?([1-9][0-9]*|0?)$") - -func CheckInteger(value string) bool { - return integerRegexp.MatchString(value) -} - -var nameRegexp = regexp.MustCompile("^[a-zA-Z][a-zA-Z0-9_]*$") - -func CheckName(value string) bool { - return nameRegexp.MatchString(value) -} - -func ExtractFloat64(valueStr string) (float64, error) { - value, err := strconv.ParseFloat(valueStr, 64) - if err != nil { - return -1, err - } - return value, nil -} - -func ExtractInt64(valueStr string) (int64, error) { - value, err := strconv.ParseInt(valueStr, 10, 64) - if err != nil { - return -1, err - } - return value, nil -} diff --git a/internal/server/utils_test.go b/internal/server/utils_test.go deleted file mode 100644 index dc1531e..0000000 --- a/internal/server/utils_test.go +++ /dev/null @@ -1,292 +0,0 @@ -package server - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestExtractFloat64(t *testing.T) { - type extractFloat64Result struct { - value float64 - isSuccessExpected bool - } - tests := []struct { - name string - input string - result extractFloat64Result - }{ - { - "good value", - "123.5", - extractFloat64Result{ - 123.5, - true, - }, - }, - { - "good value 2", - "123", - extractFloat64Result{ - 123, - true, - }, - }, - { - "bad value", - "123.F", - extractFloat64Result{ - -1, - false, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - value, err := ExtractFloat64(tt.input) - assert.Equal(t, tt.result.value, value) - assert.Equal(t, tt.result.isSuccessExpected, err == nil) - }) - } -} - -func TestExtractInt64(t *testing.T) { - - type extractInt64Result struct { - value int64 - isSuccessExpected bool - } - - tests := []struct { - name string - input string - result extractInt64Result - }{ - { - "good value", - "123", - extractInt64Result{ - 123, - true, - }, - }, - { - "bad value", - "123F", - extractInt64Result{ - -1, - false, - }, - }, - { - "bad value 2", - "123.5", - extractInt64Result{ - -1, - false, - }, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - value, err := ExtractInt64(tt.input) - assert.Equal(t, tt.result.value, value) - assert.Equal(t, tt.result.isSuccessExpected, err == nil) - }) - } -} - -func TestCheckDecimal(t *testing.T) { - testCases := []struct { - name string - input string - result bool - }{ - { - "TestCheckDecimal_1", - "a1s_asd1_1", - false, - }, - { - "TestCheckDecimal_2", - "00.123", - false, - }, - { - "TestCheckDecimal_3", - "0.123", - true, - }, - { - "TestCheckDecimal_4", - "-0.123", - true, - }, - { - "TestCheckDecimal_5", - "0.123", - true, - }, - { - "TestCheckDecimal_6", - "0.123", - true, - }, - { - "TestCheckDecimal_7", - "0.123", - true, - }, - { - "TestCheckDecimal_8", - "123", - true, - }, - { - "TestCheckDecimal_9", - "-123", - true, - }, - { - "TestCheckDecimal_10", - "123.", - true, - }, - { - "TestCheckDecimal_11", - "123.123.1", - false, - }, - { - "TestCheckDecimal_12", - "123.123.", - false, - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.result, CheckDecimal(test.input)) - }) - } -} - -func TestCheckInteger(t *testing.T) { - testCases := []struct { - name string - input string - result bool - }{ - { - "TestCheckInteger_1", - "a1s_asd1_1", - false, - }, - { - "TestCheckInteger_2", - "00.123", - false, - }, - { - "TestCheckInteger_3", - "0.123", - false, - }, - { - "TestCheckInteger_4", - "-0.123", - false, - }, - { - "TestCheckInteger_5", - "0.123", - false, - }, - { - "TestCheckInteger_6", - "123", - true, - }, - { - "TestCheckInteger_7", - "-123", - true, - }, - { - "TestCheckInteger_8", - "123.", - false, - }, - { - "TestCheckInteger_9", - "123.123.1", - false, - }, - { - "TestCheckInteger_10", - "123.123.", - false, - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.result, CheckInteger(test.input)) - }) - } -} - -func TestCheckName(t *testing.T) { - testCases := []struct { - name string - input string - result bool - }{ - { - "TestCheckName_1", - "a1s_asd1_1", - true, - }, - { - "TestCheckName_2", - "00.123", - false, - }, - { - "TestCheckName_3", - "0asd", - false, - }, - { - "TestCheckName_4", - "-A", - false, - }, - { - "TestCheckName_5", - "_asd", - false, - }, - { - "TestCheckName_6", - "A", - true, - }, - { - "TestCheckName_7", - "A0_123", - true, - }, - { - "TestCheckName_8", - "B123.1", - false, - }, - } - - for _, test := range testCases { - t.Run(test.name, func(t *testing.T) { - assert.Equal(t, test.result, CheckName(test.input)) - }) - } -} diff --git a/internal/storage/api.go b/internal/storage/api.go deleted file mode 100644 index 5cf107e..0000000 --- a/internal/storage/api.go +++ /dev/null @@ -1,12 +0,0 @@ -package storage - -type MemValue interface { - int64 | float64 -} - -type MetricsStorage[T MemValue] interface { - Set(key string, value T) - Add(key string, value T) - Get(key string) (T, bool) - Keys() []string -} diff --git a/internal/storage/mem_storage.go b/internal/storage/mem_storage.go deleted file mode 100644 index 2075d20..0000000 --- a/internal/storage/mem_storage.go +++ /dev/null @@ -1,53 +0,0 @@ -package storage - -import "sync" - -func NewMemoryFloat64Storage() MetricsStorage[float64] { - return &memStorage[float64]{} -} - -func NewMemoryInt64Storage() MetricsStorage[int64] { - return &memStorage[int64]{} -} - -type memStorage[T MemValue] struct { - mtx sync.Mutex - storage map[string]T -} - -func (ms *memStorage[T]) checkInit() { - ms.mtx.Lock() - defer ms.mtx.Unlock() - if ms.storage == nil { - ms.storage = make(map[string]T) - } -} - -func (ms *memStorage[T]) Keys() []string { - ms.checkInit() - keys := make([]string, 0, len(ms.storage)) - for k := range ms.storage { - keys = append(keys, k) - } - return keys -} - -func (ms *memStorage[T]) Set(key string, value T) { - ms.checkInit() - ms.storage[key] = value -} - -func (ms *memStorage[T]) Add(key string, value T) { - ms.checkInit() - if curVal, ok := ms.storage[key]; ok { - ms.storage[key] = curVal + value - } else { - ms.storage[key] = value - } -} - -func (ms *memStorage[T]) Get(key string) (T, bool) { - ms.checkInit() - curVal, ok := ms.storage[key] - return curVal, ok -} diff --git a/internal/storage/mem_storage_test.go b/internal/storage/mem_storage_test.go deleted file mode 100644 index 13d4654..0000000 --- a/internal/storage/mem_storage_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package storage - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestInt64MemStorage(t *testing.T) { - ms := NewMemoryInt64Storage() - ms.Add("m", 1) - ms.Add("m", 2) - k, v := ms.Get("m") - assert.True(t, v) - assert.Equal(t, int64(3), k) - - ms.Set("m", 5) - k, v = ms.Get("m") - assert.True(t, v) - assert.Equal(t, int64(5), k) - - _, v = ms.Get("m2") - assert.False(t, v) -} - -func TestFloat64MemStorage(t *testing.T) { - ms := NewMemoryFloat64Storage() - ms.Add("m", 1) - ms.Add("m", 2) - k, v := ms.Get("m") - assert.True(t, v) - assert.Equal(t, float64(3), k) - - ms.Set("m", 5) - k, v = ms.Get("m") - assert.True(t, v) - assert.Equal(t, float64(5), k) - - ms.Set("m", 7) - k, v = ms.Get("m") - assert.True(t, v) - assert.Equal(t, float64(7), k) - - _, v = ms.Get("m2") - assert.False(t, v) -}