De Ruby à Crystal par l'exemple : conversion d'une application utilitaire

En 2001, on m'a fait découvrir (avec passion) un langage de programmation né au japon. Tout y était objet, lisible comme un texte en anglais mais tout de même réservé à des usages particuliers. C'était Ruby, et il contrastait avec Perl, PHP, C ou Java que je devais utiliser à l'époque. Je n'ai pu l'intégrer à mon travail qu'en 2006, en découvrant Rails.

Ruby c'est beaucoup de bonheur, parfois un manque de performance, une consommation mémoire importante et des évolutions de MRI appréciables mais qui arrivent assez lentement.

En 2013 j'ai découvert Crystal : un langage compilé très véloce, à la grammaire quasiment identique à Ruby. Sa base, moderne, utilise LLVM pour une compilation performante et dispose d'une librairie standard “toute neuve” sans les ambiguïtés de Ruby. Que celui qui n'a jamais pesté sur includes? et include? me jette la première pierre !

Pour découvrir en détail ce langage je vous conseille cette présentation par Synbioz. Ce billet va plutôt traiter par l'exemple de comment apprendre et/ou migrer en douceur de Ruby à Crystal.

C'est mûr Crystal ?

En 2017 les objectifs de Crystal sont simple : sortir une version 1.0 utilisable en production, avec parallélisme et performances, une API figée et un mode de développement agréable. Les moyens sont là avec une équipe de 8 personnes presque à plein temps. Je vous invite à parcourir la bien documentée librairie standard, très complète fonctionnellement et selon moi assez intuitive.

Chez Level UP, notre première (toute petite) application Crystal tourne en production depuis avril 2017, avec des tests, de l'intégration continue et un déploiement automatique sur Heroku. Exactement comme nos autres applications Ruby.

Convertir un utilitaire ligne de commande Ruby en Crystal

