Rails 2.1, ça roule maintenant ! – Le tutoriel complet – 1ère partie

Cet article est la traduction de la première partie de l’article Rolling With Rails 2.1 de Fabio Akita, j’ai conservé le style de Fabio à la première personne, c’est donc Fabio qui parle dans la suite de ce texte (là c’était moi, vous avez compris) et mis à jour les exemples avec ce qui est produit par la version finale de Rails 2.1.

Je vais reprendre exactement là où nous nous étions arrêtés dans le précédent tutoriel, si vous ne l’avez pas lu, je vous suggère de le faire maintenant ou de télécharger le code qui est disponible sur GitHub. J’ai ajouté un tag ‘for_2.0’ pour ce qui concerne le précédent tuto et un nouveau tag ‘for_2.1’ pour les mises à jour que je suis sur le point de vous montrer dans ce nouvel article. Vous pouvez soit suivre mon précédent tutoriel pour voir tout fonctionner ou le sauter et vous contenter de télécharger le code d’exemple depuis ma page GitHub.

Pour la suite de cet article, je vais considérer que vous avez le projet ‘blog’ dans un répertoire de votre machine. Peu importe que vous l’ayez décompressé depuis le tarball ou que vous ayez cloné l’arbre complet depuis mon dépôt GitHub.

Ceci est la première partie et voici la deuxième partie.

Installation

Avant de commencer, je vous recommande de faire ceci :

$ gem update --system

Ou, si vous avez RubyGems 0.8.4 ou une version plus ancienne, d’essayer ça :

$ gem install rubygems-update
$ update_rubygems

Puis, pour installer Rails 2.1 de faire :

$ gem install rails

A moins que vous ne vouliez geler les gems de rails dans votre projet, dans ce cas faites :

$ rake rails:freeze:gems

Bien entendu, vous devez lancer cette tâche depuis la racine de votre projet. Pour ce tutoriel, j’ai utilisé la version 2.1 de Rails que j’ai cloné depuis GitHub, directement depuis le trunk de Rails Edge :

$ cd blog
$ git clone git://github.com/rails/rails.git vendor/rails

La ligne ci-dessus suppose que vous connaissez git et que vous l’avez installé correctement. Si ce n’est pas le cas, récupérez simplement la “tarball” de Rails : http://github.com/rails/rails/tarball/master depuis le site GitHib et décompressez la dans le dossier vendor/rails de votre projet.

EN gardant cela à l’esprit, si vous avez déjà un projet Rails en place – c’est le “blog” dans notre exemple – n’oubliez pas que vous devez mettre à jour quelques resssources de Rails comme ceci :

$ rake rails:update

A partir de Rails 2.1, la prochaine fois que vous devrez mettre à jour les gems de Rails (quand la version 2.2 ou une autre sera sortie,) cette étape ne sera plus nécessaire. Le gel des gems mettra à jour les ressources Rails de votre projet automatiquement pour vous.

Après ça, la première chose que vous devrez faire dans le fichier config/environment.rb sera de mettre à jour le numéro de la version de Rails dont votre projet dépend :

RAILS_GEM_VERSION = '2.1.0'

En pratique, cette modification n’aura d’effet que si vous utilisez la version de Rails installée comme un gem au niveau du système, si vous avez gelé Rails dans votre projet, c’est la version dans vendor/rails qui aura la prévalence sur la version installée dans le système.

Enfin, ajoutez un nouveau fichier appelé config/initializers/new_rails_defaults.rb contenant ce qui suit :

# These settins change the behavior of Rails 2 apps and will be defaults
# for Rails 3. You can remove this initializer when Rails 3 is released.
 
# Only save the attributes that have changed since the record was loaded.
ActiveRecord::Base.partial_updates = true
 
# Include ActiveRecord class name as root for JSON serialized output.
ActiveRecord::Base.include_root_in_json = true
 
# Use ISO 8601 format for JSON serialized times and dates
ActiveSupport.use_standard_json_time_format = true
 
# Don't escape HTML entities in JSON, leave that for the #json_escape helper
# if you're including raw json in an HTML page.
ActiveSupport.escape_html_entities_in_json = false

Dépendances aux RubyGems

La première chose que vous allez trouver dans le nouveau fichier environment.rb est de quoi gérer les dépendances de votre projet avec les gems qu’il utilise. Par exemple, ajoutons le code suivant dans le bloc Initializer :

config.gem "haml", :version => "1.8"
config.gem "launchy"
config.gem "defunkt-github", :lib => 'github', :source => "http://gems.github.com"

Pour l’instant, oubliez à quoi servent ces gems et regardons seulement ce que nous pouvons faire. Depuis la ligne de commande, nous pouvons utiliser :

$ rake gems

Voici le résultat :

[ ] haml = 1.8
[ ] launchy 
[ ] defunkt-github 
 
I = Installed
F = Frozen

Un bon conseil : si votre projet dépend d’un quelconque gem, soyez sur de l’avoir ajouté à la liste qui est dans le fichier environment.rb. Je vous recommande même d’indiquer de quelle version du gem vous dépendez, de cette manière, vous serez sûr de ne pas être surpris par un nouveau bug étrange qui arrivera parce que vous aurez mis à jour vos gems système et qu’un de ceux qui aura été mis à jour et dont vous dépendez aura cassé votre projet. Ca m’est déjà arrivé et je pense qu’il vaut mieux être prudent.

La méthode ‘config.gem’ accepte en paramètres le nom du gem et un tableau associatif d’options. Utilisez :version pour ajouter une condition sur le numéro de version (vous pouvez préciser >, <, =, >=, <=,) :source pour spécifier une adresse d’un dépôt de gems non standard et utilisez :lib si le chemin d’installation ne peut pas être déduit du nom du gem.

Revenons à ce que nous avons ajouté avant et disséquons chaque ligne :

config.gem "haml", :version => "1.8"

Ca signifie que votre application nécessire la version 1.8 du gem de HAML (même si, l’excellent Hampton Caitlin vient juste de sortir HAML 2.0.2). C’est un exemple de cas où nous devons geler une version antérieure à la version courante.

config.gem "launchy"
config.gem "defunkt-github", :lib => 'github', :source => "http://gems.github.com"

