feat: Refactor RaceUploadCommunicationClient and RaceUploadTabView to improve HttpClient management and resource disposal

This commit is contained in:
MaddoScientisto 2026-03-08 14:30:37 +01:00
commit bdf503c627
3 changed files with 145 additions and 81 deletions

View file

@ -2,6 +2,8 @@ using System.Net;
using Catalog.Communication.Abstractions; using Catalog.Communication.Abstractions;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Catalog.Communication.DependencyInjection; namespace Catalog.Communication.DependencyInjection;
@ -25,21 +27,29 @@ public static class CatalogCommunicationServiceCollectionExtensions
services.TryAddSingleton<CookieContainer>(); services.TryAddSingleton<CookieContainer>();
services // Create the HttpClient only when the communication client is requested.
.AddHttpClient<IRaceUploadCommunicationClient, RaceUploadCommunicationClient>((sp, client) => // This avoids constructing the DefaultHttpClientFactory (and its background cleanup timer)
// if the race-upload feature is never used.
services.AddTransient<IRaceUploadCommunicationClient>(sp =>
{ {
var options = sp.GetRequiredService<IOptions<CatalogCommunicationOptions>>().Value; var options = sp.GetRequiredService<IOptions<CatalogCommunicationOptions>>().Value;
client.BaseAddress = options.BaseUri; var logger = sp.GetService<ILogger<RaceUploadCommunicationClient>>() ?? NullLogger<RaceUploadCommunicationClient>.Instance;
})
.ConfigurePrimaryHttpMessageHandler(sp =>
{
var cookieContainer = sp.GetRequiredService<CookieContainer>(); var cookieContainer = sp.GetRequiredService<CookieContainer>();
return new HttpClientHandler
var handler = new HttpClientHandler
{ {
UseCookies = true, UseCookies = true,
CookieContainer = cookieContainer, CookieContainer = cookieContainer,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
}; };
var httpClient = new HttpClient(handler, disposeHandler: true)
{
BaseAddress = options.BaseUri,
Timeout = options.RequestTimeout,
};
return new RaceUploadCommunicationClient(httpClient, sp.GetRequiredService<IOptions<CatalogCommunicationOptions>>(), logger);
}); });
return services; return services;

View file

