Crééz votre premier microservice en Golang

Apprenez à créer votre premier microservice en Golang. Suivez un guide étape par étape pour comprendre les concepts clés, configurer votre environnement de développement, et déployer un microservice fonctionnel.

Un microservice est une petite application autonome qui fait partie d'une architecture plus large. Contrairement aux applications monolithiques, les microservices sont conçus pour être indépendants, faciles à déployer et à mettre à l'échelle. Dans ce guide, nous allons créer un microservice simple en Golang.

Pourquoi utiliser des microservices ?

Les microservices offrent plusieurs avantages par rapport aux architectures monolithiques :

  • Indépendance : Chaque microservice peut être développé, déployé et mis à l'échelle indépendamment.
  • Résilience : Si un microservice tombe en panne, les autres peuvent continuer à fonctionner.
  • Flexibilité technologique : Chaque microservice peut utiliser la technologie la plus adaptée à ses besoins.
  • Maintenance facilitée : Les équipes peuvent se concentrer sur des services spécifiques, ce qui simplifie la maintenance et les mises à jour.
  • Scalabilité : Les microservices peuvent être mis à l'échelle individuellement en fonction de la demande, ce qui optimise l'utilisation des ressources.

Inconvénients des microservices

Bien que les microservices offrent de nombreux avantages, ils présentent également certains défis :

  • Complexité accrue : La gestion de plusieurs services indépendants peut être complexe.
  • Communication inter-services : Les microservices doivent souvent communiquer entre eux, ce qui peut introduire des latences et des points de défaillance.
  • Déploiement et orchestration : La mise en place d'un environnement de déploiement pour plusieurs microservices peut être plus compliquée que pour une application monolithique.
  • Gestion des données : La cohérence des données peut être plus difficile à maintenir lorsque chaque microservice possède sa propre base de données.

Construction d'un microservice en Golang

Nous allons créer un microservice simple en Golang qui expose une API REST pour gérer des paiements avec Stripe. Ce microservice permettra de créer, lire, mettre à jour et supprimer des paiements.

Structure du projet

La structure de notre projet sera la suivante :

.
├── internal
│   ├── app
│   ├── handlers
│   ├── ticker
│   └── utils
│       └── requests
└── tests
    ├── e2e
    ├── integration
    └── utils
graph TD
    A[Start] --> B[Load Routes]
    B --> C[Initialize App]
    C --> D[Start Server]
    D --> E[Handle Requests]
    E --> F[Shutdown Server]

Le dossier app contiendra toute la logique pour gérer et lancer le serveur HTTP, y compris la configuration des routes, des middlewares et du serveur lui-même. Il utilisera chi comme routeur.

Le dossier handlers contiendra les gestionnaires pour les différentes routes de l'API. Chaque fichier dans ce dossier correspondra à un ensemble de routes liées à une entité spécifique, comme les articles de blog.

Le dossier ticker pourra contenir des tâches périodiques ou des jobs planifiés qui s'exécutent à intervalles réguliers en arrière-plan, comme l'envoi de notifications, la vérification de certaines routes API externes ou la mise à jour de certaines données.

Le dossier utils contiendra des fonctions utilitaires réutilisables dans tout le projet, comme la des fonctions pou renvoyer des requêtes en format JSON.


1. Première étape construire le serveur HTTP

Il faut voir HttpApp comme le point d'entrée principal pour gérer le serveur HTTP. Il encapsule donc la configuration du serveur, le routeur, le canal des erreurs et le contexte d'exécution.

package app

import (
    "context"
    "fmt"
    "log"
    "net/http"
    "os"
    "strconv"
    "time"

    "github.com/Zadigo/gopurchase/internal/models"
    "github.com/go-chi/chi"
    "github.com/redis/go-redis/v9"
)

type HttpApp struct {
    serverApp   models.ServerAppInterface
    router      *chi.Mux
    chErrors    chan error
    ctx         context.Context
}

