La manipulation des objets binaires SQL par des flux

Introduction

Les flux en continu (Stream en anglais) a toujours été difficile à appréhender et maîtriser en programmation. C'est pourtant un concept indispensable à connaître pour tout développeur(euse) désireux de développer des applications "scalable" et devant minimiser l'écriture de données sur les disques physiques des serveurs. Les flux en continu permettent de découper un bloc de données en une série de blocs de plus petites tailles qui sont envoyés au destinataire de la requête. Par exemple, les flux en continu sont très utilisés dans les services de vidéo à la demande où un client commence à visionner un film (pesant en général 750 Mo dans un format compressé) sans avoir à le télécharger complètement sur son disque dur local. Dans ce scénario, le serveur vidéo découpe le film en petits tronçons de données et les transmets au logiciel client qui se chargera d'en restituer l'image à l'utilisateur.

D'un point de vue développement, un flux en continu peut-être considéré comme un tableau à une dimension contenant un ensemble de données contiguës ayant un début et une fin ainsi qu'un curseur indiquant la position courante dans le flux. Les données contenues dans le flux peuvent provenir de la mémoire, d'un fichier, d'une socket TCP/IP ou encore d'une base de données (ce point sera abordé ultérieurement dans cet article). Les opérations primitives sur un flux sont: lecture, écriture, recherche. Dans le Framework .NET, un flux est représenté par la classe abstraite Stream dont dépendent toutes les autres classes de type "flux". Deux remarques importantes concernant les flux sont leur utilisation fréquente en mode «avance seul » et la taille des données contenues dans le flux qui n'est pas connue à l'avance. Chaque développeur(euse) doit être conscient de ces contraintes et essayer d'en tirer parti plutôt que de tenter de trouver des contournements.

La gestion des flux au sein des applications SaaS

Le modèle "Logiciel en tant que Service" ou "Software As A Service ou SaaS" en anglais consistant à consommer un logiciel en temps que service (à ne pas confondre avec les premiers modèles en mode ASP) est de plus en plus fortement apprécié par les utilisateurs qu'ils soient grand-public ou bien entreprise. Notons que de nombreuses suites logicielles sont, dans leur version actuelle, incompatibles avec une architecture multi-tenant (permettant d'héberger plusieurs client sur un même environnement) et ne peuvent être consommés en tant que service.

En général, toutes les données (incluant des documents ou des données binaires tels que des images appelées techniquement BLOB - Binary Large Object) sont stockés dans une base de données back-office. Lorsque qu'un client souhaite accéder à une telle ressource, le serveur d'application (généralement un serveur web) doit tout d'abord extraire le fichier de la base de données et l'envoyer au client. Une première méthode serait de récupérer la totalité du fichier puis de le transmettre au client. Si le fichier pèse 100Mo, la mémoire du serveur web consommera 100Mo de mémoire supplémentaire. Dans un environnement en mode « SaaS », où l'objectif principal est de servir potentiellement plusieurs utilisateurs simultanément, cette méthode apparaît clairement inenvisageable. Une autre méthode est l'utilisation des flux en continu. Dans ce scénario, le serveur d'application est considéré comme un "relais" dont le rôle est de transmettre les données entre le serveur backoffice, hébergeant les fichiers volumineux, et le logiciel client en découpant le fichier en petits tronçons de données. Ainsi, la mémoire du serveur d'application restera quasiment stable. Ce mécanisme est également fortement recommandé lorsque le client doit envoyer des fichiers à l'application hébergée. En utilisant des flux en continu, les données sont écrites en base de données au fur et à mesure de leur arrivée sur la connexion HTTP (si les fichiers sont envoyés via HTTP) ou sur la socket TCP/IP.

Pourquoi utiliser des flux en continu?

Parce que ce n'est pas un bon choix d'architecture que d'allouer un important espace mémoire sur un serveur pour manipuler des objets. En effet, .NET utilise deux types de piles: la pile normale et la pile pour objets larges (Large Object Heap - LOH) qui contient des objets pesant au minimum 85 000 octets. Les objets stockés dans la pile LOH peuvent potentiellement rester plus longtemps que les objets de la pile normale, occupant ainsi de la mémoire vive.

En cherchant sur la toile, nous tombons rapidement sur des articles ou des blogs expliquant qu'il faut manipuler la pile LOH avec beaucoup de précaution: "Garbage collection of the large object heap is much more infrequent than the small object heap, which means that any object that is stored on the large object heap may stay around for a substantial amount of time" (blog de Tess Ferrandez , Ingénieur Escalade chez Microsoft). Ainsi, dans un environnement SaaS où un serveur peut être partagé parmi plusieurs clients, l'usage de la mémoire est crucial. Plus l'application sera optimisée en termes d'utilisation de la mémoire, plus elle sera capable de supporter un nombre important d'utilisateurs concurrents.

Le scénario

Le problème

