Illustration de l'article

Et si vous regardiez Git comme une base de donnée ? – Version Ruby

Écrit par Yves

La première partie consacrée à Git vous a montré brièvement le stockage interne et surtout comment écrire une donnée, la lier à une clé et récupérer le contenu derrière cette clé. Pour cette deuxième partie, nous allons réaliser la même chose mais en utilisant un vrai langage de programmation, Ruby.

YES

Contexte

Le but étant d’étudier git et de l’utiliser autrement qu’à l’habitude. Par contre, histoire de ne pas travailler dans le vent, il fallait bien développer un petit quelque chose. Nous sommes donc partis sur un grand classique des tutos web : la todo list !

La base était donc une simple application web, basée sur Sinatra, avec quelques petites actions basiques :

  • lire l’ensemble des todos enregistrés
  • ajouter un nouveau todo
  • marquer un todo comme fait
  • supprimer un todo

Le code de base provient d’un petit tuto sinatra disponible ici. Assez peu de différences à part le choix de la techno pour le rendu de l’html par l’utilisation de haml à la place de erb.

Je ne vais pas plus rentrer dans le sujet, l’application est vraiment simple et ce n’est pas l’objectif de cet article.

Et si on sortait un peu de notre coquillage ?

Tout ce qu’on a vu pour le moment c’est comment utiliser des couches basses de git depuis notre shell. C’est cool mais si le but était de ne pas trop se prendre la tête avec les fichiers c’est pas encore gagné.

Heureusement, on peut faire la même chose directement depuis du code source, sans utiliser git à la main et s’amuser à parser les commandes, leur retour, etc. Un peu plus fiable tout de même…

Pour ce faire, le point d’entrée est libgit2. Il s’agit d’une implémentation en c du cœur de git. C’est totalement portable, ça ne dépend de rien d’autre (donc ça ne dépend pas de git surtout). C’est utilisé en prod par pas mal de monde aussi.

Et surtout : il y a des bindings pour de très nombreux langages ! Et oui, on va pas coder en c, faut pas déconner quand même.

En particulier, il existe rugged, un binding de libgit2 en ruby. Et c’est celui que nous allons utiliser.

Voici donc la traduction de toutes ces commandes en ruby afin de les inclures dans notre superbe, notre magnifique, notre exceptionnelle application sinatra de TodoList !

Et voici la version raboteuse !

Pour l’installation, comme vous utilisez gem et bundler (what else?) c’est super simple. Ajoutez

gem 'rugged'

dans votre Gemfile et installez le via bundle install.

Après un petit require 'rugged' vous pouvez enfin utiliser rugged.

Tout d’abord, il vous faut accéder à votre répository git.

Pour le créer :

repo = Rugged::Repository.init_at('my_repo.git', :bare)

ou pour y accéder s’il existe déjà :

repo = Rugged::Repository.new('my_repo.git')

Oui, on utilise un dépôt bare, pas besoin d’avoir une copie de travail.

La première vrai étape consiste donc à donner à git des données pour qu’il en crée un blob. Rien de plus simple :

blob_oid = repo.write '{"hello":"world!"}'

Une solution sympa plutôt que de gérer le hello world! en chaîne de caractères est, par exemple, de passer par yaml :

require 'YAML'
 
hello = {"hello": "world!"}
 
blob_oid = repo.write hello.to_yaml

L’avantage est que c’est dispo en standard en ruby, que la sortie est claire et lisible, qu’on peut facilement mapper des struct ruby et que c’est un format qui semble plutôt aisé à fusionner si besoin.

La deuxième étape consiste à créer le tree correspondant. Pour ce faire nous allons avoir besoin d’un index qui va pouvoir faire le lien entre le blob et le chemin (la clé) que nous souhaitons.

index = Rugged::Index.new
index.add(:path => '1.json', :oid => blob_oid, :mode => 0100644)
tree_oid = index.write_tree(repo)