func (a *HttpApp) Start() error {
    port, err := strconv.ParseUint(os.Getenv("PORT"), 10, 16)
    if err != nil {
        log.Panicf("Invalid port: %v", err)
    }

    log.Printf("Starting %s HTTP server on port %d...", port)
    server := &http.Server{
        Addr:    fmt.Sprintf(":%d", port),
        Handler: a.router,
    }

    go func() {
        log.Printf("HTTP server ready to receive requests...")
        a.chErrors <- server.ListenAndServe()
    }()

    select {
    case err := <-a.chErrors:
        log.Printf("HTTP server error: %v", err)
        return err
    case <-a.ctx.Done():
        log.Println("⚡️ Shutting down HTTP server...")

        timeoutCtx, cancel := context.WithTimeout(a.ctx, 10*time.Second)
        defer cancel()

        return server.Shutdown(timeoutCtx)
    }
}

Il faut noter plusieurs choses. Dans un premier temps, le serveur sera lancé dans une goroutine ce qui permet de ne pas bloquer le thread principal et de gérer les erreurs de manière asynchrone comme illustré ci-dessous.

Goroutine permettant de lancer le serveur HTTP de manière asynchrone.
go func() {
    log.Printf("HTTP server ready to receive requests...")
    a.chErrors <- server.ListenAndServe()
}()

Le select permet de gérer plusieurs canaux de manière concurrente. Dans notre cas, il attend soit une erreur provenant du serveur HTTP, soit la fin du contexte d'exécution. Cela permet de réagir rapidement aux erreurs tout en assurant une fermeture propre du serveur lorsque le contexte est annulé.

Select permettant de gérer les erreurs du serveur HTTP et la fermeture propre de celui-ci.
select {
case err := <-a.chErrors:
    log.Printf("HTTP server error: %v", err)
    return err
case <-a.ctx.Done():
    log.Println("⚡️ Shutting down HTTP server...")

    timeoutCtx, cancel := context.WithTimeout(a.ctx, 10*time.Second)
    defer cancel()

    return server.Shutdown(timeoutCtx)
}

La première branche stoppe le serveur immédiatement en cas d'erreur, tandis que la seconde branche gère la fermeture propre de ce dernier lorsque le contexte est annulé.

Une fois le struct codé, il faudra l'initialiser avant de pouvoir l'utiliser. Nous le ferons avec la fonction NewApp.

func NewApp(ctx context.Context) models.AppInterface {
    app := &HttpApp{
        ctx:         ctx,
        chErrors:    make(chan error),
    }

    app.loadRoutes()
    return app
}

Nous utilisons le contexte du parent pour gérer le cycle de vie de notre application HTTP. Cela permet de propager les annulations et les délais d'attente depuis le contexte principal vers notre serveur HTTP.

Finalement les routes sont pré-chargées lors de l'initialisation de l'application grâce à l'appel à la méthode loadRoutes() dans la fonction NewApp.

Ce struct HttpApp représente donc notre application HTTP principale qui sera lancé avec la fonction Start().


2. Pré-chargement des routes

Nous allons maintenant construire la fonction responsable du pré-chargement des routes dans notre application HTTP. Cette organisation permet une plus grande modularité et facilite la maintenance du code. Si certaines routes ne nous paraissent pas nécessaires immédiatement, elles peuvent être ajoutées plus tard sans modifier la structure principale de l'application.

package app

import (
    "log"
    "time"

    "github.com/Zadigo/gopurchase/internal/handlers"
    "github.com/go-chi/chi"
    "github.com/go-chi/chi/middleware"
    "github.com/stripe/stripe-go/v85"
)

func (a *HttpApp) loadRoutes() {
    router := chi.NewRouter()

    router.Use(middleware.RequestID)
    router.Use(middleware.RealIP)
    router.Use(Cors)
    router.Use(Authorization)
    router.Use(middleware.AllowContentType("application/json"))
    router.Use(middleware.Throttle(1000))
    router.Use(middleware.Logger)
    router.Use(middleware.Recoverer)
    router.Use(JsonHeartbeat("/health"))
    router.Use(middleware.Timeout(60 * time.Second))

    router.Route("/payments", a.loadPaymentRoutes)
    a.router = router
}

Cette fonction créer un nouveau routeur Chi, applique les middlewares nécessaires et configure les routes pour les paiements et l'authentification avant de l'assigner au champ router de l'application HTTP.