La mise en jambes avec Crystal a commencé par le transcodage de notre petit outil interne pour macOS baptisé screencapload. Invoqué en ligne de commande ou via un lanceur tel qu’Alfred, il prend une capture d'écran, la tronque et re-compresse au mieux pour accélérer l'upload sur un service d'hébergement d'image (imgur en l'occurrence). On récupère directement dans le presse papier l'URL de l'image, prête à coller dans un fichier, email, chat, SMS, etc.

Pour utiliser Crystal sous macOS, on commence par l'installer via Homebrew :

brew install crystal-lang

Ensuite on créée une application avec la commande crystal :

crystal init app screencapload

Le projet généré assez structuré par rapport à Ruby qui n'a pas de générateur par défaut. Par défaut, les bonnes pratiques open-source sont là : un dossier pour les sources, un autre pour les tests, un dépôt git pré-initialisé, des fichier de licence MIT, configuration Travis CI et README.

.git
.gitignore
.travis.yml
LICENSE
README.md
shard.yml
spec
src

Par défaut, on vous incite à loger votre code dans un module avec son espace de nom, et des sous-fichiers. Là aussi, c'est une bonne pratique.

require "./screencapload/*"

module Screencapload
  # TODO Put your code here
end

Comme avec Ruby, on charge uniquement ce dont on a besoin dans la librairie standard. Ici, on requiert de quoi :

  • générer des fichiers temporaires pour traiter les captures d'écran avec Tempfile
  • faire des requêtes HTTP avec … HTTP évidemment, dont des envois de formulaire avec HTTP::FormData
  • interpréter la réponse JSON de l'API imgur avec JSON
require "http/client"
require "http/formdata" # HTTP::FormData
require "tempfile"
require "json"

Il ne reste “presque” qu'à copier coller du code Ruby pour continuer. Les constantes sont identiques, tout comme l'accès aux variables d'environnement :

GRAPHICS_MAGICK_PATH   = "/usr/local/bin/gm"
OPTIPNG_PATH           = "/usr/local/bin/optipng"
PNGQUANT_PATH          = "/usr/local/bin/pngquant"
IMGUR_CONFIG_FILE_PATH = "#{ENV["HOME"]}/.imgur"

Comme avec un script classique, on peut créer des méthodes sans les loger dans une classe particulière. Ici pour stopper l'exécution avec le code d'état 1 après avoir affiché un message d'erreur :

def exit_fail(message)
  STDERR.puts message
  exit 1
end

Utiliser des simple quotes (apostrophes) plutôt que des doubles quotes (guillemets) en Ruby n'apporte qu'un gain infinitésimal de performances à l'execution. Cela crée par contre un code source d'aspect variable selon le goût du développeur. Crystal a tranché avec uniquement des guillemets, et vous le fait savoir dès la compilation :

Syntax error in src/screencapload.cr:28: unterminated char literal, use double quotes for strings

  exit_fail 'You have to install graphicsmagick to allow automatic image trimming.'

Si vous voulez aller et venir de Ruby à Crystal (et inversement) pensez donc à adopter ce style. Vous pouvez utiliser Rubocop pour valider la qualité uniforme de votre code Crystal, et faire taire au sujet des apostrophes évidemment.

Pour executer des commandes UNIX on utilise system() comme en Ruby. Quelques petites différences existent dans la syntaxe d'appel notamment : les arguments de la commande sont passés sous forme d'un tableau de String.

tempfile = Tempfile.new "screenshot-#{Time.now.epoch_ms}.png"
if system("/usr/sbin/screencapture", ["-x", "-i", tempfile.path])
  log "Screenshot taken."
else
  exit_fail "Failed to take screenshot."
end

Crystal est statiquement typé, mais son compilateur est intelligent : il va déduire pratiquement tout seul le type d'une variable, et vous indiquer d'éventuels problèmes à la compilation. Si par exemple je mélange des entiers et des chaines de caractères dans mon appel à system() la compilation (ou le lancement de l'application en développement) va échouer :

crystal run src/screencapload.cr
Error in src/screencapload/screenshot.cr:17: instantiating 'system(String, Array(Int32 | String))'

      if system("/usr/sbin/screencapture", ["-x", "-t", 1, @tempfile.path])
         ^~~~~~

Les messages d'erreur sont plutôt bruts — surtout comparé à ceux d'ELM, agréablement clairs et explicites — mais on voit le prototype de notre appel pose problème :

system(String, Array(Int32 | String))

On pensera donc bien à utiliser des to_s sur les entiers. On retrouvera le même “problème” quand on parcoure une réponse JSON. Si vous ne définissez / connaissez pas à l'avance la structure de votre réponse JSON, chaque objet sera de type JSON::Any et accéder à son contenu nécessitera l'utilisation de as_s pour s'assurer que l'on obtient une chaine de caractères, ou as_i pour un entier.

Comme notre petit script était un peu brut et assez dense, c'était l'occasion de la structurer en classes et méthodes. Crystal nous apprends en chemin qu'il faut parfois définir à l'avance les variables de classe et leur type, si le compilateur n'arrive à pas le deviner. On doit aussi parfois préciser s'il est possible qu'elles soient nulles. Quelques mots-clés diffèrent tels getter et setter qui remplacent attr_reader et attr_writer pour créer des méthodes retournant ou assignant des variables d'instance.

Tout combiné, cela donne un ensemble de différences notables avec Ruby :

module Screencapload
  class Screenshot
    @tempfile : Tempfile
    @url : String | Nil

    getter :url
  end
end

Dans le cas ci dessus, on doit définir que @url peut être nulle car non initialisée systématiquement dans notre méthode initialize et accessible par url() ensuite.

Ces petits accrocs sont les seuls cas où ce langage typé de manière statique m'a posé problème durant le développement. Le reste du temps, Crystal s'est comporté avec la même souplesse que Ruby. On s'y sent bien, comme chez soi !

Le code principal du script est d'ailleurs pratiquement impossible à discerner de sa version Ruby :

unless File.exists? GRAPHICS_MAGICK_PATH
  exit_fail "Graphicsmagick is required for automatic image trimming."
end

screenshot = Screencapload::Screenshot.new
screenshot.trim!
screenshot.lossless_recompress!
if screenshot.upload!
  puts screenshot.url
else
  exit_fail "Failed to upload screenshot to imgur !"
end

Crystal pour un petit script ?

Le but du projet n'était pas de gagner en performances, compatibilité ou fonctionnalités. Crystal ne fait pas, dans ce cas, quoi que ce soit de plus que Ruby. Les deux démarrent quelques dizaines de milli-secondes. Le script Ruby pèse 3 Ko et le binaire Crystal 448 Ko.

Ce coup d'essai permet simplement de vérifier les promesses de compatibilité/simplicité entre les deux langages. Apprivoiser ce nouveau né prometteur s'est fait sans douleurs, avec grand plaisir.