Enfin, il faut créer un commit et mettre à jour la référence (master) avec ce tree. Pour ce faire nous avons besoin de quelques informations supplémentaires par rapport à la version shell. En fait c’est pas que ce soit nouvelles informations, juste que git est allé cherché comme un grand ce dont il avait besoin dans sa configuration.

options = {}
options[:tree] = tree_oid # l'arbre que nous souhaitons committer
options[:author] = {:email => '…@…', :name => 'sogilis', :time => Time.now}
options[:committer] = {:email => '…@…', :name => 'sogilis', :time => Time.now}
options[:message] = 'add 1'
# le parent de notre commit est vide si c'est le premier, sinon c'est head
options[:parents] = repo.empty? ? [] : [repo.head.target]
# on demande l'update de la ref ici, pas besoin d'une nouvelle commande
options[:update_ref] = 'HEAD'
Rugged::Commit.create(repo, options)

Et voilà ! La même chose, en ruby !

Un dex

Bon, ce que vous ne voyez pas ici c’est que la gestion de l’index est par contre assez lourde. En effet, si vous désirez faire un deuxième commit qui rajoute une entrée sur un autre chemin… il vous faudra préalablement rajouter le premier à l’index. En clair il faut que l’index contienne tous les fichiers correspondant à la copie de travail courante. Si une entrée n’existe pas, elle sera alors simplement supprimée.

Voici une version naïve permettant de s’en affranchir, qui va créer un index en y ajoutant toutes les ressources voulues :

index = Rugged::Index.new
unless repo.empty?
  tree = repo.lookup(repo.head.target).tree
  tree.walk_blobs(:postorder) do |root, entry|
    index.add(:path => "#{root}#{entry[:name]}",
              :oid => entry[:oid], :mode => 0100644)
  end
end

En gros ce qui se passe :

  • récupération d’un nouvel index
  • si le dépôt est vide, on ne fait rien, évidemment
  • on récupère le hash du commit pointé par head (repo.head.target)
  • et on accède à l’arbre (repo.lookup(hash).tree)
  • on parcourt alors l’arbre à la recherche des blobs et on les ajoute à l’index

walk_blobs permet de trouver uniquement les blobs, il est possible de parcourir un arbre pour passer uniquement sur les autres arbres ou alors de tout parcourir sans distinction.

Maintenant que vous avez compris le principe, on peut tout de même faire ça plus simplement :

index = Rugged::Index.new
unless repo.empty?
  tree = repo.lookup(repo.head.target).tree
  index.read_tree(tree)
end

read_tree s’occupe justement pour nous de lire tous les blobs correspondant à un arbre et les ajoute à l’index.

Et si on lisait ?

Petit aparté sur l’index terminé (enfin presque…), il est maintenant intéressant de lire une donnée basée sur sa clé.

Nous allons donc parcourir l’arbre relié à head à la recherche de la clé.

Une première version est d’utiliser ce qu’on vient de faire avec l’index :

def show repo, key
  return nil if repo.empty?
  tree = repo.lookup(repo.head.target).tree
  tree.walk_blobs(:postorder) do |root, entry|
    if "#{root}#{entry[:name]}" == key
      oid = entry[:oid]
      return repo.read(oid).data
    end
  end
  nil
end

Ainsi show(repo, '1.json') retournera '{"hello":"world!"}' (si vous l’avez stocké en json). Et si vous avez fait du YAML, vous pourrez accéder à l’objet en faisant un YAML.load(show(repo, '1.json')).

Une autre solution est d’accéder directement à l’objet sous le tree :

def show repo, key
  return nil if repo.empty?
  tree = repo.lookup(repo.head.target).tree
  oid = tree[key][:oid]
  repo.read(oid).data
end

Un peu plus simple, non ? Sauf que ce n’est pas suffisant. Cela marche très bien dans ce cas, mais la réalité est un poil plus complexe. Ceci ne fonctionne que si vous êtes dans le cas d’une clé posée à la racine. Si vous utilisez des clés “hiérarchiques” (comme des fichiers placés dans un répertoire) alors vous aurez une structure légèrement différente.

