Salta al contenuto principale

Design Pattern in Go: Implementazioni Idiomatiche

·8 minuti
Indice dei contenuti
Go affronta i design pattern in modo diverso rispetto a Java o C++. Poiche Go usa la composizione invece dell’ereditarieta, e le funzioni sono valori di prima classe, molti pattern che richiedono elaborate gerarchie di classi nei linguaggi OOP si riducono a pochi costrutti idiomatici Go. Questo articolo mostra le implementazioni corrette, pronte per la produzione, e, altrettanto importante, quando non usare affatto un pattern.

I design pattern non sono una checklist. In Go, applicare pattern in stile Java porta rapidamente a codice che appare estraneo a ogni sviluppatore Go che lo legge. I pattern qui sotto sono presentati nella loro forma idiomatica Go, spesso molto piu semplice della versione da libro di testo.

Singleton con sync.Once
#

Il singleton canonico in Go usa sync.Once, che garantisce l’esecuzione dell’inizializzatore esattamente una volta, indipendentemente da quante goroutine chiamano GetInstance contemporaneamente. Non serve il doppio lock, nessuna keyword volatile: il runtime gestisce tutto internamente.

singleton.go
package config

import "sync"

type AppConfig struct {
    DatabaseURL string
    Port        int
    Debug       bool
}

var (
    instance *AppConfig
    once     sync.Once
)

func GetConfig() *AppConfig {
    once.Do(func() {
        instance = &AppConfig{
            DatabaseURL: "postgres://localhost/mydb",
            Port:        8080,
            Debug:       false,
        }
    })
    return instance
}
Nota

sync.Once e sicuro per l’uso concorrente. Una volta che Do e stato eseguito, le chiamate successive ritornano immediatamente senza acquisire alcun lock. Questo lo rende essenzialmente gratuito sul percorso caldo dopo l’inizializzazione.

Il pattern e appropriato per stato veramente globale: configurazione caricata una volta all’avvio, un pool di connessioni database condiviso, o un registro di metriche. Evitarlo per qualsiasi cosa che deve essere reimpostata tra i test.

Functional Options: il Builder Idiomatico Go
#

Il pattern Builder classico esiste in Go, ma l’approccio idiomatico e il pattern delle functional options, introdotto da Rob Pike. Invece di un oggetto builder, si passano funzioni di opzione variadiche che modificano una struct di configurazione prima che l’oggetto reale venga costruito.

server.go
package server

import (
    "net/http"
    "time"
)

type Server struct {
    addr           string
    readTimeout    time.Duration
    writeTimeout   time.Duration
    maxHeaderBytes int
    handler        http.Handler
}

// ServerOption e una funzione che configura un Server.
type ServerOption func(*Server)

func WithAddr(addr string) ServerOption {
    return func(s *Server) {
        s.addr = addr
    }
}

func WithReadTimeout(d time.Duration) ServerOption {
    return func(s *Server) {
        s.readTimeout = d
    }
}

func WithWriteTimeout(d time.Duration) ServerOption {
    return func(s *Server) {
        s.writeTimeout = d
    }
}

func WithMaxHeaderBytes(n int) ServerOption {
    return func(s *Server) {
        s.maxHeaderBytes = n
    }
}

func NewServer(handler http.Handler, opts ...ServerOption) *Server {
    s := &Server{
        addr:           ":8080",
        readTimeout:    5 * time.Second,
        writeTimeout:   10 * time.Second,
        maxHeaderBytes: 1 << 20,
        handler:        handler,
    }
    for _, opt := range opts {
        opt(s)
    }
    return s
}

func (s *Server) ListenAndServe() error {
    srv := &http.Server{
        Addr:           s.addr,
        Handler:        s.handler,
        ReadTimeout:    s.readTimeout,
        WriteTimeout:   s.writeTimeout,
        MaxHeaderBytes: s.maxHeaderBytes,
    }
    return srv.ListenAndServe()
}

Punto di chiamata:

main.go
srv := server.NewServer(
    myHandler,
    server.WithAddr(":9090"),
    server.WithReadTimeout(30 * time.Second),
)

Questo approccio e superiore al Builder classico perche i valori di default risiedono in un unico posto (NewServer), le funzioni di opzione sono testabili e componibili singolarmente, e i chiamanti specificano solo cio che vogliono sovrascrivere.