Mido, développeur chez CarRental Inc., une société de location de voitures, doit développer une solution de consolidation de données provenant de plusieurs documents de grandes tailles émis par chaque concessionnaire dans le but de générer des rapports financiers (ventes, etc.). Il décide d'architecturer ces développements autour d'un serveur web dont le rôle principal est de récupérer l'ensemble des fichiers issus des différents concessionnaires. Un(e) responsable au niveau groupe aura à sa charge la récupération de ces fichiers (par mèl, partage de fichiers, etc.) puis leurs chargements dans l'application web. Désireux par ailleurs d'offrir une fonctionnalité d'historisation des documents, Mido décide de stocker chaque fichier unitaire en base de données. Suite à de nombreuses recherches sur la toile, il est convaincu que l'usage des flux en continu est le meilleur choix. Il se pose alors la question suivante: quelle est la meilleure manière d'implémenter des streams en .NET et les bases de données référencées chez CarRental: Microsoft SQL Server 2000 et 2005.

1er partie: stocker des fichiers volumineux sous SQL Server

Rappel: commandes SQL pour manipuler des BLOBs

La manipulation des BLOBs sous SQL Serveur requiert les commandes suivantes: READTEXT, UPDATETEXT et TEXTPTR. Il est également possible d'utiliser WRITETEXT pour insérer une grande quantité de données dans une colonne mais cette commande n'est pas adaptée dans un approche SaaS où les données doivent être insérées au fur et à mesure de leur arrivée par petits tronçons. En effet, la commande WRITETEXT ne permet pas de manipuler des tronçons de données. Il agit sur la globalité de la donnée à insérer.

Expliquons brièvement le rôle des trois commandes citées précédemment:

READTEXT: utilise un pointeur mémoire et permet l'extraction d'un tronçon de données. Vous devez préciser la position de départ (offset) et le nombre d'octets devant être lu (size). Si vous souhaitez assurer une cohérence des données lors de la lecture et maintenir un pointeur toujours valide, il est possible d'utiliser le mot clé HOLDLOCK (ou bien spécifier le niveau d'isolation à Repeatable Read) afin que la donnée contenu dans la colonne ne soit pas modifiée (la donnée est bloquée) le temps de la transaction. D'autres utilisateurs peuvent tout de même lire la donnée mais ne peuvent pas la modifier.

Syntaxe: READTEXT { table_name.column_name text_ptr offset size } [ HOLDLOCK ]

UPDATETEXT: fonctionne avec un pointeur et permet d'ajouter ou mettre à jour des tronçons de données. La commande UPDATETEXT affiche une réelle amélioration par rapport à WRITETEXT qui impose de réécrire entièrement les données d'une colonne que vous souhaitiez ajouter ou encore modifier un tronçon de données . Dans un scénario SaaS, il est conseillé d'utiliser UPDATETEXT.

Syntaxe:
UPDATETEXT table_name.dest_column_name dest_text_ptr
offset delete_length [inserted_data |
table_name.src_column_name src_text_ptr ]

Remarque: le paramètre delete_length définit le nombre de caractères (si applicable) devant être supprimés à partir du paramètre offset. Si vous souhaitez supprimer tout les caractères il faut utiliser la chaîne NULL.. Si vous souhaitez au contraire conserver toutes les données, il faut utiliser 0 (comme dans notre exemple).

TEXTPTR: retourne un pointeur au format varbinary correspondant à l'adresse mémoire de la colonne text, ntext ou image. Ce pointeur peut alors être utilisé dans les clauses READTEXT, WRITETEXT et UPDATETEXT.

Syntaxe: TEXTPTR (column_name)

Remarque: lorsque vous souhaitez récupérer un pointeur sur une colonne text ou image, il est indispensable d'avoir préalablement initialisé cette colonne auquel cas le pointeur retournera NULL. Dans notre exemple, nous initialisons cette colonne avec la valeur hexadécimal 0x0.

Remarque sur l'exemple

Notre exemple repose sur une application WinForms. Si vous souhaitez tester le code au sein d'une application web, il suffit de développer une page ASPX contenant le contrôle FileUpload (ASP.NET 2.0) et appeler la propriété FileUpload.PostedFile.InputStream sur le serveur afin de récupérer le flux de données, débuter la lecture et insérer les tronçons de données au fur et à mesure de leurs arrivées.

Note: le code fournit peut être utilisé dans un environnement de production après l'avoir éventuellement réadapté à vos propres besoins. Cela vous permet de bénéficier rapidement d'un composant permettant de stocker/extraire des fichiers volumineux dans Microsoft SQL Serveur.

Explications

Nous avons défini une classe Document représentant un "fichier", qu'il soit au format texte où binaire. Dans un souci de clarté, toutes les couches applicatives (métier, accès aux données, etc.) ont été regroupées au sein de cette classe qui offre ainsi les propriétés d'un "fichier" mais également les méthodes de chargement et d'écriture. Nous avons également limité la taille maximum d'un fichier à importer à 232 octets (environ 4Go) , ce qui devrait suffire dans le cadre de cette article:).