Souvenez-vous de la structure présentée dans le premier article :

Structure git hiérarchie

L’arbre pointé par le commit va vous permettre d’accéder au répertoire. Il faudra ensuite recommencer le travail sur l’arbre correspondant au répertoire pour trouver le fichier.

En plus clair, si votre clé est items/1.json cela ressemblera à :

tree = repo.lookup(repo.head.target).tree
items_oid = tree["items"][:oid]
items_tree = repo.lookup(items_oid)
oid = items_tree["1.json"][:oid]
repo.read(oid).data

Et évidemment il faudra faire ça de manière récursive si nécessaire.

Voici donc une solution permettant d’accéder à vos données de manière un peu plus sympa :

def show repo, key
  return nil if repo.empty?
  tree = repo.lookup(repo.head.target).tree
  paths = path.split('/')
  oid = get_oid repo, tree, paths
  repo.read(oid).data
end

def get_oid repo, tree, paths
  key = paths.shift
  return nil if tree[key].nil?
  oid = tree[key][:oid]
  return oid if paths.empty?
  return nil if tree[key][:type] != :tree
  get_oid repo, repo.lookup(oid), paths
end

Evidemment votre show(repo, '1.json') fonctionnera toujours pareil.

Cette méthode est un peu plus explicite que celle utilisant walk_tree mais surtout elle permet de ne pas parcourir potentiellement toutes les branches afin de trouver le contenu de la bonne clé.

Il existe encore une autre solution pour récupérer les données liées à chemin : l’index.

Une fois l’index relu via index.read_tree(tree) vous pouvez accéder à n’importe quel fichier facilement :

def show repo, path
  return nil if repo.empty?
  tree = repo.lookup(repo.head.target).tree
  index = Rugged::Index.new
  index.read_tree(tree)
  oid = index[path][:oid]
  repo.read(oid).data
end

A vous de choisir celle qui vous semble la plus intéressante 🙂

Résultat

On a donc vu comment écrire et lire des données dans git sous forme de couple clé/valeur.

Vous conviendrez que la lecture (surtout) est pas génialissime, un peu lourde. Il manque une petite couche d’abstraction au dessus de rugged pour accéder aux items facilement.

Néanmoins cela fonctionne !

Extra

Si on veut aller plus loin, on peut commencer par naviguer dans l’historique.

Par exemple, au lieu d’utiliser repo.head.target comme révision de base, il suffit de prendre la révision d’un parent de ce commit et ensuite de cherche le/les objets souhaités.

Par exemple, si on souhaite trouver le contenu de 1.json à l’avant dernière révision, on peut le réaliser de la sorte :

# arbre du commit de head
tree = repo.lookup(repo.head.target).tree
# récupération de la révision parente
hash = tree.parents.first
# tree du commit
prev = repo.lookup(hash)
# on récupère alors l'oid
oid = prev['1.json'][:oid]

On vient alors de naviguer dans l’historique afin de récupérer les données telles qu’elles étaient présentes dans le passé.

Gungnir

Vous pouvez trouver le code de l’application de TodoList avec stockage dans git sur notre github, dans le projet gungnir.

Pour le tester, rien de plus simple :

bundle install
bundle exec rackup

et rendez-vous sur http://localhost:9292. Vous pouvez alors ajouter des items, les marquer comme fait et les supprimer. Et aussi naviguer dans les différentes versions existantes !

Et pour une petite démo en live rendez-vous sur gungnir.herokuapp.com/

Conclusion

Ainsi s’achève notre petite découverte de git comme moteur de stockage.

Dr House

Il y a encore beaucoup de choses à découvrir comme par exemple les possibilités d’utilisation concurrente, les branches, les hooks, etc. Bien que l’accès ne soit pas des plus simples, pouvoir profiter du moteur de git peut être vraiment intéressant.

Ha oui, un dernier rappel sur git avant de se quitter. Sogilis dispense toujours des formations Git !

Ressources

Illustration de l'article
comments powered by Disqus