Ch’feir share – À la découverte du Go | Part 2


La team SFEIR Lille s’est réunie il y a peu pour une soirée dédiée au langage Go. Le programme était chargé : prise en main de Go autour d’un microservice REST JSON, adossé à un MongoDB et déployé dans Docker. Tout un programme et beaucoup de gros mots à la mode. Nous avons décidé de vous partager l’expérience acquise pendant cette soirée à travers trois articles, pour rendre la chose plus digeste. 

Si vous l’avez ratée, nous vous conseillons de lire :

Ch’feir share – À la découverte du Go | Part 1 – Les bases

Voici la deuxième partie de notre dossier consacré à ce langage.

Concurrence, Channel et Go Routine

Voyons à présent comment tirer parti de toute la puissance de la machine hôte au travers de la concurrence et du parallélisme, deux problématiques fortes adressées par le langage. Pour illustrer la concurrence et ses outils en Go, nous allons mettre en place un compteur de requêtes sur notre API. Ceci va se faire en deux étapes, la première étant le compteur proprement dit. Pour ne pas pénaliser nos performances, nous allons nous imposer de ne pas utiliser de verrou ou mutex sur notre compteur. Un des leitmotivs en  Go est :

Don’t communicate by sharing memory,

share memory by communicating.

Nous allons donc utiliser des Go routines et des channels de la manière suivante :

Le langage Go, les Go routines web, les channel avec capacité et les Go routine counter

Par quoi commencer quand on se trouve devant une feuille blanche ? Une nouvelle structure est un bon point de départ.

// Statistics is the worker to persist the request statistics 

 type Statistics struct {
	statistics        chan uint8 
	counter           uint32 
	start             time.Time 
	loggingPeriod     time.Duration 
}

Ensuite nous allons créer le constructeur qui lui est associé :


// NewStatistics creates a new statistics structure and launches its worker routine
func NewStatistics(loggingPeriod time.Duration) *Statistics {
	sw := Statistics{
		statistics:    make(chan uint8, statisticsChannelSize),
		counter:       0,
		start:         time.Now(),
		loggingPeriod: loggingPeriod,
	}
	
            go sw.run()

	return &sw
}

Reste le plus dur à faire, la routine qui écoute les « hits » sur le channel et affiche périodiquement les statistiques.


func (sw *Statistics) run() {
	ticker := time.NewTicker(sw.loggingPeriod)
	for {
		select {
		case stat := <-sw.statistics:
			logger.WithField("stat", stat).Debug("new count received")
			sw.counter += uint32(stat)
		case <-ticker.C:
			elapsed := time.Since(sw.start)
			logger.WithField("elapsed time", elapsed).
                                              WithField("count", sw.counter).
			           Warn("request monitoring")
			sw.counter = 0
			sw.start = time.Now()
		}
	}
}

Dans une boucle infinie, le select permet d’écouter les évènements sur des channel distincts. Nous allons donc ici écouter les « hits” des requêtes pour les additionner depuis notre channel statistics. Un Ticker, qui n’est autre qu’un timer sans fin, servira à afficher régulièrement les statistiques calculées. Il nous restera à intégrer tout ça dans un middleware de notre serveur web et le tour sera joué !

Data access

Comment gérer le modèle de données et les accès à la base ? Comment écrire des tests pour notre logiciel ? Comment « bouchonner » facilement les accès en base pour faciliter les tests ? Ce sont les questions auxquelles nous allons répondre maintenant.

Premièrement, le modèle de données. En grand amateur de rhum que je suis, j’ai décidé de me lancer dans un service de gestion de spiritueux. Nous manipulerons donc un Spirit dans le reste de notre exercice. En voici la structure :


// Spirit is the structure to define a spirit
type Spirit struct {
	ID               bson.ObjectId `json:"id" bson:"_id,omitempty" `
	Name             string        `json:"name" bson:"name"`
	Distiller        string        `json:"distiller" bson:"distiller"`
	Bottler          string        `json:"bottler" bson:"bottler"`
	Country          string        `json:"country" bson:"country"`
	Region           string        `json:"region" bson:"region"`
	Composition      string        `json:"composition" bson:"composition"`
	SpiritType       string        `json:"type" bson:"type"`
	Age              uint8         `json:"age" bson:"age"`
	BottlingDate     time.Time     `json:"bottlingDate" bson:"bottlingDate"`
	Score            float32       `json:"score" bson:"score"`
	Comment          string        `json:"comment" bson:"comment"`
}

Pour plus de facilité, nous allons utiliser un ObjectId BSON comme identifiant principal de notre entité. Nous aurions pu utiliser un entier non signé ou un UUID, mais cela facilite l’intégration avec MongoDB qui utilise du binary JSON (BSON) dans ses communications. Vous aurez également remarqué les commentaires en fin de chaque ligne. Il s’agit de la version Go des annotations. Je vous conseille la lecture de la documentation du package JSON de Go, qui vous expliquera comment utiliser JSON en Go.

