Introduction à Crystal

Publié le 28 avril 2016 par Théo Delaune | back

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

Ces dernières années bon nombre de nouveaux langages ont éclos, comme Elixir, Go, Rust et Crystal pour n’en citer que quelques uns. Nous allons nous intéresser aujourd’hui à Crystal dont le principal attrait est d’être un langage compilé avec la syntaxe du Ruby.

Présentation de Crystal

Crystal est un langage dont la première mouture est sortie le 19 juin 2014, ce langage compilé est typé statiquement et comme tout langage moderne le code source est évalué lors de la compilation pour éviter toute erreur lors de l’éxécution. Il dispose également de son propre compilateur qui est lui aussi écrit en Crystal.

Bien que très jeune, la communauté autour de ce langage est très active. La version actuelle est la 0.15.0, Crystal est toujours en phase alpha, et donc non adapté pour la mise en production.

L’une de ses forces est d’être typé au moment de la compilation, c’est à dire que nous n’aurons pas à typer nos variables ou méthodes, le compilateur gérera cela de son côté.

counter = 0 # sera traité comme un int32 lors de la compilation

Votre premier code Crystal

Préambule

Avant de passer à la suite, je vous invite à installer Crystal sur votre machine. La documentation officielle est complète sur ce point là et différencie l’installation pour chaque système d’exploitation. Vous trouverez ces instructions ici: procédure d’installation.

Des fichiers .cr ?

Crystal étant un langage compilé, il nécessite de passer notre code à la moulinette pour chaque modification. Les fichiers Crystal sont reconnaissables grâce à leur extension en .cr. Libre à vous d’utiliser l’IDE qui vous convient pour rentrer dans le vif du sujet, il existe par exemple une extension pour Sublime Text sublime-crystal ou pour les plus aventuriers un support sur Vim avec vim-crystal.

Bonjour Synbioz

Pour débuter, nous allons passer par un classique, dites “Bonjour” à Synbioz.

# hello.cr

class Reader

  def initialize(name)
    @name = name
  end

  def say_hello(company)
    company.someone_say_hi(self)
  end

  def name
    @name
  end
end

class Synbioz

  def initialize
    @name = "synbioz"
  end

  def someone_say_hi(reader)
    puts "Hello #{reader.name} from #{@name}"
  end
end

me = Reader.new("theo")
synbioz = Synbioz.new

me.say_hello(synbioz)

Pour pouvoir exécuter notre code, deux méthodes s’offrent à nous, le compiler et le jouer ou le jouer directement.

Pour compiler notre code nous utilisons la commande crystal build, qui nous génére un éxécutable hello, il ne nous reste plus qu’à le lancer:

crystal build hello.cr

./hello

➜ crystal build hello.cr
➜ ./hello.cr
Hello theo from synbioz

Si nous souhaitons directement avoir le résultat sans générer le binaire, nous utilisons crystal run:

➜ crystal run hello.cr
Hello theo from synbioz

➜ crystal hello.cr # est un alias pour crystal run
Hello theo from synbioz

Nous avons vu sur ce court exemple comment utiliser une classe en Crystal et la première conclusion qui nous vient est que rien ne diffère pour l’instant de Ruby, à part l’étape de compilation.

Nous verrons par la suite que quelques différences subsistes entre ces deux langages autre que leur syntaxe.

Initialiser un nouveau projet

Crystal propose un outil simple pour initialiser un projet vide : la commande crystal init. Cette commande prend comme paramètres:

  • le type, lib ou app
  • le nom de votre application
  • le répertoire si il diffère du nom de votre application

Vous vous demandez sans doute quelle est la différence entre lib et app, et bien il n’y en a quasiment pas, par quasiment j’entends seulement une différence dans le .gitignore, dans une application de type lib git va ignorer le blocage des dépendances avec l’inclusion du fichier shard.lock.

Le squelette de l’application ou librairie générée ressemble à ceci:

➜ crystal init app monapp
  create  monapp/.gitignore
  create  monapp/LICENSE
  create  monapp/README.md
  create  monapp/.travis.yml
  create  monapp/shard.yml
  create  monapp/src/monapp.cr
  create  monapp/src/monapp/version.cr
  create  monapp/spec/spec_helper.cr
  create  monapp/spec/monapp_spec.cr
Dépôt Git vide initialisé dans /home/theo/projects/synbioz/crystal-lang/monapp/.git/

Ici, peu de choses diffèrent de la génération d’un template de gem en Ruby. Mais qu’est-ce que le fichier shard.yml, j’en ai jamais entendu parler moi !