Habituellement, la commande ‘gem’ cherchera les gems dans le dépôt ‘official’ sur gems.rubyforge.org. Mais maintenant nous avons un autre endroit qui devient de plus en plus populaire et qui est Github. Comme la commande gem standard ne sait rien de ce dépôt, nous devons la spécifier explicitement en utilisant l’option :source.

Un autre détail : le nom du gem sur GitHub suit une convention différence de RubyForge : ”[nom de l’utilisateur]-[nom du gem]”, bien que l’utilisateur defunkt (qui est le grand Chris Wanstrath lui-même) ait un gem nommé github-gem la commande ‘require’ doit le charger depuis la lib nommée ‘github’, sans le préfixe ‘defunkt’, nous utilisons donc l’option :lib pour l’indiquer. Le gem ‘launchy’ n’est là que parce que ‘github-gem’ en dépend.

Nous n’utiliserons aucun de ces gems dans notre projet de blog de démo, ils sont juste là pour illustrer la nouvelle fonctionnalité de Rails 2.1. Maintenant que nous avons indiqué de quels gems nous dépendons, et que le résultat de la commande nous a montré qu’ils n’étaient pas installés sur notre système, nous pouvons les installer de cette façon :

$ sudo rake gems:install

Si vous êtes sous Windows, ignorez toujours que j’utilise la commande sudo, mais sur les systèmes Unix (comme Linux et OS X) nous ne pouvons probablement rien installer dans /usr/local ou /opt sans les autorisations adaptées, d’où le ‘sudo’. Cette commande doit revoyer quelque chose comme ça (tout dépend s’ils sont, ou pas, déjà installés) :

gem install haml --version "= 1.8"
ERROR:  While generating documentation for haml-1.8.0
... MESSAGE:   Unhandled special: Special: type=17, text="<!-- This is the peanutbutterjelly element -->"
... RDOC args: --ri --op /usr/lib64/ruby/gems/1.8/doc/haml-1.8.0/ri --title Haml --main README --exclude lib/haml/buffer.rb --line-numbers --inline-source --quiet lib VERSION MIT-LICENSE README
(continuing with the rest of the installation)
Successfully installed haml-1.8.0
1 gem installed
Installing ri documentation for haml-1.8.0...
Installing RDoc documentation for haml-1.8.0...
gem install launchy
Successfully installed launchy-0.3.2
1 gem installed
Installing ri documentation for launchy-0.3.2...
Installing RDoc documentation for launchy-0.3.2...
gem install defunkt-github --source http://gems.github.com
Successfully installed defunkt-github-0.1.3
1 gem installed
Installing ri documentation for defunkt-github-0.1.3...
Installing RDoc documentation for defunkt-github-0.1.3...

Après que tout les gems ont été correctement installés, la tâche ‘gems’ de la commande ‘rake’ doit nous renvoyer le résultat suivant :

[I] haml = 1.8
[I] launchy 
[I] defunkt-github 
 
I = Installed
F = Frozen

Une autre fonctionnalité sympa vous permettra de vous en sortir quand vous serez coincé dans une situation où vous ne pourrez pas installer des gems au niveau système. Par exemple, si vous devez déployer votre application dans un compte limité d’un hébergement mutualisé, ou si votre client vous interdit d’installer de nouveaux logiciels.

Vous avez alors l’alternative de ‘vendoriser’ vos gems, c’est à dire, de faire en sorte que les gems dont vous avez besoin soient embarqués dans la structure de votre projet, comme ça :

$ rake gems:unpack:dependencies

Ca va ‘dépaqueter’ tous les gems dont vous avez besoin dans la structure de votre projet sous vendor/gems :

Unpacked gem: '~/rails/tuto/vendor/gems/haml-1.8.0'
Unpacked gem: '~/rails/tuto/vendor/gems/launchy-0.3.2'
Unpacked gem: '~/rails/tuto/vendor/gems/defunkt-github-0.1.3'

Si on lance de nouveau la tâche ‘gems’ maintenant, on voit ceci :

[I] haml = 1.8
[F] launchy 
[F] defunkt-github 
 
I = Installed
F = Frozen

Notez que la plupart des gems ont changé de statut en passant de [I]nstallé à [F]rozen (gelé). Mais, même si le gem HAML a été copié, pour une étrange raison, il continue d’être affiché comme [I] au lieu de [F], probablement à cause d’un bug dans Rails. Je pense qu’ils devraient tous être affichés [F] à moins qu’il n’y ait une bonne raison pour que la version installée de HAML sur le système prévale sur celle qui est dans vendor/gems.

De toutes manières, nous n’y pouvons rien, disons que nous avons besoin d’un autre gem qui a besoin d’une compilation native comme RMagick, alors ajoutons une nouvelle ligne à notre fichier environment.rb :

...
config.gem "rmagick", :lib => "RMagick2"
...

En particulier, nous devons ‘savoir’ que ce gem est appelé ‘rmagick’ mais qu’il doit être requis comme ‘RMagick2’. Référez vous à la documentation du gem pour savoir quoi faire. Sans la bonne option :lib ici nous aurions une exception disant qu’il ne peut pas trouver la librairie ‘Rmagick2.so’.

Maintenant nous pouvons l’installer et le dépaqueter dans notre dossier vendor/gems en utilisant la même commande ‘gems:unpack:dependencies’. Après quoi nous devrons utiliser la tâche ‘build’, comme ça :

$ sudo rake gems:install
$ rake gems:unpack:dependencies
$ rake gems:build

Nous l’installons et nous le dépaquetons (dans le cas où vous n’avez pas déjà RMagick), puis nous lancons la tâche ‘build’. C’est nécessaire pour réaliser toutes les compilations natives (si vous êtes sous OS X, vous devez avoir installé XCode pour avoir des compilateurs à disposition. Sous Linux, regardez si votre distribution n’aurait pas un paquet comme build-essential) :

Built gem: '~/rails/tuto/vendor/gems/haml-1.8.0'
Built gem: '~/rails/tuto/vendor/gems/launchy-0.3.2'
Built gem: '~/rails/tuto/vendor/gems/defunkt-github-0.1.3'
Built gem: '~/rails/tuto/vendor/gems/rmagick-2.5.2'

