Communication réseau avec Go

Publié le 9 octobre 2013 par Nicolas Zermati | back

Cet article est publié sous licence CC BY-NC-SA

Cet article fait partie de notre série d’articles d’introduction au langage Go. Dans ma présentation des premiers pas avec go j’ai brièvement couvert l’installation du compilateur et l’organisation des sources. Dans mon second article j’ai détaillé quelques uns des éléments de base du langage au travers d’un exemple : le jeu de Quarto. Dans ce même article nous avions créé un joueur artificiel sous la forme d’une fonction. Aujourd’hui nous allons voir comment passer de cette fonction à une petite application web permettant de se mesurer à notre joueur artificiel.

Communiquer !

Pour moi qui suis maintenant un habitué des applications Web, un composant écrit en Go c’est l’occasion d’avoir la sécurité et la performance d’un langage statiquement typé et compilé pour les composants qui en ont besoin. D’un autre coté il est agréable de disposer de Ruby, de Rails, des nombreux outils de la communauté Ruby, etc. Il est donc indispensable de pouvoir concilier ces deux mondes.

Pour parvenir à ce compromis, on peut regarder dans deux directions soit utiliser le système de bindings soit un protocole de communication réseau. C’est cette dernière solution que je vais aborder.

Nous allons donc construire une application Ruby qui communiquera avec un serveur de coups écrit en Go et utilisant le package Go réalisé dans le précédent article. Le serveur Go aura un rôle très simple : recevoir un jeu et retourner un coup à jouer sur ce jeu. L’application Ruby sera donc chargée de recevoir les connexions des joueurs, de créer et de maintenir l’état de leurs parties et de communiquer avec le serveur de coups quand ce sera nécessaire.

Établir un protocole

Bien que nous ayons définit grossièrement les responsabilités de nos deux composants, il nous faut être un peu plus précis sur la manière dont ces derniers vont s’interconnecter. Pour cela, je vais me baser sur les structures de données Go que nous avons déjà vu :

type Piece uint8
type Board [4][4]Piece
type Stash [16]Piece
type Game struct {
  Board  Board
  Stash  Stash
}

L’idée est de pouvoir reconstituer un type Game coté serveur ainsi que l’index de la pièce à jouer dans la réserve. Pour cela, on enverra, au travers d’une connexion TCP, une succession d’octets correspondant au plateau (16 octets), à la réserve de pièces (16 octets) et à l’index de la pièce selectionnée dans la réserve (1 octet). On a donc un message de quelques dixaines d’octets, ce qui est très faible.

La réponse du serveur sera composée d’au plus 4 octets. Chacun d’entre eux correspondant à :

  • un code d’état :
    • 0 si le jeu reçu est déjà gagnant,
    • 1 si un coup a pu être renvoyé et
    • 2 si un coup gagnant a pu être renvoyé.
  • la ligne sur laquelle la pièce sélectionnée a été placée,
  • la colonne sur laquelle la pièce sélectionnée a été placée et
  • l’index de la prochaine pièce à selectionner dans la reserve, 0 s’il n’en reste plus.

Serveur Go

Le serveur Go se résume à un centaine de lignes qui permettront d’accepter les connexions et les requêtes et d’y répondre. Le serveur va supposer que, pour chaque connexion établie, un message sera reçu et que ce message pourra être transformé en requête :

import "synbioz/quarto"

type Message []byte

type Request struct {
  Game quarto.Game
  Index uint8
}

Pour décomposer la succession d’octets composant le message en requête, on utilise le package bytes qui permet de lire morceaux par morceaux les octets du message grâce à un objet bytes.Reader. Cet objet dispose d’une méthode Read qui permet de lire une valeur depuis le flux d’octets du Reader et d’écrire le résultat dans une structure passée par référence. Le nombre d’octets lu dépend de la stucture de destination.

import (
  "binary"
  "bytes"
)

func readMessage(message Message) (*Request) {
  var request Request       = new(Request)
  var reader  *bytes.Reader = bytes.NewReader(message)

  binary.Read(reader, binary.LittleEndian, request)

  return request
}

Remarquez que dans notre cas, on peut directement copier le message dans une requête vide. C’est car la structure Request correspond exactement à la séquence d’octets reçue : un jeu, composé d’un plateau et d’une réserve, et l’index d’une pièce de la réserve.

Une fois que la requête est en place, il va falloir être capable de produire un message de réponse en accord avec notre protocole. La fonction suivante s’en occupe :

func handleRequest(request *Request) Message {
  if request.Game.IsWinning() {
    return Message{0}
  } else {
    move, win := request.Game.PlayWith(request.Index)
    i, j, k   := move.ToRepr()

    if win {
      return Message{2, i, j, k}
    } else {
      return Message{1, i, j, k}
    }
  }
}

Bien sur une fois que nous avons ces deux fonctions métiers, il faut mettre en place la partie serveur, plus classique :