Le fichier shard.yml est issu de Shards et permet de gérer les dépendances de vos projets Crystal dans l’esprit du Gemfile en Ruby.

# exemple d'un fichier shard.yml

name: monapp
version: 0.1.0

dependencies:
  malib:
    github: synbioz/malib.cr
    branch: master

license: MIT

Shards est inclus dans Crystal, une fois vos librairies spécifiées il ne vous reste plus qu’à les installer avant de compiler votre application:

shards install

Les différences avec le Ruby

Crystal à beau avoir une syntaxe très proche du Ruby, quelques différences sont présentes et reprennent quelques principes issus d’autres langages.

Car ce langage n’a pas vocation à compiler directement du Ruby ou à faire tourner votre application Ruby sur du code compilé, mais est bien un langage différent avec une syntaxe qui se veut proche de celle de Ruby.

Les tuples

Un tuple est un ensemble d’éléments de taille finie et immutable, pouvant être de types différents. C’est une structure très utilisée dans beaucoup d’autres langages de programmation. Nous allons découvrir son intérêt et sa définition par quelques exemples:

n = {1, "Hello", 2}
puts n                # {1, "Hello", 2}
puts n[1]             # Hello

n[1] = "test"         # Error: undefined method '[]=' for {Int32, String, Int32}

counter, hello, length = n
puts counter          # 1
puts hello            # Hello
puts length           # 2

Outre les premières lignes servant d’exemple, un tuple peut également être destructuré. Ici counter, hello, length ont respectivement chacune la valeur de leur position.

def concat(counter, hello, length)
  puts "counter: #{counter}, hello: #{hello}, length: #{length}"
end

concat(*n)            # counter: 1, hello: Hello, length: 2

Nous pouvons apprécié ici l’instanciation des arguments de la méthode concat avec notre tuple grâce à l’utilisation du * appelé splat argument pour lui spécifier que nous passons un tuple en entrée.

def concat(*tuple)
  tuple
end

b = concat({1, "Hello", 2})
puts b                # {1, "Hello", 2}

Une méthode peut également nous retourner un tuple, nous pourrions par exemple remplacer b, pour utiliser de l’assignation multiple, par counter, hello, length = concat({1, "Hello", 2}).

Les tuples sont l’une des structures les plus souvent utilisées dans beaucoup de langages, sa force tient également dans sa simplicité d’utilisation, son empreinte mémoire est réduite, car elle est immutable et bornée, ce qui dans un langage compilé est important pour éviter toute fuite de mémoire.

La fonction finalize

L’un des principes lorsque nous développons une application compilée est de bien entendu essayer de minimiser son empreinte mémoire. Crystal introduit au sein de la définition de ses classes la méthode finalize qui permet d’exécuter du code lorsqu’une instance de cette classe est libérée en mémoire.

class TestGarbage

  def finalize
    puts "Mayday"
  end
end

TestGarbage.new

Si vous exécutez votre morceau de code tel quel rien ne s’affichera, car l’application se terminera trop vite avant que le garbage collector ne se mette en route. Essayez de mettre votre TestGarbage.new dans un loop do et vous verrez l’appel à votre méthode finalize.

Cette méthode peut-être très utile pour relâcher de la mémoire sur d’autres objets lorsque TestGarbage est libérée.

C’est une bonne pratique à garder sous le coude, dans le but de toujours contrôler l’empreinte mémoire de son programme.

Les arguments typés

Le langage nous permet de spécifier des arguments typés sur des méthodes, comme par exemple de type String ou Int32.

Ce typage a un double avantage, de pouvoir tout d’abord restreindre le type accepté par une méthode, mais également de pouvoir surcharger une méthode avec un type spécifique.

Dans ce premier exemple nous mettons en place une surcharge sur la méthode hello de la classe Typed:

class Typed

  def self.hello(s)
    puts s
  end

  def self.hello(s : Int32)
    puts "hello #{s}"
  end
end

Typed.hello("hello")    # hello
Typed.hello(1)          # hello 1

Si vous souhaitez restreindre le type voulu par une méthode, rien de plus simple ! Il nous suffit de spécifier un type pour chaque méthode:

class Typed

  def self.hello(s : String)
    puts s
  end

  def self.hello(s : Int32)
    puts "hello #{s}"
  end
end

Typed.hello("hello")    # hello
Typed.hello(1)          # hello 1
Typed.hello(1.0)


# Error in ./typemethod.cr:14: no overload matches 'Typed::hello' with type Float64
# Overloads are:
#  - Typed::hello(s : String)
#  - Typed::hello(s : Int32)