Factory per la Costruzione Basata su Interfacce
#

Si usa una factory quando si ha bisogno di produrre tipi concreti diversi dietro un’interfaccia condivisa. Un esempio realistico: un sender di notifiche con backend Email, SMS e Slack.

notify/factory.go
package notify

import "fmt"

// Sender e l'interfaccia che ogni backend di notifica deve implementare.
type Sender interface {
    Send(to, subject, body string) error
}

type EmailSender struct{ SMTPHost string }

func (e *EmailSender) Send(to, subject, body string) error {
    fmt.Printf("[EMAIL] to=%s subject=%s\n", to, subject)
    return nil
}

type SMSSender struct{ APIKey string }

func (s *SMSSender) Send(to, subject, body string) error {
    fmt.Printf("[SMS] to=%s body=%s\n", to, body)
    return nil
}

type SlackSender struct{ WebhookURL string }

func (sl *SlackSender) Send(to, subject, body string) error {
    fmt.Printf("[SLACK] channel=%s body=%s\n", to, body)
    return nil
}

// NewSender e la factory function.
func NewSender(provider string, config map[string]string) (Sender, error) {
    switch provider {
    case "email":
        return &EmailSender{SMTPHost: config["smtp_host"]}, nil
    case "sms":
        return &SMSSender{APIKey: config["api_key"]}, nil
    case "slack":
        return &SlackSender{WebhookURL: config["webhook_url"]}, nil
    default:
        return nil, fmt.Errorf("provider di notifica sconosciuto: %s", provider)
    }
}
Suggerimento

Restituire (Sender, error) dalla factory, mai solo Sender. I problemi di configurazione devono emergere al momento della costruzione, non quando il primo messaggio viene inviato alle 3 di notte.

Observer con Channels: Implementazione Concorrente Corretta
#

L’implementazione naive dell’Observer con channel non bufferizzati causa deadlock o panic. Usare un WaitGroup e channel bufferizzati risolve entrambi i problemi: il mittente non si blocca mai, e la cleanup attende che tutte le goroutine finiscano di elaborare.

observable.go
package events

import (
    "fmt"
    "sync"
)

type Observable struct {
    observers []chan int
}

func (o *Observable) AddObserver(c chan int) {
    o.observers = append(o.observers, c)
}

// Notify invia n a tutti gli observer senza bloccarsi.
// Ogni channel observer deve essere bufferizzato almeno quanto la dimensione massima del burst atteso.
func (o *Observable) Notify(n int) {
    for _, c := range o.observers {
        c <- n
    }
}

// CloseAll segnala a tutte le goroutine observer di fermarsi chiudendo i loro channel.
func (o *Observable) CloseAll() {
    for _, c := range o.observers {
        close(c)
    }
}

func Example() {
    observable := &Observable{}

    // I channel bufferizzati impediscono a Notify di bloccarsi.
    observer1 := make(chan int, 10)
    observer2 := make(chan int, 10)

    observable.AddObserver(observer1)
    observable.AddObserver(observer2)

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {
        defer wg.Done()
        for n := range observer1 {
            fmt.Println("Observer 1 ricevuto:", n)
        }
    }()

    go func() {
        defer wg.Done()
        for n := range observer2 {
            fmt.Println("Observer 2 ricevuto:", n)
        }
    }()

    observable.Notify(1)
    observable.Notify(2)
    observable.Notify(3)

    // Chiudi i channel per segnalare alle goroutine di uscire, poi aspetta.
    observable.CloseAll()
    wg.Wait()
}

Il WaitGroup garantisce che tutti gli eventi vengano elaborati prima che il programma termini.

Strategy Pattern con Funzioni di Prima Classe
#

In Java, Strategy richiede un’interfaccia con un singolo metodo piu una famiglia di classi implementanti. In Go, un tipo funzione e gia una strategy. Nessuna interfaccia, nessuna struct, nessun insieme di metodi.

sorter.go
package sorter

// SortStrategy e un tipo funzione. Qualsiasi funzione con questa firma e una strategy.
type SortStrategy func(data []int)

type Sorter struct {
    strategy SortStrategy
}

func NewSorter(s SortStrategy) *Sorter {
    return &Sorter{strategy: s}
}