func handleConnection(conn net.Conn) {
  defer conn.Close()

  timeoutAt := time.Now().Add(time.Second * 10)
  conn.SetDeadline(timeoutAt)

  msg := make(Message, 33)
  _, err := conn.Read(msg)

  if err != nil {
    if err.(net.Error).Timeout() == true {
      fmt.Print("Read timeout, closing connection.\n")
    } else {
      fmt.Printf("Error while reading: %s\n", err.Error())
    }
    return
  }

  request := ReadMessage(msg)
  response := handleRequest(request)
  conn.Write(response)
}

func main() {
  ln, err := net.Listen("tcp", ":1234")

  if err != nil {
    fmt.Printf("Error while openning the server: %s\n", err.Error())
    return
  } else {
    defer ln.Close()
  }

  for {
    conn, err := ln.Accept()

    if err != nil {
      fmt.Printf("Error while accepting the connection: %s\n", err.Error())
      continue
    }

    go handleConnection(conn)
  }
}

La fonction principale se charge d’écouter sur un port donné et et d’accepter toutes les connexions entrantes. Pour chacune de ces connexions, la fonction handleConnection se charge d’attendre un message, de convertir ce dernier en requête, de traiter celle-ci avec handleRequest, de répondre au client puis de fermer la connexion.

On remarque que ce code est assez important, en principalement à cause de la gestion des erreurs. Je serais intéressé par vos avis là dessus. Peut-on faire plus simple, plus rapide, plus robuste ?

Une fois toutes ces étapes passées vous devriez vous retrouver avec un fichier $GOPATH/src/synbioz/quarto-server/quarto-server.go plus ou moins identique à celui présent sur le dépot Github de l’article.

Vous pouvez le compiler avec la commande go install synbioz/quarto-server. Il ne faudra pas oublier de récuperer le code de l’article précédent et de le mettre à disposition dans le répertoire $GOPATH/src/synbioz/quarto.

Client Ruby

À présent voyons comment une application tierce, écrite en Ruby, va interagir avec ce serveur. Vous pourrez trouver le code de cette partie sur ce dépot Github.

Première chose pénible, c’est qu’il va falloir dupliquer une partie de la connaissance liée au jeu de Quarto dans le client. En effet, dans le fichier quarto.rb on retrouve les objets que l’on a défini en Go.

Chaque modèle doit permettre d’être exporté dans une représentation sous forme d’octets. Ruby ne gère pas si finement l’occupation mémoire, nous exportons donc sous forme de tableaux d’entiers grace à la méthode Game#to_repr. Nous devrons aussi être capable d’appliquer un coup donné sur un jeu (Game#apply_move).

Une fois que l’on dispose de ces modèles (réduits). Notre objectif va être de les communiquer au serveur. Cela se passe dans le fichier comm.rb.

Dans un premier temps il faut être capable de créer une succession d’octets a envoyer. C’est la fonction Comm.build_message qui s’en charge, ces octets seront représentés sous forme de chaine de caractère mais il s’agit en réalité de binaire.

Ensuite, il faut être en mesure d’étalir la connexion avec le serveur et de lui transmettre notre message. C’est le rôle de la fonction Comm.send_message. Cette dernière retourne une liste de nombres.

Les deux fonctions s’appuient sur Array#pack et Array#unpack, deux fonctions qui permettent de convertir vers et depuis le format binaire. Vous devriez consulter la documentation de ces dernières si vous n’êtes pas familier avec. Le motif C* signifie que je vais encoder les éléments de mon tableau en entiers non signés sur 8 bits.

Serveur web

Je ne vais pas rentrer dans les détails du serveur Web, cet article étant consacré au langage Go. Je vais toutefois dire quelques mots dessus.

Le serveur utilise un système de persistance en mémoire. Une fois le serveur arrêté, les parties en cours sont perdues. Voir le fichier persistance.rb pour plus d’informations.

Le serveur, web.rb, se base sur sinatra et slim pour gérer routes et rendu des pages. Il peut certainement être amélioré en terme de lisibilité du code, tout particulièrement lors de la soumission d’un coup.

Une partie non négligeable en Javascript s’occupe de l’interactivité de l’interface ainsi que de la génération de coups aléatoires pour un client, lorsque l’on ne sait pas quoi jouer par exemple.

Conclusion

Dans cet article nous avons vu comment rendre exploitable son application Go par un programme tiers. Dans notre cas, l’algorithme de sélection d’un coup s’exécute en langage machine plutôt que dans la machine virtuelle Ruby. C’est, je l’espère, un gain de performance.

Si vous lancez les deux serveurs, vous pourrez jouer contre un joueur artificiel.

Dans un prochain article je tenterais de modifier notre algorithme en utilisant les mécanismes de concurrence du langage Go. Ce sera l’occasion de faire ce que l’on fait peu en Ruby : exploiter son processeur à 100%.

L’équipe Synbioz.

Libres d’être ensemble.