# Typed.hello(1.0)
#      ^~~~~

Lors de notre essai de passer un argument de type Float64 à notre méthode, le programme nous remonte l’erreur de surcharge non disponible pour le type Float64.

Cette restriction peut s’avérer très utile sur des points critiques de notre application, où le type attendu doit forcément être du Int32 par exemple.

Retour typé d’une méthode

Crystal prend en compte le typage des arguments de méthode, mais il peut également prendre en compte le type de retour d’une méthode, dans le but une nouvel fois de restreindre le type de sortie d’une méthode.

def hello(s) : String
  s
end

puts hello("hello")  # hello
puts hello(1)        # error instantiating 'hello(Int32)'

Notre méthode hello ne peut pas retourner de variable de type Int32, si nous voulons également accepter ce type, il nous suffit de surcharger la méthode comme vu juste au dessus.

La généricité

La généricité permet de rendre une méthode ou une classe indépendante de son type, le niveau d’abstraction de la dite classe ou méthode en est plus élevée et donc réutilisable plus facilement qu’importe le type voulu lors de l’utilisation de cette classe.

class Storage(T)

  def initialize(value : T)
    @stored = value
  end

  def type
    T
  end
end

v = Storage(Int32).new(1)
puts v.type     # Int32
k = Storage(String).new("hello")
puts k.type     # String
j = Storage.new("hello")
puts j.type     # String

Dans le dernier exemple nous ne spécifions pas de type à la classe, Crystal supporte l’inférence de type. Notre classe va alors prendre le type de la valeur passée en argument de la méthode new.

Nous pouvons mettre cette classe au type générique en relation avec les arguments typés, si notre classe est de type String alors l’argument value de la méthode new devra être de type String également, sinon une erreur sera levée lors de la compilation.

C’est un principe très utilisé dans les langages compilés, car il permet une grande souplesse lors de la définition d’une classe qui sera réutilisée qu’importe le type choisi.

Cross-compilation

Crystal, comme expliqué dans sa documentation, supporte la compilation pour d’autres plateformes à travers LLVM et gcc.

Il nous faut avant tout récupérer les informations nécessaires pour la cross-compilation comme le flag name de la machine locale et celui de la machine distante. Il ne nous reste qu’à donner ces informations au compilateur Crystal pour qu’il fasse le travail.

 uname -m -s
Linux x86_64

 llvm-config --host-target
x86_64-pc-linux-gnu

 crystal build tuple.cr --cross-compile "Linux x86_64" --target "x86_64-pc-linux-gnu"
cc tuple.o -o tuple  -rdynamic  /opt/crystal/src/ext/libcrystal.a -levent -lrt -lpcre -lgc -lpthread -ldl

 cc tuple.o -o tuple  -rdynamic  /opt/crystal/src/ext/libcrystal.a -levent -lrt -lpcre -lgc -lpthread -ldl

llvm-config --host-target est à exécuter sur la machine hôte de votre futur binaire.

Il ne vous reste plus qu’à jouer la commande gcc que crystal nous rend pour récupérer le binaire compatible sur la target plateforme.

Librairie standard

La librairie standard souffre encore de la jeunesse du langage, mais tout cela tend à se résoudre au fur et à mesure de l’évolution du langage. Il serait intéressant dans le futur d’avoir un multiplexeur permettant de gérer nos routes dans un serveur http plus facilement.

Le Python et le Go incluent par exemple la gestion des archives zip. Ce qui n’est pas encore le cas en Crystal même si plusieurs contributeurs ont créés leur librairie pour gérer ce type d’archive, il serait intéressant de l’inclure au cœur du langage.

L’API d’accès aux différents composants de la librairie standard est très proche de la librairie standard de Ruby, mais elles ne sont pas identiques, veillez donc à bien lire la documentation avant la mise en place d’une de ses composantes.

Nous pouvons par exemple prendre en exemple le traitement des CSV en Ruby et en Crystal.

Pour lire chaque ligne d’un fichier Ruby utilise la méthode CSV.foreach(...) do |row| alors que l’API de Crystal utilise CSV.each_row(...) do |row|, ce qui prouve la différence de nommage alors que ces méthodes sont destinées au même usage.

Un autre point notable sur l’API de Crystal est qu’elle n’a aucune définition pour une boucle for, elle n’existe pas, il est recommandé d’utilisé dans ce cas la méthode Object.each.

La librairie standard tend à se rapprocher de la définition des normes RFC comme on peut le voir sur leurs tickets GitHub

