Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e187140c4 | ||
|
|
f3dedf47f3 | ||
|
|
1dde741885 | ||
|
|
4807edd23a | ||
|
|
ec689c6152 | ||
|
|
6623ced227 |
@@ -41,6 +41,11 @@ make test
|
||||
make lint
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Configuration Guide](docs/configuration.md): Details on YAML config, environment variables, and CLI flags.
|
||||
- [Logging Guide](docs/logging.md): Information about log levels and structured logging format.
|
||||
|
||||
## Docker
|
||||
|
||||
Build the Docker image:
|
||||
|
||||
@@ -2,16 +2,33 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/placeholder/golang-template/internal/config"
|
||||
"github.com/placeholder/golang-template/internal/logging"
|
||||
)
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||
slog.SetDefault(logger)
|
||||
// Parse CLI flags
|
||||
var configPath string
|
||||
flag.StringVar(&configPath, "config", "config/config.yaml", "path to config file")
|
||||
flag.Parse()
|
||||
|
||||
// Load Configuration
|
||||
cfg, err := config.Load(configPath, "APP")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Setup Logger
|
||||
logging.Configure(cfg.Logging)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
12
config/config.yaml
Normal file
12
config/config.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
# Application Configuration
|
||||
# This file contains the default configuration for the application.
|
||||
# Values can be overridden by environment variables with the prefix "APP_".
|
||||
# Example: APP_LOGGING_LEVEL=debug
|
||||
|
||||
logging:
|
||||
# Log Level: debug, info, warn, error
|
||||
level: "info"
|
||||
|
||||
server:
|
||||
host: "localhost"
|
||||
port: 8080
|
||||
45
docs/configuration.md
Normal file
45
docs/configuration.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Configuration
|
||||
|
||||
This application uses a flexible configuration system that supports YAML files and environment variable overrides.
|
||||
|
||||
## Configuration File
|
||||
|
||||
The configuration is loaded from a YAML file. By default, the application looks for `config/config.yaml`. You can specify a different configuration file using the `-config` CLI flag.
|
||||
|
||||
```bash
|
||||
./app -config /path/to/my-config.yaml
|
||||
```
|
||||
|
||||
### Structure
|
||||
|
||||
The configuration file supports the following structure:
|
||||
|
||||
```yaml
|
||||
# Logging configuration
|
||||
logging:
|
||||
# Log Level: debug, info, warn, error
|
||||
level: "info"
|
||||
|
||||
# Server configuration
|
||||
server:
|
||||
host: "localhost"
|
||||
port: 8080
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
All configuration values can be overridden using environment variables. The application uses the `APP_` prefix for all environment variables. The variable name is constructed by capitalizing the path to the configuration value and replacing dots/nesting with underscores.
|
||||
|
||||
| YAML Path | Environment Variable | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| `logging.level` | `APP_LOGGING_LEVEL` | Sets the logging level (debug, info, warn, error) |
|
||||
| `server.host` | `APP_SERVER_HOST` | Sets the server host address |
|
||||
| `server.port` | `APP_SERVER_PORT` | Sets the server listening port |
|
||||
|
||||
**Example:**
|
||||
|
||||
To start the application with `debug` logging and listening on port `9090`:
|
||||
|
||||
```bash
|
||||
APP_LOGGING_LEVEL=debug APP_SERVER_PORT=9090 ./app
|
||||
```
|
||||
24
docs/logging.md
Normal file
24
docs/logging.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logging
|
||||
|
||||
The application uses structured logging (JSON format) via the standard library's `log/slog` package.
|
||||
|
||||
## Log Levels
|
||||
|
||||
The supported log levels are:
|
||||
|
||||
* `debug`: Detailed information for debugging purposes.
|
||||
* `info`: General operational events (startup, shutdown, etc.).
|
||||
* `warn`: Non-critical errors or potential issues.
|
||||
* `error`: Critical errors that require attention.
|
||||
|
||||
The log level is configured via the `logging.level` setting in the configuration file or the `APP_LOGGING_LEVEL` environment variable.
|
||||
|
||||
## Log Output
|
||||
|
||||
Logs are output to `stdout` in JSON format to facilitate parsing by log management systems.
|
||||
|
||||
**Example Log Entry:**
|
||||
|
||||
```json
|
||||
{"time":"2023-10-27T10:00:00.123Z","level":"INFO","msg":"Starting application..."}
|
||||
```
|
||||
2
go.mod
2
go.mod
@@ -1,3 +1,5 @@
|
||||
module github.com/placeholder/golang-template
|
||||
|
||||
go 1.23
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -0,0 +1,4 @@
|
||||
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
144
internal/config/config.go
Normal file
144
internal/config/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/placeholder/golang-template/internal/logging"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// Config holds the application configuration.
|
||||
type Config struct {
|
||||
Logging logging.Config `yaml:"logging"`
|
||||
Server ServerConfig `yaml:"server"`
|
||||
}
|
||||
|
||||
// ServerConfig holds server-specific configuration.
|
||||
type ServerConfig struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
}
|
||||
|
||||
// Load reads configuration from a YAML file and overrides it with environment variables.
|
||||
// The envPrefix is used to namespace environment variables (e.g. "APP").
|
||||
// Env vars should be in the format: PREFIX_FIELD_NAME (uppercase).
|
||||
func Load(configPath string, envPrefix string) (*Config, error) {
|
||||
cfg := &Config{}
|
||||
|
||||
// 1. Read YAML file
|
||||
file, err := os.Open(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open config file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
decoder := yaml.NewDecoder(file)
|
||||
if err := decoder.Decode(cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode config file: %w", err)
|
||||
}
|
||||
|
||||
// 2. Override from Environment Variables
|
||||
if err := overrideFromEnv(cfg, envPrefix); err != nil {
|
||||
return nil, fmt.Errorf("failed to process environment variables: %w", err)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// overrideFromEnv uses reflection to traverse the struct and update fields from env vars.
|
||||
func overrideFromEnv(cfg interface{}, prefix string) error {
|
||||
v := reflect.ValueOf(cfg)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
|
||||
return setFromEnv(v, prefix)
|
||||
}
|
||||
|
||||
func setFromEnv(v reflect.Value, prefix string) error {
|
||||
t := v.Type()
|
||||
|
||||
for i := 0; i < v.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
fieldVal := v.Field(i)
|
||||
|
||||
// Determine the key for this field
|
||||
// We use the yaml tag if available to guess the name, or just the field name
|
||||
keyName := field.Name
|
||||
if tag := field.Tag.Get("yaml"); tag != "" {
|
||||
parts := strings.Split(tag, ",")
|
||||
if parts[0] != "" {
|
||||
keyName = parts[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize key name for Env (uppercase, replace non-alphanum with _)
|
||||
envKey := strings.ToUpper(fmt.Sprintf("%s_%s", prefix, keyName))
|
||||
// Clean up common separators like hyphens in yaml keys
|
||||
envKey = strings.ReplaceAll(envKey, "-", "_")
|
||||
|
||||
if fieldVal.Kind() == reflect.Struct {
|
||||
// Recursive call for nested structs
|
||||
if err := setFromEnv(fieldVal, envKey); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if !fieldVal.CanSet() {
|
||||
continue
|
||||
}
|
||||
|
||||
val, present := os.LookupEnv(envKey)
|
||||
if present {
|
||||
if err := setField(fieldVal, val); err != nil {
|
||||
return fmt.Errorf("failed to set field %s from env %s: %w", field.Name, envKey, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func setField(field reflect.Value, value string) error {
|
||||
switch field.Kind() {
|
||||
case reflect.String:
|
||||
field.SetString(value)
|
||||
case reflect.Bool:
|
||||
b, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetBool(b)
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
// Handle Duration specially if it's an int64 underlying type?
|
||||
// For now, let's assume standard ints.
|
||||
// Real generic implementations often handle time.Duration
|
||||
if field.Type() == reflect.TypeOf(time.Duration(0)) {
|
||||
d, err := time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetInt(int64(d))
|
||||
} else {
|
||||
i, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetInt(i)
|
||||
}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
f, err := strconv.ParseFloat(value, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
field.SetFloat(f)
|
||||
default:
|
||||
return fmt.Errorf("unsupported type: %s", field.Kind())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
65
internal/config/config_test.go
Normal file
65
internal/config/config_test.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
// Create a temporary config file
|
||||
tmpDir := t.TempDir()
|
||||
configFile := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
yamlContent := []byte(`
|
||||
logging:
|
||||
level: "info"
|
||||
server:
|
||||
host: "localhost"
|
||||
port: 8080
|
||||
`)
|
||||
if err := os.WriteFile(configFile, yamlContent, 0644); err != nil {
|
||||
t.Fatalf("failed to write temp config file: %v", err)
|
||||
}
|
||||
|
||||
t.Run("Load from YAML", func(t *testing.T) {
|
||||
cfg, err := Load(configFile, "TEST")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.Logging.Level != "info" {
|
||||
t.Errorf("expected Logging.Level 'info', got '%s'", cfg.Logging.Level)
|
||||
}
|
||||
if cfg.Server.Host != "localhost" {
|
||||
t.Errorf("expected Server.Host 'localhost', got '%s'", cfg.Server.Host)
|
||||
}
|
||||
if cfg.Server.Port != 8080 {
|
||||
t.Errorf("expected Server.Port 8080, got %d", cfg.Server.Port)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Override from Env", func(t *testing.T) {
|
||||
os.Setenv("TEST_LOGGING_LEVEL", "debug")
|
||||
os.Setenv("TEST_SERVER_PORT", "9090")
|
||||
os.Setenv("TEST_SERVER_HOST", "0.0.0.0")
|
||||
defer func() {
|
||||
os.Unsetenv("TEST_LOGGING_LEVEL")
|
||||
os.Unsetenv("TEST_SERVER_PORT")
|
||||
os.Unsetenv("TEST_SERVER_HOST")
|
||||
}()
|
||||
|
||||
cfg, err := Load(configFile, "TEST")
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.Logging.Level != "debug" {
|
||||
t.Errorf("expected Logging.Level 'debug', got '%s'", cfg.Logging.Level)
|
||||
}
|
||||
if cfg.Server.Port != 9090 {
|
||||
t.Errorf("expected Server.Port 9090, got %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Server.Host != "0.0.0.0" {
|
||||
t.Errorf("expected Server.Host '0.0.0.0', got '%s'", cfg.Server.Host)
|
||||
}
|
||||
})
|
||||
}
|
||||
37
internal/logging/logging.go
Normal file
37
internal/logging/logging.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Config holds the logging configuration.
|
||||
type Config struct {
|
||||
Level string `yaml:"level"`
|
||||
}
|
||||
|
||||
// Configure sets up the global logger based on the provided configuration.
|
||||
func Configure(cfg Config) *slog.Logger {
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: parseLogLevel(cfg.Level),
|
||||
}
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))
|
||||
slog.SetDefault(logger)
|
||||
return logger
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) slog.Level {
|
||||
switch strings.ToLower(level) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "info":
|
||||
return slog.LevelInfo
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
37
internal/logging/logging_test.go
Normal file
37
internal/logging/logging_test.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package logging
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseLogLevel(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected slog.Level
|
||||
}{
|
||||
{"debug", slog.LevelDebug},
|
||||
{"DEBUG", slog.LevelDebug},
|
||||
{"info", slog.LevelInfo},
|
||||
{"warn", slog.LevelWarn},
|
||||
{"error", slog.LevelError},
|
||||
{"unknown", slog.LevelInfo},
|
||||
{"", slog.LevelInfo},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
if got := parseLogLevel(tt.input); got != tt.expected {
|
||||
t.Errorf("parseLogLevel(%q) = %v, want %v", tt.input, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigure(t *testing.T) {
|
||||
cfg := Config{Level: "debug"}
|
||||
logger := Configure(cfg)
|
||||
if logger == nil {
|
||||
t.Error("Configure() returned nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user