Développer en Ruby sur Google App Engine

le 14/02/2010 par Antoine Sabourin
Tags: Software Engineering

Avec l'ajout du support de Java sur la plateforme Google App Engine en avril 2009, l'étendue des possibilités offertes aux développeurs de tout bord s'est vue considérablement augmentée.

Il est notamment possible grâce à JRuby, l'implémentation en Java du fameux langage Ruby, de combiner la simplicité de ce langage avec la puissance du Cloud de Google pour développer rapidement des applications web évoluées et performantes.

Etant encore totalement novice dans le domaine il y a quelques mois, j'ai pris plaisir à découvrir ces technologies au cours de mon dernier projet. Je vous propose d'en faire vous aussi l'expérience à travers ce billet maintenant qu'elles ont atteint un niveau de maturité raisonnable.

JRuby on AppEngine

La renommée de Ruby a en grande partie été acquise grâce au framework Rails qui fait référence dans le vaste monde des frameworks orientés Web. Aussitôt l'annonce du support de Java au sein d'AppEngine faite, diverses initiatives pour porter Ruby on Rails sur cette plateforme ont vues le jour, rapidement regroupées au sein du projet AppEngine-JRuby. Plusieurs difficultés ont cependant amené à privilégier le micro-framework Sinatra, plus léger et avec moins de dépendances, pour le développement sur App Engine.

Le projet AppEngine-JRuby propose ainsi sous forme de gems (le système standard de packages Ruby) tous les outils et librairies nécessaires pour installer un environnement de développement et ensuite déployer une application Ruby sur l'App Engine.

