Go, ou Golang, est rapidement devenu la colonne vertébrale de l'infrastructure cloud-native moderne. Sa simplicité, son modèle de concurrence et son explicititude séduisent les développeurs qui valorisent l'efficacité. Cependant, une idée reçue courante chez les développeurs qui passent à Go est qu'ils devraient strictement éviter les modèles de conception que l'on trouve dans des langages comme Java ou C++. C'est faux. Go adopte les modèles de conception mais privilégie une approche plus minimaliste et idiomatique. Dans cet article, nous explorerons comment implémenter trois modèles classiques — Singleton, Factory et Observer — d'une manière qui semble native à l'écosystème Go.
La philosophie Go : La simplicité avant la complexité
Avant de plonger dans le code, il est crucial de comprendre que Go encourage la composition plutôt que l'héritage. Les modèles de conception traditionnels (Gang of Four) reposent souvent lourdement sur des hiérarchies d'héritage profondes. En Go, nous résolvons les problèmes structurels en composant de petites interfaces à usage unique. Cela réduit le couplage et rend les tests significativement plus faciles. Bien que vous n'ayez peut-être pas besoin d'une classe abstraite complexe, vous utiliserez fréquemment des interfaces pour définir le comportement et des structs pour contenir l'état.
1. Le modèle Singleton : Sûr et idiomatique
Le modèle Singleton garantit qu'une classe n'a qu'une seule instance et fournit un point d'accès global à celle-ci. Dans les langages avec initialisation différée (lazy initialization), la sécurité des threads est une préoccupation majeure. En Go, la manière idiomatique de gérer les singletons consiste à utiliser le package sync, spécifiquement sync.Once, qui garantit que l'initialisation se produit exactement une fois, même dans des environnements concurrents.
package singleton
import "sync"
// DatabaseConn représente une seule connexion à la base de données.
type DatabaseConn struct {
Host string
Port int
}
var (
instance *DatabaseConn
once sync.Once
)
// GetInstance retourne l'unique instance de DatabaseConn.
func GetInstance() *DatabaseConn {
once.Do(func() {
instance = &DatabaseConn{
Host: "localhost",
Port: 5432,
}
})
return instance
}
En utilisant sync.Once, nous évitons la verbosité des vérifications manuelles de verrouillage et de déverrouillage des mutex. C'est la méthode préférée de la bibliothèque standard pour l'initialisation différée de l'état global.
2. Le modèle Factory : Abstraction de la logique de création
Les modèles Factory sont utilisés pour créer des objets sans spécifier la classe exacte de l'objet qui sera créé. En Go, cela est souvent implémenté à l'aide de fonctions ou de méthodes qui retournent une interface. Cela vous permet de remplacer les implémentations sans modifier le code du consommateur, ce qui est vital pour l'injection de dépendances et les tests.
package notification
// Notifier est l'interface qui définit le contrat pour l'envoi de messages.
type Notifier interface {
Send(message string) error
}
// EmailService implémente Notifier.
type EmailService struct{}
func (e *EmailService) Send(message string) error {
// Logique pour envoyer un email
return nil
}
// SMSService implémente Notifier.
type SMSService struct{}
func (s *SMSService) Send(message string) error {
// Logique pour envoyer un SMS
return nil
}
// NewNotifier crée un Notifier en fonction du type fourni.
func NewNotifier(serviceType string) (Notifier, error) {
switch serviceType {
case "email":
return &EmailService{}, nil
case "sms":
return &SMSService{}, nil
default:
return nil, fmt.Errorf("type de notifier non supporté : %s", serviceType)
}
}
Cette approche centralise la logique de création et permet au reste de l'application de dépendre de l'interface Notifier plutôt que de structs concrets.
3. Le modèle Observer : La concurrence rendue facile
Bien que Go ne dispose pas d'écouteurs d'événements intégrés comme JavaScript, nous pouvons implémenter le modèle Observer en utilisant des goroutines et des canaux (channels). Cela est particulièrement utile pour découpler les services, comme la journalisation des événements système ou la diffusion de mises à jour aux clients connectés.
package observer
type EventBus struct {
subscribers map[string][]chan string
}
func NewEventBus() *EventBus {
return &EventBus{
subscribers: make(map[string][]chan string),
}
}
func (eb *EventBus) Subscribe(event string, ch chan string) {
eb.subscribers[event] = append(eb.subscribers[event], ch)
}
func (eb *EventBus) Publish(event string, message string) {
if chs, ok := eb.subscribers[event]; ok {
for _, ch := range chs {
go func(c chan string) {
c <- message
}(ch)
}
}
}
En lançant une goroutine pour chaque abonné, nous nous assurons que l'éditeur ne bloque pas si un abonné est lent. Cela exploite les primitives de concurrence de Go pour implémenter un modèle qui est traditionnellement plus complexe dans d'autres langages.
Conclusion
Implémenter des modèles de conception en Go consiste moins à suivre des modèles stricts qu'à tirer parti des forces du langage : les interfaces, la composition et la concurrence. En utilisant sync.Once pour les singletons, des fonctions retournant des interfaces pour les factories, et des canaux pour les observateurs, vous écrivez du code qui est non seulement fonctionnel, mais aussi idiomatique et maintenable. Rappelez-vous, le meilleur code Go est simple, explicite et exploite la bibliothèque standard chaque fois que possible.