d95c62bad4
Remove 557-line server-design.md from _go-learning/architecture - content is now covered in updated architecture documentation with real implementation examples and test coverage.
10 KiB
10 KiB
Singleton Pattern in Go
Pattern Overview
The Singleton Pattern ensures that a class has only one instance and provides a global point of access to it. In Go, this is typically achieved through package-level variables and sync.Once for thread-safe initialization.
Pattern Structure
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{
// initialization
}
})
return instance
}
Real Implementation: Configuration Singleton
Configuration Loading
// internal/config/config.go
var (
instance *Config
once sync.Once
)
// Config holds application configuration
type Config struct {
Server ServerConfig
Templates TemplateConfig
}
// Load returns singleton configuration instance
func Load() *Config {
once.Do(func() {
instance = &Config{
Server: ServerConfig{
Host: getEnvOrDefault("HOST", "localhost"),
Port: getEnvOrDefault("PORT", ":8080"),
},
Templates: TemplateConfig{
Dir: getEnvOrDefault("TEMPLATE_DIR", "templates"),
PartialsDir: getEnvOrDefault("PARTIALS_DIR", "templates/partials"),
HotReload: getBoolEnv("HOT_RELOAD", true),
},
}
})
return instance
}
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
Template Manager Singleton
// In a larger application, template manager might be singleton
var (
templateManager *templates.Manager
tmplOnce sync.Once
)
func GetTemplateManager() (*templates.Manager, error) {
var err error
tmplOnce.Do(func() {
cfg := Load() // Get config singleton
templateManager, err = templates.NewManager(cfg.Templates)
})
return templateManager, err
}
Thread-Safe Singleton
Using sync.Once
// sync.Once guarantees initialization happens exactly once
type Database struct {
conn *sql.DB
}
var (
db *Database
once sync.Once
)
func GetDatabase() (*Database, error) {
var err error
once.Do(func() {
db = &Database{}
db.conn, err = sql.Open("postgres", "connection-string")
if err != nil {
db = nil // Reset on error
}
})
if db == nil {
return nil, err
}
return db, nil
}
Thread-Safety Comparison
// ❌ NOT thread-safe
var instance *Singleton
func GetInstance() *Singleton {
if instance == nil { // Race condition!
instance = &Singleton{}
}
return instance
}
// ✅ Thread-safe with mutex (but slower)
var (
instance *Singleton
mu sync.Mutex
)
func GetInstance() *Singleton {
mu.Lock()
defer mu.Unlock()
if instance == nil {
instance = &Singleton{}
}
return instance
}
// ✅ Thread-safe with sync.Once (best)
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}
Singleton vs Package-Level Variables
Simple Package-Level Variable
// For simple, non-lazy initialization
package logger
var std = New(os.Stdout, InfoLevel)
func Info(msg string) {
std.Log(InfoLevel, msg)
}
func Debug(msg string) {
std.Log(DebugLevel, msg)
}
When to Use Singleton vs Package Variable
Use Singleton (sync.Once) when:
- Initialization is expensive
- Initialization might fail
- Need lazy initialization
- Need thread-safe initialization
Use Package Variable when:
- Initialization is cheap
- Initialization always succeeds
- Want immediate initialization
- Simple, stateless utility
Singleton Use Cases
1. Configuration
// config/config.go
var (
cfg *Config
once sync.Once
)
func Load() *Config {
once.Do(func() {
cfg = &Config{}
// Load from file, env, etc.
cfg.loadFromEnv()
cfg.loadFromFile()
})
return cfg
}
2. Database Connection Pool
// database/db.go
var (
pool *sql.DB
once sync.Once
)
func GetPool() (*sql.DB, error) {
var err error
once.Do(func() {
pool, err = sql.Open("postgres", getConnectionString())
if err != nil {
return
}
pool.SetMaxOpenConns(25)
pool.SetMaxIdleConns(5)
err = pool.Ping()
if err != nil {
pool.Close()
pool = nil
}
})
if pool == nil {
return nil, err
}
return pool, nil
}
3. Logger
// logger/logger.go
var (
logger *Logger
once sync.Once
)
type Logger struct {
writer io.Writer
level Level
}
func Get() *Logger {
once.Do(func() {
logger = &Logger{
writer: os.Stdout,
level: InfoLevel,
}
})
return logger
}
// Convenience functions
func Info(msg string) {
Get().Log(InfoLevel, msg)
}
func Error(msg string) {
Get().Log(ErrorLevel, msg)
}
4. Cache
// cache/cache.go
var (
cache *Cache
once sync.Once
)
type Cache struct {
data map[string]interface{}
mu sync.RWMutex
}
func Get() *Cache {
once.Do(func() {
cache = &Cache{
data: make(map[string]interface{}),
}
})
return cache
}
func Set(key string, value interface{}) {
c := Get()
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
func Retrieve(key string) (interface{}, bool) {
c := Get()
c.mu.RLock()
defer c.mu.RUnlock()
val, ok := c.data[key]
return val, ok
}
Anti-Pattern: Global State
Problem
// ❌ BAD: Mutable global state
var Config = &AppConfig{
Timeout: 30,
}
func main() {
Config.Timeout = 60 // Mutating global state
// Hard to test, unpredictable behavior
}
Solution: Immutable Singleton
// ✅ GOOD: Immutable singleton
var (
config *Config
once sync.Once
)
func GetConfig() *Config {
once.Do(func() {
config = &Config{
Timeout: 30,
}
})
return config // Read-only access
}
// To change config, create new instance
func WithTimeout(timeout int) *Config {
old := GetConfig()
return &Config{
Timeout: timeout,
// Copy other fields from old
}
}
Testing Singletons
Problem with Testing
// Singleton makes testing difficult
func TestFeature(t *testing.T) {
instance := GetInstance()
instance.value = "test1"
// Test 1 passes
// But now instance.value is "test1" for next test!
}
Solution: Reset for Tests
// Add reset function for tests
func ResetForTest() {
once = sync.Once{}
instance = nil
}
func TestFeature(t *testing.T) {
defer ResetForTest()
instance := GetInstance()
instance.value = "test1"
// Test with clean state
}
Alternative: Dependency Injection
// Instead of singleton, use DI for testability
type Handler struct {
config *Config // Injected, not singleton
}
func NewHandler(config *Config) *Handler {
return &Handler{config: config}
}
// Easy to test with different configs
func TestHandler(t *testing.T) {
testConfig := &Config{Timeout: 10}
handler := NewHandler(testConfig)
// Test with test config
}
Singleton Variations
1. Eager Initialization
// Initialize at package load time
var instance = &Singleton{
// initialization
}
func GetInstance() *Singleton {
return instance
}
2. Lazy Initialization
// Initialize on first use
var (
instance *Singleton
once sync.Once
)
func GetInstance() *Singleton {
once.Do(func() {
instance = &Singleton{}
})
return instance
}
3. With Error Handling
var (
instance *Singleton
once sync.Once
err error
)
func GetInstance() (*Singleton, error) {
once.Do(func() {
instance, err = initialize()
})
return instance, err
}
func initialize() (*Singleton, error) {
s := &Singleton{}
if err := s.connect(); err != nil {
return nil, err
}
return s, nil
}
Best Practices
✅ DO
// Use sync.Once for thread-safety
var once sync.Once
// Make fields private
type Singleton struct {
privateField string
}
// Provide accessor methods
func (s *Singleton) GetValue() string {
return s.privateField
}
// Handle initialization errors
func GetInstance() (*Singleton, error) {
var err error
once.Do(func() {
instance, err = newSingleton()
})
return instance, err
}
// Document singleton nature
// GetDatabase returns the singleton database connection pool.
// Thread-safe and initialized lazily on first call.
func GetDatabase() *Database {
// ...
}
❌ DON'T
// DON'T use mutable global state
var GlobalConfig Config // Mutable!
// DON'T forget thread-safety
if instance == nil { // Race condition!
instance = &Singleton{}
}
// DON'T make everything a singleton
// Only use for truly global, single-instance resources
// DON'T ignore errors in initialization
once.Do(func() {
instance, _ = newSingleton() // Ignoring error!
})
When NOT to Use Singleton
- Testing is Important: Dependency injection is better
- Multiple Instances Needed: Use factory pattern
- State Changes: Avoid mutable singletons
- Simple Utilities: Use package functions
- Request-Scoped: Use context pattern
Alternatives to Singleton
Dependency Injection
// Better for testability
type Handler struct {
config *Config // Injected
db *DB // Injected
}
func NewHandler(config *Config, db *DB) *Handler {
return &Handler{config: config, db: db}
}
Context Values
// For request-scoped "singletons"
ctx := context.WithValue(ctx, ConfigKey, config)
// Retrieve in handler
config := ctx.Value(ConfigKey).(*Config)
Package Functions
// For stateless utilities
package mathutil
func Max(a, b int) int {
if a > b {
return a
}
return b
}
// No singleton needed
Related Patterns
- Dependency Injection: Alternative to singleton
- Factory Pattern: Can create singletons
- Multiton Pattern: Multiple instances keyed by ID