Au moins ça, c’est la théorie. Il faut encore que j’explore ces fonctionnalités un peu plus parce que je n’ai aucune idée de la manière dont c’est censé fonctionner en production. Le concept en général semble fonctionner correctement. Il sera probablement utile dans la phase de “setup” des recettes Capistrano quand vous devrez configurer un nouveau déploiement. Puis vous pourrez utiliser la tâche ‘gems:install’ pour installer une version système des gems requis. Ou vous pourriez préalablement les dépaqueter dans votre projet et utiliser la tâche ‘gems:install’ séparément ensuite, mais la librairie devrait alors être compilée dans le répertoire home d’un utilisateur au lieu d’être dans le dossier des librairies du système.

C’est la partie dont je ne sais encore si elle fonctionne ou pas. Est-ce que quelqu’un a déjà essayé ? Pour plus d’informations, référez vous à RailsCasts et à Ryan Daigle

Nouveau support des heures, jusqu’à Rails 2.0 (partie 1)

Avertissement : n’utilisez, ni n’exécutez rien de cette section. Je vais simplement vous montrer quelques trucs pour expliquer mon point de vue. Nous referons des exercices dans la section suivante. C’est une longue explication des détails du fonctionnement des opérations sur les heures jusqu’à Rails 2.0. J’ai déjà rencontré plusieurs débutants qui n’avaient pas compris ça. Si vous savez déjà tout, sautez à la section suivante.

Puisque tous les gems dont nous avons besoin sont installés, regardons plus en détail l’un des aspects qui m’intéresse le plus dans cette nouvelle version : Les Heures.

Jusqu’à la version 2.0 nous devions passer beaucoup de temps pour traiter les problèmes d’heures. La version 2.1 ne résoud pas tous les problèmes, mais elle rend au moins les choses beaucoup plus cohérentes. Il reste encore plein de possibilités pour l’améliorer.

En premier et avant toute chose, pour les non-initiés, il faut comprendre comment on faisait avant. Ruby a trois classes différentes pour jouer avec les Dates et les Heures : La classe Date, la classe Time et la classe DateTime (qui étend la classe Date.) La plus rapide étant la classe Time dont une partie est écrite en C et compilée. “Mais”, la classe Time ne peut gérer que des datetimes de l’époque Unix (1970) alors que Date/DateTime peut gérer des datetimes beaucoup plus complexes.

Donc, vous devez commencer par choisir judicieusement. En général, la plupart des gens finissent par utiliser la classe Time pour les combinaisons de dates et d’heures et la classe Date pour les dates seules. “Mais” il y a un autre problème avec lequel quelques-uns d’entre nous devons composer : la Localisation.

Le premier problème se pose quand votre application est destinée à des gens qui sont dans plusieurs fuseaux horaires. Vous n’avez pas besoin d’aller très loin : dans le même pays, deux états peuvent facilement se retrouver dans des fuseaux horaires différents. Rails a toujours fourni une classe TimeZone utilisable telle quelle pour tenter de s’occuper de ce problème. Mais, de par sa conception, elle pose un autre problème sur lequel je reviendrai plus tard. Finalement on vous recommande en général de décommenter cette ligne dans le fichier config/environment.rb :

config.active_record.default_timezone = :utc

Après avoir fait ça, il y a un autre “design pattern” qu’il faut appliquer : coller un fuseau horaire à chaque utilisateur. Pour faire çà, on se contente généralement d’ajouter un champ chaîne “time_zone” dans le modèle User. Puis l’utilisateur doit se logger et choisir son fuseau horaire dans une sorte de liste déroulante ou mieux, si vous avez.

Celà fait, il s’agit en fait d’ajouter un filtre ‘around_filter’ au niveau de l’application pour adapter le fuseau horaire de l’utilisateur à chaque requête. Pour faire ça vous devez ajouter ça dans le fichier app/controllers/application.rb :

class ApplicationController < ActionController::Base
  around_filter :set_timezone
 
  private
  def set_timezone
    begin
      TzTime.zone = self.current_user.time_zone
      yield
    ensure
      TzTime.reset!
    end
  end
end

“Aha!” Je vous entend. Que fout ce ‘TzTime’ ici ? Bien, comme je vous l’ai expliqué avant, Ruby est fourni avec 3 classes de gestion des Dates/Heures. Pour que Rails fonctionne correctement avec les fuseaux horaires des utilisateurs, vous devez ajouter une nouvelle classe de gestion des dates et des heures appellée ‘TzTime’. Cette classe est ajoutée par le plugin TzTime de Jamis Buck’s. Vous l’obtiendrez en installant son plugin dans votre projet Rails, comme ça :

$ ./script/plugin install tztime

Le problème est que la classe Time de Ruby utilise le fuseau horaire de la machine sur laquelle elle s’exécute. Alors quand vous voudrez utiliser ‘Time.now’ pour créer une nouvelle instance de Time, elle sera relative au fuseau horaire de la machine locale. Le filtre (le hack en fait) qu’on a ajouté surcharge ce comportement en utilisant la nouvelle classe singleton TzTime qui ‘se comporte’ comme la classe Time mais qui dispose de son propre système de gestion des fuseaux horaires.

Le filtre doit être de type ‘around_filter’ parcequ’il faut que nous le réinitialisions après que la requête a été traitée pour éviter que des erreurs surviennent lorsqu’un autre utilisateur arrive, par exemple, sans que sa propriété time_zone ait été définie correctement. Comme nous changeons l’état d’un singleton, toutes les requêtes s’exécuterons dans ce contexte unique.

Pour mieux comprendre, lançons ‘script/console’ et exécutons quelques requêtes (avant ça, vous devez avoir installé le plugin TzTime dans votre projet) :

>> Time.now
=> Sun May 25 02:59:17 -0300 2008

Notez que ‘Time.now’ utilise le fuseau horaire de ma machine qui est GMT-3 (heure du Brésil.) Vous pouvez voir ça dans le ’-0300’ de la représentation de l’instance de l’heure.

>> Time.utc(2008,5,25,3)
=> Sun May 25 03:00:00 UTC 2008

Le singleton Time dispose d’une méthode ‘utc’ qui nous renvoie une instance à GMT-0 (heure de Greenwich.)

>> Time.local(2008,5,25,3)
=> Sun May 25 03:00:00 -0300 2008