func (s *Sorter) Sort(data []int) {
    s.strategy(data)
}

func BubbleSort(data []int) {
    n := len(data)
    for i := 0; i < n-1; i++ {
        for j := 0; j < n-1-i; j++ {
            if data[j] > data[j+1] {
                data[j], data[j+1] = data[j+1], data[j]
            }
        }
    }
}

func QuickSort(data []int) {
    if len(data) <= 1 {
        return
    }
    pivot := data[len(data)/2]
    var left, right, equal []int
    for _, v := range data {
        switch {
        case v < pivot:
            left = append(left, v)
        case v > pivot:
            right = append(right, v)
        default:
            equal = append(equal, v)
        }
    }
    QuickSort(left)
    QuickSort(right)
    copy(data, append(append(left, equal...), right...))
}

Decorator Pattern con Middleware HTTP
#

In Go, il middleware HTTP e l’implementazione canonica del Decorator. Un http.Handler viene avvolto da una funzione che aggiunge funzionalita trasversali (logging, autenticazione, rate limiting) senza modificare il handler interno.

middleware.go
package middleware

import (
    "log"
    "net/http"
    "time"
)

// Logger avvolge un http.Handler per registrare la durata delle richieste.
func Logger(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
    })
}

// RequireAuth avvolge un http.Handler per imporre l'autenticazione bearer token.
func RequireAuth(token string, next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("Authorization") != "Bearer "+token {
            http.Error(w, "non autorizzato", http.StatusUnauthorized)
            return
        }
        next.ServeHTTP(w, r)
    })
}

Concatenare i decorator al punto di chiamata:

main.go
var handler http.Handler = myAPIHandler
handler = middleware.Logger(handler)
handler = middleware.RequireAuth(os.Getenv("API_TOKEN"), handler)
http.ListenAndServe(":8080", handler)

Quando NON Usare Questi Pattern
#

Il modello di composizione di Go spesso rende i pattern non necessari o addirittura controproducenti.

SituazionePattern che potresti usareCosa fare in Go invece
Stato globale condivisoSingletonPassa le dipendenze come argomenti; usa sync.Once solo quando l’init globale e davvero necessaria
Configurare una structBuilderFunctional options o una plain struct con zero value sensati
Dispatch polimorficoInterfaccia StrategyTipo funzione o interfaccia con un unico metodo
Aggiungere comportamento a un tipoDecoratorIncorpora il tipo in una nuova struct e aggiungi/sovrascrivi metodi
Avviso

Simulare l’ereditarieta in stile Java in Go (incorporare struct per “estenderle”) produce codice difficile da seguire e rompe la convenzione Go che la composizione sia esplicita. Se ti trovi a chiederti “come posso fare in modo che una struct erediti da un’altra struct”, fermati e riconsideri il modello dei dati.

**Over-engineering con il Builder pattern.** Una struct con tre campi non ha bisogno di un Builder. Usa un literal di struct o functional options solo quando il numero di parametri opzionali e abbastanza grande da rendere illeggibile una lunga lista di argomenti (circa cinque o piu campi opzionali). **Chiudere i channel prima che le goroutine finiscano.** Il `WaitGroup` garantisce che tutti gli eventi vengano elaborati prima che il programma termini. Senza di esso, le goroutine potrebbero essere terminate a meta elaborazione. **Usare `interface{}` (o `any`) nei tipi di ritorno.** Un metodo `Build() interface{}` forza il chiamante a fare type-assertion ad ogni chiamata, spostando gli errori al runtime invece che al compile time. Restituisci il tipo concreto o un'interfaccia specifica. **Riprodurre la gerarchia AbstractFactory.** Una factory function che restituisce un'interfaccia e quasi sempre sufficiente. Piu struct factory con metodi factory e indirezione senza beneficio. **Singleton per tutto cio che e iniettabile.** I singleton rendono i test dipendenti dall'ordine e difficili da parallelizzare. Preferisci la dependency injection tramite parametri di funzione.

Se vuoi approfondire questi temi, offro sessioni di coaching 1:1 per ingegneri che lavorano su integrazione AI, architettura cloud e platform engineering. Prenota una sessione (50 EUR / 60 min) o scrivimi a manuel.fedele+website@gmail.com.

Articoli correlati