Skip to content

Common Go packages for writing applications, services, and tools

License

Notifications You must be signed in to change notification settings

acronis/go-appkit

Repository files navigation

Common Go packages for writing applications, services, and tools

The project includes the following packages:

  • config - loading configuration from environment variables, files, and io.Reader. YAML and JSON formats are supported out of the box.
  • httpclient - helpers and a set of http.RoundTripper implementations for simplifying typical HTTP client operations (e.g. retries, client-side throttling, setting any header for each request, etc.).
  • httpserver - configurable HTTP server (wrapper around http.Server) that includes graceful shutdown support, panic recovery, metrics collection, and logging.
  • httpserver/middleware - collection of middlewares for HTTP server (e.g. request logging, metrics collection, panic recovery, in-flight request limiting, rate limiting, request-id tracing, etc.).
  • httpserver/middleware/throttle - ready-to-use middleware for server-side throttling that can be flexibly configured via JSON or YAML. The package has its own README, so check it out for more details.
  • log - unified interface for structured logging with included configurable adapter for tiny, fast, and memory-efficient (zero-allocation) logf logger.
  • lrucache - in-memory LRU cache with collecting Prometheus metrics.
  • netutil - utilities for working with network.
  • profserver - profiling HTTP server (pprof).
  • restapi - set of simple functions for doing requests and sending responses in the REST API.
  • retry - helper functions for doing retryable operations.
  • service - ready-to-use primitives for creating services and managing their lifecycle.
  • testutil - helpers for writing tests.

Installation

go get -u github.com/acronis/go-appkit

Examples

Simple service that provides HTTP API

The following example demonstrates how to create a simple service with

  • Configuration loading from environment variables and a yaml file.
  • HTTP server with request logging, metrics collection, primitive tracing and versioned API endpoints.
  • Profiling server (pprof) for debugging purposes running on a separate port.
  • Graceful shutdown on SIGTERM and SIGINT signals.
package main

import (
	"fmt"
	golog "log"
	"net/http"

	"github.com/go-chi/chi/v5"

	"github.com/acronis/go-appkit/config"
	"github.com/acronis/go-appkit/httpserver"
	"github.com/acronis/go-appkit/httpserver/middleware"
	"github.com/acronis/go-appkit/log"
	"github.com/acronis/go-appkit/profserver"
	"github.com/acronis/go-appkit/restapi"
	"github.com/acronis/go-appkit/service"
)

func main() {
	if err := runApp(); err != nil {
		golog.Fatal(err)
	}
}

func runApp() error {
	cfg, err := loadConfigFromFile("config.yaml")
	if err != nil {
		return fmt.Errorf("load config: %w", err)
	}

	logger, closeFn := log.NewLogger(cfg.Log)
	defer closeFn()

	var serviceUnits []service.Unit

	// Create HTTP server that provides /healthz, /metrics, and /api/{service-name}/v{number}/* endpoints.
	httpServer, err := makeHTTPServer(cfg.Server, logger)
	if err != nil {
		return err
	}
	serviceUnits = append(serviceUnits, httpServer)

	if cfg.ProfServer.Enabled {
		// Create HTTP server for profiling (pprof is used under the hood).
		serviceUnits = append(serviceUnits, profserver.New(cfg.ProfServer, logger))
	}

	return service.New(logger, service.NewCompositeUnit(serviceUnits...)).Start()
}

func makeHTTPServer(cfg *httpserver.Config, logger log.FieldLogger) (*httpserver.HTTPServer, error) {
	const errorDomain = "MyService" // Error domain is useful for distinguishing errors from different services (e.g. proxies).

	apiRoutes := map[httpserver.APIVersion]httpserver.APIRoute{
		1: func(router chi.Router) {
			router.Get("/hello", v1HelloHandler())
		},
		2: func(router chi.Router) {
			router.Get("/hi", v2HiHandler(errorDomain))
		},
	}

	opts := httpserver.Opts{
		ServiceNameInURL: "my-service",
		ErrorDomain:      errorDomain,
		APIRoutes:        apiRoutes,
		HealthCheck: func() (httpserver.HealthCheckResult, error) {
			// 503 status code will be returned if any of the components is unhealthy.
			return map[httpserver.HealthCheckComponentName]httpserver.HealthCheckStatus{
				"component-a": httpserver.HealthCheckStatusOK,
				"component-b": httpserver.HealthCheckStatusOK,
			}, nil
		},
	}

	httpServer, err := httpserver.New(cfg, logger, opts)
	if err != nil {
		return nil, err
	}

	// Custom routes can be added using chi.Router directly.
	httpServer.HTTPRouter.Get("/custom-route", customRouteHandler)

	return httpServer, nil
}

