I have a Blazor component that needs to get data via the API, the problem is I call fetch API in OnInitializedAsync function, so every time I render the component the fetch API will call.
Because the results of data are the same in these components, so I want to call API once time and cache in session storage to reuse
My idea is to implement SemaphoreSlim service and the others call to it should be waiting until it is released, and I can reuse the data from storage. I don’t know if this is a bad or good idea. Could you please share your opinion and some other ideas to do this? Thank you for your response.
This is my code
// Razor code bind
public class DateEdit: ComponentBase
{
[Inject] ICalendDataListService calendDataListService { get; set; }
List<Calend> listData;
protected override async Task OnInitializedAsync()
{
listData = await calendDataListService.GetDataByDays(
new DateOnly(DateTime.Now.Year, 1, 1).ToString("yyyyMMdd"),
new DateOnly(DateTime.Now.Year, 12, 31).ToString("yyyyMMdd"));
}
}
// Service
public interface ICalendDataListService
{
Task<List<Calend>> GetDataByDays(string startdate, string enddate, CancellationToken token = default);
}
public class CalendDataListService : ICalendDataListService
{
private IJSInProcessRuntime _jSInProcessRuntime;
private static readonly SemaphoreSlim _semaphore = new(initialCount: 1, 1);
private NavigationManager navigationManager;
public CalendDataListService(IJSInProcessRuntime jSInProcessRuntime, NavigationManager navigationManager)
{
_jSInProcessRuntime = jSInProcessRuntime;
this.navigationManager = navigationManager;
}
public async Task<List<Calend>> GetDataByDays(string startdate, string enddate, CancellationToken token = default)
{
try
{
await _semaphore.WaitAsync(token);
Console.WriteLine($"Starting get api at {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture)}");
var calendars = await GetData(startdate, enddate, token);
if (calendars is null)
return [];
return calendars.Where(e => string.Compare(startdate[..6], e.CalenYm) <= 0 && string.Compare(enddate[..6], e.CalenYm) >= 0).ToList();
}
catch (Exception ex)
{
Console.WriteLine($"{ex.Message}");
return [];
}
finally
{
_semaphore.Release();
}
}
private async Task<List<Calend>?> GetData(string startdate, string enddate, CancellationToken token = default)
{
HttpClient _httpClient = new HttpClient()
{
BaseAddress = new Uri(navigationManager.BaseUri)
};
string key = $"calendar_{startdate[..4]}";
string dataFromStorage = _jSInProcessRuntime.Invoke<string>("sessionStorage.getItem", key);
if (dataFromStorage is not null)
{
return System.Text.Json.JsonSerializer.Deserialize<List<Calend>>(dataFromStorage)!;
}
var calendars = await _httpClient.GetFromJsonAsync<List<Calend>>("./calendar.json", token);
_jSInProcessRuntime.Invoke<string>("sessionStorage.setItem", key, System.Text.Json.JsonSerializer.Serialize(calendars));
return calendars;
}
}
1
I’m assuming you’re using the semaphore to prevent two processes starting the GetData process.
As long as all the calls originate in components, you don’t need to worry. Just use and await Tasks. Why? The Blazor UI operates in a Synchronisation Context which guarantees a single virtual thread of execution. There’s only one line.
Here’s a demo using a Country/Continent context [I’ve lifted the majority of the code from a previous answer].
First the Service:
public record Country(string country, string continent);
public class CountryService
{
private readonly HttpClient httpClient;
private IEnumerable<Country>? _countries;
private bool _loading = true;
private Task? _loadingTask;
public CountryService(HttpClient httpClient)
{
this.httpClient = httpClient;
}
// This will always get called from componments, and thus from the Synchronisation Context
// That guarantees a single thread of execution, so GetDataAsync
// can't get called before it yields on the firat await
// and _loadingTask is not null
public async Task<IEnumerable<Country>> GetDataAsync(string continent)
{
if (_loadingTask is null)
_loadingTask = LoadDataAsync();
await _loadingTask;
return _countries?.Where(item => item.continent == continent) ?? Enumerable.Empty<Country>();
}
private async Task LoadDataAsync()
{
// Load from Session Storage if exists
// make it sloooooow
await Task.Delay(5000);
_countries = await httpClient.GetFromJsonAsync<IEnumerable<Country>>("sample-data/countries.json") ?? Enumerable.Empty<Country>();
// Save to Session Storage if new
}
}
Service Setup [I’m using Server]:
builder.Services.AddScoped<CountryService>();
var services = builder.Services;
{
// Server Side Blazor doesn't register HttpClient by default
// Thanks to Robin Sue - Suchiman https://github.com/Suchiman/BlazorDualMode
if (!services.Any(x => x.ServiceType == typeof(HttpClient)))
{
// Setup HttpClient for server side in a client side compatible fashion
services.AddScoped<HttpClient>(s =>
{
// Creating the URI helper needs to wait until the JS Runtime is initialized, so defer it.
var uriHelper = s.GetRequiredService<NavigationManager>();
return new HttpClient
{
BaseAddress = new Uri(uriHelper.BaseUri)
};
});
}
}
And my demo page to show multiple loads against the service:
@page "/"
@inject CountryService CountryService
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
<div class="row mb-3">
<div class="col-4">
<select class="form-select" @bind="@_filterContinent1" @bind:after="UpdateList1">
<option selected disabled>--Filter--</option>
@foreach (var continent in Continents)
{
<option value="@continent">@continent</option>
}
</select>
</div>
<div class="col-4">
<select class="form-select" @bind="@_filterContinent2" @bind:after="UpdateList2">
<option selected disabled>--Filter--</option>
@foreach (var continent in Continents)
{
<option value="@continent">@continent</option>
}
</select>
</div>
<div class="col-4">
<select class="form-select" @bind="@_filterContinent3" @bind:after="UpdateList3">
<option selected disabled>--Filter--</option>
@foreach (var continent in Continents)
{
<option value="@continent">@continent</option>
}
</select>
</div>
</div>
<div class="row mt-3">
<div class=" col-4">
<h2>Filtered List of Countries in @_filterContinent1</h2>
@foreach (var item in _countries1)
{
<div>@item.country - @item.continent</div>
}
</div>
<div class=" col-4">
<h2>Filtered List of Countries in @_filterContinent2</h2>
@foreach (var item in _countries2)
{
<div>@item.country - @item.continent</div>
}
</div>
<div class=" col-4">
<h2>Filtered List of Countries in @_filterContinent3</h2>
@foreach (var item in _countries3)
{
<div>@item.country - @item.continent</div>
}
</div>
</div>
@code {
private IEnumerable<string> Continents = new List<string> { "Africa", "Asia", "Europe", "Oceania", "North America", "South America" };
private IEnumerable<Country> _countries1 = Enumerable.Empty<Country>();
private IEnumerable<Country> _countries2 = Enumerable.Empty<Country>();
private IEnumerable<Country> _countries3 = Enumerable.Empty<Country>();
private string? _filterContinent1;
private string? _filterContinent2;
private string? _filterContinent3;
private string? FilterAuto;
private async Task UpdateList1()
{
_countries1 = await this.CountryService.GetDataAsync(_filterContinent1 ?? "None");
}
private async Task UpdateList2()
{
_countries2 = await this.CountryService.GetDataAsync(_filterContinent2 ?? "None");
}
private async Task UpdateList3()
{
_countries3 = await this.CountryService.GetDataAsync(_filterContinent3 ?? "None");
}
}
If you did need to worry about concurrency, just use a lock like this:
private readonly object _lock = new();
public async Task<IEnumerable<Country>> GetDataAsync(string continent)
{
// only allow one process access to the start the loading task
// any subsequent waiting calls will detect an existing Task
if (_loadingTask is null)
{
lock (_lock)
{
if (_loadingTask is null)
_loadingTask = LoadDataAsync();
}
}
await _loadingTask;
return _countries?.Where(item => item.continent == continent) ?? Enumerable.Empty<Country>();
}