@ -10,7 +10,7 @@ using Microsoft.Extensions.Options;
namespace Catalog.Communication; namespace Catalog.Communication;
public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient, IDisposable
{ {
private const string AdminMenuPath = "admin/menu/Menu4.abl"; private const string AdminMenuPath = "admin/menu/Menu4.abl";
private const string PublicLogonPath = "Logon.abl"; private const string PublicLogonPath = "Logon.abl";
@ -27,6 +27,7 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ILogger<RaceUploadCommunicationClient> _logger; private readonly ILogger<RaceUploadCommunicationClient> _logger;
private readonly IOptions<CatalogCommunicationOptions> _options; private readonly IOptions<CatalogCommunicationOptions> _options;
private bool _disposed;
public RaceUploadCommunicationClient( public RaceUploadCommunicationClient(
HttpClient httpClient, HttpClient httpClient,
@ -38,6 +39,18 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie
_logger = logger; _logger = logger;
} }
public void Dispose()
{
if (_disposed)
{
return;
}
_httpClient.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
public Task<RawEndpointResponse> LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default) public Task<RawEndpointResponse> LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default)
{ {
ArgumentNullException.ThrowIfNull(request); ArgumentNullException.ThrowIfNull(request);

View file

@ -17,18 +17,21 @@ namespace ImageCatalog_2.AvaloniaViews;
public partial class RaceUploadTabView : Avalonia.Controls.UserControl public partial class RaceUploadTabView : Avalonia.Controls.UserControl
{ {
private readonly IRaceUploadCommunicationClient _apiClient;
private readonly ILogger<RaceUploadTabView> _logger; private readonly ILogger<RaceUploadTabView> _logger;
public RaceUploadTabView() public RaceUploadTabView()
{ {
InitializeComponent(); InitializeComponent();
_apiClient = Program.ServiceProvider.GetService(typeof(IRaceUploadCommunicationClient)) as IRaceUploadCommunicationClient
?? throw new InvalidOperationException("IRaceUploadCommunicationClient non disponibile.");
_logger = Program.ServiceProvider.GetService(typeof(ILogger<RaceUploadTabView>)) as ILogger<RaceUploadTabView> _logger = Program.ServiceProvider.GetService(typeof(ILogger<RaceUploadTabView>)) as ILogger<RaceUploadTabView>
?? NullLogger<RaceUploadTabView>.Instance; ?? NullLogger<RaceUploadTabView>.Instance;
} }
private static IRaceUploadCommunicationClient CreateClient()
{
return Program.ServiceProvider.GetService(typeof(IRaceUploadCommunicationClient)) as IRaceUploadCommunicationClient
?? throw new InvalidOperationException("IRaceUploadCommunicationClient non disponibile.");
}
private async void CreateRace_Click(object? sender, RoutedEventArgs e) private async void CreateRace_Click(object? sender, RoutedEventArgs e)
{ {
var outputBox = this.FindControl<Avalonia.Controls.TextBox>("ApiOutputTextBox"); var outputBox = this.FindControl<Avalonia.Controls.TextBox>("ApiOutputTextBox");
@ -76,7 +79,14 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
var loginResponse = await LoginAsync(login, password).ConfigureAwait(true); var loginResponse = await LoginAsync(login, password).ConfigureAwait(true);
var saveResponse = await _apiClient.SaveRaceAsync( RawEndpointResponse saveResponse;
RawEndpointResponse createPointsResponse;
long raceId;
var client = CreateClient();
try
{
saveResponse = await client.SaveRaceAsync(
new RaceSaveRequest new RaceSaveRequest
{ {
IdGara = 0, IdGara = 0,
@ -90,9 +100,9 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
PathBase = model.ApiPathBase?.Trim(), PathBase = model.ApiPathBase?.Trim(),
Localita = model.ApiLocalita?.Trim(), Localita = model.ApiLocalita?.Trim(),
}, },
CancellationToken.None); CancellationToken.None).ConfigureAwait(false);
var raceId = ExtractRaceId(saveResponse.Body); raceId = ExtractRaceId(saveResponse.Body);
if (raceId <= 0) if (raceId <= 0)
{ {
throw new InvalidOperationException("Impossibile ricavare id_gara dalla risposta di salvataggio."); throw new InvalidOperationException("Impossibile ricavare id_gara dalla risposta di salvataggio.");
@ -100,8 +110,12 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
model.ApiRaceId = raceId.ToString(); model.ApiRaceId = raceId.ToString();
var createPointsResponse = await _apiClient.CreateRacePointsAsync(raceId, CancellationToken.None); createPointsResponse = await client.CreateRacePointsAsync(raceId, CancellationToken.None).ConfigureAwait(false);
}
finally
{
(client as IDisposable)?.Dispose();
}
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine($"Login HTTP: {(int)loginResponse.StatusCode} {loginResponse.StatusCode}"); sb.AppendLine($"Login HTTP: {(int)loginResponse.StatusCode} {loginResponse.StatusCode}");
sb.AppendLine($"Save Gara HTTP: {(int)saveResponse.StatusCode} {saveResponse.StatusCode}"); sb.AppendLine($"Save Gara HTTP: {(int)saveResponse.StatusCode} {saveResponse.StatusCode}");
@ -197,6 +211,11 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.AppendLine($"File da inviare: {files.Count}"); sb.AppendLine($"File da inviare: {files.Count}");
List<long> pointIds;
var client = CreateClient();
try
{
foreach (var file in files) foreach (var file in files)
{ {
var relativePath = Path.GetRelativePath(model.DestinationPath, file); var relativePath = Path.GetRelativePath(model.DestinationPath, file);
@ -204,7 +223,7 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
var remotePath = CombineRemotePath(remoteProcessedBase, racePathBase, relativeDir); var remotePath = CombineRemotePath(remoteProcessedBase, racePathBase, relativeDir);
await using var stream = File.OpenRead(file); await using var stream = File.OpenRead(file);
await _apiClient.UploadFileToReceiverAsync( await client.UploadFileToReceiverAsync(
new ReceiveFileUploadRequest new ReceiveFileUploadRequest
{ {
FileName = Path.GetFileName(file), FileName = Path.GetFileName(file),
@ -224,14 +243,18 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
sb.AppendLine($"Upload completato: {uploaded}/{files.Count}"); sb.AppendLine($"Upload completato: {uploaded}/{files.Count}");
statusBlock.Text = "Creazione punti foto e indicizzazione in corso..."; statusBlock.Text = "Creazione punti foto e indicizzazione in corso...";
await _apiClient.CreateRacePointsAsync(raceId, CancellationToken.None).ConfigureAwait(true); await client.CreateRacePointsAsync(raceId, CancellationToken.None).ConfigureAwait(true);
var pointIds = await LoadPointIdsWithRetryAsync(raceId, CancellationToken.None).ConfigureAwait(true); pointIds = await LoadPointIdsWithRetryAsync(raceId, CancellationToken.None).ConfigureAwait(true);
foreach (var pointId in pointIds) foreach (var pointId in pointIds)
{ {
await _apiClient.IndexRacePointAsync(pointId, CancellationToken.None).ConfigureAwait(true); await client.IndexRacePointAsync(pointId, CancellationToken.None).ConfigureAwait(true);
}
}
finally
{
(client as IDisposable)?.Dispose();
} }
sb.AppendLine($"Punti foto indicizzati: {pointIds.Count}"); sb.AppendLine($"Punti foto indicizzati: {pointIds.Count}");
outputBox.Text = sb.ToString(); outputBox.Text = sb.ToString();
statusBlock.Text = "Upload e indicizzazione completati."; statusBlock.Text = "Upload e indicizzazione completati.";
@ -251,7 +274,10 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
private async Task<RawEndpointResponse> LoginAsync(string login, string password) private async Task<RawEndpointResponse> LoginAsync(string login, string password)
{ {
return await _apiClient.LoginAdminAsync( var client = CreateClient();
try
{
return await client.LoginAdminAsync(
new AdminLoginRequest new AdminLoginRequest
{ {
Login = login, Login = login,
@ -260,14 +286,22 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
}, },
CancellationToken.None).ConfigureAwait(false); CancellationToken.None).ConfigureAwait(false);
} }
finally
{
(client as IDisposable)?.Dispose();
}
}
private async Task<List<long>> LoadPointIdsWithRetryAsync(long raceId, CancellationToken cancellationToken) private async Task<List<long>> LoadPointIdsWithRetryAsync(long raceId, CancellationToken cancellationToken)
{ {
const int maxAttempts = 10; const int maxAttempts = 10;
var client = CreateClient();
try
{
for (var attempt = 1; attempt <= maxAttempts; attempt++) for (var attempt = 1; attempt <= maxAttempts; attempt++)
{ {
var response = await _apiClient.GetRaceDetailAsync(raceId, cancellationToken).ConfigureAwait(false); var response = await client.GetRaceDetailAsync(raceId, cancellationToken).ConfigureAwait(false);
var ids = ExtractPointIds(response.Body); var ids = ExtractPointIds(response.Body);
if (ids.Count > 0) if (ids.Count > 0)
@ -278,6 +312,13 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false);
} }
return new List<long>();
}
finally
{
(client as IDisposable)?.Dispose();
}
return []; return [];
} }