Crystal vs Go

Nous allons nous implémenter ici un serveur http qui interagit avec un objet json. Nous mettrons en pratique cette implémentation en Crystal et en Go.

Dans chaque langage nous utiliserons uniquement la librairie standard de chaque langage.

Dans cet exemple nous mettrons en place une route http, qui accepte une requête POST avec un objet utilisateur au format json:

{ "firstname": "Theo", "lastname": "Delaune", "age": 24, "email": "theo@synbioz" }

Nous convertirons ensuite cet objet en un objet Crystal et Go. Nous lui ajoutons lors de son traitement un champ company qui contient le nom de l’entreprise à laquelle appartient l’utilisateur. Puis nous retournons cet Utilisateur agrémenté du nom de l’entreprise au client qui à fait la requête.

post /parse, { "firstname": "Theo", "lastname": "Delaune", "age": 24, "email": "theo@synbioz" } => http serveur
http server => { "firstname": "Theo", "lastname": "Delaune", "age": 24, "email": "theo@synbioz", "company": "Synbioz" }

Nous analyserons avec apache-benchmark la performance de chaque serveur http.

Crystal

Nous définissons en premier lieu une classe User qui est notre représentation de notre utilisateur au format json.

JSON.mapping va créer pour chaque attribut spécifié un getter et un setter au sein de notre classe User, ce qui va rendre la conversion de notre objet json<->crystal plus aisée.

Pour le champ company, nous souhaitons lui spécifier qu’il n’est pas nécessaire lors de la création de notre objet, nous utilisons pour cela le paramètre nilable.

require "http/server"
require "json"

class User
  JSON.mapping({
    firstname: String,
    lastname: String,
    age: Int32,
    email: String,
    company: {type: String, nilable: true},
  })
end

def parseUser(context)
    body = context.request.body
    user = User.from_json(body.to_s)

    user.company = "Synbioz"
    context.response.print user.to_json
end

server = HTTP::Server.new(3000) do |context|
  context.response.content_type = "application/json"

  if context.request.path.to_s == "/parse"
    parseUser(context)
  end
end

server.listen

Nous testons maintenant notre serveur avec apache-benchmark avec 100 000 requêtes et 60 clients simultanés.

