Injection de dépendance en Go avec uber-go/fx
Depuis que je travaille sur des projets Go, la question principale qui se pose à chaque initialisation est la gestion de l'injection de dépendance.
Lors d'un récent échange, on m'a parlé de la librairie uber-go/fx et je dois dire que j'ai vraiment apprécié leur approche.
Voyons voir comment vous pouvez l'utiliser sur vos projets.
Package main
Avant tout, le point d'entrée de votre projet, en Go, est le package main
et sa fonction main()
.
Lire le code de cette fonction doit être simple, court et clair. Vous devez être capable de comprendre comment fonctionne votre application en lisant une dizaine de lignes de code, et la librairie répond parfaitement à ce besoin.
J'ai développé une application qui me permet de définir une configuration, de loguer des éléments en output et principalement : d'exécuter une API HTTP.
Voici à quoi ressemble le fichier main
de cette application :
package main
import (
"context"
"github.com/eko/http-api/config"
"github.com/eko/http-api/internal/http"
"github.com/eko/http-api/internal/log"
"go.uber.org/fx"
)
func main() {
ctx := func() context.Context {
return context.Background()
}
fx.New(
fx.Provide(
fx.Annotate(ctx, fx.As(new(context.Context))),
config.New,
log.NewLogger,
http.NewServer,
),
fx.Invoke(http.HTTPHandler),
fx.NopLogger,
).Run()
}
En lisant ceci, on s'apperçoit des éléments suivants :
- Commençons par une particularité : on annote via
fx.Annotate()
une instance de*context.emptyCtx{}
en lui disant que cette instance sera utilisée lorsque mes services demanderont une interfacecontext.Context
, - On définie ensuite les fonctions constructeurs de nos différents objets
config
,log
ethttp
, - Puis, lorsque l'application sera exécutée (ici via la méthode
Run
), le handlerhttp.HTTPHandler
sera exécuté.
Enfin fx.NopLogger
est facultatif ici. Par défaut, la librairie log des informations sur la séquence d'initialisation des dépendances, vous pouvez ajouter cette ligne pour ne pas les loguer ou lui donner votre propre instance de logger :
[Fx] PROVIDE context.Context <= fx.Annotate(main.main.func1(), fx.As([[context.Context]])
[Fx] PROVIDE *config.Base <= github.com/eko/http-api/config.New()
[Fx] PROVIDE *zerolog.Logger <= github.com/eko/http-api/internal/log.NewLogger()
[Fx] PROVIDE *http.Server <= github.com/eko/http-api/internal/http.NewServer()
[Fx] PROVIDE fx.Lifecycle <= go.uber.org/fx.New.func1()
[Fx] PROVIDE fx.Shutdowner <= go.uber.org/fx.(*App).shutdowner-fm()
[Fx] PROVIDE fx.DotGraph <= go.uber.org/fx.(*App).dotGraph-fm()
[Fx] INVOKE github.com/eko/http-api/internal/http.HTTPHandler()
[Fx] HOOK OnStart github.com/eko/http-api/internal/http.NewServer.func1() executing (caller: github.com/eko/http-api/internal/http.NewServer)
[Fx] RUNNING
Un bon outil qui vous permettra de débuguer vos injections de dépendances si vous êtes amenés à rencontrer des soucis.
Fonctionnement de la librairie
Injection de dépendances en arguments
Evidemment, chacune des méthodes des constructeurs déclarées ici prennent des arguments, qui sont automatiquement gérés par l'injection de dépendance.
Par exemple, si nous regardons la déclaration du constructeur de configuration, il prend un context.Context
en argument et retourne une instance *config.Base
qui pourra ensuite être injectée à son tour dans d'autres constructeurs :
package config
import (
"context"
"time"
"github.com/heetch/confita"
"github.com/heetch/confita/backend/env"
"github.com/heetch/confita/backend/flags"
)
type Base struct {
Server
}
type Server struct {
HTTPAddr string `config:"HTTP_ADDR"`
}
// New loads configuration over different backends and returns values.
func New(ctx context.Context) *Base {
cfg := &Base{
Server: Server{
HTTPAddr: ":8000",
},
}
loader := confita.NewLoader(env.NewBackend(), flags.NewBackend())
if err := loader.Load(ctx, cfg); err != nil {
panic(err)
}
return cfg
}
Regardons maintenant la déclaration de notre serveur HTTP :
func NewServer(lc fx.Lifecycle, cfg *config.Base, logger *zerolog.Logger) *Server {
server := &Server{
Server: &http.Server{
Addr: cfg.HTTPAddr,
},
}
lc.Append(fx.Hook{
OnStart: func(context.Context) error {
logger.Info().Msg("Starting HTTP server")
go server.ListenAndServe()
return nil
},
OnStop: func(ctx context.Context) error {
logger.Info().Msg("Stopping HTTP server")
return server.Shutdown(ctx)
},
})
return server
}
Comme dit précédemment, l'injection de dépendance est bien en place et les trois arguments (fx.Lifecycle
, *config.Base
et *zerolog.Logger
) seront bien automatiquement injectés par la librairie.
Cycle de vie avec fx.Lifecycle
Nous n'avons pas encore évoqués fx.Lifecycle
et c'est l'occasion : il peut être injecté lorsque vous avez besoin de définir des hooks sur deux événements : OnStart
et OnStop
. Autrement dit, des fonctions à exécuter au démarrage et à l'arrêt de l'application.
Dans ce cas précis, nous voulons que notre serveur HTTP soit démarré et arrêté correctement.
Invocation avec fx.Invoke
Une fois vos dépendances définies via fx.Provide()
prêtes, et uniquement lorsque l'application démarre, (via les méthodes Run()
ou en gérant vous-même le Start()
et Stop()
), les méthodes déclarées dans la section fx.Invoke()
vont être exécutées.
Dans notre cas, il s'agit simplement des handlers de notre serveur HTTP mais vous pouvez bien évidemment déclarer plusieurs arguments si vous exécutez, par exemple, un serveur HTTP et un serveur gRPC en même temps.
func HTTPHandler(cfg *config.Base, server *Server) {
r := chirouter.NewWrapper(chi.NewRouter())
// ...
r.Method(http.MethodPost, "/user", CreateUserHandler())
server.Handler = r
}
Aller plus loin
Nous avons vu les grands principes d'utilisation de la librairie uber-go/fx
mais il existe bien évidemment d'autres méthodes qui vous permettront de mener à bien les différents use-case que vous pourrez rencontrés dans vos projets.
fx.Supply
Par exemple, fx.Supply()
vous permet de spécifier des valeurs d'instances déjà construites afin de les utiliser ensuite dans vos arguments :
var a, b, c = &TypeA{}, TypeB{}, &TypeC{}
fx.Supply(a, b, fx.Annotated{Target: c})
fx.Module
Sur des projets plus larges, vous pourrez également découper davantage la déclaration de vos services sous forme de modules, par exemple :
redis := fx.Module(
"redis",
fx.Provide(
logger.New,
redis.NewClient,
),
fx.Invoke(cleaner.New),
)
dynamodb := fx.Module(
"dynamodb",
fx.Provide(dynamodb.NewClient),
fx.Invoke(persister.New),
)
app := fx.New(
redis,
dynamodb,
fx.Invoke(func(l *Logger, redisClient *redis.Client, dynamoClient *dynamodb.Client) {
// Do something.
}),
)
fx.In et fx.Out
Vous pouvez également injecter des dépendances en utilisant des tags sur vos struct
grâce à ces fonctions.
Voici un exemple :
type serverManager struct {
fx.Out
httpServer1 *http.Server `name:"httpServer1"`
httpServer2 *http.Server `name:"httpServer2"`
}
newServerManager := func() *serverManager {
return &serverManager{
httpServer1: &http.Server{},
httpServer2: &http.Server{},
}
}
type server2Handlers struct {
fx.In
httpServer2 *http.Server `name:"httpServer2"`
}
fx.New(
fx.Provide(newServerManager),
fx.Invoke((func(handlers server2Handlers) {
// Do something.
})),
)
Les tags sont ici facultatifs mais il nous permettent d'illustrer la méthode suivante.
fx.Replace
Dans certains cas, vous aurez également peut-être besoin de passer une version de l'objet plutôt qu'une autre, principalement dans le cas des interfaces.
Pour cela, fx.Replace()
permet de déclarer une nouvelle version d'un objet et également de spécifier un paramètre de tag afin de sélectionner le bon objet :
fx.Replace(
fx.Annotate(&http.Server{Addr: ":3000"}, fx.ResultTags(`name:"httpServer1"`)),
)
Conclusion
Après avoir vu beaucoup de solutions faites "maison", cette librairie peut réellement s'avérer un atout qui vous évitera d'avoir à gérer vous-même l'injection de dépendance dans vos projets, vous évitant ainsi un bon nombre de lignes de code de déclaration de services.
L'apprentissage des notions de celle-ci a un petit coût d'entrée mais les méthodes se lisent assez bien et si vous disposez de plusieurs projets : les uniformiser sur une même librairie permettra à ce que vos développeurs entrent encore plus rapidement dans les projets.
La librairie semble bien maintenue et régulièrement mise à jour, je suis donc prêt à l'utiliser sur des projets de mon côté.
Crédits
Photo by Stéphane Mingot on Unsplash