Le singleton Time a aussi une méthode ‘local’ qui nous renvoie une instance dans le fuseau horaire courant (-0300). Maintenant voyons la différence lorsqu’on utilise TzTime à la place.

>> TzTime.zone = TimeZone['Mountain Time (US & Canada)']
=> #<TimeZone:0x192155c @name="Mountain Time (US & Canada)", @tzinfo=nil, @utc_offset=-25200>
>> TzTime.now
=> 2008-05-25 00:01:39 MDT

Notez tout d’abord que nous devons informer le singleton TzTime sur quel fuseau horaire arbitraire nous souhaitons opérer. Dans l’exemple ci-dessus, il a été défini à Mountain Time. Vous pouvez voir que la méthode ‘now’ de TzTime renvoie l’heure dans le fuseau horaire MDT ce qui est correct.

>> TzTime.zone.utc_to_local(Time.utc(2008,5,25,3))
=> Sat May 24 21:00:00 UTC 2008
>> TzTime.zone.local_to_utc(Time.utc(2008,5,25,3))
=> Sun May 25 09:00:00 UTC 2008

Une chose bien utile dans TzTime est que je peux lui donner une instance de Time et la forcer au fuseau horaire du singleton (MDT dans notre cas) sans tenir compte du fuseau horaire de la machine (utc_to_local) et, bien entendu, je peux faire le contraire et forcer l’instance de Time à être utilisée comme une heure locale – ‘locale’ est relative au fuseau MDT dans ce cas – et renvoyer une heure UTC (GMT-0) (local_to_utc.)

La chose importante qu’il faut retenir : avec tout ça en place, à chaque fois que l’utilisateur entrera une information de date et d’heure dans une vue (depuis son navigateur web) elle sera postée au controlleur qui passera l’information à ActiveRecord. Dans le fichier environment.rb nous l’avons configuré pour qu’il utilise le fuseau horaire de l’utilisateur, défini dans le bloc ‘around_filter’ de Application.

ActiveRecord considérera que la Date/Heure fournie est dans le format local et la première chose qu’il fera sera de la convertir en UTC. Seulement ensuite il stockera l’enregistrement dans la base de données. Quand il relira l’enregistrement, il chargera la Date/Heure en UTC qu’il convertira en heure locale (en considérant que TzTime.zone est défini correctement.)

Cette explication est longue mais on y est presque. Il ne reste plus qu’une chose :

$ sudo gem install tzinfo
$ ./script/plugin install tzinfo_timezone

Quelque paragraphes avant, je vous ai prévenu que la classe TimeZone fournie avec Rails avait un défaut : elle supporte différents fuseaux horaires mais ne supporte pas les Heures d’été (Daylight Saving Time ou DST.) Le problème est que les heures d’été ne peuvent être déterminées par aucun algorithme. La raison est que chaque pays est totalement libre de décider s’il mettra en oeuvre, ou pas, une la politique de l’heure d’été qui consiste à définir l’heure une heure plus tôt ou plus tard. Et s’il le fait, il peut décider de manière totalement arbitraire du jour où ça doit arriver chaque année. Nous savons, approximativement, à quelles dates ça se produira, mais nous devons en obtenir la confirmation tous les ans.

Alors la seule manière pour nous de gérer les DST correctement est d’avoir une base de données continuellement à jour. Dans le monde Ruby, c’est le rôle du gem TzInfo (ne le confondez pas avec “TzTime” !) TzInfo est mis à jour suivant une base de données internationale accessible en lecture des fuseaux horaires et des heures d’été.