La méthode SaveContent a pour rôle d'insérer le contenu du fichier en base de données par petits morceaux (la taille est configurable à l'aide de la propriété bufferLength). Après une opération d'insertion, l'offset est incrémenté par le nombre d'octets précédemment ce qui doit correspondre à bufferLength (vrai tant que la fin du flux n'a pas été atteint). Comme vous pouvez le constater, le fichier n'est pas chargé en mémoire puis stocké en base de données. Seuls des tronçons de données pesant 32768 octets (défini par bufferLength dans notre exemple) sont chargés en mémoire et immédiatement stockés en base. Cette méthode fait appel à son tour à la procédure stockée up_UpdateDocumentChunk qui a deux rôles: insérer le document (BLOB) s'il n'a pas encore été inséré en prenant garde d'initialiser la colonne à 0x0 ou mettre à jour le document en ajoutant le nouveau tronçon de données à l'aide du pointeur mémoire.

C'est top, nous savons désormais comment stocker de manière élégante des BLOBs en utilisant SQL Serveur et les flux .NET. Mais en général, lorsque nous stockons des données à un moment, c'est pour pouvoir les récupérer à un autre moment. Alors comment faire? Nous allons présenter 2 méthodes de lecture de BLOBs toujours en nous basant sur nos chers objets "streams":).

2è partie: récupérer des fichiers volumineux stockés dans Microsoft SQL Serveur - commandes SQL

La première méthode est dite "traditionnelle". De nombreux articles existent sur le sujet et fournissent de précieuses informations. Vous trouverez également des liens intéressants à la fin de cet article.

La méthode LoadContentAlternate a pour rôle de lire le document (BLOB) et s'appuie sur deux procédures stockées faisant appel à TXTPTR et READTXT. La première partie de la méthode récupère le pointeur ainsi que la taille des données de la colonne à l'aide de la procédure SQL up_GetDocumentPointerAndLength. La seconde partie récupère les tronçons de données et les stocke dans un flux de sortie. Dans notre exemple, ce flux est de type FileStream pointant sur un fichier local. Dans un contexte web, ce flux peut-être de type HttpResponse.OutputStream, représentant les données brutes contenues dans la requête HTTP. Voici une image écran des commandes T-SQL envoyées à Microsoft SQL Serveur:

Comme vous pouvez le constater, up_GetDocumentChunk est exécuté autant de fois qu'il y a des tronçons de données de 32768 octets devant être récupérés de la base de données.

3è partie: récupérer des fichiers volumineux stockés dans Microsoft SQL Serveur - ADO.NET SequentialAccess

La méthode LoadContent a pour rôle de lire le document (BLOB) and s'appuie sur deux procédures stockées "très simples" up_GetDocument et up_GetDocumentContent. La première procédure récupère tous les champs de l'objet Document sauf le champ content de type image (contient le BLOB). Au contraire, la seconde procédure récupère uniquement le champ content. Jetons un œil sur la commande T-SQL envoyée à Microsoft SQL serveur:

N'est ce pas étrange? La procédure stockée up_GetDocumentContent n'est appelée qu'une seule fois. La question venant à l'esprit est la suivante: la globalité du fichier est elle ramenée en une seule requête sur le serveur? La réponse est NON. La documentation MSDN fournit l'explication suivante concernant le mode SequentialAccess: "provides a way for the DataReader to handle rows that contain columns with large binary values. Rather than loading the entire row, SequentialAccess enables the DataReader to load data as a stream. You can then use the GetBytes or GetChars method to specify a byte location to start the read operation, and a limited buffer size for the data being returned". Dans ce cas, pourquoi le "profiler" de Microsoft SQL serveur n'affiche t-il rien? Parce que la communication entre le client (ou le serveur d'application) et la base de données est établie à un niveau plus proche du réseau en utilisant le protocole natif de Microsoft SQL Serveur: Tabular Data Stream ou TDS sur le port 1433. Voici une image écran du trafic réseau circulant entre mon ordinateur et le serveur SQL après avoir cliqué sur "Get file with ADO" :

Est ce suffisamment clair? :)

Jetons un coup d'œil sur la consommation mémoire lors d'une extraction depuis la base de données d'un fichier pesant 138Mo. L'image suivante indique l'empreinte mémoire avant la récupération du fichier:

Et voici l'empreinte mémoire durant le rapatriement du fichier sur le disque dur local

La consommation mémoire reste quasiment inchangée ce qui est cohérent avec l'utilisation des "streams".

Téléchargement

Postez vos questions et commentaires à omid.bayani@softfluent.com ou simon.mourier@softfluent.com.

Pour en savoir plus (en anglais)

Writing BLOB with SQL

Large Object Heap Liens 1 Liens 2

Omid Bayani & Simon Mourier

Back