Injection de dépendance en Go avec uber-go/fx

Injection de dépendance en Go

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 interface context.Context,
  • On définie ensuite les fonctions constructeurs de nos différents objets config, log et http,
  • Puis, lorsque l'application sera exécutée (ici via la méthode Run), le handler http.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