Aujourd’hui on va s’atteler à une fonctionnalité de PostgreSQL que j’ai mis pas mal de temps à comprendre car la documentation n’est soit pas très fournit, soit peut compréhensible (pour moi-même).
Nous allons parler de l’import des données binaires. Il s’agit de fichiers de données dans divers formats comme des images, des sons, des vidéos, des programmes… Car oui, PostgreSQL peut stocker tout type de fichiers !
Stockage
Avant de parler import, parlons stockage. Bien comprendre le stockage permettra de bien comprendre comment importer des données.
Tout fichier informatique (y compris les programme car ils sont eux-même constitués de fichiers) est composé de données dans différents langages mais dont le support de stockage est le binaire : une suite de 0
et de 1
. Ainsi, lorsqu’un fichier est copié ou déplacé, l’opération est effectué sur le code binaire, cette suite de 0
et de 1
.
PostgreSQL, comme d’autres SGBD, permet de stocker ce type de données : les données binaires. Pour cela, deux solutions, que l’on peut apparenter au type « standard » BLOB (Binary Large OBject), sont disponibles :
- Les
large objects
(ou objets larges). - Le type de données
bytea
associé au système de stockage TOAST.
Large object
Le stockage large object
repose sur l’importation des fichiers binaires directement dans les répertoires de la base de données. Chaque fichier est découpé en morceaux (nommés les pages). Coté base, chaque fichier est donc identifié par un oid et est lié à une série de numéro de pages (l’ensemble des morceaux du fichier).
Les large objects
sont particuliers et ne fonctionnent pas comme les autres objets de la base pour lesquels on définit un schéma, un tablespace, un nom… Ils sont tous regroupés et stockés au même endroit. Deux tables systèmes sont dédiées au stockage des large objects
:
- La table
pg_largeobject_metadata
liste chaquelarge object
ainsi que son propriétaire et les droits d’accès associés. - La table
pg_largeobject
contient les données découpées en pages : une page par ligne. Chaque ligne contient ainsi l’id de l’objets correspondant, le numéro de page (en démarrant de 0) et la donnée binaire (d’une taille maximale de 2048 octets par défaut).
Ceci permet d’accéder à des morceaux du fichier sans avoir à récupérer la totalité de la données (par exemple pour ne modifier qu’une partie du fichier).
-- Visualiser la liste des large objects stockés en base SELECT oid, * FROM pg_largeobject_metadata ;
-- Visualiser les données des large objects stockés en base SELECT * FROM pg_largeobject ;
Attention, le stockage de la donnée binaire est effectué dans une colonne de type bytea
mais il n’a rien à voir avec le stockage binaire détaillé dans le paragraphe suivant.
Les large objects
sont si particuliers qu’ils doivent être manipulés à travers une API : des fonctions spécifiques, dédiés à la manipulation des large objects
. Ces fonctions sont appelées via des requêtes SQL mais travaillent sur les large objects
directement, sans SQL. Elles permettent de créer, d’écrire, de lire, d’importer, d’exporter…
Ainsi, l’import d’un large object
ne se fera pas via un simple INSERT
. Ce qui est intéressant de noter, c’est que l’import se fait directement via transfert des données binaires brutes. Ce qui permet d’être rapide sans trop charger l’utilisation du processeur.
Bytea et TOAST
Le type de données bytea
est directement accessible en SQL sous PostgreSQL. Il permet de définir une colonne qui contiendra de la donnée au format binaire brute.
L’intérêt est la facilité d’utilisation, ce type est associé à une colonne standard PostgreSQL : le nom est libre, elle peut faire partie de n’importe quelle table (et même plusieurs par table), le tablespace peut être défini…
L’import et l’export de données dans ces colonnes se fait au moyen de requêtes SQL standard ce qui impose la conversion de la donnée dans un format textuel pour pouvoir l’écrire dans la requête. Plusieurs formats sont disponibles :
- Hex : encodage des données binaires sur 2 valeurs hexadécimales par bit (plus d’info ici). Le poids de la données se trouve alourdie d’un facteur 2.
- Escape : encodage des données binaires sur des caractères ASCII (plus d’info ici). Le poids de la données se trouve alourdie d’un facteur 1 à 4.
- Base64 : encodage des données binaires sur 64 caractères (plus d’info ici). Le poids de la données se trouve alourdie d’un facteur 4/3 (1/3 de plus) au moins.
La conversion des données d’un format textuel comme ceux cités précédemment au format binaire brut implique une consommation de ressource CPU ainsi qu’un temps de traitement.
Un exemple :
Valeur textuelle | Encodage ‘Hex’ | Encodage ‘Escape’ | Encodage ‘base64’ |
---|---|---|---|
arthurbazin | 61727468757262617a696e | arthurbazin | YXJ0aHVyYmF6aW4= |
Et les biscottes TOAST dans tout ça ? Il s’agit en fait d’un fonctionnement interne de PostgreSQL qui permet de stocker de grosses valeurs dans les attributs d’une table.
TOAST = The Oversized-Attribute Storage Technique
Ce type de stockage s’applique à certains types de données (numérique, textuel, géométrique et dans notre cas binaire) dont la longueur est variable (un exemple de type à longueur variable : varchar(20)
).
Pour chaque tuple (= ligne d’une table), PostgreSQL stocke la donnée dans ce que l’on appelle une « page ». Un tuple ne peut être stocké que dans une seul page maximum mais une page peut contenir plusieurs tuples. Ces pages ne peuvent excéder 8 ko et donc un tuple non plus. Ainsi, lorsqu’une donnée excède cette taille, le système TOAST s’enclenche pour outrepasser cette limite.
Le système TOAST compresse puis divise les données dans plusieurs « sous-tuples » (de nouvelles lignes) ce qui permet de répartir le poids des données et de les stocker de façon classique, sans dépasser la taille limitée d’une page. Ces lignes supplémentaires sont créées dans des tables spécifiques nommées pg_toast_xxx
(le xxx
étant remplacé par l’oid de la table d’origine) et stockées dans le schéma système pg_toast
.
Ce fonctionnement est entièrement automatisé et totalement transparent pour l’utilisateur qui n’a rien à gérer. L’activation du système est paramétré par la variable de configuration TOAST_TUPLE_THRESHOLD
qui fixe par défaut à 2 ko la limite à partir de laquelle la données est TOASTée.
Notez que la structure des tables pg_toast_xxx
est identique à la structure de la table pg_largeobject
bien que le système de stockage soit différent.
Pour identifier la table pg_toast_xxx
liée à une table, vous pouvez utiliser la requête suivante :
SELECT reltoastrelid::regclass as nom_table, pg_relation_filepath(reltoastrelid) as emplacement_stockage, pg_size_pretty(pg_relation_size(reltoastrelid)) as taille FROM pg_class WHERE relnamespace = 'mon_schema'::regnamespace AND relname = 'ma_table' ;
Import
L’import de données binaires se résume donc à un déplacement de données du lieu d’origine vers l’instance PostgreSQL. Selon le type de stockage utilisé, le traitement sera donc différent :
- Format
large object
: il faut obligatoirement utiliser l’API PostgreSQL dédiée. - Type de donnée
bytea
: la donnée doit être encodée dans un format binaire textuel puis envoyé au moyen d’une requête.
Voici un exemple, Je souhaite importer une image dans une colonne au format bytea : je dois d’abord convertir mon image dans un format binaire textuel comme par exemple le base64 via un outil (par exemple celui-ci) puis l’importer via la fonction decode()
.
-- Import dans un champ bytea d'une image encodée en base64 INSERT INTO ma_table ( mon_champ_bytea) SELECT decode('iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAY[...]3+/wA93TsnnJMLsQAAAABJRU5ErkJggg==', 'base64') ;
Pour utiliser une image stockée au format binaire, il est possible de l’encoder au format base64 pour l’utiliser dans du HTML par exemple.
-- Création d'une chaine HTML pour afficher une image stockée au format binaire SELECT '<img src="data:image/png;base64, ' || encode(champ_bytea, 'base64') || '" alt="mon_image" width="150" height="150">' FROM ma_table ;
On le voit donc tout de suite : pour du stockage dans une colonne en bytea
, il faut d’abord convertir la donnée d’origine avant de pouvoir l’inclure dans une requête. Ceci implique donc l’utilisation d’un logiciel tiers permettant cet encodage… Pas très simple.
La suite de cet article va donc s’intéresser à une méthode d’import de données en tant que large object
qui permet ensuite de récupérer ce large object
dans une colonne de type bytea
.
Le logiciel client utilisé est tout simplement PSQL.
Attention, le programme qui s’occupe de l’import du fichier (l’instance de PostgreSQL ou le logiciel client) doit avoir les autorisations d’accès en lecture sur ce fichier.
Pour rappel, toute requête SQL est envoyée par le logiciel client vers le serveur PostgreSQL qui va exécuter cette requête. Cela veut dire que le serveur ne peut atteindre des fichiers situés sur la machine cliente. C’est donc au logiciel client d’envoyer ces fichiers binaires au serveur.
A l’inverse, le serveur PostgreSQL est capable d’atteindre tout fichier présent sur la machine sur laquelle l’instance est installée.
Données coté serveur : server-side
Lorsque les données à importées sont stockées dans un répertoire accessible par l’instance de PostgreSQL (sur la même machine ou dans un répertoire réseau monté sur la machine), il suffit de faire appel aux fonctions fournies par l’API large object
au travers de requêtes SQL.
L’importation du fichier utilise la fonction lo_import()
qui renvoie l’oid du large object
créé.
-- Importation d'un fichier situé sur le serveur SELECT lo_import(/emplacement/de/mon/fichier.ext);
Un large object
peut être récupéré au format bytea
grâce à la fonction lo_get()
à laquelle on passe l’oid du large object
à récupérer.
-- Récupération d'un large object au format bytea SELECT lo_get(123456);
Cette fonction peut donc être utilisée dans une requête d’UPDATE
pour importer le large object
dans une colonne de type bytea
.
-- Importation d'un large object dans une colonne de type bytea UPDATE mon_schema.ma_table SET ma_colonne_bytea = lo_get(123456) ;
La fonction lo_unlink()
permet de supprimer un large object
en indiquant l’oid de celui-ci.
-- Récupération d'un large object au format bytea SELECT lo_unlink(123456);
Il est possible de grouper les différentes étapes dans une fonction d’import de données dans le type bytea
:
CREATE OR REPLACE FUNCTION bytea_import(emplacement_fichier text) RETURNS bytea AS $corps$ DECLARE -- oid du large object importé v_oid_objet oid; -- Données du fichier au format binaire v_donnees; BEGIN -- Import du fichier sous forme de large objet SELECT lo_import(emplacement_fichier) INTO v_oid_objet; -- Récupération des données du large objet SELECT lo_get(v_oid_objet) INTO v_donnees; -- Suppression du large objet PERFORM lo_unlink(v_oid_objet); -- Renvoi des données RETURN v_donnees; END; $corps$ LANGUAGE PLPGSQL ;
Données coté client : client-side
Lorsque les données à importées sont stockées dans un répertoire situé sur la machine cliente et donc non accessibles par l’instance de PostgreSQL, il faut passer par un logiciel client capable d’importer les fichiers. Nous allons ici faire appel à PSQL qui possède la même fonction que l’API de PostgreSQL : la méta commande \lo_import
.
L’importation du fichier utilise la méta commande \lo_import()
qui renvoie l’oid du large object
créé.
-- Importation d'un fichier situé sur la machine cliente \lo_import(/emplacement/de/mon/fichier.ext);
Les autres fonctions sont identiques à la version serveur car une fois le fichier importé, il se trouve coté serveur. C’est donc via des requêtes SQL que le large object
sera ensuite traité.
Dans ce cas là, il n’est pas possible d’avoir une fonction qui regroupe les différentes étapes comme dans la partie précédente car la méta commande \lo_import
n’est disponible que coté client alors que la fonction sera exécutée cotée serveur.
Import en masse
L’import d’objet en masse est un peu différent car il nécessite de faire appel aux fonctions vues précédemment et de les combiner avec d’autres mécanismes pour correctement récupérer les données et leurs informations.
Server-side
Bien que ça ne soit pas obligatoire, nous allons passer par une procédure pour exécuter toutes les requêtes ce qui permettra l’utilisation de variables et pour réutiliser le code plus facilement.
DO $fonction$ DECLARE -- Table d'import schema_import TEXT := 'public'; table_import TEXT := 'import_fichier'; -- Programme à utiliser pour localiser les fichiers à importer (sous Windows) -- Lister tous les fichiers du répertoire tmp -- WHERE "C:\tmp:*" -- Lister tous les fichiers jpeg et png du répertoire tmp -- WHERE "C:\tmp:*.jpg" "C:\tmp:*.png" -- Lister tous les fichiers du répertoire tmp et de ses sous-répertoires -- DIR "C:\tmp" /b /S /a-d liste_fichier TEXT := 'WHERE "C:\tmp:*.jpg"'; -- Autres variables -- Date et heure de démarrage var_timestamp timestamp := now(); BEGIN -- Création de la table d'import EXECUTE $a$CREATE TABLE IF NOT EXISTS $a$ || quote_ident(schema_import) || $a$.$a$ || quote_ident(table_import) || $a$ ( -- Un identifiant id serial PRIMARY KEY, -- L'emplacement d'origine du fichier emplacement_fichier TEXT, -- Le nom d'origine du fichier nom_fichier TEXT, -- L'extension d'origine du fichier extension_fichier TEXT, -- L'oid du large object créé lo_oid oid, -- Le fichier stocké au format binaire data bytea, -- Le timestamp d'import date_import timestamp ) $a$ ; -- Récupérer la liste des fichiers situés dans un dossier EXECUTE $a$COPY $a$ || quote_ident(schema_import) || $a$.$a$ || quote_ident(table_import) || $a$ (emplacement_fichier) FROM PROGRAM '$a$ || liste_fichier || $a$' WITH (format 'csv')$a$ ; -- Calcul des métadonnées des fichiers EXECUTE $a$UPDATE $a$ || quote_ident(schema_import) || $a$.$a$ || quote_ident(table_import) || $a$ SET -- Récupération du nom du fichier nom_fichier = (SELECT regexp_matches(emplacement_fichier,'\\([^\\]*)\.(\w+)$'))[1], -- Récupération de l'extension du fichier extension_fichier = (SELECT regexp_matches(emplacement_fichier,'\.(\w+)$'))[1], -- Création d'un timestamp date_import = $a$ || quote_literal(var_timestamp) ; -- Import du document et récupération de l'oid EXECUTE $a$UPDATE $a$ || quote_ident(schema_import) || $a$.$a$ || quote_ident(table_import) || $a$ SET lo_oid = lo_import(emplacement_fichier) $a$ ; -- Récupération des large objects dans la colonne de type bytea EXECUTE $a$UPDATE $a$ || quote_ident(schema_import) || $a$.$a$ || quote_ident(table_import) || $a$ SET data = lo_get(lo_oid) $a$ ; -- Suppression des large objects EXECUTE $a$SELECT lo_unlink(lo_oid) FROM $a$ || quote_ident(schema_import) || $a$.$a$ || quote_ident(table_import) ; END $fonction$
Client-side
Le principe est similaire à la version server-side mais avec quelques subtilités.
Comme nous allons avoir plusieurs fichiers à traiter, l’idéal est de passer par un script batch qui va générer un fichier SQL qui sera ensuite exécuté par PSQL.
@echo off setlocal enabledelayedexpansion :: ====================================== :: Définition des variables parametrables :: ====================================== :: Connexion BDD SET param.host=127.0.0.1 SET param.port=5432 SET param.bdd=ma_bdd SET param.user=postgres SET param.pwd=postgres :: Table d'import SET param.schema_import=public SET param.table_import=import_fichier :: Programme à utiliser pour localiser les fichiers à importer :: Lister tous les fichiers du répertoire tmp :: WHERE "C:\tmp:*" :: Lister tous les fichiers jpeg et png du répertoire tmp :: WHERE "C:\tmp:*.jpg" "C:\tmp:*.png" :: Lister tous les fichiers du répertoire tmp et de ses sous-répertoires :: DIR "C:\tmp" /b /S /a-d SET param.liste_fichier=WHERE "C:\tmp:*.jpg" :: Autres variables SET var_dir_install=%~dp0 SET scriptfile="%var_dir_install%script_import_bytea.sql" :: Date et heure de démarrage :: Le timestamp permettra de gérer plusieurs imports en parallèle si besoin SET var_timestamp=%date:~6,4%-%date:~3,2%-%date:~0,2% %time:~0,2%:%time:~3,2%:%time:~6,5% SET "var_timestamp=%var_timestamp:,=.%" :: Initialisation du script SQL ( echo -- Script d'import SQL ) > "%scriptfile%" :: Création de la table d'import ( echo -- Table d'import echo CREATE TABLE IF NOT EXISTS "%param.schema_import%"."%param.table_import%" ^( echo -- Un identifiant echo id serial PRIMARY KEY, echo -- L'emplacement d'origine du fichier echo emplacement_fichier TEXT, echo -- Le nom d'origine du fichier echo nom_fichier TEXT, echo -- L'extension d'origine du fichier echo extension_fichier TEXT, echo -- L'oid du large object créé echo lo_oid oid, echo -- Le fichier stocké au format binaire echo data bytea, echo -- Le timestamp d'import echo date_import timestamp echo ^) echo ; echo. echo. echo. ) >> "%scriptfile%" :: Boucle pour chaque fichier à importer FOR /f "usebackq delims=|" %%a in (`%param.liste_fichier%`) do ( SET import_file=%%a SET "import_file=%import_file:\=/%" ( echo -- Fichier : %import_file% echo. ) >> "%scriptfile%" :: Insertion de l'emplacement du fichier dans la table d'import ( echo -- Insertion emplacement du fichier echo INSERT INTO "%param.schema_import%"."%param.table_import%" ^( echo emplacement_fichier, echo date_import echo ^) echo VALUES echo ^('%import_file%', '%var_timestamp%'^) echo ; echo. ) >> "%scriptfile%" :: Importation du fichier avec la méta commande \lo_import ( echo -- Importation du fichier avec la méta commande \lo_import echo \lo_import '%import_file%' echo. ) >> "%scriptfile%" :: Récupération de l'OID du large object créé ( echo -- Récupération de l'OID du large object créé echo UPDATE "%param.schema_import%"."%param.table_import%" echo SET lo_oid = :LASTOID echo WHERE emplacement_fichier = '%import_file%' echo AND date_import = '%var_timestamp%' echo ; echo. echo. ) >> "%scriptfile%" ) :: Calcul des métadonnées des fichiers ( echo. echo. echo -- Calcul des métadonnées des fichiers echo UPDATE "%param.schema_import%"."%param.table_import%" echo SET echo -- Récupération du nom du fichier echo nom_fichier = ^(SELECT regexp_matches^(emplacement_fichier,'/^([^^/]*^)\.\w+$'^)^)[1], echo -- Récupération de l'extension du fichier echo extension_fichier = ^(SELECT regexp_matches^(emplacement_fichier,'\.^(\w+^)$'^)^)[1] echo WHERE echo date_import = '%var_timestamp%' echo ; echo. ) >> "%scriptfile%" :: Récupération des large objects dans la colonne de données binaire ( echo. echo. echo -- Récupération des large objects dans la colonne de données binaire echo UPDATE "%param.schema_import%"."%param.table_import%" echo SET echo data = lo_get^(lo_oid^) echo WHERE echo date_import = '%var_timestamp%' echo ; echo. ) >> "%scriptfile%" :: Suppression des large objects ( echo. echo. echo -- Suppression des large objects echo SELECT echo lo_unlink^(lo_oid^) echo FROM echo "%param.schema_import%"."%param.table_import%" echo WHERE echo date_import = '%var_timestamp%' echo ; echo. ) >> "%scriptfile%" :: Execution du script SQL psql -d "postgresql://%param.user%:%param.pwd%@%param.host%:%param.port%/%param.bdd%" -f "%scriptfile%"
-- Script d'import SQL -- Table d'import CREATE TABLE IF NOT EXISTS "public"."import_fichier" ( -- Un identifiant id serial PRIMARY KEY, -- L'emplacement d'origine du fichier emplacement_fichier TEXT, -- Le nom d'origine du fichier nom_fichier TEXT, -- L'extension d'origine du fichier extension_fichier TEXT, -- L'oid du large object créé lo_oid oid, -- Le fichier stocké au format binaire data bytea, -- Le timestamp d'import date_import timestamp ) ; -- Fichier : C:/tmp/mon_image_1.jpg -- Insertion emplacement du fichier INSERT INTO "public"."import_fichier" ( emplacement_fichier, date_import ) VALUES ('C:/tmp/mon_image_1.jpg', '2022-02-06 21:00:00.00') ; -- Importation du fichier avec la méta commande \lo_import \lo_import 'C:/tmp/mon_image_1.jpg' -- Récupération de l'OID du large object créé UPDATE "public"."import_fichier" SET lo_oid = :LASTOID WHERE emplacement_fichier = 'C:/tmp/mon_image_1.jpg' AND date_import = '2022-02-06 21:00:00.00' ; -- Fichier : C:/tmp/mon_image_2.jpg -- Insertion emplacement du fichier INSERT INTO "public"."import_fichier" ( emplacement_fichier, date_import ) VALUES ('C:/tmp/mon_image_2.jpg', '2022-02-06 21:00:00.00') ; -- Importation du fichier avec la méta commande \lo_import \lo_import 'C:/tmp/mon_image_2.jpg' -- Récupération de l'OID du large object créé UPDATE "public"."import_fichier" SET lo_oid = :LASTOID WHERE emplacement_fichier = 'C:/tmp/mon_image_2.jpg' AND date_import = '2022-02-06 21:00:00.00' ; -- Calcul des métadonnées des fichiers UPDATE "public"."import_fichier" SET -- Récupération du nom du fichier nom_fichier = (SELECT regexp_matches(emplacement_fichier,'/([^/]*)\.\w+$'))[1], -- Récupération de l'extension du fichier extension_fichier = (SELECT regexp_matches(emplacement_fichier,'\.(\w+)$'))[1] WHERE date_import = '2022-02-06 21:00:00.00' ; -- Récupération des large objects dans la colonne de données binaire UPDATE "public"."import_fichier" SET data = lo_get(lo_oid) WHERE date_import = '2022-02-06 21:00:00.00' ; -- Suppression des large objects SELECT lo_unlink(lo_oid) FROM "public"."import_fichier" WHERE date_import = '2022-02-06 21:00:00.00' ;
Pour en savoir plus
Un comparatif entre les stockages large object
et binary
: https://blog-postgresql.verite.pro/2017/11/15/large-objects-bytea.html
Un bel article sur les TOAST : https://medium.com/gojekengineering/a-toast-from-postgresql-83b83d0d0683
Un autre article sur les TOAST : https://fljd.in/2020/10/12/toast-la-meilleure-chose-depuis-le-pain-en-tranches/
Cet article fait parti du cours sur PostgreSQL, partie 8 du Vade-mecum.
Cet article vous a plu ?
N'hésitez pas à le partager, il interessera surement certains de vos contacts.
Les thèmes suivants contiennent des articles en lien avec celui-ci, allez faire un tour :
BDDPostgreSQLProgrammationSQL binairebyteaimportlarge objectTOAST