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 de 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 :
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 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 de 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.
Go 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 !
The post Ch’feir share – À la découverte du Go | Part 2 appeared first on SFEIR Mag.