func loadConfigFromFile(filePath string) (*AppConfig, error) {
	cfg := NewAppConfig()
	err := config.NewDefaultLoader("my_service").LoadFromFile(filePath, config.DataTypeYAML, cfg)
	return cfg, err
}

func v1HelloHandler() func(rw http.ResponseWriter, r *http.Request) {
	return func(rw http.ResponseWriter, r *http.Request) {
		logger := middleware.GetLoggerFromContext(r.Context())
		restapi.RespondJSON(rw, map[string]string{"message": "Hello from v1"}, logger)
	}
}

func v2HiHandler(errorDomain string) func(rw http.ResponseWriter, r *http.Request) {
	return func(rw http.ResponseWriter, r *http.Request) {
		logger := middleware.GetLoggerFromContext(r.Context())
		name := r.URL.Query().Get("name")
		if len(name) < 3 {
			apiErr := restapi.NewError(errorDomain, "invalidName", "Name must be at least 3 characters long")
			restapi.RespondError(rw, http.StatusBadRequest, apiErr, middleware.GetLoggerFromContext(r.Context()))
			return
		}
		restapi.RespondJSON(rw, map[string]string{"message": fmt.Sprintf("Hi %s from v2", name)}, logger)
	}
}

func customRouteHandler(rw http.ResponseWriter, r *http.Request) {
	logger := middleware.GetLoggerFromContext(r.Context())
	if _, err := rw.Write([]byte("Content from the custom route")); err != nil {
		logger.Error("error while writing response body", log.Error(err))
	}
}

type AppConfig struct {
	Server     *httpserver.Config
	ProfServer *profserver.Config
	Log        *log.Config
}

func NewAppConfig() *AppConfig {
	return &AppConfig{
		Server:     httpserver.NewConfig(),
		ProfServer: profserver.NewConfig(),
		Log:        log.NewConfig(),
	}
}

func (c *AppConfig) SetProviderDefaults(dp config.DataProvider) {
	config.CallSetProviderDefaultsForFields(c, dp)
}

func (c *AppConfig) Set(dp config.DataProvider) error {
	return config.CallSetForFields(c, dp)
}

Configuration file config.yaml:

server:
  address: ":8888"
  timeouts:
    write: 1m
    read: 15s
    readHeader: 10s
    idle: 1m
    shutdown: 5s
  limits:
    maxBodySize: 1M
  log:
    requestStart: true
profServer:
  enabled: true
  address: ":8889"
log:
  level: info
  format: json
  output: stdout

Run the service:

$ go run main.go

Check the service API:

$ curl -w "\nHTTP code: %{http_code}\n" localhost:8888/api/my-service/v1/hello                                                                                                                                                               [130]
{"message":"Hello"}
HTTP code: 200

$ curl -w "\nHTTP code: %{http_code}\n" 'localhost:8888/api/my-service/v2/hi?name='
{"error":{"domain":"MyService","code":"invalidName","message":"Name must be at least 3 characters long"}}
HTTP code: 400

$ curl -w "\nHTTP code: %{http_code}\n" 'localhost:8888/api/my-service/v2/hi?name=Alice'
{"message":"Hi Alice"}
HTTP code: 200

Check the service health and metrics:

$ curl -w "\nHTTP code: %{http_code}\n" localhost:8888/healthz
{"components":{"component-a":true,"component-b":true}}
HTTP code: 200