N.B. : S’il y a un piège à retenir sur la sérialisation en Go, c’est la visibilité. Si les attributs de votre structure sont en minuscule, ne cherchez pas plus loin, il vous sera impossible de convertir votre objet vers du JSON et vice-versa, le sérialiseur n’ayant pas accès à vos attributs.

Maintenant que nous savons ce que nous avons à persister, encore nous faut-il définir l’architecture de notre couche de persistance. Amateur de Java depuis de nombreuses années, c’est tout naturellement vers des pattern DAO et Factory que je me suis tourné. Nous aurons donc une interface commune d’accès aux données, deux implémentations de celle-ci, une MongoDB et un Mock. Enfin, la factory nous permettra de récupérer l’implémentation de notre choix au besoin. Cette architecture nous permettra également de faciliter les tests. Première étape : la définition de notre interface de DAO :


// SpiritDAO is the DAO interface to work with spirits
type SpiritDAO interface {

	// GetSpiritByID returns a spirit by its ID
	GetSpiritByID(ID string) (*model.Spirit, error)

	// GetAllSpirits returns all spirits with paging capability
	GetAllSpirits(start, end int) ([]model.Spirit, error)

	// GetSpiritsByName returns all spirits by name
	GetSpiritsByName(name string) ([]model.Spirit, error)

	// GetSpiritsByType returns all spirits by type
	GetSpiritsByType(spiritType string) ([]model.Spirit, error)

	// GetSpiritsByTypeAndScore returns all spirits by type and score greater or equal
	GetSpiritsByTypeAndScore(spiritType string, score uint8) ([]model.Spirit, error)

	// SaveSpirit saves the spirit
	SaveSpirit(spirit *model.Spirit) error

	// UpsertSpirit updates or creates a spirit
	UpsertSpirit(ID string, spirit *model.Spirit) (bool, error)

	// DeleteSpirit deletes a spirits by its ID
	DeleteSpirit(ID string) error
}

En Go, pour implémenter une interface, il « suffit » d’en implémenter les méthodes en respectant leur signature. Nous allons donc recréer un constructeur pour nos différentes classes d’implémentation et ajouter le code de persistance à nos CRUD (Create Read Update Delete). Ci-dessous l’exemple de la méthode de base GetSpiritByID :


// GetSpiritByID returns a spirit by its ID
func (s *SpiritDAOMongo) GetSpiritByID(ID string) (*model.Spirit, error) {
	// check ID
	if !bson.IsObjectIdHex(ID) {
		return nil, errors.New("Invalid input to ObjectIdHex")
	}

	session := s.session.Copy()
	defer session.Close()

	spirit := model.Spirit{}
	c := session.DB("").C("spirit")
	err := c.Find(bson.M{"_id": bson.ObjectIdHex(ID)}).One(&spirit)
	return &spirit, err
}

Pour bien comprendre comment fonctionne le driver MongoDB en Go, nous allons revoir l’action au ralenti :

Tout d’abord, on vérifie que l’ID passé en paramètre est bien un ObjectID, le cas contraire on retourne une erreur.


	// check ID 

	if !bson.IsObjectIdHex(ID) {

            return nil, errors.New("Invalid input to ObjectIdHex") 

        }

La partie la plus importante est celle qui concerne l’initialisation et la libération de la session de connexion à la base de donnée MongoDB. Le copy implique que nous réutilisons la session initiale, mais que nous souhaitons une copie de celle-ci. La mécanique qui se met en place derrière la copie fait que nous allons récupérer une copie de la session avec tous les paramètres que nous avons positionnés au démarrage (timeout, etc.), mais la connexion à la base va quant à elle être prise dans un pool de connexion déjà existante et libre.

Cette manipulation va nous permettre d’exécuter des requêtes parallèles à la base, là où le Clone() nous aurait fait attendre la fin de la requête précédente. Tout dépend de votre utilisation et architecture de base MongoDB. Je vous encourage encore une fois à lire la documentation du driver à ce sujet, la philosophie du driver GoCQL de Cassandra étant par exemple tout à fait différente de celle-ci.


	session := s.session.Copy() 

 defer session.Close()

On initialise ensuite une structure vide à hydrater :


spirit := model.Spirit{}

On récupère la collection « spirit » à requêter :


c := session.DB("").C("spirit")

Et on exécute la requête écrite en BSON (l’élément d’_id = ID ) pour récupérer un résultat unique (One) :


err := c.Find(bson.M{"_id": bson.ObjectIdHex(ID)}).One(&spirit)

On retourne le couple résultat/erreur à traiter :


return &spirit, err

Pour nos besoins de test, nous allons réaliser une seconde implémentation qui retournera toujours le même Spirit :


