Une des contraintes que l’on s’est imposée sur le projet PeerPx est de rendre le service accessible au plus grand nombre et ce aussi bien dans son usage que dans son déploiement et sa maintenance. En d’autres termes on ne doit pas être un admin chevronné pour déployer et maintenir une instance PeerPx et cela passe, entre autre, par une automatisation des migrations SQL. Je vais vous expliquer dans ce billet la solution que nous avons retenu et comment la mettre en oeuvre au sein de votre code Go.

Migration SQL ?

Avant d’aller plus loin il convient de définir le précisément ce que l’on entend par migration SQL. Dans le contexte qui nous intéresse ici, il ne s’agit pas de migrer les données d’une base SQL vers une autre base, ou de migrer, par exemple de MySQL vers Postgresql mais de maintenir à jour le schéma SQL d’une base de données. Le cas d’usage le plus fréquent est certainement celui qui consiste à avoir un base de données dédiée au développement, en parallèle avaoir une basse qui sert pour la production et de mettre la seconde à jour facilement quand on déploie les modifications.

Dans notre cas il s’agira de mettre à jour les bases de données des différentes instances PeerPx.

La solution retenue: golang-migrate

Au tout début du projet, on utilisait Gorm , qui en tant qu’ ORM faisait le lien entre les modèles au niveau du code (donc des structures Go) et leurs représentations en base de données. Dans ce contexte il est relativement simple de maintenir les schémas SQL: l’ORM voit un changement dans la structure, il la répercute sur le schéma de la la base de données. Avec Gorm, il suffisait donc, au lancement de l’application, d’appeler la fonction AutoMigrate().

On à ensuite décidé de ne plus utiliser d’ORM et donc de se passer du lien structure <=> schéma SQL, il a donc fallut trouver une autre solution pour tenir à jours les schémas sur les instances déployées. Il existe plusieurs solutions mais globalement le principe est il même, il consiste à maintenir une sorte de changelog des modifications faites et associer chaque changement à une version. Ainsi pour vérifier qu’une base de données est à jour on compare sa version à la dernière enregistrée dans le changelog. Si elle n’est pas à jour on exécute les requêtes nécessaires pour qu’elle le devienne.

Parmi les différentes librairies existantes on a décidé d’utiliser golang-migrate car elle correspondait à nos besoins et est celle qui est certainement la plus utilisé, ce qui est, généralement, gage de qualité et de suivi.

Mise en oeuvre

Pour chaque étape nécessitant un changement de schéma de notre base de données, on va utiliser le CLI migrate qui va créer deux fichiers, un UP et un DOWN. Le fichier UP va contenir les instructions SQL pour faire “monter” la structure vers son nouveau schéma, le DOWN va au contraire contenir les instructions pour faire machine arrière.

Prenons un exemple, avec une base SQLite:

migrate --database sqlite3://peerpx.db create --ext sql --dir migrate init
  • L’option ext va nous permettre de spécifier l’extension de nos fichiers de migration.
  • dir va nous permettre de spécifier le répertoire qui va stocker les fichiers de migration.
  • init est le nom que l’on donne à cette migration

Cette commande va donc créer deux fichiers dans le répertoire migrate:

$ tree
.
└── migrate
    ├── 20180702112737_init.down.sql
    └── 20180702112737_init.up.sql

Imaginons que nous souhaitions créer une table users ayant le schéma suivant:

create  table users  
(  
id integer primary  key autoincrement,  
firstname varchar(255),  
lastname varchar(255),  
email varchar(255),  
);

On va mettre ces commandes SQL dans notre fichier UP et on mettra dans le fichier DOWN:

drop table users

Il nous suffit maintenant d’exécuter la commande:

$ migrate --path ./migrate --database sqlite3://peerpx.db up
20180702112737/u init (47.248111ms)

Pour que notre base soit créé en suivant le bon schéma:

$ tree
.
├── migrate
│   ├── 20180702112737_init.down.sql
│   └── 20180702112737_init.up.sql
└── peerpx.db

En inspectant la base on remarquera la table schema_migrations :

sqlite> SELECT name FROM sqlite_master WHERE type='table';
schema_migrations
users
sqlite_sequence

qui contient la version courante via le timestamp de nos fichiers de migration:

sqlite> SELECT * FROM schema_migrations;
20180702112737|false

Bien imaginons maintenant que nous désirions créer une contrainte de type UNIQUE sur le champs email. On va créer deux nouveaux fichier de migrations:

migrate --database sqlite3://peerpx.db create --ext sql --dir migrate add-unique-constaint

On va mettre dans le nouveau fichier UP crée:

create unique index uix_users_email on users (email);

Et dans le DOWN:

DROP INDEX uix_users_email ON users;

Si on exécute:

$ migrate --path ./migrate --database sqlite3://peerpx.db up

Et que l’on regarde le schéma de la base on verra que l’adresse a été créé et que la version courante à été modifiée.

Si on supprime la base de données et que l’on relance cette commande, elle sera recrée en tenant compte de tous les fichier UP.

Intégration au code d’une application

Devoir lancer les commandes de migration depuis le terminal n’est pas très “user friendly” et dans tous les cas ne répond pas à la contrainte que l’on s’était fixée. Il faut simplifier le processus de mise à jour du schéma. Golang-migrate peut s’utiliser en tant que librairie et donc va nous permettre depuis le code de notre application de mettre a jours le schéma de notre base de données. Mieux on va pourvoir récupérer les fichiers de migration depuis un repo Github.

Typiquement on va réaliser la migration au lancement de l’application, voici la fonction utilisée (à l’heure où j’écris ces lignes) pour PeerPx:

func migrateDb() error {
	var m *migrate.Migrate
	var err error
	// check for local file
	if _, err = os.Stat("./sql"); os.IsNotExist(err) {
		m, err = migrate.New("github://toorop:a8ded4740bc467f6203f85f6ffe9c0cdf25515c7@peerpx/peerpx/cmd/server/dist/sql", "sqlite3://peerpx.db")
	} else {
		// from local file
		m, err = migrate.New("file://sql", "sqlite3://peerpx.db")
	}
	if err != nil {
		return err
	}
	defer m.Close()
	if err = m.Up(); err != nil {
		if err == migrate.ErrNoChange {
			return nil
		}
	}
	return err
}

Lorsque le programme se lance, on appelle cette fonction qui va:

  1. Vérifier si il y a des fichiers de migration dans l’arborescence locale. C’est utile dans les phase de dev, ça évite d’aller voir sur Github à chaque lancement.
  2. Si il n’y en pas pas, on va les chercher sur Github.
  3. Si la version locale est en retard on joue les différents fichiers de migration.

Vous noterez au passage que m.Up()va retourner une erreur dans le cas où la base locale est à jour. Pensez à gérer correctement cette erreur.

Conclusion

En utilisant golang-migrate les utilisateurs de PeerPx vont maintenir à jour leur base de données avec un minimum d’effort et de connaissance ce qui répond parfaitement à notre contrainte: on ne doit pas être un admin chevronné pour déployer et maintenir une instance PeerPx.
Job done !