Enfin, le plugin ‘tzinfo_timezone’ patche Rails lui même à l’exécution (c’est un “Monkey-Patch:http://fr.wikipedia.org/wiki/Monkey-Patch.) Il remplace la classe TimeZone fournie avec Rails par celle de TzInfo. De cette manière vous gagnez automatiquement la partie DST de l’équation.

Maintenant vous savez combien il a été difficile de jouer avec les Heures jusqu’à Rails 2.0. Et ce n’est pas nouveau : il n’est pas trivial de trouver une solution propre et légère au problème de la gestion des Dates et des Heures. Même en Java vous devez utiliser des librairies OpenSource tierces comme JodaTime à la place des classes standard.

Le nouveau support des Heures – Rails 2.1 (partie 2)

Averissement : le code et les commandes de la section précédente n’étaient pas supposés être exécutés. Dans cette section nous revenons à notre exemple de blog de démo alors vous pouvez les essayer.

Maintenant faisons un peu de ménage (si vous avez installé les plugins de la section précédente) et découvrons la nouvelle manière de gérer les heures dans Rails 2.1 :

$ rm -Rf vendor/plugins/tzinfo_timezone
$ rm -Rf vendor/plugins/tztime

Le gem TzInfo a été vendorisé et il est maintenant fourni avec les gems de Rails alors vous n’avez plus besoin de l’installer manuellement comme nous l’avons fait avant. Nous n’avons plus non plus besoin des vieux plugins et la classe TzTime n’a plus besoin d’être utilisée.

Alors, réfléchissez très sérieusement avant de faire la mise à jour : si vous utilisiez vous utilisiez l’ActiveRecord standard et les design pattern du contrôlleur d’Application, vous n’avez probablement rien à changer. Mais si vous dépendiez fortement de TzTime, il faudra que vous testiez tout intégralement. Maintenant, si vous avez déjà un jeu de tests complet et détaillé, ça ne sera probablement pas un si gros défi puisque les tests devraient s’arrêter de passer et vous informeront de ce qui ne va pas.

Ce que nous devons faire depuis Rails 2.1 est, en partant de config/environment.rb:

config.time_zone = 'UTC'

Notez que la configuration est plus légère que précédemment. Puis dans app/controllers/application.rb, encore. Commencez par supprimer le vieux filtre ‘around_filter’ et écrivez ce simple filtre ‘before_filter’:

class ApplicationController < ActionController::Base
  ....
  before_filter :set_timezone
 
  private
  def set_timezone
    # current_user.time_zone #=> 'London'
    Time.zone = current_user.time_zone rescue nil
  end
end

Encore une fois, vous avez besoin que votre modèle User (si vous en avez déjà un, nous y reviendrons) contiennent un champ chaîne ‘time_zone’Again. L’exemple considère que vous utilisez le plugin restful_authentication (‘current_user’ est défini par ce plugin) ou un équivalent pour gérer l’authentification des usagers.

Mais cette fois, point de TzTime, nous définissons le fuseau horaire de l’utilisateur dan la classe Singleton elle-même. En fait, dans le proxy ’#zone’ du singleton. Et nous n’avons plus besoin d’accèder à un tableau, nous passons juste la représentation textuelle du fuseau horaire. Alors que s’est-il passé ? Pour le comprendre, ouvrons une nouvelle ‘script/console’ et jetons-y un coup d’oeil :

>> Time.zone = "Mountain Time (US & Canada)"
=> "Mountain Time (US & Canada)"
 
>> Time.now
=> Sun May 25 03:24:34 -0300 2008
>> Time.zone.now
=> Sun, 25 May 2008 00:24:36 MDT -06:00

Voilà qui est intéressant. De la même manière que précédemment, nous avons du assigner le fuseau horaire que nous voulions au singleton Time. Nous l’avons encore une fois défini à MDT. Nous pouvons toujours appeler ‘Time.now’ comme nous le faisions précédemment et son comportement n’a pas changé, il nous renvoie toujours l’heure dans mon fuseau horaire GLT-3 au lieu de MDT.

Mais Rails 2.1 ajoute une méthode proxy ‘zone’ bien pratique, de la même façon que le singleton String dispose d’une méthode proxy ‘chars’ pour masquer les opérations Unicode. Pour la classe Time, ‘zone’ masque les opérations sur les heures qui utilisent les fuseaux horaires. C’est pourquoi ‘Time.zone.now’ renvoie l’heure locale dans le fuseau MDT.

>> Time.zone.local(2008,5,25,3)
=> Sun, 25 May 2008 03:00:00 MDT -06:00
>> Time.zone.parse('2008-5-25 3:00:00')
=> Sun, 25 May 2008 03:00:00 MDT -06:00

Ce proxy expose plusieurs méthodes d’assistance bien utiles comme ‘local’, qui permet de construire une instance Time dans le fuseau local et ‘parse’ qui, évidemment, analyse une chaîne et la converti en instance de Time dans le fuseau local.

>> Time.utc(2008,5,25,3).in_time_zone
=> Sat, 24 May 2008 21:00:00 MDT -06:00
>> Time.local(2008,5,25,3).in_time_zone
=> Sun, 25 May 2008 00:00:00 MDT -06:00
>> Time.utc(2008,5,25,3).in_time_zone('Brasilia')
=> Sun, 25 May 2008 00:00:00 ART -03:00

Enfin, vous avez vu précédemment que nous avions utilisé ‘TzTime.zone.utc_to_local’ pour convertir une instance de Time UTC en instance locale, maintenant, l’instance de Time elle-même dispose d’une méthode ‘in_time_zone’ qui la converti dans le bon fuseau, celui qui est stocké dans le proxy ‘zone’. Nous pouvons même lui passer un fuseau horaire en paramètre si nous voulons déroger temporairement de celui qui a été défini globalement. Dans cet exemple, même si Time.zone était configuré pour MDT, je peux quand même la convertir dans l’heure du Brésil.

Pour ActiveRecord, ce comportement est, en gros, identique. Jetons encore un oeil à ‘script/console’ (considérons que vous avez au moins une ligne dans la table des posts, sinon créez en une arbitraire):

>> Post.find(:first).created_at_before_type_cast
=> "2008-05-05 13:55:21"
>> Post.find(:first).created_at
=> Mon, 05 May 2008 07:55:21 MDT -06:00
>> Post.find(:first).created_at.in_time_zone("Brasilia")
=> Mon, 05 May 2008 10:55:21 ART -03:00

ActiveRecord peut reconnaitre le message “before_type_cast”. Dans cet exemple en particulier, il nous renvoie 13h55. Mais si nous récupérons l’enregistrement comme nous le ferions normalement, il le convertirait à -6 heures qui est le fuseau MDT, et nous afficherait 7h55. Et nous avons vu précédemment que nous pouvions appeler ‘in_time_zone’ pour convertir cette heure dans un autre fuseau horaire. Approfondissons :

>> Post.find(:first).created_at.class
=> ActiveSupport::TimeWithZone
>> Post.find(:first).created_at.time_zone
=> #<TimeZone:0x192155c @name="Mountain Time (US & Canada)", @tzinfo=#<TZInfo::DataTimezone: America/Denver>, utc_offset-25200

L’autre astuce c’est qu’au lieu de retourner une instance de la classe Time, il retourne une instance d’une nouvelle class qui a été ajoutée dans Rails 2.1 appelée TimeWithZone. TimeWithZone remplace la vieille classe TzTime de Jamis. Elle se comporte quasiment comme la classe Time standard mais embarque, en plus, le fuseau horaire de l’utilisateur. De cette manière, nous pouvons faire des calculs corrects en tenant compte des fuseaux horaires. Vous vous rappellez du Duck Typing ? Le typage à la canard, en théorie, nous pouvons utiliser les instances de TimleWithZone exactement de la même manière que les instances de Time, donc toutes les librairies tierces qui acceptent Time devraient aussi accepter TimeWithZone.

Ma recommandation : assurez-vous que vos suites de tests ont été développées sérieusement, vous allez en avoir besoin ! Si vos suites de tests continuent de fonctionner après avoir fait la mise à jour vers Rails 2.1, vous êtes sur la bonne voie. Sinon vous devez suspecter que des opérations de TzTime trainent encore dans votre code et vous devrez les changer pour leurs équivalents avec TimeWithZone et Time.zone. Faites beaucoup de tests !

Pour finir cette section sur les heures, il est important de dire que Rails 2.1 est fourni avec 3 nouvelles tâches rake qui peuvent être utiles :

$ rake time:zones:all
 
* UTC -11:00 *
International Date Line West
Midway Island
Samoa
...
* UTC +13:00 *
Nuku'alofa

‘time:zones:all’ liste touts les fuseaus horaires qui sont supportés par le gem TzInfo qui est vendorisé avec Rails. Nous avons, en plus :

$ rake time:zones:local
 
* UTC -03:00 *
Brasilia
Buenos Aires
Georgetown
Greenland

‘times:zones:local’ montre les noms des fuseaux horaires définis sur votre machine et enfin :

$ rake time:zones:us   
 
* UTC -10:00 *
Hawaii
...
* UTC -05:00 *
Eastern Time (US & Canada)
Indiana (East)

‘time:zones:us’ montre tous les fuseaux horaires des USA, c’est seulement intéressant pour ceux qui vivent aux USA mais presque sans intérêt pour les autres. Une autre chose intéressante est vous serez capable de créer des vues depuis lesquelles vos utilisateurs pourront changer leur fuseau horaire (probablement en le sauvegardant dans un champ texte ‘time_zone’ du modèle User.) Pour réaliser celà, vous pouvez utiliser cet assistant :

<%= f.time_zone_select :time_zone, TimeZone.us_zones %>

Ca créera une liste déroulante contenant tous les fuseaux horaires disponibles dans TzInfo. Le second paramètre (‘TimeZone.us_zones’, dans cet exemple) indique le fuseau prioritaire. L’objectif est de grouper les fuseaux horaires les plus pertinents au début de la liste. La classe TimeZone dispose d’un tableau ‘us_zones’ d’instances de TimeZones, mais vous pouvez créer et utiliser autant de groupes de fuseaux horaires que vous avez besoin.

Lorsqu’un utilisateur en choisit une, vous devez le récupérer et le sauvegarder quelque part (dans le modèle User, probablement.) Puis, à chaque requête d’un utilisateur identifié dans le système, le filtre ‘before_filter’ du contrôlleur Application affectera le singleton ‘Time.zone’ comme nous l’avons vu avant.

Et voilà pour le supporte des heures. Ca ouvre un tas de possibilités pour de futures améliorations, mais pour l’instant, il est bon de savoir que ça nous évite l’installation d’un gem, de deux plugins différents et que l’alternative incomplète à la classe Time a été remplacée par quelque chose de plus autonome et de plus cohérent. Pour en savoir plus, allez regarder l’épisode 106 de RailsCasts, Ryan Daigle et Geoff Buesing.

Les migration sont encore plus “Sexy”

Ce n’est pas leur nom officiel, mais les Migrations ont finallement été l’objet d’un peu d’attention et d’amour. A ce jour, avec de plus en plus de gros projets en cours de réalisation, les Migrations originelles devaient évoluer. J’ai été confronté à ce problème il y a quelque temps et la solution la plus raisonnable que j’ai trouvé était celle de Revolution Health’s Enhanced Migration. Il existe d’autres solutions pour tenter de résoudre ce puzzle mais c’est encore la moins alambiquée.

“Mais, quel est le problème ?” allez-vous me demander. Soit, laissez-moi vous présenter un scénario hypothétique.

  • Le développeur A démarre un nouveau projet Rails et crée plusieurs migrations. Comme vous le savez, elles vont être identifiée séquentiellement 1, 2, etc. Il crée 4 migrations et il commit son code dans le dépôt (Subversion ou celui que vous avez.)
  • Le développeur B récupère le de code et entame sa collaboration sur le projet. Il a besoin de deux nouveaux modèles alors il utilise le ‘script/generate migration’ qui va créer les migrations 5 et 6.
  • Au même moment, le développeur A qui continue de code a besoin de 3 autres modèles. Souvenez vous qu’à ce moment, sa machine ne connait que les migrations 1 à 4, puisque le développeur B n’a pas encore commité son travail, donc le développeur A va générer les migrations 5, 6 et 7.
  • Le développeur B a maintenant fini son travail et commite son code dans le dépôt.
  • Le développeur A met à jour sa copie locale depuis le dépôt et reçoit les migrations 5 et 6 du développeur B. Maintenant le développeur A a deux migrations numérotées ‘5’ et deux autres numérotées ‘6’. Quand il lance ‘rake db:migrate’, les changements du développeur B ne seront jamais exécutés parce qu’il en est déjà à sa migration 7.

    Vous pouvez trouver plusieurs variantes de ce scénario dont la ligne de fond est toujours la même :

  • Plusieurs personnes travaillent sur le même projet
  • De part sa nature collaborative, ce travail va nécessairement induire des changements et des migrations qui vont se chevaucher et qui vont, soit entrer en conflit, soit ne jamais être pris en compte
  • Très vite, plusieurs sortes de problèmes désagréables et non-triviaux vont survenir et votre productivité va se casser la gueule à toute vitesse.

    Qu’ont fait les gars de Revolution Health ? Au lieu de numéroter les migrations avec des nombres entiers, ils les ont identifiées par un horodatage numérique, par exemple, un fichier d’une migration “améliorée” serait nommé : “1203964042_Add_foo_column.rb”.

    Ca permet d’éviter le problème des conflits de numérotation, mais n’apporte pas de réponse au scénario suivant :

  • Le développeur A crée 2 migrations à 10h, les exécute et commite tout de suite après
  • Le développeur B met à jour depuis le dépôt, crée deux nouvelles migrations à 11h, les exécute et commite juste après
  • Le développeur A crée 2 migrations à midi, les exécute et ne se souvient qu’après qu’il doit mettre à jour sa copie locale depuis le dépôt avant de commiter son travail.

    Vous voyez le problème ? Parce que les deux dernières migrations que le développeur A a créés sont plus récentes que celles du développeur B, même après s’être synchronisé avec le dépôt et avoir lancé ‘rake db:migrate’, rien ne se passera, car les migrations du développeur B leur sont antérieures et que le plugin de Revolution Health’s n’enregistre l’heure que du dernier fichier de migration qui a été exécuté.

    Même si vous arrivez à éviter les conflits, il arrivera toujours plein de situations dans lesquelles vous perdrez des migrations et vous aurez des difficultés à trouver d’où viennent les problèmes, comme quand il vous manquera une colonne parce que sa migration n’aura pas été exécutée.

    Rails 2.1 trace l’historique complet de toutes les migrations (l’ancienne table schema_info a été remplacée par une table schema_migrations). Dans le même scénario que ci-dessus, il aurait détecté que les migrations du développeur B n’ont jamais été exécutées et il aurait essayé de les exécuter, bien qu’elles soient dépassées. Ca fonctionne presque tout le temps parce que les changements réalisés par chaque développeur sont assez peu dépendants de ceux réalisés par les autres (à moins que la dernière migration du développeur A ne supprime une table dont une migration du développeur B a besoin pour fonctionner. Mais c’est assez rare.)

    Détour : Finallement, ca pourrait être le bon moment pour ajouter Restful Authentication à notre projet. Pour celà, nous allons utiliser une autre nouvelle fonctionnalité de Rails 2.1 : l’installation d’un plugin depuis un dépôt Git, comme ça :

    $ ./script/plugin install git://github.com/technoweenie/restful-authentication.git

    Encore une fois, cet exemple suppose que vous avez installé Git correctement. Si ce n’est pas le cas, vous pouvez télécharger la tarball et la décompresser dans vendor/plugins/restful-authentication.

    Nous allons le configurer en utilisant les paramètres par défaut. Pour plus d’informations sur Restful Authentication allez sur la page officielle de leur projet sur leur dépôt GitHub. Lançons le générateur :

    $ mkdir lib
    $ ./script/generate authenticated user sessions

    Enfin, pour la clarté de l’explication, ajoutons les routes recommandées dans config/routes.rb :

    map.signup '/signup', :controller => 'users', :action => 'new'
    map.login  '/login',  :controller => 'sessions', :action => 'new'
    map.logout '/logout', :controller => 'sessions', :action => 'destroy'

    Et lançons la migration :

    $ rake db:migrate
     
    == 20080525080231 CreateUsers: migrating ======================================
    -- create_table("users", {:force=>true})
       -> 0.0565s
    -- add_index(:users, :login, {:unique=>true})
       -> 0.0323s
    == 20080525080231 CreateUsers: migrated (0.0893s) =============================

    Continuons avec un autre exemple pour tester un peu le concept. Créons d’abord une nouvelle migration :

    $ ./script/generate migration AddTimeZoneToUser
     
    exists  db/migrate
    create  db/migrate/20080525080653_add_time_zone_to_user.rb

    Notez l’horodatage “20080525080653”. En comparaison avec l’ancien plugin Enhanced Migration, cet horodatage est encodé un peu différemment. Je n’en ai pas recherché la raison, mais dans Rails 2.1 il s’agit simplement d’un DateTime formatté YYYYMMDDHHMMSS et converti en UTC, pour éviter les conflits si d’autres développeurs travaillent offshore à d’autres endroits sur la planète (ce qui est mon cas, au passage.)

    Modifions la, comme ça :

    class AddTimeZoneToUser < ActiveRecord::Migration
      def self.up
        change_table :users do |t|
          t.string :time_zone
          t.belongs_to :role
        end
      end
     
      def self.down
        change_table :users do |t|
          t.remove :time_zone
          t.remove_belongs_to :role
        end    
      end
    end

    Une nouvelle fonctionnalité bien pratique de Rails 2.1 est change_table. Elle fonctionne presque comme la méthode create_table qui accepte un bloc dans lequel nous définissons de nouvelles colonnes. Mais cette nouvelle méthode autorise d’autres opérations comme rename, remove, etc.

    En voici la liste complète :

  • t.column – l’ancien style, celui des migrations pas “sexy”
  • t.remove – supprime une colonne
  • t.index
  • t.remove_index
  • t.timestamps – ajoute les champs created_at et updated_at
  • t.remove_timestamps – enlève les champs created_at et updated_at
  • t.change – change le type d’une colonne
  • t.change_default – change la valeur par défaut d’une colonne
  • t.rename – renomme une colonne
  • t.references – ajoute une colonne ‘clé étrangère’ en utilisant la convention [table_name]_id
  • t.remove_references – supprime une ‘clé étrangère’
  • t.belongs_to – alias pour :references
  • t.remove_belongs_to – alias pour :remove_references
  • t.string
  • t.text
  • t.integer
  • t.float
  • t.decimal
  • t.datetime
  • t.timestamp
  • t.time
  • t.date
  • t.binary
  • t.boolean

    Exécuter la migration précédente devrait donner quelque chose comme :

    $ rake db:migrate
     
    == 20080525080653 AddTimeZoneToUser: migrating ================================
    -- change_table(:users)
       -> 0.1435s
    == 20080525080653 AddTimeZoneToUser: migrated (0.1437s) =======================

    Une autre nouvelle fonctionnalité de Rails 2.1 : si votre fichier config/database.yml est configuré convenablement (c’est toujours le même que celui qu’on a toujours fait depuis que Rails 1.0 est sorti) alors nous pouvons exécuter le nouveau script/dbconsole. Il ouvrira la console de la base de données configurée (si elle en a une) en vous évitant de devoir fournir ces informations redondantes comme les noms d’utilisateur et les mots de passe. Dans cet exemple (depuis le tutoriel pour Rails 2.0) nous avons utilisé MySQL alors il essaiera de lancer l’invite de commande ‘mysql’ depuis laquelle nous pourrons faire ceci :

    mysql> select * from schema_migrations;
    +----------------+
    | version        |
    +----------------+
    | 1              | 
    | 2              | 
    | 20080525080231 | 
    | 20080525080653 | 
    +----------------+
    4 rows in set (0.00 sec)

    Comme nous pouvons le voir, ce projet date de Rails 2.0 et les deux premières migrations utilisent l’ancienne numérotation entière et les deux dernières l’horodatage. La première a été créée à 08:02:31 et la seconde à 08:06:53. Amusons nous à créer une migration exactement entre ces deux là. Tapez ‘quit’ pour quitter la console MySQL si nécessaire et tapez cette commande :

    $ ./script/generate migration AddRoleTable
     
    exists  db/migrate
    create  db/migrate/20080525080830_add_role_table.rb

    Normalement, tous les noms de fichiers listés ici sont différent des votres parce que vous aurez l’horodatage exact du moment où vous aurez utilisé ‘script/generate’. Attendez vous à avoir des noms de fichiers différents. Celà dit, pour que cet exemple fonctionne, nous devons changer l’horodatage de cette dernière migration pour qu’il soit juste avant celui de la migration AddTimeZoneUser. En utilisant les horodatages des exemples ci-dessus, renommez le fichier, comme ça (faites attention de prendre en compte les horodatages de VOS fichiers, ne copiez collez pas ce qui est ici !) :

    $ mv db/migrate/20080525080830_add_role_table.rb db/migrate/20080525080600_add_role_table.rb

    Avez-vous compris ce que nous venons de faire ? Nous avons artificiellement reculé la date de la migration du fichier dans le temps de quelques secondes, en la passant de 08h08 et 30s à 08h06. Modifions le fichier :

    class AddRoleTable < ActiveRecord::Migration
      def self.up
        create_table :roles, :force => true do |t|
          t.string :name
          t.belongs_to :user
          t.timestamps
        end
      end
     
      def self.down
        drop_table :roles
      end
    end

    Et laçons ‘rake db:migrate’ une nouvelle fois :

    == 20080525080600 AddRoleTable: migrating =====================================
    -- create_table(:roles, {:force=>true})
       -> 0.0412s
    == 20080525080600 AddRoleTable: migrated (0.0413s) ============================

    C’est assez surprenant, ça marche ! Jetons un oeil à la table schema_migrations en utilisant de nouveau script/dbconsole :

    mysql> select * from schema_migrations;
    +----------------+
    | version        |
    +----------------+
    | 1              | 
    | 2              | 
    | 20080525080231 | 
    | 20080525080600 | 
    | 20080525080653 | 
    +----------------+
    5 rows in set (0.00 sec)

    Notez que le 08h06 que nous venons de créer s’est calé entre les deux autres. C’est exactement la situation qui se produira lorsqu’une migration d’un second développeur sera récupérée depuis le dépôt et qu’elle sera plus ancienne que la vôtre la plus récente. Rails 2.1 comprendra qu’il en a sauté une et qu’elle doit être exécutée, alors il essaiera. Ce sera un gain de temps pour beaucoup d’équipes et une cause de stress et de maintenance en moins parce que ‘ça marche’.

    Bien entendu ce n’est pas une solution absolument parfaite. Plusieurs choses peuvent encore se produire mais vous devriez probablement déjà les avoir sous contrôle :

  • 2 développeurs pourraient, en théorie, créer deux migrations à exactement la même seconde de la même journée ! C’est pratiquement impossible mais la probabilité n’est pas nulle. D’un autre côté, les chances sont tellement faibles que vous pouvez probablement ignorer ce cas à moins que vous ne soyez ultra-paranoïaque.
  • Les migrations devraient être indépendantes. On doit être très prudent pour ne pas marcher sur les pieds les uns des autres. L’exemple le plus évident est la suppression d’une table. Rails fait le choix de ne pas spéculer et n’être pas trop “intelligent”. Vous devrez intervenir si une telle situation devait se produire. Mais encore une fois, le risque que ça arrive est faible.

    Selon moi, c’est la deuxième amélioration la plus importante (la première étant le support des heures et des fuseaux horaires.) Voilà tout pour les migrations “encore plus sexy”. Vous pouvez aller regarder ce RailsCasts et Ryan Daigle.

    A venir, la deuxième partie de ce tutorial sur Rails 2.1 !

    Be Sociable, Share!

Tags: , ,

  1. Troops’s avatar

    Han écrire tous ses trucs durant tes heures de travail :o) si j’étais ta direction je te flagelerai les fesses avant de te supprimer (Bah oui supprimer physiquement pas avec la touche DEL ! ralala j’te jure c geek !) :o)

  2. Pierre’s avatar

    Heureusement que tu n’es pas mon patron… pfiou 😉

  3. Toto’s avatar

    J’ai une base de données mysql. je viens de démarrer un projet rails. j’ai seulement généré un ‘model’ et je me suis aperçu que je me suis trompé en nommant une colonne. Certes je pourrais tout recommencer mais j’ai voulu la renommer comme vous l’indiquez, avec change_table puis en faisant un t.rename :table, :ancien_nom, :nouveau_nom.

    db:migrate ne me donne aucune sortie et la modification n’est pas prise en compte.

    Il s’agit bien de rails2.1, alors quoi faire?

  4. Pierre’s avatar

    Sans compter les erreurs de syntaxe probables, avez-vous généré une nouvelle migration pour y mettre votre code de changement du nom de la table ?
    Exemple :
    $ script/generate migration ChangeColumnName
    Editer le fichier généré et ajouter dans self.up : rename_column “table”, “ancien_nom”, “nouveau_nom” et dans self.down rename_column “table”, “ancien_nom”, “nouveau_nom”
    Puis lancer $ rake db:migrate

    Pour votre information rails 2.2.2 est sorti il y a un petit moment et rails 2.3 devrait arriver fin janvier.

  5. Toto’s avatar

    Un grand merci pour votre réponse!
    En effet cela fait 48H maintenant que j’explore toute l’API Rails pour comprendre pourquoi cela ne marchait pas. Il fallait en effet un nouveau fichier de migration, je n’avais trouvé cette info nul part.

    Merci pour tous vos renseignement!

  6. Pierre’s avatar

    Le principe des migrations c’est qu’on fait un changement “utile” par fichier et qu’une fois que la migration a été appliquée on ne revient plus dessus. Ca c’est pour la théorie et c’est surtout valable lorsque l’application est en ligne et qu’on veut gérer proprement les mises à jour. En cours de développement, il est toujours possible :

    • d’annuler une migration avec $ rake db:migrate VERSION=[le numéro de la migration à laquelle on souhaite revenir], on peut alors modifier le fichier migration (pour corriger la boulette avec le nom de la colonne qui n’est pas le bon) et relancer la migration avec $ rake db:migrate
    • de modifier directement le fichier de la dernière migration pour corriger la boulette et de lancer $ rake db:migrate:redo (attention aux effets de bord car self.down sera appelé !)
    • d’annuler toutes les migrations pour revenir à la base d’origine avec $ rake db:migrate:reset, de corriger ou de fusionner à la main l’ensemble des migrations, de faire un peu de refactoring et quelques améliorations avant de relancer $ rake db:migrate

    Pour avoir la liste des tâches disponibles avec rake : $ rake—tasks

    Ces conseils seront difficilement applicables si l’application a déjà été déployée et qu’on souhaite publier une nouvelle version en mettant à jour l’existant et si l’on est plusieurs à travailler sur le même code et à toucher aux modèles et à la base de données.

Reply

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *