Html5 Backbone.js et localStorage sont dans un bateau

Backbone.js

Toujours dans le cadre de mon travail chez Novelys, nous avons utilisé pour le projet d’application web pour iPad pouvant fonctionner en mode déconnecté.

c’est quoi ?

Backbone.js

Backbone supplies structure to JavaScript-heavy applications by providing models with key-value binding and custom events, collections with a rich API of enumerable functions, views with declarative event handling, and connects it all to your existing application over a RESTful JSON interface.

Backbone is an open-source component of DocumentCloud.

Directement copié de la homepage du projet disponible ici  : http://documentcloud.github.com/backbone/

Pourquoi ce choix ?

  1. concis
  2. souple, surtout pour la partie Vue.
  3. structurant
  4. exploite underscore.js
  5. s’intègre avec jQuery
  6. pattern MVC bien connu
  7. une documentation claire
  8. le logo est chouette (hum…)

La couche model selon Backbone.js

La couche modèle de Backbone est constituée de 2 composants :

  • le model
  • la collection

Bonjour Model

Comme d’habitude le Model va intégrer toutes les logiques métiers propre à votre donnée : validation, calcul et autre truc…

Voici un exemple :

var Craft = Backbone.Model.extend({
    has_children: function(){
        return _.isEmpty(this.attributes.child_ids) == false
    },

    children : function() {
        var ary
        if(this.has_children) {
            ary = _.map(this.attributes.child_ids, function(id){
                return crafts.get(id);
            });
            ary = _.uniq(ary);
        } else {
            ary = [];
        }
        return new Crafts(ary);
    },

    users : function() {
        var ary = [];
        var w;
        if(this.attributes.wastes_ids) {
            _.each(this.attributes.users_ids, function(user_id){
                w = user.get(waste_id);
                ary.push(w);
            });
        }
        return new Users(ary);
    }
});

La class Craft représente des métiers qui sont organisés sous la forme d’arbre permettant ainsi d’avoir des sous métiers puis des sous sous métiers puis des … etc… etc… et jusqu’à l’infini et au delà 🙂

La méthode children retourne donc la collection des sous métiers et users la collection des utilisateurs étant affecté à ce métier.

Voici quelques exemples d’utilisation :

//init
var craft = new Craft({ title: "Métier du bois" }); 

//recupère l'attribut title
craft.get('title'); 

//tous les attributes
craft.attibutes

//est enregistré ou pas ?
craft.isNew();

//sauvegarde
craft.save();

Sauf que dans l’état du code, le dernier exemple plante. Parce que le Model a besoin d’une collection. Nous allons voir celà toute de suite 🙂

Bonjour Collection

La collection représente donc une … liste ou collection d’un model données, mais pas seulement. C’est cette dernière qui va faire la liaison avec le support de stockage.

Voici un exemple

var Crafts = Backbone.Collection.extend({
    model: Craft,
    url: 'crafts',

    roots : function() {
        var ary = _.select(this.models, function(m){
            return _.isEmpty(m.attributes.parent_ids);
        });

        return new Crafts(ary);
    }
});
var crafts = new Crafts();

De base la collection doit définir deux attributs :

  • l’url de base de la ressource, nous approfondirons celà après.
  • le model forcément 🙂

La méthode roots permet, toujours dans nos histoires d’arbre des métiers, de récupérer les métiers racines.  C’est un peu le principe des scope avec Mongoid.

La synchronisation des données selon Backbone.js

Par défaut, le stockage des données se fait au travers d’une API Rest JSON exposée par un serveur.

Voici le code original

// Map from CRUD to HTTP for our default `Backbone.sync` implementation.
  var methodMap = {
    'create': 'POST',
    'update': 'PUT',
    'delete': 'DELETE',
    'read'  : 'GET'
  };

  // Backbone.sync
  // -------------

  // Override this function to change the manner in which Backbone persists
  // models to the server. You will be passed the type of request, and the
  // model in question. By default, uses jQuery to make a RESTful Ajax request
  // to the model's `url()`. Some possible customizations could be:
  //
  // * Use `setTimeout` to batch rapid-fire updates into a single request.
  // * Send up the models as XML instead of JSON.
  // * Persist models via WebSockets instead of Ajax.
  //
  Backbone.sync = function(method, model, success, error) {
    $.ajax({
      url       : getUrl(model),
      type      : methodMap[method],
      data      : {model : JSON.stringify(model)},
      dataType  : 'json',
      success   : success,
      error     : error
    });
  };

Si on couple celà avec une application Rails et les routes classiques et quelques respond_to format JSON l’affaire est dans le sac. Amoureux de Django c’est tout aussi facile 😉

Super ! Sauf que…

Y’a comme une couille dans la Matrix

Mes besoins

Nous avons besoin de faire la persistance dans le localStorage et non pas sur le serveur, ceci afin d’avoir une application dans le navigateur complètement fonctionnelle sans connexion.

Sauf que comme on le lit dans le code (et la documentation), on peut surcharger comme on le veut Backbone.sync.

On peut donc très aisément connecter le backend qu’on veut !

localStorage à la sauce Rest en 4 actions

Notre implémentation

function getIndexName(collection_name) {
    return "index_"+collection_name;
}

function readIndex(collection_name) {
    var index = localStorage[getIndexName(collection_name)];

    if(index == undefined || index === null) {
        index = [];
    } else {
        index = JSON.parse( index );
    }

    return index;
}

function writeIndex(collection_name, index) {
     localStorage[getIndexName(collection_name)] = JSON.stringify(index);
     return index;
}

function addToIndex(collection_name, model_id) {
    var index = readIndex(collection_name);

    if(_.include(index, model_id) === false) {
        index.push(model_id);
    }

    return writeIndex(collection_name, index);
}

function removeFromIndex(collection_name, model_id) {
    var index = readIndex(collection_name);
    var odds = _.reject(index, function(item){ return item == model_id; });
    return writeIndex(collection_name, odds);
}

Backbone.sync = function(method, model, success, error) {
    /**
     * model.url use the id and the url attributes of the collection
      */

    if(method == 'create') {
        model.id = Math.uuid(16);
        localStorage.setItem(model.url(), JSON.stringify(model));
        addToIndex(model.collection.url, model.id);
        success({model:model});
    }

    if(method == 'update') {
        localStorage.removeItem(model.url(), JSON.stringify(model));//Workaround of Error QUOTA_EXCEEDED_ERR DOM Exception 22
        localStorage.setItem(model.url(), JSON.stringify(model));
        addToIndex(model.collection.url, model.id);
        success({model:model});
    }

    if(method == 'read') {
        var json;
        if(_.isFunction(model.url)) {
            json = localStorage[model.url()];
            if(json == undefined || json === null) {
                error({model:null});
            } else {
                success({model:JSON.parse(json)});
            }
        } else {
            var index = readIndex(model.url);
            var models = [];
            var key = '';
            var obj = {}
            json = null;
            _.each(index, function(model_id) {
                key = model.url+'/'+model_id;
                json = localStorage[key];
                if(json != undefined && json !== null) {
                    obj = JSON.parse(json);
                    obj.id = model_id;
                    models.push(obj);
                }
            });

            success({models:models});
        }
    }

    if(method == 'delete') {
        localStorage.removeItem(model.url());
        removeFromIndex(model.collection.url, model.id);
        success();
    }
};

Nous avons fait le choix d’optimiser les accès au localStorage en constituant un index. Nos données ne sont pas très volumineuses mais se présentent souvent sous la forme de liste complète. La lecture de l’index des ressources a donc besoin d’être « optimisée ».

bug iPad iOS 3.2 lors de l’update

Revenons rapidement sur le traitement de la mise à jour (update).

    if(method == 'update') {
        localStorage.removeItem(model.url(), JSON.stringify(model));//Workaround of Error QUOTA_EXCEEDED_ERR DOM Exception 22
        localStorage.setItem(model.url(), JSON.stringify(model));
        addToIndex(model.collection.url, model.id);
        success({model:model});
    }

Il faut impérativement supprimer l’élément avant de vouloir l’écraser… Aucune explication pour l’instant des gens de la pomme. Sans celà au délà de la centaine de clés enregistrées dans le localStorage on se mange une vilaine exception. Sympathique…

Et si je veux pas créer le model ?

L’API des collections de Backbone.js ne fournit pas une méthode build ce qui peut être génant. Car comme nous l’avons vu, il faut fournir une collection au Model.

En réalité pour sauvegarder nous avons besoin d’une url pour générer l’endroit où sera fait « l’écriture ».

Voici deux moyen de faire :

var craft = new Craft({title:"mon métier"});
craft.collection = new Crafts(); // ou tout autre instance de crafts
craft.save();

var craft2 = new Craft({title:"mon métier"});
craft2.url = 'crafts';

J’ai une préférence pour la première 😉 DRY toussa toussa.

Et les données elles viennent d’où ?

Dans notre cas, nous avons surtout des référentiels à stocker. Nous avons donc simplement mis en place une API Rest qui exposent aux format JSON les données de la base de données du serveur.

Toujours sur l’exemple de l’arbre des métiers :

function crafts_sync() {
  $.ajax({
    url: '/api/crafts.json',
    dataType: 'json',
    success: function(data) {
      crafts.refresh(data);
      crafts.each(function(elem) {
          elem.id = elem.attributes._id;
          elem.save();
      });
    },
    error: function() {
      alert("ouink erreur");
    }
  });
}

Nous hydratons la collection Crafts, ce qui a pour effet de générer autant de models à partir du JSON. Comme nous travaillons avec une application Rails 3, avec mongodb et mongoid, l’attribut id n’existe pas. Nous devons donc pour chaque instance du model Craft forcer l’id. Ainsi s’il existe l’entrée du localStorage est mis à jour et dans l’autre cas elle est rajoutée.

Pour les petits curieux voici le model de notre appli Rails :

class Craft
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Acts::Tree #see mongoid_tree gem

  field :title
  validates_presence_of :title

  def ancestors
    self.class.where(:_id.in => self.parent_ids)
  end
end

Voilà, Backbone rend cette partie du travail facile et vraiment plaisante à exploiter. Amusez vous bien !

Le prochaine épisode sera la présentation de la partie vue de Backbone, toujours dans notre cadre d’application iPad.

À très vite.

Publicités

Html5 offline + Rails : épisode 1 Monsieur Manifest

Dans le cadre de mon boulot diurne, je développe en ce moment une application web pour iPad. Cette dernière doit pouvoir continuer à fonctionner sans connexions, c’est à dire passer en mode déconnecté.

Notre choix s’est porté sur les technologies offerte par l’HTML5, pour une raison évidente : Steve Jobs aime l’HTML5. Donc les utilisateurs de la tablette à la pomme aime l’HTML5. Ne cherchez pas Steve a raison.

Je ne vais pas vous donner un cours, des liens vers des spécs, mais plus vous donner du code que j’ai mis en place pour démarrer le projet. C’est loin d’être parfait et tant qu’à faire toute amélioration (avec du code, le blabla j’m’en scotche) est vivement encouragée.

Sinon je vous recommande http://www.html5rocks.org, c’est une bonne ressource pour débuter avec les notions d’appli web offline.

Bref, pour que notre application fonctionne, nous devons stocker en local, l’ensemble des fichiers dont elle aura besoin pour avoir une tronche acceptable.

Ce qu’il faut savoir :

  • dès que le manifest change d’un octet il est considéré comme modifié et donc l’API d’applicationCache permettra de lancer la mise à jour
  • le manifest doit être servit au format manifest et surtout pas du text
  • utiliser un FALLBACK permet d’éviter à l’API JS de péter lors de la récupérations des fichiers. En cas réels avec un ensemble d’assets important, il est dommage de bloquer la synchronisation pour un fichier.
  • le manifest permet de définir des zones où la connexion est obligatoire, celà évite de se faire chier à gérer le cas de l’authentification partout
  • le chemin vers le manifest de la page doit être ajouté dans la balise html

Chez Novelys, nous utilisons Ruby on rails pour la partie serveur de notre application, nous avons rajouté le mime type manifest, afin d’avoir une API Rest qui sache servir ce nouveau format.

Le code suivant dans un initializer fait bien le boulot :

Mime::Type.register_alias "text/cache-manifest", :manifest

Bien, à ce stade, il faut générer notre Manifest.

Dans une applications Rails, nous allons devoir coller dans l’applicationCache : les feuilles de styles, les lib JS, les assets et suivant les cas les fichiers uploadés.

Si un fichier est modifié, il faudra impérativement que le manifest soit modifié, pour que ce fichier soit par la suite téléchargé en local.

Nous avons donc opté pour intégrer dans un commentaire une date formatée, celle de la dernière modification opérée sur les fichiers du manifest. Ainsi on est sûr que le manifest soit bien considéré comme mis à jour et que les fichiers soit téléchargé par les iPads. (nous sommes riches).

Voici le helper qui aidera à accomplir la partie chiante :

def manifest_files(options = {})
    timestamp_format = "%Y%m%d%H%M%S"

    #Assets
    assets = options[:assets] || []
    max_time = options[:max_time] || DateTime.now

    # Interface
    stylesheets = Dir["public/stylesheets/**/*.*"].map{|entry| entry.gsub(/public/, "") }
    javascripts = Dir["public/javascripts/**/*.*"].map{|entry| entry.gsub(/public/, "") }
    images      = Dir["public/images/**/*.*"].map{|entry| entry.gsub(/public/, "") }
    interface = stylesheets + javascripts + images

    if interface.present?
      max_mtime = interface.map{|file| File.mtime("public"+file)}.max
      max_time = [max_time, max_mtime].max
    end

    max_mtime = max_time.strftime(timestamp_format)

    manifest_files = (assets + interface).flatten.uniq
    return manifest_files, max_mtime
  end

Le helper exploite deux options :

  1. max_time : un timestamp arbitraire qui sera tout de même comparés au plus récent des fichiers
  2. une liste des chemins des chiers à rajouter dans le manifest (typiquement les fichiers que vous gérer avec Paperclip dans vos models).

Et on récupère :

  1. la liste des fichiers
  2. la date formatée avec le timestamp le plus récent, c’est à dire la version de note Manifest

Une actions :

def mister_manifest
    @manifest_files, @max_mtime = manifest_files()

    respond_to do |format|
      format.manifest
    end
  end

Une vue ERB:

CACHE MANIFEST
# version <%= @max_mtime %>
/
<%= @manifest_files.join("\n") %>
FALLBACK:/ /offline.html

Le manifest est maintenant généré, reste maintenant à l’intégrer dans l’application côté client.

Primo rajoutons le chemin vers le manifest, voici un exemple dans notre layout général :

!!!
%html{:manifest => application_offline_manifest_path(:format => :manifest) }
  %head
    %title MyApp
    != stylesheet_link_tag %w(sensassional)
    != javascript_include_tag %w(jquery sensassional application_cache)
    = csrf_meta_tag
  %body
    = yield

Là tout doux, maintenant il faut taper dans les API Javascripts pour synchroniser le cache en locale. Voici le code que j’ai rapidement mise en oeuvre. Rassurez vous c’est une solution temporaire (permanente) :

var req=0;
var manifest_size = 0;

var cacheStatusValues = [];
cacheStatusValues[0] = 'uncached';
cacheStatusValues[1] = 'idle';
cacheStatusValues[2] = 'checking';
cacheStatusValues[3] = 'downloading';
cacheStatusValues[4] = 'updateready';
cacheStatusValues[5] = 'obsolete';

