Exportación sencilla de archivos a OpenStack Swift en .NET C#
Sube archivos mediante la API HTTP de Swift con HttpClient, integrado en .NET.
Este ejemplo crea segmentos reales y publica un manifiesto estático de objetos grandes solo después
de recibir la confirmación de cada segmento.
Introducción a OpenStack Swift
Swift almacena objetos en contenedores. Su API de objetos grandes estáticos combina una lista ordenada de segmentos subidos en un único objeto descargable. El despliegue debe tener habilitado el middleware SLO para este ejemplo.
Configura tu entorno
Requisitos previos
Instala un .NET 10 SDK que siga recibiendo mantenimiento. El ejemplo se probó en Bash sobre Linux con SDK 10.0.111, entorno de ejecución 10.0.11 y Swift 2.38.2. Obtén un token con ámbito de proyecto y la URL de la cuenta de almacenamiento de objetos mediante el flujo de autenticación de OpenStack de tu despliegue. El token necesita permisos para crear contenedores, subir objetos, leer los metadatos de los segmentos para la validación SLO y eliminar los segmentos de subidas fallidas.
El ejemplo usa segmentos de 5 MiB y un máximo de 1.000 segmentos (5.242.880.000 bytes, incluido ese
valor límite). Confirma que los límites de SLO, tamaño de objetos y cuota de tu despliegue permiten
estos valores.
La subida completa tiene un plazo de cinco minutos, por lo que una conexión más lenta puede fallar
antes de alcanzar el límite de tamaño.
Usa rutas locales y nombres de directorios de proyecto en ASCII. El programa impone esta restricción
antes de abrir el archivo para que los bytes no válidos del nombre no puedan seleccionar otro
archivo después de decodificar los argumentos.
Las claves de objetos remotos pueden contener Unicode.
Empieza en un directorio con permiso de escritura y sin una entrada SwiftExport.
La configuración de .NET SDK, NuGet y MSBuild de los directorios superiores sigue aplicándose.
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 configuración termina correctamente, tu shell entra en el directorio del proyecto. Si falla
la restauración, el proyecto nuevo permanece y tu shell se queda en el directorio superior.
Resuelve el problema de SDK o NuGet indicado y ejecuta dotnet restore SwiftExport/SwiftExport.csproj && cd SwiftExport allí para
completar la configuración.
No se necesitan paquetes NuGet adicionales.
Gestión segura de la configuración
Asigna a SWIFT_STORAGE_URL el endpoint HTTPS completo de la cuenta que figura en el
catálogo de servicios, incluida su ruta /v1/AUTH_…. Asigna a
SWIFT_TOKEN el token con ámbito definido. Proporciona ambos mediante tu shell o
gestor de secretos. La obtención y renovación del token forman parte del flujo de Keystone de tu
despliegue.
Un token vencido provoca una salida con código distinto de cero; obtén un nuevo token con ámbito
definido antes de volver a ejecutar el programa.
Implementa las operaciones de almacenamiento de Swift
Reemplaza Program.cs por este programa completo. El
HttpClient inyectado pertenece al código que llama al exportador; el exportador
libera cada solicitud y respuesta. Las redirecciones están deshabilitadas para que el token no se
reenvíe a otro destino.
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; }
}
}
}
Gestiona archivos grandes
Cada archivo no vacío recibe un nuevo prefijo de segmentos en <container>_segments.
Las subidas secuenciales mantienen el búfer de datos en 5 MiB. El manifiesto contiene la ruta, el
ETag y el tamaño de cada segmento, que Swift valida antes de publicar el objeto. Un archivo vacío
usa un PUT vacío normal, lo que evita depender del soporte específico del despliegue para
manifiestos SLO vacíos.
La clave del objeto remoto puede contener espacios y Unicode. La codificación conserva los separadores de ruta y rechaza los segmentos vacíos, de punto y de doble punto para evitar la normalización de URI. Mantén el archivo local sin cambios durante toda la subida; las comprobaciones de tamaño no pueden detectar todas las modificaciones simultáneas que mantienen el mismo tamaño.
Implementa una gestión sólida de errores
La creación de contenedores acepta HTTP 201 o 202; las subidas de objetos y manifiestos requieren 201. La suma de control del segmento se compara con el valor sin procesar del encabezado ETag de Swift, incluso en despliegues que omiten las comillas. Los fallos de cuota, autenticación, suma de control, conexión y cancelación provocan una salida con código distinto de cero. Ctrl+C y el plazo de cinco minutos se propagan a las lecturas de archivos y las operaciones HTTP. Este ejemplo no realiza reintentos.
Antes de enviar el manifiesto, una subida fallida intenta eliminar únicamente sus propios segmentos por sus nombres exactos. La limpieza tiene un plazo independiente de 15 segundos, por lo que la cancelación no la omite; las eliminaciones fallidas aún pueden dejar segmentos huérfanos. El objeto existente permanece disponible hasta que se publica su reemplazo.
Una vez que comienza el envío del manifiesto, el programa conserva los nuevos segmentos si se produce un fallo. Una respuesta perdida puede ocultar una publicación exitosa, por lo que eliminar esos segmentos podría dejar el objeto inutilizable. Inspecciona el manifiesto actual antes de volver a intentarlo o de eliminar versiones sin referencias. Un reemplazo usa un prefijo nuevo y una lista explícita de segmentos, lo que evita que un archivo más corto incorpore la parte final de uno anterior. Los segmentos antiguos permanecen después del reemplazo; elimina únicamente las versiones que te pertenezcan y que ya no tengan referencias ni lectores activos. Este ejemplo no implementa ese flujo de mantenimiento.
Optimiza el rendimiento
Reutiliza el cliente para un lote de exportaciones. El límite de conexiones corresponde a
SocketsHttpHandler.
Si añades subidas simultáneas, limita la concurrencia y reserva un búfer de segmento por operación.
Ejemplo práctico: implementación completa
Con ambas variables de entorno configuradas, coloca el archivo que quieres exportar en
report.bin dentro del directorio del proyecto. Este comando crea o reemplaza el
objeto remoto con la clave indicada:
dotnet run -- exports 'reports/report #1.bin' report.bin
El comando crea exports y exports_segments si es necesario, sube
los segmentos y, finalmente, publica reports/report #1.bin. Una entrada vacía crea un objeto
vacío normal en su lugar.
El comando imprime Uploaded. y termina con código
cero solo después de la respuesta final exitosa. Descarga el objeto mediante la API GET autenticada
habitual de Swift para comparar sus bytes con report.bin; el ETag SLO de un objeto
no vacío no es el MD5 del archivo completo.
