Réfactorisation du code legacy en Go : guide étape par étape
Travailler avec du code legacy en Go fait partie intégrante de la vie d'un développeur. Souvent, ce code a été écrit à la hâte, sans tests, avec des variables globales et des fonctions monolithiques. Cependant, la réfactorisation du code legacy en Go n'est pas simplement une réécriture, mais un processus stratégique qui nécessite prudence et approche systématique. Dans cet article, nous verrons comment améliorer le code existant de manière sûre et efficace, sans casser le produit.
Pourquoi la réfactorisation du code legacy en Go est-elle difficile ?
Le code legacy en Go présente souvent des problèmes caractéristiques :
- Variables globales — compliquent les tests et l'exécution parallèle.
- Fonctions monolithiques — des fonctions de centaines de lignes qui font tout à la fois.
- Absence d'interfaces — un couplage rigide à des implémentations spécifiques.
- Pas de tests — la réfactorisation sans tests revient à marcher dans un champ de mines.
Mais Go fournit des outils puissants pour améliorer progressivement le code. L'essentiel est d'avancer par petites étapes, en maintenant la fonctionnalité du système à chaque étape.
Étape 1 : Préparation et tests
Avant de modifier le code, il est nécessaire de créer un « filet de sécurité » — des tests. Même si le code n'a pas été testé, vous pouvez écrire des tests de caractérisation (characterization tests). Ils figent le comportement actuel du système, afin que vous puissiez vous assurer que rien n'a été cassé après la réfactorisation.
package main
import "testing"
func TestCalculateTotal(t *testing.T) { // Supposons que nous ayons une fonction legacy CalculateTotal result := CalculateTotal(100, 20) expected := 120.0
if result != expected { t.Errorf("CalculateTotal(100, 20) = %f; want %f", result, expected) }}Exécutez les tests et assurez-vous qu'ils réussissent. Vous avez maintenant une base pour des modifications sûres.
Étape 2 : Extraction des interfaces
L'un des principaux problèmes du code legacy en Go est le couplage fort entre les composants. La solution est l'extraction des interfaces. Cela permet de remplacer les dépendances réelles par des mocks lors des tests, et simplifie également le remplacement de l'implémentation à l'avenir.
Prenons un exemple. Supposons que nous ayons une fonction qui enregistre des données directement dans une base de données :
package main
import "database/sql"
type User struct { ID int Name string}
func SaveUser(db *sql.DB, user User) error { _, err := db.Exec("INSERT INTO users (name) VALUES ($1)", user.Name) return err}Problème : nous ne pouvons pas tester cette fonction sans une vraie base de données. Solution : extraire une interface :
package main
// UserStorage définit le contrat pour le stockage des utilisateurstype UserStorage interface { Save(user User) error}
type UserSaver struct { storage UserStorage}
func NewUserSaver(storage UserStorage) *UserSaver { return &UserSaver{storage: storage}}
func (s *UserSaver) SaveUser(user User) error { return s.storage.Save(user)}Maintenant, nous pouvons créer un mock pour les tests :
package main
import "testing"
type mockStorage struct{}
func (m *mockStorage) Save(user User) error { return nil // toujours réussi}
func TestUserSaver_SaveUser(t *testing.T) { saver := NewUserSaver(&mockStorage{}) err := saver.SaveUser(User{ID: 1, Name: "Alice"}) if err != nil { t.Errorf("unexpected error: %v", err) }}Étape 3 : Injection de dépendances
Après l'extraction des interfaces, l'étape suivante est l'injection de dépendances (Dependency Injection, DI). Au lieu de créer des dépendances à l'intérieur de la fonction (par exemple, ouvrir une connexion à la base de données), nous les passons de l'extérieur.
Prenons un code legacy typique :
package