var cache = window.applicationCache;

console.log(window);

function logEvent(e) {
  var online, status, type, message;
  req ++;
  online = (navigator.onLine) ? 'yes' : 'no';
  status = cacheStatusValues[cache.status];
  type = e.type;
  message = '' + type;
  message+= '/' + status;
  message+= '/' + req  + '/' + manifest_size ;

  if (type == 'error' && navigator.onLine) {
      message+= ' (probably a syntax error in manifest)';
      console.log(message);
      console.dir(e);
  }

}

cache.addEventListener('loadstart', logEvent, false);
cache.addEventListener('stalled', logEvent, false);
cache.addEventListener('load', logEvent, false);
cache.addEventListener('loadend', logEvent, false);
cache.addEventListener('cached', logEvent, false);
cache.addEventListener('checking', logEvent, false);
cache.addEventListener('downloading', logEvent, false);
cache.addEventListener('error', logEvent, false);
cache.addEventListener('noupdate', logEvent, false);
cache.addEventListener('obsolete', logEvent, false);
cache.addEventListener('progress', logEvent, false);
cache.addEventListener('updateready', logEvent, false);

Nous devrons sans doute encore l’enrichir, mais ça sera pour un prochain billet 🙂

Maintenant débranchez votre RJ45 ou votre connexion Wifi et magie !

Vous allez maintenant pouvoir frimer avec votre iPad sans connexion 3G.

Le prochain épisode : Backbone.js et localStorage sont dans un bateau.

Devise et l’authentification HTTP

Par défaut Devise (la gem) utilise les informations d’authentification HTTP fournies par votre daemon préféré (ngix, apache…).

Seulement quand vous souhaitez mettre en place un environnement privé, par hasard un staging… Il devient impossible de vous connecter avec un compte géré par votre application Rails.

Heureusement, tout ceci est configurable, voici comment :

--- a/config/initializers/devise.rb
+++ b/config/initializers/devise.rb
@@ -26,7 +26,7 @@ Devise.setup do |config|
# config.params_authenticatable = tru
# Tell if authentication through HTTP Basic Auth is enabled. True by default.
- # config.http_authenticatable = true
+ config.http_authenticatable = false
# Set this to true to use Basic Auth for AJAX requests. True by default.
# config.http_authenticatable_on_xhr = true

Comme promis, Devise c’est vraiment pour les développeurs feignasses 🙂

101010

Jour geek 10/10/10 🙂 Et n’oubliez pas 42 est la réponse.

Vivement le 11/11/11 😉

Starcraft 2


Petite dédicace aux joueurs du #cpplex

Huh ?

Source:  http://www.flickr.com/photos/starfeeder/

[tip] vim replace

Pour faire une modification sur la ligne courante :
:s/texte_à_trouver/nouveau_texte/g .

Pour faire une modification sur tout le doc :
:%s/texte_à_trouver/nouveau_texte/g


Installer rapidement rtm sur un dédié OVH

RTM ou Real Time Monitoring est un truc de chez OVH pour avoir un monitoring du pauvre sur votre serveur dédié. Il permet ainsi d’avoir des alertes mail en cas de problème de capacité disque, d’un service (genre le 80) dans les choux et d’avoir une vision globale (un peu trop) sur les ressources consommées.

Je l’ai complété pour ma part avec du munin pour les parties métiers de chaque serveur. Mais c’est une autre histoire.

L’installation et la mise à jour de RTM, je le gérais jusqu’à présent via le système de package (pour ma part du portage). Mais vu que les mecs ce chez ovh ne mettent pas à jour leur overlay gentoo… J’avais comme un goût de Marty Mac Fly.

Je me suis décidé à mettre le RTM à jour directement via leur script d’install, FEAR !

wget ftp://ftp.ovh.net/made-in-ovh/rtm/install_rtm.sh -O install_rtm.sh

sh install_rtm.sh

Bon  ben ça tourne… Reste à voir la prochaine mise à jour 🙂