$ curl localhost:8888/metrics
...
# HELP http_request_duration_seconds A histogram of the HTTP request durations.
# TYPE http_request_duration_seconds histogram
http_request_duration_seconds_bucket{method="GET",route_pattern="/api/my-service/v1/hello",status_code="200",user_agent_type="http-client",le="0.01"} 1
...
http_request_duration_seconds_bucket{method="GET",route_pattern="/api/my-service/v1/hello",status_code="200",user_agent_type="http-client",le="600"} 1
http_request_duration_seconds_bucket{method="GET",route_pattern="/api/my-service/v1/hello",status_code="200",user_agent_type="http-client",le="+Inf"} 1
http_request_duration_seconds_sum{method="GET",route_pattern="/api/my-service/v1/hello",status_code="200",user_agent_type="http-client"} 0.000184125
http_request_duration_seconds_count{method="GET",route_pattern="/api/my-service/v1/hello",status_code="200",user_agent_type="http-client"} 1
...
http_request_duration_seconds_bucket{method="GET",route_pattern="/api/my-service/v2/hi",status_code="400",user_agent_type="http-client",le="0.01"} 1
...
http_request_duration_seconds_bucket{method="GET",route_pattern="/api/my-service/v2/hi",status_code="400",user_agent_type="http-client",le="600"} 1
http_request_duration_seconds_bucket{method="GET",route_pattern="/api/my-service/v2/hi",status_code="400",user_agent_type="http-client",le="+Inf"} 1
http_request_duration_seconds_sum{method="GET",route_pattern="/api/my-service/v2/hi",status_code="400",user_agent_type="http-client"} 0.000326041
http_request_duration_seconds_count{method="GET",route_pattern="/api/my-service/v2/hi",status_code="400",user_agent_type="http-client"} 1
...
# HELP http_requests_in_flight Current number of HTTP requests being served.
# TYPE http_requests_in_flight gauge
http_requests_in_flight{method="GET",route_pattern="/api/my-service/v1/hello",user_agent_type="http-client"} 0
http_requests_in_flight{method="GET",route_pattern="/api/my-service/v2/hi",user_agent_type="http-client"} 0
...

Service logs:

{"level":"info","time":"2024-06-04T20:22:01.862351+03:00","msg":"starting application HTTP server...","pid":8455,"address":":8888","write_timeout":"1m0s","read_timeout":"15s","read_header_timeout":"10s","idle_timeout":"1m0s","shutdown_timeout":"
5s"}
{"level":"info","time":"2024-06-04T20:22:01.862516+03:00","msg":"starting profiling HTTP server...","pid":8455,"address":":8889"}
...
{"level":"info","time":"2024-06-04T20:22:08.075376+03:00","msg":"request started","pid":8455,"request_id":"cpfkqg3juspi21pmber0","int_request_id":"cpfkqg3juspi21pmberg","trace_id":"","method":"GET","uri":"/api/my-service/v1/hello","remote_addr":"[::1]:59994","content_length":0,"user_agent":"curl/8.6.0","remote_addr_ip":"::1","remote_addr_port":59994}
{"level":"info","time":"2024-06-04T20:22:08.075518+03:00","msg":"response completed in 0.000s","pid":8455,"request_id":"cpfkqg3juspi21pmber0","int_request_id":"cpfkqg3juspi21pmberg","trace_id":"","method":"GET","uri":"/api/my-service/v1/hello","remote_addr":"[::1]:59994","content_length":0,"user_agent":"curl/8.6.0","remote_addr_ip":"::1","remote_addr_port":59994,"duration_ms":0,"duration":184,"status":200,"bytes_sent":19}
...
{"level":"info","time":"2024-06-04T20:31:14.002993+03:00","msg":"service got signal","pid":8455,"signal":"interrupt"}
{"level":"info","time":"2024-06-04T20:31:14.003051+03:00","msg":"closing profiling HTTP server...","pid":8455}
{"level":"info","time":"2024-06-04T20:31:14.00321+03:00","msg":"profiling HTTP served closed","pid":8455,"address":":8889"}
{"level":"info","time":"2024-06-04T20:31:14.003254+03:00","msg":"shutting down application HTTP server...","pid":8455,"timeout":"5s"}
{"level":"info","time":"2024-06-04T20:31:14.003365+03:00","msg":"application HTTP server closed","pid":8455,"address":":8888","write_timeout":"1m0s","read_timeout":"15s","read_header_timeout":"10s","idle_timeout":"1m0s","shutdown_timeout":"5s"}
{"level":"info","time":"2024-06-04T20:31:14.003412+03:00","msg":"application HTTP server shut down","pid":8455}

License

Copyright © 2024 Acronis International GmbH.

Licensed under MIT License.