Notre point de départ sera donc un environnement de développement Mac, Linux ou Windows (si vous êtes courageux) avec bien évidemment Ruby installé (cf http://www.ruby-lang.org/fr/downloads/ ) ainsi que le gestionnaire de gems RubyGems.

Installation de l'environnement de développement

Classiquement :

sudo gem install google-appengine

La "meta-gem" google-appengine (en version 0.0.9 à l'heure où j'écris ces lignes) s'installe alors accompagnée par les gems suivantes:

  • appengine-tools
  • appengine-sdk
  • appengine-rack
  • appengine-jruby-jars

Cela peut prendre quelques minutes, la taille du SDK AppEngine et de JRuby qui sont inclus étant assez conséquente (une vingtaine de Mo à eux deux). Doivent aussi s'installer si elles ne sont pas déjà présentes les gems Rack et Bundler. En cas d'erreur, vérifiez avec "gem source" que la source http://gemcutter.org est bien configurée.

Il est alors important de bien comprendre que ces gems sont installées sur votre environnement local, aussi connu sous le nom de MRI, et qu'elles sont bien distinctes des gems que vous pourrez être amené à utiliser au sein de votre application. De plus, le recours à RubyGems pour charger ces dernières dans une application reposant sur JRuby étant trop lent, une solution consistant à les packager avec Bundler a été privilégiée. Les gems sont ainsi "bundlées" sous forme de fichier JAR dans un répertoire WEB-INF qui sera déployé sur AppEngine, aux côtés de JRuby et des fichiers de configuration de l'application.

Cette procédure délicate est heureusement maintenant très simplifiée et automatisée grâce aux outils fournis. De façon similaire à ce qui est proposé par le SDK AppEngine Java de Google, 2 scripts sont disponibles: appcfg.rb pour configurer l'application et dev_appserver.rb pour lancer un serveur local qui émule la plateforme AppEngine.

HelloWorld

La création d'un squelette d'application se fait ainsi très simplement:

appcfg.rb generate_app hello

Explorons alors l'architecture de l'application:

  • bin/
  • public/
  • WEB-INF
  • WEB-INF/appengine-generated
  • WEB-INF/lib
  • .gems/bundler_gems/
  • config.ru
  • Gemfile

Cette application implémente le middleware Rack qui standardise l'interface entre un serveur web supportant Ruby et les frameworks de plus haut niveau. Le fichier binaire "rackup" dans le répertoire bin/ est utilisé pour cela.

Rack est lui-même connecté aux servlets Java d'AppEngine via "jruby-rack". L'ensemble des composants assurant ainsi le fonctionnement de Ruby à travers Java sur l'environnement AppEngine sont inclus dans le répertoire WEB-INF/lib .

Se trouve aussi dans ce répertoire le fichier gems.jar qui regroupe les gems bundlées conformément au fichier de configuration Gemfile. Le répertoire .gems est un répertoire de travail pour effectuer ce packaging après avoir téléchargé et structuré les gems concernées.

La configuration de l'application vis-à-vis de la plateforme AppEngine se fait via le fichier config.ru à partir duquel sont générés automatiquement les fichiers de configuration appengine-web.xml et web.xml. Ce fichier Ruby sera par ailleurs le point d'entrée de l'application et on peut observer dans sa configuration par défaut:

require 'appengine-rack'
AppEngine::Rack.configure_app(
  :application => "hello",
  :precompilation_enabled => true,
  :version => "1")
run lambda { Rack::Response.new("Hello from AppEngine !").finish }

"appengine-rack" assure l'inclusion de tous les composants nécessaires et contient une méthode permettant de configurer le nom de l'application et sa version que l'on retrouve ensuite sur AppEngine une fois le déploiement effectué. Vous aurez compris que cette application se contente de répondre "Hello from AppEngine" à toute requête HTTP par l'intermédiaire de Rack, et vous pouvez en vérifier le bon fonctionnement en lançant le serveur local:

dev_appserver.rb hello

Accessible à l'adresse http://localhost:8080, ce serveur émule le fonctionnement de la plateforme AppEngine et présente une interface d'administration (assez sommaire) à l'URL http://localhost:8080/_ah/admin. Son comportement n'est toutefois pas toujours parfaitement identique à celui de l'environnement de production, il est donc utile de vérifier que votre application fonctionne correctement une fois déployée sur le Cloud. Pour cela, après avoir pris soin de faire correspondre le nom de votre application dans le fichier config.ru avec celle que vous avez créée sur votre compte AppEngine:

appcfg.rb update hello

Votre application est alors uploadée et accessible à l'adresse http://hello.appspot.com/ !

Cependant, vous expérimenterez sans doute l'effet "cold-start" d'AppEngine dont les effets sont amplifiés par l'utilisation de JRuby: la plateforme de Google fonctionne en effet en lançant et détruisant des instances de votre application pour répondre au mieux à la charge du moment, et l'initialisation de l'ensemble de la stack JRuby et Rack peut prendre plusieurs dizaines de secondes. C'est pourquoi lorsque votre application vient juste d'être déployée ou après un certain temps d'inactivité la première requête déclenche le chargement d'une nouvelle instance qui nécessite un temps conséquent.

En ajoutant encore par dessus cela le système de chargement RubyGems et un framework conséquent comme Rails, le timeout de 30s pour les requêtes était souvent atteint lors d'un démarrage à froid, ce qui rendait l'application quasi-inutilisable et explique en partie les choix techniques effectués par l'équipe du projet JRuby-Appengine.

En cas de problème avec l'application déployée, n'hésitez pas à consulter les Logs de l'application dans l'interface d'administration de votre compte AppEngine si ceux de Sinatra ne sont pas suffisants.

Créer une application avec Sinatra et DataMapper

De par ses dépendances et sa complexité, le support du framework Rails a été plutôt chaotique depuis le début de l'initiative JRuby-AppEngine. Si aux dernières nouvelles il est à nouveau possible de le faire fonctionner moyennant quelques bidouilles, il reste plus simple de se contenter de Sinatra. Certes beaucoup moins complet et pas encore parfaitement documenté, ce framework a le mérite de faire le minimum vital et de le faire bien. On y perd la "magie" de Rails mais on y gagne une bonne compréhension des mécanismes d'une application Ruby, bref un bon équilibre pour découvrir une nouvelle technologie.

DataMapper est le petit ORM qui monte parmi la communauté Ruby et qui a rapidement eu les faveurs de l'équipe JRuby-AppEngine avec l'ajout d'un "adapter" pour le DataStore de Google AppEngine. Il reste bien évidemment possible d'accéder directement au DataStore à travers les APIs Ruby, mais ce serait dommage de ne pas profiter de la puissance et de la souplesse de DataMapper, surtout avec un DataStore non relationnel.

Commençons donc par ajouter les gems correspondantes au bundle en complétant le fichier Gemfile:

# Critical default settings:
disable_system_gems
disable_rubygems
bundle_path ".gems/bundler_gems"

# List gems to bundle here:
gem "appengine-rack"
gem "dm-appengine"
gem "sinatra"

Puis créons un fichier app.rb qui constituera le coeur de l'application:

require 'sinatra'
require 'dm-core'

get '/' do
  "Hello from Sinatra on AppEngine with DataMapper!"
end

DataMapper a été conçu de façon modulaire et dm-core contient ainsi ses fonctions principales. Il est possible d'ajouter d'autres modules comme dm-validations, dm-timestamps ou encore dm-aggregates, mais tous ne sont malheureusement pas encore compatibles avec le DataStore.

Et modifions enfin le fichier config.ru en conséquence pour inclure le fichier app.rb, spécifier la base de données à utiliser puis lancer notre application Sinatra:

require 'appengine-rack'
require 'app.rb'

AppEngine::Rack.configure_app(
    :application => "hello",
    :precompilation_enabled => true,
    :version => "1")

DataMapper.setup(:default, "appengine://auto")

run Sinatra::Application

Vous pouvez vérifier que l'application tourne correctement en relançant dev_appserver.rb qui devrait mettre à jour les gems bundlées en téléchargeant Sinatra, DataMapper et leurs dépendances. Voilà, nous sommes maintenant prêts à ajouter des fonctionnalités à notre application.

Sinatra laisse beaucoup de liberté dans la structure de l'application, mais il est naturel de s'orienter vers un modèle MVC et de découper de la même façon les fichiers pour faire grandir et évoluer facilement le projet.  En vue de réaliser une ébauche de Blog, ajoutons donc un modèle dans un fichier post.rb:

class Post
  include DataMapper::Resource

  property :id,         Serial
  property :title,      String
  property :body,       Text
  property :created_at, DateTime

end

N'hésitez pas à vous reporter à la documentation de DataMapper pour plus de précisions. Sachez par ailleurs qu'a priori il n'y a pas de problèmes de migration pour gérer la structure des tables de la base de données puisque le Datastore met à jour automatiquement les entités lorsque vous ajoutez ou supprimez des propriétés. Attention toutefois aux index qui sont référencés suite à leur accès sur le serveur local dans le fichier WEB-INF/appengine-generated/datastore-indexes-auto.xml puis mis à jour (parfois très lentement) sur AppEngine après l'upload de l'application.

Complétons maintenant app.rb pour y insérer la logique applicative:

require 'sinatra'
require 'dm-core'

require 'post'

get '/' do
  @posts = Post.all(:order => [:created_at.desc])
  erb :'index.html'
end

post '/create' do
 Post.create(:title => params[:title], :body => params[:body], :created_at => Time.now)
 redirect '/'
end

Sinatra est compatible avec de nombreux systèmes de template, mais ERB est inclus en natif et sera suffisant dans un premier temps. Il ne nous reste donc plus qu'à créer la vue correspondante dans le fichier index.html.erb à placer par défaut dans un répertoire ./views :

Relancez alors une fois de plus dev_appserver.rb pour admirer le résultat !

Mais vous commencez sans doute déjà à être agacé par le fait qu'il soit nécessaire de relancer le serveur de développement pour prendre en compte chaque nouvelle modification, surtout que cela peut prendre plusieurs secondes à chaque fois! Voici donc un morceau de code à insérer dans votre config.ru, juste avant le "run", pour activer le rechargement automatique de votre application Sinatra dans votre environnement de développement (voir le wiki JRuby-AppEngine pour plus de détails) :

configure :development do
  class Sinatra::Reloader < ::Rack::Reloader
    def safe_load(file, mtime, stderr)
      if File.expand_path(file) == File.expand_path(::Sinatra::Application.app_file)
        ::Sinatra::Application.reset!
        stderr.puts "#{self.class}: reseting routes"
      end
      super
    end
  end
  use Sinatra::Reloader
end

Utiliser les APIs AppEngine

Vous avez maintenant toutes les clés en main pour développer une véritable application web en Ruby, déployable sur AppEngine. Mais pour vraiment tirer parti de tout le potentiel de cette plateforme, vous aurez intérêt à profiter des différents services qu'elle propose et qui sont accessibles facilement grâce à des APIs en Ruby:

  • AppEngine::Logger
  • AppEngine::Testing
  • AppEngine::Users
  • AppEngine::Mail
  • AppEngine::Memcache
  • AppEngine::URLFetch
  • AppEngine::Datastore
  • AppEngine::XMPP
  • AppEngine::Labs::TaskQueue

La documentation de ces APIs manque parfois de clarté mais leur utilisation est en général très simple ! Voici quelques exemples rapides pour exposer leurs possibilités:

Users

Cette API permet de gérer l'authentification des visiteurs par l'intermédiaire des comptes Google. On peut ainsi par exemple facilement protéger l'accès à une zone d'administration de notre application:

require 'appengine-apis/users'
before do
  if env['REQUEST_URI'].to_s.match '/admin'
    if AppEngine::Users.logged_in? and AppEngine::Users.admin?
      #OK
    else
      redirect AppEngine::Users.create_login_url env['REQUEST_URI']
    end
  end
end

"before" est un filtre de Sinatra qui exécute une action avant chaque accès à une route. On vérifie donc ici que l'utilisateur est loggé et qu'il est administrateur de l'application et on le redirige vers une page de login (avec la page demandée en paramètre) si ce n'est pas le cas.

XMPP

Tout message envoyé via XMPP à l'adresse myapp@appspot.com (ou anything@myapp.appspotchat.com) est alors redirigé par AppEngine sous forme de requête HTTP POST avec le message en paramètre. Voici comment utiliser l'API pour y répondre:

require 'appengine-apis/xmpp'

post '/_ah/xmpp/message/chat/' do

  msg = AppEngine::XMPP::Message.new params
  AppEngine::XMPP.send_message :to => msg.sender, :body => 'You said: ' + msg.body

end

Il est possible de tester manuellement cette fonctionnalité XMPP en simulant la réception de message via la console d'administration du serveur local: une fois dev_appserver.rb démarré, vous pouvez y accéder par l'URL http://localhost:8080/_ah/admin . Les envois de messages sont quant à eux visibles dans l'output du terminal dans lequel est lancé le serveur.

TaskQueue

Bien que toujours classée dans la section Labs, cette API semble tout à fait stable, et est très pratique pour effectuer des actions asynchrones.

require 'appengine-apis/labs/taskqueue'
AppEngine::Labs::TaskQueue.add :name => 'DoLater', :url => '/task', :eta => (Time.now + rand(10))

Pour aller plus loin...

La mise en place de tests automatisés avec les outils classiques de l'écosystème Ruby est depuis peu possible grâce à l'ajout du module AppEngine::Testing qui propose de "mocker" les méthodes des APIs AppEngine. Je n'ai malheureusement pas eu le temps de le découvrir car mon projet a été débuté avant l'apparition de ce module, et j'ai d'ailleurs passé des moments difficiles en faisant tout à la main avec Mocha.

Il est par ailleurs intéressant de noter que l'on peut aussi en cas de besoin écrire directement des instructions en Java au sein de notre application JRuby, voir même réaliser une application hybride avec des composants en Java pur et des vrais servlets.

Le projet JRuby-AppEngine reste ainsi assez jeune mais il semble prometteur. Comme vous l'avez vu la création et le déploiement d'une application se fait très simplement et permet d'allier la richesse de Ruby avec la puissance de la plateforme de Cloud de Google, ce qui en fait une solution avec des arguments techniques et fonctionnels non négligeables. Mais c'est surtout la sortie prochaine de Rails3, pleinement compatible avec JRuby sur AppEngine, qui devrait réellement faire décoller le projet.