// MockedSpirit is the spirit returned by this mocked interface
var MockedSpirit = model.Spirit{
	Name:             "Caroni",
	Distiller:        "Caroni",
	Bottler:          "Velier",
	Country:          "Trinidad",
	Composition:      "Molasse",
	SpiritType:       model.TypeRhum,
	Age:              15,
	BottlingDate:     time.Date(2015, 01, 01, 0, 0, 0, 0, time.UTC),
	Score:            8.5,
	Comment:          "heavy tire taste",
}

Et voici l’implémentation simplifiée suivante :


// GetSpiritByID returns a spirit by its ID 

 func (s *SpiritDAOMock) GetSpiritByID(ID string) (*model.Spirit, error) {

 return &MockedSpirit, nil 

}

La dernière étape, pour finaliser notre couche de persistance, est la factory qui nous fournira l’implémentation à la demande :


// GetSpiritDAO returns a SpiritDAO according to type and params
func GetSpiritDAO(param string, daoType int) (SpiritDAO, error) {
	switch daoType {
	case DAOMongo:
		// mongo connection
		mgoSession, err := mgo.DialWithTimeout(param, timeout)
		if err != nil {
			return nil, err
		}

		// set 30 sec timeout on session
		mgoSession.SetSyncTimeout(timeout)
		mgoSession.SetSocketTimeout(timeout)
		// set mode
		mgoSession.SetMode(mgo.Monotonic, true)
		mgoSession.SetPoolLimit(poolSize)

		return NewSpiritDAOMongo(mgoSession), nil
	case DAOMock:
		return NewSpiritDAOMock(), nil
	default:
		return nil, ErrorDAONotFound
	}
}

Pour les paramètres de base de la session MongoDB, nous utilisons ici des constantes qui pourraient être issues d’un fichier de configuration pour plus de souplesse au déploiement de notre application. Nous créons ici la session « mère » de toutes les sessions que nous utiliserons dans notre application par clonage, et fixons la taille du pool de connexion à une limite en fonction de la charge prévue. Le switch utilise ici des constantes que nous pouvons définir grâce à un des rares sucres syntaxiques en Go, les Iota.


const (
	// DAOMongo is used for Mongo implementation of SpiritDAO
	DAOMongo int = iota  // = 0
	// DAOMock is used for mocked implementation of SpiritDAO
	DAOMock // = 1

	// mongo timeout
	timeout = 15 * time.Second
	// poolSize of mongo connection pool
	poolSize = 35
)

var (
	// ErrorDAONotFound is used for unknown DAO type
	ErrorDAONotFound = errors.New("Unknown DAO type")
)

N.B. : Dans le cas de clé DAO inconnue, une erreur de visibilité publique ErrorDAONotFound est retournée. Il est alors possible de la comparer pour effectuer un traitement particulier.

Alors qu’il est assez facile de tester notre Mock, il serait intéressant de tester notre implémentation MongoDB. Cela est rendu possible par l’utilisation d’une image docker MongoDB qui est lancée et détruite à la demande dans le Makefile à l’exécution des tests. Nous n’entrerons pas dans les détails du lancement de l’image Docker, mais vous pouvez consulter le Makefile du projet pour cela.

En Go, il dispose nativement de bibliothèque pour l’écriture de test et de benchmark. La convention est de créer un fichier de nom identique à celui contenant le code à tester et de le suffixer par _test , ici spirit-dao-mongo_test.go .


func TestDAOMongo(t *testing.T) {
	// get config
	config := os.Getenv("MONGODB_SRV")

	daoMongo, err := GetSpiritDAO(config, DAOMongo)
	if err != nil {
		t.Error(err)
	}

	toSave := model.Spirit{
		Name:               "Caroni 2000",
		Distiller:          "Caroni",
		Bottler:            "Velier",
		Country:            "Trinidad",
		Composition:        "Melasse",
		SpiritType:         model.TypeRhum,
		Age:                15,
		BottlingDate:       time.Date(2015, 01, 01, 0, 0, 0, 0, time.UTC),
		Score:              8.5,
		Comment:            "heavy tire taste",
	}

	err = daoMongo.SaveSpirit(&toSave)
	if err != nil {
		t.Error(err)
	}

	t.Log("initial spirit saved", toSave)
)

On récupère le DAO d’implémentation MongoDB, et on tente de persister un Spirit. Vous pourrez aller voir les sources du projet sur GitHub pour le test complet. Il faut remarquer ici le paramètre « t » de type pointeur de Testing. Ce dernier permet de logger et retourner les erreurs au besoin. Pas de fonction d’Assert élégante, cela reste rustique, mais efficace. Pour du test plus poussé, je vous recommande GoConvey, qui dispose même d’une interface web pour la visualisation des résultats des tests.

Il est temps de souffler, avant d’attaquer la part 3 !

Vous aimerez aussi...