$ ab -n 100000 -c 60 -p user.json -T 'application/json' http://localhost:3000/parse
...
Concurrency Level:      60
Time taken for tests:   7.133 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      17300000 bytes
Total body sent:        23100000
HTML transferred:       10100000 bytes
Requests per second:    14018.66 [#/sec] (mean)
Time per request:       4.280 [ms] (mean)
Time per request:       0.071 [ms] (mean, across all concurrent requests)
Transfer rate:          2368.39 [Kbytes/sec] received
                        3162.41 kb/s sent
                        5530.80 kb/s total

Go

Nous ne reviendrons pas sur l’implémentation d’un serveur http en Go, vous trouverez seulement le code utilisé par le apache-benchmark. Si vous souhaitez en savoir plus sur la mise en place d’un serveur http en Go je vous invite à lire cet article. ~~~go package main

import ( “encoding/json” “io/ioutil” “log” “net/http” )

type User struct { Firstname string json:"firstname" Lastname string json:"lastname" Age int json:"age" Email string json:"email" Company string json:"company" }

func main() { http.HandleFunc(“/parse”, parseHandler) log.Fatal(http.ListenAndServe(“:3000”, nil)) }

func parseHandler(w http.ResponseWriter, r *http.Request) { var user User defer r.Body.Close()

    body, err := ioutil.ReadAll(r.Body)

    if err != nil {
            w.WriteHeader(422)
    }

    err = json.Unmarshal(body, &user)

    if err != nil {
            w.WriteHeader(422)
    }

    user.Company = "Synbioz"
    json.NewEncoder(w).Encode(user) } ~~~

Nous utiliserons à nouveau apache-benchmark avec la même configuration que lors du test en Crystal.

$ ab -n 100000 -c 60 -p user.json -T 'application/json' http://localhost:3000/parse
...
Concurrency Level:      60
Time taken for tests:   6.277 seconds
Complete requests:      100000
Failed requests:        0
Total transferred:      22000000 bytes
Total body sent:        23100000
HTML transferred:       10200000 bytes
Requests per second:    15931.71 [#/sec] (mean)
Time per request:       3.766 [ms] (mean)
Time per request:       0.063 [ms] (mean, across all concurrent requests)
Transfer rate:          3422.83 [Kbytes/sec] received
                        3593.97 kb/s sent
                        7016.80 kb/s total

Résultats

Ces benchmarks sont à prendre au conditionnel, car ils dépendent de l’environnement dans lequel ils tournent. Néanmoins ils nous donnent une bonne indication sur la performance de ces deux langages compilés.

Au final Crystal et Go sont assez proches en durée pour la manipulation d’un objet json au travers d’un serveur http, avec une très légère supériorité pour le Go qui traite ces requêtes un peu plus rapidement que Crystal, mais ça reste négligeable.

Ces temps proches démontrent la puissance de Crystal, alors que ce langage est encore dans une version beta.

Parser un CSV Ruby vs Crystal

Nous allons dans cet exemple traiter un fichier CSV, en prenant pour exemple ce fichier issu des d’open-data Nantes.

Cet exemple va nous démontrer la différence de performances entre Ruby et Crystal, même si ils ne jouent pas sur le même tableau, c’est purement à titre indicatif.

Nous allons parser chaque ligne de ce CSV pour en ressortir un objet Ruby ou Crystal, et avoir en résultat un tableau d’objets contenant les quatre premières colonnes de notre fichier.

Notre code Ruby:

require 'csv'

class Activity

  def initialize(id, name, initials, seat)
    @id = id
    @name = name
    @initials = initials
    @seat = seat
  end
end

activities = []

CSV.foreach('nantes.csv') do |row|
  activities << Activity.new(row[0], row[1], row[2], row[3])
end

puts activities.count

Notre code Crystal:

require "csv"

class Activity

  def initialize(id, name, initials, seat)
    @id = id
    @name = name
    @initials = initials
    @seat = seat
  end
end

activities = [] of Activity

CSV.each_row(File.read("nantes.csv")) do |row|
  activities << Activity.new(row[0], row[1], row[2], row[3])
end

puts activities.size

Nous pouvons y voir quelques différences, entre l’implémentation en Ruby et en Crystal, même si ces deux implémentations sont très proches.

Ruby va ouvrir automatiquement le fichier et le lire dans son traducteur CSV, alors qu’avec Crystal nous devons ouvrir le fichier pour pouvoir le lire dans notre méthode .each_row.

Pas de problèmes de buffer en Crystal, car File.read nous retourne directement un objet String.

Nous pouvons ici mieux nous apercevoir de la différence d’API entre ces deux langages pour lire chaque ligne de notre CSV, alors qu’elles ont le même résultat.

Comparaison Ruby vs Crystal

time est très utile pour nous donner le temps d’exécution d’un programme au sein d’un environnement unix, nous lançons chacune de nos deux implémentations avec cette commande.

time ruby parser.rb
5274
ruby parser.rb  0,45s user 0,03s system 94% cpu 0,510 total
➜ time ./parser
5274
./parser  0,29s user 0,00s system 99% cpu 0,293 total

Ce test est je le répète purement indicatif, il est présent uniquement pour nous donner un ordre d’idée en terme de temps consommé par nos deux morceaux de code.

On pourrait rapprocher la comparaison langage interprété/langage compilé à Disque mécanique/Disque SSD, ils ont le même rôle mais pas la même puissance car ils n’utilisent pas la même technologie.

Sans surprise, Crystal est plus performant que son homologue en Ruby, de quasiment 45%, ce qui est énorme.

Conclusion

Crystal est comme nous l’avons vu, un langage proche de la syntaxe de Ruby, mais je le répète une fois encore, ce ne sont pas les mêmes langages.

Crystal manque actuellement une librairie standard plus complète, car pour l’instant elle ne fait aucune concurrence aux autres langages, ce qui rebute actuellement beaucoup de monde sur l’adoption de ce langage.

L’un des points les plus importants reste l’orientation du développement de la librairie standard qui n’est pas clairement définie, actuellement il tend principalement vers une API proche de celle de Ruby. Mais est-ce que dans le futur les développeurs ne vont pas souhaiter se diriger plutôt vers une API comme celle du langage C.

Il a su prendre de bonnes directions en tant que langage compilé comme pour la mise en place des structures tuples, de la généricité, etc. Mais Il reste jeune, très jeune. C’est un langage qui reste trop récent pour une utilisation sereine en production.

Même si au premier abord, développer du compilé avec la syntaxe Ruby peut-être déroutant, nous y prenons vite goût. Manquant cruellement de shards (les gems Crystal), je me demande si Crystal fera une percée un jour ou restera comme pour beaucoup de nouveaux langages un PoC intéressant.

L’équipe Synbioz.

Libres d’être ensemble.