Deux middlewares importants sont utilisés ici : JsonHeartbeat pour vérifier la santé de l'application et middleware.Timeout pour limiter le temps d'exécution des requêtes.Le JsonHeartbeat est une middleware custom qui répond avec un statut de santé JSON pour indiquer que l'application est en cours d'exécution.
func JsonHeartbeat(endpoint string) func(http.Handler) http.Handler {
    f := func(h http.Handler) http.Handler {
        fn := func(w http.ResponseWriter, r *http.Request) {
            if (r.Method == "GET" || r.Method == "HEAD") && strings.EqualFold(r.URL.Path, endpoint) {
                w.Header().Set("Content-Type", "application/json")
                w.WriteHeader(http.StatusOK)
                w.Write([]byte(`{"status":"ok"}`))
                return
            }
            h.ServeHTTP(w, r)
        }
        return http.HandlerFunc(fn)
    }
    return f
}
Nous utilisons aussi un autre middleware important: Cors qui gère les requêtes Cross-Origin Resource Sharing, permettant à notre application de contrôler quelles origines peuvent accéder à ses ressources.
func Cors(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Access-Control-Allow-Origin", "*")
        w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
        w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")

        if r.Method == "OPTIONS" {
            w.WriteHeader(http.StatusOK)
            return
        }

        origin := r.Header.Get("Origin")

        if _, ok := utils.AllowedOrigins[origin]; !ok {
            log.Printf("🟠 Origin not allowed: %s", origin)
            http.Error(w, "Origin not allowed", http.StatusForbidden)
            return
        }

        next.ServeHTTP(w, r)
    }
    return http.HandlerFunc(fn)
}
Il récupère les origines stockées dans la constante AllowedOrigins puis éssayes de vérifier si l'origine de la requête est autorisée en la comparant avec cette liste. Si l'origine n'est pas autorisée, il renvoie une erreur HTTP 403 (Forbidden).
package utils

var AllowedOrigins = map[string]bool{
    "Postman/x":             true,
    "http://localhost:3000": true,
    "http://127.0.0.1:8000": true,
}
Le middleware Authorization lui est optionnelle mais permet de sécuriser l'accès aux routes en vérifiant la présence et la validité d'un token d'autorisation qui devra être comparé avec les informations stockées côté serveur.
func Authorization(next http.Handler) http.Handler {
    fn := func(w http.ResponseWriter, r *http.Request) {
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "Unauthorized", http.StatusUnauthorized)
            return
        }

        // Ici, vous pouvez ajouter la logique pour vérifier la validité du token d'autorisation.

        next.ServeHTTP(w, r)
    }
    return http.HandlerFunc(fn)
}
Une implémentation simple refuse l'accès si la requête contient un en-têteAuthorization vide.
Maintenant pour chaque route de notre application HTTP, nous allons créer des fonctions spécifiques pour charger les sous-routes correspondantes. Ces fonctions seront appelées dans loadRoutes() pour organiser et structurer les routes de manière modulaire.
func (a *HttpApp) loadPaymentRoutes(router chi.Router) {
    paymentApi := handlers.PaymentApi{
        PaymentClient: &stripe.Client{},
        App:           a,
        Ctx:           a.ctx,
    }

    err := paymentApi.SetupStripeClient()
    if err != nil {
        log.Fatalf("Failed to setup Stripe client: %v", err)
    }

    router.Post("/intent", paymentApi.CreateIntent)
    router.Post("/update", paymentApi.UpdateIntent)
    router.Post("/capture", paymentApi.CaptureIntent)
}
Attention : Assurez-vous de configurer correctement vos clés API Stripe avant d'appeller la fonctionSetupStripeClient.

3. Création des groupes de routes

