From a0aa7469df583df9de7b6fa7fbcc286e9c96b47b Mon Sep 17 00:00:00 2001 From: maliming Date: Sun, 8 Feb 2026 13:38:09 +0800 Subject: [PATCH 1/4] Concurrently retrieving configuration/localization info in `MvcCachedApplicationConfigurationClient`. --- framework/Volo.Abp.slnx | 1 + ...MvcCachedApplicationConfigurationClient.cs | 32 ++++++- ...olo.Abp.AspNetCore.Mvc.Client.Tests.csproj | 17 ++++ .../Client/AbpAspNetCoreMvcClientTestBase.cs | 11 +++ .../AbpAspNetCoreMvcClientTestModule.cs | 17 ++++ ...hedApplicationConfigurationClient_Tests.cs | 90 +++++++++++++++++++ 6 files changed, 164 insertions(+), 4 deletions(-) create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo.Abp.AspNetCore.Mvc.Client.Tests.csproj create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientTestBase.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientTestModule.cs create mode 100644 framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs diff --git a/framework/Volo.Abp.slnx b/framework/Volo.Abp.slnx index 3d74af9f95..1302600c09 100644 --- a/framework/Volo.Abp.slnx +++ b/framework/Volo.Abp.slnx @@ -181,6 +181,7 @@ + diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs index e3d3c12370..27856fe7e4 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using Microsoft.AspNetCore.Http; using System.Threading.Tasks; using Microsoft.Extensions.Caching.Distributed; @@ -84,20 +85,43 @@ public class MvcCachedApplicationConfigurationClient : ICachedApplicationConfigu private async Task GetRemoteConfigurationAsync() { - var config = await ApplicationConfigurationAppService.GetAsync( + var cultureName = CultureInfo.CurrentUICulture.Name; + + var configTask = ApplicationConfigurationAppService.GetAsync( new ApplicationConfigurationRequestOptions { IncludeLocalizationResources = false } ); - var localizationDto = await ApplicationLocalizationClientProxy.GetAsync( - new ApplicationLocalizationRequestDto { - CultureName = config.Localization.CurrentCulture.Name, + var localizationTask = ApplicationLocalizationClientProxy.GetAsync( + new ApplicationLocalizationRequestDto + { + CultureName = cultureName, OnlyDynamics = true } ); + var config = await configTask; + + ApplicationLocalizationDto localizationDto; + // In most cases, the culture matches and we can reuse the concurrent request. + // If not, we discard it and make a new request with the correct culture. + if (string.Equals(config.Localization.CurrentCulture.Name, cultureName, StringComparison.OrdinalIgnoreCase)) + { + localizationDto = await localizationTask; + } + else + { + localizationDto = await ApplicationLocalizationClientProxy.GetAsync( + new ApplicationLocalizationRequestDto + { + CultureName = config.Localization.CurrentCulture.Name, + OnlyDynamics = true + } + ); + } + config.Localization.Resources = localizationDto.Resources; return config; diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo.Abp.AspNetCore.Mvc.Client.Tests.csproj b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo.Abp.AspNetCore.Mvc.Client.Tests.csproj new file mode 100644 index 0000000000..d1190652a3 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo.Abp.AspNetCore.Mvc.Client.Tests.csproj @@ -0,0 +1,17 @@ + + + + + + net10.0 + + + + + + + + + + + diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientTestBase.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientTestBase.cs new file mode 100644 index 0000000000..96e82fe105 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientTestBase.cs @@ -0,0 +1,11 @@ +using Volo.Abp.Testing; + +namespace Volo.Abp.AspNetCore.Mvc.Client; + +public abstract class AbpAspNetCoreMvcClientTestBase : AbpIntegratedTest +{ + protected override void SetAbpApplicationCreationOptions(AbpApplicationCreationOptions options) + { + options.UseAutofac(); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientTestModule.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientTestModule.cs new file mode 100644 index 0000000000..04e9f856a2 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/AbpAspNetCoreMvcClientTestModule.cs @@ -0,0 +1,17 @@ +using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Autofac; +using Volo.Abp.Modularity; + +namespace Volo.Abp.AspNetCore.Mvc.Client; + +[DependsOn( + typeof(AbpAspNetCoreMvcClientModule), + typeof(AbpAutofacModule) +)] +public class AbpAspNetCoreMvcClientTestModule : AbpModule +{ + public override void ConfigureServices(ServiceConfigurationContext context) + { + context.Services.AddHttpContextAccessor(); + } +} diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs new file mode 100644 index 0000000000..ed20593873 --- /dev/null +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using NSubstitute; +using Shouldly; +using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; +using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ClientProxies; +using Xunit; + +namespace Volo.Abp.AspNetCore.Mvc.Client; + +public class MvcCachedApplicationConfigurationClient_Tests : AbpAspNetCoreMvcClientTestBase +{ + private AbpApplicationConfigurationClientProxy _configProxy; + private AbpApplicationLocalizationClientProxy _localizationProxy; + + private readonly ICachedApplicationConfigurationClient _applicationConfigurationClient; + + public MvcCachedApplicationConfigurationClient_Tests() + { + _applicationConfigurationClient = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + _configProxy = Substitute.For(); + _localizationProxy = Substitute.For(); + + services.Replace(ServiceDescriptor.Transient(_ => _configProxy)); + services.Replace(ServiceDescriptor.Transient(_ => _localizationProxy)); + } + + [Fact] + public async Task Should_Use_Concurrent_Requests_When_Culture_Matches() + { + var cultureName = "en"; + + _configProxy.GetAsync(Arg.Any()).Returns(CreateConfigDto(cultureName)); + + var expectedResources = new Dictionary + { + ["TestResource"] = new() + }; + + _localizationProxy.GetAsync(Arg.Any()).Returns(new ApplicationLocalizationDto { Resources = expectedResources }); + + var result = await _applicationConfigurationClient.GetAsync(); + + result.Localization.Resources.ShouldBe(expectedResources); + + await _configProxy.Received(1).GetAsync(Arg.Is(x => x.IncludeLocalizationResources == false)); + await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == cultureName && x.OnlyDynamics == true)); + } + + [Fact] + public async Task Should_Refetch_Localization_When_Culture_Differs() + { + var currentCulture = "en"; + var serverCulture = "tr"; + + _configProxy.GetAsync(Arg.Any()).Returns(CreateConfigDto(serverCulture)); + + var wrongResources = new Dictionary(); + var correctResources = new Dictionary + { + ["TestResource"] = new() + }; + + _localizationProxy.GetAsync(Arg.Is(x => x.CultureName == currentCulture)).Returns(new ApplicationLocalizationDto { Resources = wrongResources }); + _localizationProxy.GetAsync(Arg.Is(x => x.CultureName == serverCulture)).Returns(new ApplicationLocalizationDto { Resources = correctResources }); + + var result = await _applicationConfigurationClient.GetAsync(); + + result.Localization.Resources.ShouldBe(correctResources); + + await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == serverCulture)); + } + + private static ApplicationConfigurationDto CreateConfigDto(string cultureName) + { + return new ApplicationConfigurationDto + { + Localization = + { + CurrentCulture = new CurrentCultureDto { Name = cultureName } + } + }; + } +} From bf397757a7325a0fcd3764d696ad97a9c1fba4b0 Mon Sep 17 00:00:00 2001 From: maliming Date: Sun, 8 Feb 2026 14:13:09 +0800 Subject: [PATCH 2/4] improve culture compatibility checks and handle task exceptions in MvcCachedApplicationConfigurationClient --- ...MvcCachedApplicationConfigurationClient.cs | 9 +++- ...hedApplicationConfigurationClient_Tests.cs | 50 +++++++++++-------- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs index 27856fe7e4..684e99022b 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs @@ -8,6 +8,7 @@ using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ClientProxies; using Volo.Abp.Caching; using Volo.Abp.DependencyInjection; +using Volo.Abp.Localization; using Volo.Abp.Threading; using Volo.Abp.Users; @@ -107,12 +108,18 @@ public class MvcCachedApplicationConfigurationClient : ICachedApplicationConfigu ApplicationLocalizationDto localizationDto; // In most cases, the culture matches and we can reuse the concurrent request. // If not, we discard it and make a new request with the correct culture. - if (string.Equals(config.Localization.CurrentCulture.Name, cultureName, StringComparison.OrdinalIgnoreCase)) + if (CultureHelper.IsCompatibleCulture(config.Localization.CurrentCulture.Name, cultureName)) { localizationDto = await localizationTask; } else { + // Observe the discarded task to prevent UnobservedTaskException. + _ = localizationTask.ContinueWith( + static t => _ = t.Exception, + TaskContinuationOptions.OnlyOnFaulted + ); + localizationDto = await ApplicationLocalizationClientProxy.GetAsync( new ApplicationLocalizationRequestDto { diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs index ed20593873..99c3e552d2 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs @@ -6,6 +6,7 @@ using NSubstitute; using Shouldly; using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations; using Volo.Abp.AspNetCore.Mvc.ApplicationConfigurations.ClientProxies; +using Volo.Abp.Localization; using Xunit; namespace Volo.Abp.AspNetCore.Mvc.Client; @@ -36,21 +37,24 @@ public class MvcCachedApplicationConfigurationClient_Tests : AbpAspNetCoreMvcCli { var cultureName = "en"; - _configProxy.GetAsync(Arg.Any()).Returns(CreateConfigDto(cultureName)); - - var expectedResources = new Dictionary + using (CultureHelper.Use(cultureName)) { - ["TestResource"] = new() - }; + _configProxy.GetAsync(Arg.Any()).Returns(CreateConfigDto(cultureName)); + + var expectedResources = new Dictionary + { + ["TestResource"] = new() + }; - _localizationProxy.GetAsync(Arg.Any()).Returns(new ApplicationLocalizationDto { Resources = expectedResources }); + _localizationProxy.GetAsync(Arg.Any()).Returns(new ApplicationLocalizationDto { Resources = expectedResources }); - var result = await _applicationConfigurationClient.GetAsync(); + var result = await _applicationConfigurationClient.GetAsync(); - result.Localization.Resources.ShouldBe(expectedResources); + result.Localization.Resources.ShouldBe(expectedResources); - await _configProxy.Received(1).GetAsync(Arg.Is(x => x.IncludeLocalizationResources == false)); - await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == cultureName && x.OnlyDynamics == true)); + await _configProxy.Received(1).GetAsync(Arg.Is(x => x.IncludeLocalizationResources == false)); + await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == cultureName && x.OnlyDynamics == true)); + } } [Fact] @@ -59,22 +63,26 @@ public class MvcCachedApplicationConfigurationClient_Tests : AbpAspNetCoreMvcCli var currentCulture = "en"; var serverCulture = "tr"; - _configProxy.GetAsync(Arg.Any()).Returns(CreateConfigDto(serverCulture)); - - var wrongResources = new Dictionary(); - var correctResources = new Dictionary + using (CultureHelper.Use(currentCulture)) { - ["TestResource"] = new() - }; + _configProxy.GetAsync(Arg.Any()).Returns(CreateConfigDto(serverCulture)); + + var wrongResources = new Dictionary(); + var correctResources = new Dictionary + { + ["TestResource"] = new() + }; - _localizationProxy.GetAsync(Arg.Is(x => x.CultureName == currentCulture)).Returns(new ApplicationLocalizationDto { Resources = wrongResources }); - _localizationProxy.GetAsync(Arg.Is(x => x.CultureName == serverCulture)).Returns(new ApplicationLocalizationDto { Resources = correctResources }); + _localizationProxy.GetAsync(Arg.Is(x => x.CultureName == currentCulture)).Returns(new ApplicationLocalizationDto { Resources = wrongResources }); + _localizationProxy.GetAsync(Arg.Is(x => x.CultureName == serverCulture)).Returns(new ApplicationLocalizationDto { Resources = correctResources }); - var result = await _applicationConfigurationClient.GetAsync(); + var result = await _applicationConfigurationClient.GetAsync(); - result.Localization.Resources.ShouldBe(correctResources); + result.Localization.Resources.ShouldBe(correctResources); - await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == serverCulture)); + await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == currentCulture)); + await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == serverCulture)); + } } private static ApplicationConfigurationDto CreateConfigDto(string cultureName) From 5eddbb92242802b01fcd3f0e699104daeb3463f7 Mon Sep 17 00:00:00 2001 From: maliming Date: Sun, 8 Feb 2026 14:29:24 +0800 Subject: [PATCH 3/4] update localization test to use current UI culture and handle concurrent requests --- ...cCachedApplicationConfigurationClient_Tests.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs index 99c3e552d2..6784c69954 100644 --- a/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs +++ b/framework/test/Volo.Abp.AspNetCore.Mvc.Client.Tests/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient_Tests.cs @@ -33,13 +33,14 @@ public class MvcCachedApplicationConfigurationClient_Tests : AbpAspNetCoreMvcCli } [Fact] - public async Task Should_Use_Concurrent_Requests_When_Culture_Matches() + public async Task Should_Use_CurrentUICulture_For_Localization_Request() { var cultureName = "en"; using (CultureHelper.Use(cultureName)) { - _configProxy.GetAsync(Arg.Any()).Returns(CreateConfigDto(cultureName)); + var configTcs = new TaskCompletionSource(); + _configProxy.GetAsync(Arg.Any()).Returns(configTcs.Task); var expectedResources = new Dictionary { @@ -48,12 +49,18 @@ public class MvcCachedApplicationConfigurationClient_Tests : AbpAspNetCoreMvcCli _localizationProxy.GetAsync(Arg.Any()).Returns(new ApplicationLocalizationDto { Resources = expectedResources }); - var result = await _applicationConfigurationClient.GetAsync(); + var resultTask = _applicationConfigurationClient.GetAsync(); + + // Localization request should be fired before config completes (concurrent). + await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == cultureName && x.OnlyDynamics == true)); + + // Now let config complete. + configTcs.SetResult(CreateConfigDto(cultureName)); + var result = await resultTask; result.Localization.Resources.ShouldBe(expectedResources); await _configProxy.Received(1).GetAsync(Arg.Is(x => x.IncludeLocalizationResources == false)); - await _localizationProxy.Received(1).GetAsync(Arg.Is(x => x.CultureName == cultureName && x.OnlyDynamics == true)); } } From 9133ce97e2cd1aae8a5f8fd62ceec8c32c1ae137 Mon Sep 17 00:00:00 2001 From: maliming Date: Sun, 8 Feb 2026 14:40:17 +0800 Subject: [PATCH 4/4] refactor: make GetRemoteConfigurationAsync and Get methods virtual for extensibility --- ...MvcCachedApplicationConfigurationClient.cs | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs b/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs index 684e99022b..5d87371ae8 100644 --- a/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs +++ b/framework/src/Volo.Abp.AspNetCore.Mvc.Client/Volo/Abp/AspNetCore/Mvc/Client/MvcCachedApplicationConfigurationClient.cs @@ -84,7 +84,7 @@ public class MvcCachedApplicationConfigurationClient : ICachedApplicationConfigu return configuration; } - private async Task GetRemoteConfigurationAsync() + protected virtual async Task GetRemoteConfigurationAsync() { var cultureName = CultureInfo.CurrentUICulture.Name; @@ -103,23 +103,13 @@ public class MvcCachedApplicationConfigurationClient : ICachedApplicationConfigu } ); - var config = await configTask; + await Task.WhenAll(configTask, localizationTask); - ApplicationLocalizationDto localizationDto; - // In most cases, the culture matches and we can reuse the concurrent request. - // If not, we discard it and make a new request with the correct culture. - if (CultureHelper.IsCompatibleCulture(config.Localization.CurrentCulture.Name, cultureName)) - { - localizationDto = await localizationTask; - } - else - { - // Observe the discarded task to prevent UnobservedTaskException. - _ = localizationTask.ContinueWith( - static t => _ = t.Exception, - TaskContinuationOptions.OnlyOnFaulted - ); + var config = configTask.Result; + var localizationDto = localizationTask.Result; + if (!CultureHelper.IsCompatibleCulture(config.Localization.CurrentCulture.Name, cultureName)) + { localizationDto = await ApplicationLocalizationClientProxy.GetAsync( new ApplicationLocalizationRequestDto { @@ -134,7 +124,7 @@ public class MvcCachedApplicationConfigurationClient : ICachedApplicationConfigu return config; } - public ApplicationConfigurationDto Get() + public virtual ApplicationConfigurationDto Get() { string? cacheKey = null; var httpContext = HttpContextAccessor?.HttpContext;