Exporter facilement des fichiers vers OpenStack Swift en .NET C#
Téléversez des fichiers via l’API HTTP de Swift avec HttpClient, intégré à .NET.
Cet exemple crée de véritables segments et ne publie un manifeste d’objet volumineux statique
qu’après confirmation de chaque segment.
Présentation d’OpenStack Swift
Swift stocke les objets dans des conteneurs. Son API d’objets volumineux statiques combine une liste ordonnée de segments téléversés en un seul objet téléchargeable. Le middleware SLO doit être activé dans le déploiement pour cet exemple.
Configurer votre environnement
Prérequis
Installez un SDK .NET 10 maintenu. L’exemple a été testé avec Bash sous Linux, le SDK 10.0.111, l’environnement d’exécution 10.0.11 et Swift 2.38.2. Obtenez un jeton limité au projet et l’URL du compte de stockage d’objets via le processus d’authentification OpenStack de votre déploiement. Le jeton doit permettre de créer des conteneurs, de téléverser des objets, de lire les métadonnées des segments pour la validation SLO et de supprimer les segments des téléversements échoués.
L’exemple utilise des segments de 5 MiB et au maximum 1 000 segments (5 242 880 000 octets,
borne incluse). Vérifiez que les limites SLO, de taille des objets et de quota de votre déploiement
autorisent ces valeurs. L’ensemble du téléversement dispose d’un délai maximal de cinq minutes :
une connexion plus lente peut donc entraîner un échec même sous la limite de taille.
Utilisez uniquement des caractères ASCII dans les chemins locaux et les noms des répertoires de
projet. Le programme impose cette restriction avant d’ouvrir le fichier, afin que des octets de nom
de fichier mal formés ne puissent pas sélectionner un autre fichier après le décodage des arguments.
Les clés des objets distants peuvent contenir des caractères Unicode.
Commencez dans un répertoire accessible en écriture, sans entrée SwiftExport.
Les configurations englobantes du SDK .NET, de NuGet et de MSBuild s’appliquent toujours.
if ! command -v dotnet >/dev/null 2>&1; then
printf '%s\n' 'Install the .NET 10 SDK first.' >&2
false
elif [ -e SwiftExport ] || [ -L SwiftExport ]; then
printf '%s\n' 'SwiftExport already exists; choose another parent directory.' >&2
false
else
dotnet new console --framework net10.0 --name SwiftExport --no-restore &&
dotnet restore SwiftExport/SwiftExport.csproj &&
cd SwiftExport
fi
Si la configuration réussit, votre shell se place dans le répertoire du projet. Si la restauration
échoue, le nouveau projet reste en place et votre shell demeure dans le répertoire parent.
Corrigez le problème de SDK ou de NuGet signalé, puis exécutez
dotnet restore SwiftExport/SwiftExport.csproj && cd SwiftExport dans ce répertoire pour terminer la configuration.
Aucun paquet NuGet supplémentaire n’est nécessaire.
Gestion sécurisée de la configuration
Définissez SWIFT_STORAGE_URL sur le point de terminaison HTTPS complet du compte
fourni par le catalogue des services, y compris son chemin /v1/AUTH_….
Définissez SWIFT_TOKEN sur le jeton à portée limitée. Fournissez ces valeurs
via votre shell ou votre gestionnaire de secrets. L’obtention et le renouvellement du jeton
relèvent du processus Keystone de votre déploiement. Un jeton expiré entraîne une sortie avec
un code non nul ; obtenez un nouveau jeton à portée limitée avant de relancer le programme.
Implémenter les opérations de stockage Swift
Remplacez Program.cs par ce programme complet. L’instance injectée de
HttpClient appartient à l’appelant ; l’exportateur libère chaque requête et
chaque réponse. Les redirections sont désactivées pour que le jeton ne soit pas transmis à une
autre destination.
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
public static class Program
{
public static async Task<int> Main(string[] args)
{
using var deadline = new CancellationTokenSource(TimeSpan.FromMinutes(5));
Console.CancelKeyPress += (_, e) => { e.Cancel = true; deadline.Cancel(); };
try
{
if (args.Length != 3) throw new ArgumentException("Pass container, object key, and local file.");
if (args[2].Any(character => character > 127) ||
Directory.GetCurrentDirectory().Any(character => character > 127))
throw new ArgumentException("Use ASCII local paths and working directory names.");
var endpoint = new Uri(Required("SWIFT_STORAGE_URL"));
if (endpoint.Scheme != "https" || endpoint.UserInfo.Length != 0 ||
endpoint.Query.Length != 0 || endpoint.Fragment.Length != 0)
throw new ArgumentException("Use the HTTPS account endpoint from the service catalog.");
using var handler = new SocketsHttpHandler { AllowAutoRedirect = false, MaxConnectionsPerServer = 4 };
using var http = new HttpClient(handler) { Timeout = Timeout.InfiniteTimeSpan };
var exporter = new SwiftExporter(http, endpoint, Required("SWIFT_TOKEN"));
await exporter.UploadAsync(args[0], args[1], args[2], deadline.Token);
Console.WriteLine("Uploaded.");
return 0;
}
catch (OperationCanceledException) { Console.Error.WriteLine("Upload canceled or timed out."); }
catch (HttpRequestException error) { Console.Error.WriteLine($"Swift request failed (HTTP {error.StatusCode})."); }
catch (Exception) { Console.Error.WriteLine("Upload failed. Check the file and Swift configuration."); }
return 1;
}
private static string Required(string name) =>
Environment.GetEnvironmentVariable(name) is { Length: > 0 } value
? value : throw new ArgumentException($"Missing {name}.");
}
public sealed class SwiftExporter(HttpClient http, Uri endpoint, string token)
{
private const int SegmentSize = 5 * 1024 * 1024;
private const int MaxSegments = 1000;
private static string Encode(string value)
{
var parts = value.Split('/');
if (parts.Any(part => part.Length == 0 || part == "." || part == ".."))
throw new ArgumentException("Use nonempty path segments other than dot or dot-dot.");
return string.Join("/", parts.Select(Uri.EscapeDataString));
}
private async Task<string?> PutAsync(string path, HttpContent content,
bool container, CancellationToken ct, string? etag = null, string query = "")
{
content.Headers.ContentType ??= new MediaTypeHeaderValue("application/octet-stream");
using var request = new HttpRequestMessage(HttpMethod.Put,
endpoint.AbsoluteUri.TrimEnd('/') + "/" + Encode(path) + query) { Content = content };
request.Headers.Add("X-Auth-Token", token);
if (etag != null) request.Headers.Add("ETag", etag);
using var response = await http.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
var status = (int)response.StatusCode;
if (status != 201 && !(container && status == 202))
throw new HttpRequestException("Unexpected Swift response.", null, response.StatusCode);
// Swift can return an unquoted MD5 that the typed ETag accessor cannot parse.
return response.Headers.TryGetValues("ETag", out var values)
? values.Single().Trim('"') : null;
}
public async Task UploadAsync(string container, string key, string file, CancellationToken ct)
{
if (container.Contains('/')) throw new ArgumentException("A container cannot contain slashes.");
Encode(container);
Encode(key);
await using var input = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.Read);
long length = input.Length;
if (length > (long)SegmentSize * MaxSegments) throw new IOException("File exceeds the example limit.");
await PutAsync(container, new ByteArrayContent([]), true, ct);
if (length == 0)
{
await PutAsync(container + "/" + key, new ByteArrayContent([]), false, ct);
return;
}
string segments = container + "_segments";
await PutAsync(segments, new ByteArrayContent([]), true, ct);
string version = Guid.NewGuid().ToString("N");
var manifest = new List<object>();
var attempted = new List<string>();
bool publicationStarted = false;
var buffer = new byte[SegmentSize];
long remaining = length;
try
{
for (int index = 0; remaining > 0; index++)
{
int count = (int)Math.Min(remaining, SegmentSize);
await input.ReadExactlyAsync(buffer.AsMemory(0, count), ct);
string hash = Convert.ToHexString(MD5.HashData(buffer.AsSpan(0, count))).ToLowerInvariant();
string name = $"{segments}/{version}/{index:D8}";
attempted.Add(name);
using var content = new ByteArrayContent(buffer, 0, count);
string? returned = await PutAsync(name, content, false, ct, hash);
if (!string.Equals(hash, returned, StringComparison.OrdinalIgnoreCase))
throw new IOException("Swift did not confirm the segment checksum.");
manifest.Add(new { path = "/" + name, etag = hash, size_bytes = count });
remaining -= count;
}
if (input.Length != length || input.ReadByte() != -1) throw new IOException("The input file changed.");
using var manifestContent = new StringContent(JsonSerializer.Serialize(manifest));
manifestContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
// After dispatch, a lost response cannot tell us whether these segments are live.
publicationStarted = true;
await PutAsync(container + "/" + key, manifestContent, false, ct,
query: "?multipart-manifest=put");
}
catch
{
if (!publicationStarted) await RemoveAttemptedAsync(attempted);
throw;
}
}
private async Task RemoveAttemptedAsync(List<string> paths)
{
using var cleanup = new CancellationTokenSource(TimeSpan.FromSeconds(15));
foreach (string path in paths)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Delete,
endpoint.AbsoluteUri.TrimEnd('/') + "/" + Encode(path));
request.Headers.Add("X-Auth-Token", token);
using var response = await http.SendAsync(request,
HttpCompletionOption.ResponseHeadersRead, cleanup.Token);
if ((int)response.StatusCode != 204 && (int)response.StatusCode != 404)
Console.Error.WriteLine("An unreferenced segment could not be removed.");
}
catch (HttpRequestException) { Console.Error.WriteLine("Segment cleanup lost its connection."); }
catch (OperationCanceledException) { Console.Error.WriteLine("Segment cleanup timed out."); break; }
}
}
}
Gérer les fichiers volumineux
Chaque fichier non vide reçoit un nouveau préfixe de segment dans <container>_segments.
Les téléversements séquentiels maintiennent le tampon de données à 5 MiB. Le manifeste contient
le chemin, l’ETag et la taille de chaque segment, que Swift valide avant de publier l’objet.
Un fichier vide utilise une requête PUT vide classique, sans dépendre de la prise en charge des
manifestes SLO vides propre au déploiement.
La clé de l’objet distant peut contenir des espaces et des caractères Unicode. L’encodage préserve les séparateurs de chemin et rejette les segments vides, point et double point pour éviter la normalisation des URI. Gardez le fichier local inchangé pendant toute la durée du téléversement ; les vérifications de taille ne peuvent pas détecter toutes les modifications simultanées qui conservent la même taille.
Implémenter une gestion robuste des erreurs
La création de conteneurs accepte HTTP 201 ou 202 ; le téléversement d’objets et de manifestes exige 201. La somme de contrôle du segment est comparée à l’en-tête ETag brut de Swift, y compris dans les déploiements qui omettent les guillemets. Les échecs liés aux quotas, à l’authentification, aux sommes de contrôle, à la connexion ou à l’annulation se traduisent par un code de sortie non nul. L’annulation par Ctrl+C et l’expiration du délai de cinq minutes sont propagées aux lectures de fichiers et aux opérations HTTP. Cet exemple n’effectue aucune nouvelle tentative.
Si le téléversement échoue avant l’envoi du manifeste, le programme tente de supprimer uniquement les segments de ce téléversement, en ciblant leurs noms exacts. Le nettoyage dispose d’un délai distinct de 15 secondes, de sorte qu’une annulation ne l’empêche pas ; les suppressions qui échouent peuvent toutefois laisser des segments orphelins. L’objet existant reste disponible jusqu’à la publication de son remplacement.
Une fois l’envoi du manifeste commencé, le programme conserve les nouveaux segments en cas d’échec. Une réponse perdue peut masquer une publication réussie ; supprimer ces segments pourrait donc rendre l’objet inutilisable. Inspectez le manifeste actuel avant de réessayer ou de supprimer les versions non référencées. Un remplacement utilise un nouveau préfixe et une liste explicite de segments, ce qui empêche un fichier plus court d’intégrer la fin d’une ancienne version. Les anciens segments restent en place après le remplacement ; ne supprimez que les versions qui vous appartiennent et qui n’ont plus ni références ni lecteurs actifs. Cet exemple n’implémente pas ce processus de maintenance.
Optimisation des performances
Réutilisez le client pour un lot d’exports. La limite de connexions relève de
SocketsHttpHandler. Si vous ajoutez des téléversements simultanés, limitez leur nombre
et prévoyez un tampon de segment par opération.
Exemple pratique : implémentation complète
Une fois les deux variables d’environnement configurées, placez le fichier à exporter à
l’emplacement report.bin dans le répertoire du projet. Cette commande crée
ou remplace l’objet distant correspondant à la clé indiquée :
dotnet run -- exports 'reports/report #1.bin' report.bin
La commande crée exports et exports_segments si nécessaire,
téléverse les segments, puis publie reports/report #1.bin. Une entrée vide crée plutôt
un objet vide classique.
La commande affiche Uploaded. et ne se termine
avec un code zéro qu’après la réponse finale réussie. Téléchargez l’objet via l’API GET authentifiée
habituelle de Swift pour comparer ses octets à ceux de report.bin ;
l’ETag SLO d’un objet non vide n’est pas le MD5 du fichier complet.