PaymentApi est le struct qui nous permettra de regrouper les routes relatives aux paiements et d'organiser la logique associée de manière centralisée. Elle reçoit comme dépendances le client Stripe, l'application et le contexte parent.
Il n'est pas nécessaire de passer le contexte explicitement sur le struct car l'application pourrait disposer d'une fonctionGetContext pour récupérer le contexte parent lorsque cela est nécessaire.
type PaymentApi struct {
    PaymentClient *stripe.Client
    App           models.AppInterface
    Ctx           context.Context
}
Une fois le struct PaymentApi défini, nous pouvons l'utiliser pour regrouper toutes les routes relatives aux paiements. Premièrement, celle pour initialiser le client Stripe.
func (p *PaymentApi) SetupStripeClient() error {
    key := os.Getenv("STRIPE_API_KEY")
    if key == "" {
        return fmt.Errorf("STRIPE_API_KEY environment variable is not set")
    }
    p.PaymentClient = stripe.NewClient(key)
    return nil
}
Ainsi que des routes supplémentaires:
func (p *PaymentApi) CreateIntent(w http.ResponseWriter, r *http.Request) {
    // Implémentation de la création d'un intent de paiement
}

func (p *PaymentApi) UpdateIntent(w http.ResponseWriter, r *http.Request) {
    // Implémentation de la mise à jour d'un intent de paiement
}

func (p *PaymentApi) CaptureIntent(w http.ResponseWriter, r *http.Request) {
    // Implémentation de la capture d'un intent de paiement
}

4. Initialisation de l'application

Nous avons maintenant défini toutes les routes nécessaires pour notre application HTTP et configuré les middlewares essentiels. Il est temps de passer à l'initialisation de l'application et au démarrage du serveur.
package main

import (
    "context"
    "log"
    "os"
    "os/signal"

    "github.com/Zadigo/gopurchase/internal/app"
    "github.com/Zadigo/gopurchase/internal/utils"
    "github.com/joho/godotenv"
)

func main() {
    err := godotenv.Load(".env")
    if err != nil {
        panic(err)
    }

    ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
    defer cancel()

    absPath, err := utils.GetAbsolutePath(".")
    if err != nil {
        log.Panicf("Could not get absolute path: %v", err)
    }

    ctx = context.WithValue(ctx, "rootDir", absPath)
    ctx = context.WithValue(ctx, "debug", os.Getenv("DEBUG") == "true")

    app := app.NewApp(ctx)
    err = app.Start()
    if err != nil {
        log.Panicf("Could not start server: %v", err)
    }
}
Nous créons ici un contexte parent qui sera utilisé tout au long de la durée de vie de l'application. Ce contexte est annulé automatiquement lorsque le programme reçoit un signal d'interruption, permettant ainsi un arrêt propre et ordonné des différentes routines et services.
Nous utilisons signal.NotifyContext pour créer un contexte qui sera annulé lorsque le programme reçoit un signal d'interruption (comme Ctrl+C). Cela permet de gérer proprement l'arrêt de l'application.
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()
À noter nous passons ici des valeurs supplémentaires dans le contexte, telles que le répertoire racine de l'application et le mode debug. Cela permet aux différentes parties de l'application d'accéder facilement à ces informations.
ctx = context.WithValue(ctx, "rootDir", absPath)
ctx = context.WithValue(ctx, "debug", os.Getenv("DEBUG") == "true")
La fonction GetAbsolutePath est utilisée pour obtenir le chemin absolu du répertoire racine de l'application. Cela est utile pour s'assurer que toutes les opérations de fichiers sont effectuées à partir d'un chemin absolu, évitant ainsi les problèmes liés aux chemins relatifs.
package utils

import (
    "fmt"
    "path"
    "path/filepath"
)

func GetAbsolutePath(strPath string) (string, error) {
    absPath, err := filepath.Abs(strPath)
    if err != nil {
        return "", fmt.Errorf("Failed to get absolute path: %v", err)
    }

    result := path.Ext(absPath)
    if result != "" {
        return "", fmt.Errorf("Base directory should be a directory, got a file: %s", absPath)
    }

    return absPath, nil
}
Cependant il est vraiment déconseillé de passer des structures de données trop complexes dans le contexte pour garder un typage sûr et éviter des erreurs difficiles à déboguer.

4. Démarrage du serveur

go run .

Conclusion

Les microservices représentent une approche moderne et flexible pour développer des applications. En les utilisant, vous pouvez créer des systèmes plus résilients, évolutifs et faciles à maintenir. Ce guide vous a montré comment créer un microservice simple en Golang, mais les concepts peuvent être étendus à des architectures plus complexes.

FAQ