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
.
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
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.
.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.
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.
Crystal propose un outil simple pour initialiser un projet vide : la commande crystal init
. Cette commande prend comme paramètres:
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
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
.
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.
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.
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.
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é 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.
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.
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
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.
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
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
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.
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.
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.
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.
Nos conseils et ressources pour vos développements produit.