Framework-first doctrine, DI logger factory, config refactor

Introduced framework-first design rules and updated documentation to clarify framework vs. consumer boundaries. Added AcLoggerOptions and DI-based logger factory extensions to AyCode.Core, enabling per-category logger instantiation from appsettings.json. Standardized SignalR connection setup with AddAcDefaults, replacing project-specific code. Enhanced protocol configuration for DI scope isolation. Refactored appsettings.json structure and added MSBuild targets for config propagation. Removed obsolete code and updated comments to match new patterns.
This commit is contained in:
Loretta 2026-04-23 16:11:22 +02:00
parent 33d84a8257
commit 711c3c8ec0
15 changed files with 201 additions and 174 deletions

View File

@ -26,7 +26,7 @@ internal static class TestSignalRClientFactory
new SignaRClientLogItemWriter(AppType.TestUnit, LogLevel.Detail, testCategoryName)
};
Func<string, AcLoggerBase> loggerFactory =
Func<string, LoggerClient> loggerFactory =
categoryName => new LoggerClient(categoryName, logWriters.ToArray());
var connectionOptions = new AcHubConnectionOptions
@ -45,7 +45,7 @@ internal static class TestSignalRClientFactory
var logger = loggerFactory(nameof(FruitBankSignalRClient));
var hubBuilder = new HubConnectionBuilder().AddFruitBankDefaults(logger, connectionOptions);
var hubBuilder = new HubConnectionBuilder().AddAcDefaults(logger, connectionOptions);
hubBuilder.AddAcBinaryProtocol(opts => opts.ProtocolMode = BinaryProtocolMode.AsyncSegment);
return new FruitBankSignalRClient(hubBuilder, loggerFactory);

View File

@ -1,35 +0,0 @@
using AyCode.Core.Loggers;
using AyCode.Services.SignalRs;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Logging;
namespace FruitBankHybrid.Shared.Services.SignalRs;
/// <summary>
/// FruitBank-specific hub-connection setup.
/// <para>
/// Connection-level configuration (URL, buffers, timeouts, reconnect) is delegated to the
/// framework's <see cref="AcSignalRConnectionExtensions.AddAcConnection"/>, driven by
/// <see cref="AcHubConnectionOptions"/> from <c>appsettings.json</c>. The logger is supplied
/// by the caller (typically via a DI-registered <c>Func&lt;string, AcLoggerBase&gt;</c> factory
/// in <c>Program.cs</c>), keeping this extension free of <c>LoggerClient</c> construction.
/// </para>
/// </summary>
public static class FruitBankHubConnectionExtensions
{
/// <summary>
/// Applies <see cref="AcSignalRConnectionExtensions.AddAcConnection"/> with the given
/// <paramref name="connectionOptions"/>, then bridges <paramref name="logger"/> into
/// SignalR's internal <c>ILogger</c> pipeline.
/// </summary>
public static IHubConnectionBuilder AddFruitBankDefaults(this IHubConnectionBuilder builder, AcLoggerBase logger, AcHubConnectionOptions connectionOptions)
{
return builder
.AddAcConnection(connectionOptions)
.ConfigureLogging(logging =>
{
logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Information);
logging.AddAcLogger(_ => logger);
});
}
}

View File

@ -26,7 +26,7 @@ namespace FruitBankHybrid.Shared.Services.SignalRs
{
public class FruitBankSignalRClient : AcSignalRClientBase, IFruitBankDataControllerClient, ICustomOrderSignalREndpointClient, IStockSignalREndpointClient
{
public FruitBankSignalRClient(IHubConnectionBuilder hubBuilder, Func<string, AcLoggerBase> loggerFactory)
public FruitBankSignalRClient(IHubConnectionBuilder hubBuilder, Func<string, LoggerClient> loggerFactory)
: base(hubBuilder, loggerFactory(nameof(FruitBankSignalRClient)))
{
EnableBinaryDiagnostics = FruitBankConstClient.SignalRSerializerDiagnosticLog;

View File

@ -0,0 +1,44 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning"
}
},
"AyCode": {
"ProjectId": "aad53443-2ee2-4650-8a99-97e907265e4e",
"Urls": {
"BaseUrl": "https://localhost:7144",
"ApiBaseUrl": "https://localhost:7144"
},
"Logger": {
"AppType": "Server",
"LogLevel": "Detail",
"LogWriters": [
{
"LogLevel": "Detail",
"LogWriterType": "FruitBank.Common.Server.Services.Loggers.ConsoleLogWriter, FruitBank.Common.Server, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
}
]
}
},
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"WaitForFlush": false,
"FlushTimeout": "00:00:10"
}
}

View File

@ -26,6 +26,27 @@
<ProjectReference Include="..\FruitBankHybrid.Shared\FruitBankHybrid.Shared.csproj" />
</ItemGroup>
<!-- Shared appsettings.json synced into wwwroot at build time.
Approach: pre-build Copy Target (not <Content Include Link=...>), because:
- <Content Include="..\..." Link="wwwroot\..."/> triggers StaticWebAssets.Normalize() "Illegal characters"
- pre-normalizing via [System.IO.Path]::GetFullPath(...) still fails (the absolute path also trips the validator)
- a pre-build Copy creates a physical wwwroot/appsettings.json which the StaticWebAssets auto-discovery
picks up naturally, same as any other wwwroot file
BeforeTargets lists multiple early targets to ensure the Copy runs before static-asset discovery,
regardless of which one triggers first in a given SDK version.
NOTE: a clean build (delete obj/) is required the first time, because the static-asset manifest
is cached in obj/ and stale entries persist across incremental builds.
The physical wwwroot/appsettings.json is a build artifact — commit or gitignore per team policy;
edits should always be made in FruitBankHybrid.Shared/appsettings.json (the canonical source). -->
<Target Name="CopySharedAppSettings"
BeforeTargets="CollectPackageReferences;AssignTargetPaths;ResolveStaticWebAssetsInputs;BeforeBuild"
Inputs="..\FruitBankHybrid.Shared\appsettings.json"
Outputs="wwwroot\appsettings.json">
<Copy SourceFiles="..\FruitBankHybrid.Shared\appsettings.json"
DestinationFiles="wwwroot\appsettings.json"
SkipUnchangedFiles="true" />
</Target>
<ItemGroup>
<Reference Include="AyCode.Core">
<HintPath>..\..\..\..\Aycode\Source\AyCode.Core\AyCode.Services.Server\bin\FruitBank\$(Configuration)\net9.0\AyCode.Core.dll</HintPath>

View File

@ -42,22 +42,26 @@ builder.Services.Configure<AcBinaryHubProtocolOptions>(opts =>
if (opts.ProtocolMode == BinaryProtocolMode.AsyncSegment) opts.ProtocolMode = BinaryProtocolMode.Segment;
});
// Logger factory — thin Logger, DI-singleton writers. Each call yields a fresh LoggerClient
// with the caller's categoryName. Mirrors Microsoft's ILoggerFactory / ILogger<T> pattern.
builder.Services.AddSingleton<Func<string, AcLoggerBase>>(sp => categoryName => new LoggerClient(categoryName, sp.GetServices<IAcLogWriterClientBase>().ToArray()));
// Logger options + framework factory. LoggerClient instances are created per caller category,
// with AppType+LogLevel from appsettings and writers resolved from DI via IAcLogWriterClientBase.
builder.Services.Configure<AcLoggerOptions>(builder.Configuration.GetSection("AyCode:Logger"));
builder.Services.AddAcLoggerFactory<LoggerClient, IAcLogWriterClientBase>();
// HubConnectionBuilder — transient so each consumer gets a fresh builder to Build().
// All connection and protocol configuration flows from appsettings.json via IOptions<T>;
// AddFruitBankDefaults only bridges the provided logger into SignalR's internal pipeline.
// AddAcDefaults (framework) applies AcHubConnectionOptions and bridges the provided logger into SignalR's internal pipeline.
// NOTE: AcBinaryHubProtocolOptions is resolved from the OUTER service provider and passed
// explicitly — HubConnectionBuilder's inner DI cannot see outer services.Configure<T>() registrations.
builder.Services.AddTransient<IHubConnectionBuilder>(sp =>
{
var loggerFactory = sp.GetRequiredService<Func<string, AcLoggerBase>>();
var loggerFactory = sp.GetRequiredService<Func<string, LoggerClient>>();
var connectionOpts = sp.GetRequiredService<IOptions<AcHubConnectionOptions>>().Value;
var protocolOpts = sp.GetRequiredService<IOptions<AcBinaryHubProtocolOptions>>().Value;
var logger = loggerFactory(nameof(FruitBankSignalRClient));
var hubBuilder = new HubConnectionBuilder().AddFruitBankDefaults(logger, connectionOpts);
var hubBuilder = new HubConnectionBuilder().AddAcDefaults(logger, connectionOpts);
hubBuilder.AddAcBinaryProtocol(); // IOptions<AcBinaryHubProtocolOptions> from DI
hubBuilder.AddAcBinaryProtocol(protocolOpts);
return hubBuilder;
});

View File

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,28 +1,44 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning"
}
},
"AyCode": {
"ProjectId": "aad53443-2ee2-4650-8a99-97e907265e4e",
"Urls": {
"BaseUrl": "https://localhost:7144",
"ApiBaseUrl": "https://localhost:7144"
},
"Logger": {
"AppType": "Server",
"LogLevel": "Detail",
"LogWriters": [
{
"LogLevel": "Detail",
"LogWriterType": "FruitBank.Common.Server.Services.Loggers.ConsoleLogWriter, FruitBank.Common.Server, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
}
]
}
},
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"WaitForFlush": false,
"FlushTimeout": "00:00:10"
}
},
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "Segment",
"BufferSize": 4096,
"WaitForFlush": true,
"FlushTimeout": "00:00:10"
}
}

View File

@ -85,6 +85,19 @@
<Folder Include="Services\SignalRs\" />
</ItemGroup>
<!-- Shared appsettings.json copied into project root at build time.
Why a Copy target instead of <Content Include="..\..." Link="appsettings.json" CopyToOutputDirectory="PreserveNewest"/>:
the Link approach only copies the file to bin/Debug output, but ASP.NET Core's WebApplicationBuilder
in development reads appsettings.json from the ContentRoot (= project directory), not from the output folder.
Materializing the file physically in the project root makes it discoverable by the default configuration
loader in every run mode (F5 / dotnet run / published), and the SDK auto-include for appsettings*.json
takes care of copy-to-output from there. -->
<Target Name="CopySharedAppSettings" BeforeTargets="BeforeBuild" Inputs="..\FruitBankHybrid.Shared\appsettings.json" Outputs="appsettings.json">
<Copy SourceFiles="..\FruitBankHybrid.Shared\appsettings.json"
DestinationFiles="appsettings.json"
SkipUnchangedFiles="true" />
</Target>
</Project>

View File

@ -5,7 +5,6 @@ using FruitBank.Common;
using FruitBank.Common.Models;
using FruitBank.Common.Services;
using FruitBank.Common.Server.Services.Loggers;
using FruitBank.Common.Server.Services.SignalRs;
using FruitBankHybrid.Shared.Databases;
using FruitBankHybrid.Shared.Services;
using FruitBankHybrid.Shared.Services.Loggers;
@ -21,14 +20,15 @@ builder.Services.AddRazorComponents().AddInteractiveWebAssemblyComponents();
builder.Services.AddDevExpressBlazor(configure => configure.SizeMode = DevExpress.Blazor.SizeMode.Medium);
builder.Services.AddMvc();
builder.Services.AddSignalR(options => options.MaximumReceiveMessageSize = 256 * 1024);
builder.Services.AddSingleton<IFormFactor, FormFactor>();
builder.Services.AddSingleton<ISecureCredentialService, ServerSecureCredentialService>();
builder.Services.AddSingleton<IAcLogWriterBase, ConsoleLogWriter>();
// Logger factory — thin Logger, DI-singleton writers. Each call yields a fresh LoggerClient
// with the caller's categoryName. Mirrors Microsoft's ILoggerFactory / ILogger<T> pattern.
builder.Services.AddSingleton<Func<string, AcLoggerBase>>(sp => categoryName => new LoggerClient(categoryName, sp.GetServices<IAcLogWriterClientBase>().ToArray()));
// Logger options + framework factory. LoggerClient instances are created per caller category,
// with AppType+LogLevel from appsettings and writers resolved from DI via IAcLogWriterBase.
builder.Services.Configure<AcLoggerOptions>(builder.Configuration.GetSection("AyCode:Logger"));
builder.Services.AddAcLoggerFactory<LoggerClient>();
builder.Services.AddSingleton<LoggedInModel>(sp => new LoggedInModel(sp.GetRequiredService<ISecureCredentialService>()));
@ -48,16 +48,19 @@ builder.Services.Configure<AcBinaryHubProtocolOptions>(opts =>
// HubConnectionBuilder — transient so each consumer gets a fresh builder to Build().
// All connection and protocol configuration flows from appsettings.json via IOptions<T>;
// AddFruitBankDefaults only bridges the provided logger into SignalR's internal pipeline.
// AddAcDefaults (framework) applies AcHubConnectionOptions and bridges the provided logger into SignalR's internal pipeline.
// NOTE: AcBinaryHubProtocolOptions is resolved from the OUTER service provider and passed
// explicitly — HubConnectionBuilder's inner DI cannot see outer services.Configure<T>() registrations.
builder.Services.AddTransient<IHubConnectionBuilder>(sp =>
{
var loggerFactory = sp.GetRequiredService<Func<string, AcLoggerBase>>();
var loggerFactory = sp.GetRequiredService<Func<string, LoggerClient>>();
var connectionOpts = sp.GetRequiredService<IOptions<AcHubConnectionOptions>>().Value;
var protocolOpts = sp.GetRequiredService<IOptions<AcBinaryHubProtocolOptions>>().Value;
var logger = loggerFactory(nameof(FruitBankSignalRClient));
var hubBuilder = new HubConnectionBuilder().AddFruitBankDefaults(logger, connectionOpts);
var hubBuilder = new HubConnectionBuilder().AddAcDefaults(logger, connectionOpts);
hubBuilder.AddAcBinaryProtocol(); // IOptions<AcBinaryHubProtocolOptions> from DI
hubBuilder.AddAcBinaryProtocol(protocolOpts);
return hubBuilder;
});
@ -82,9 +85,6 @@ else
app.UseHsts();
}
app.MapHub<LoggerSignalRHub>($"/{FruitBankConstClient.LoggerHubName}");
app.MapHub<DevAdminSignalRHub>($"/{FruitBankConstClient.DefaultHubName}");
app.UseHttpsRedirection();
app.UseStaticFiles();

View File

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,48 +1,44 @@
{
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Logging": {
"LogLevel": {
"Default": "Debug",
"Microsoft.AspNetCore": "Warning"
}
},
"AyCode": {
"ProjectId": "aad53443-2ee2-4650-8a99-97e907265e4e",
"Urls": {
"BaseUrl": "https://localhost:7144",
"ApiBaseUrl": "https://localhost:7144"
},
"Logger": {
"AppType": "Server",
"LogLevel": "Detail",
"LogWriters": [
{
"LogLevel": "Detail",
"LogWriterType": "FruitBank.Common.Server.Services.Loggers.ConsoleLogWriter, FruitBank.Common.Server, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
}
]
}
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"WaitForFlush": true,
"FlushTimeout": "00:00:10"
},
"AyCode": {
"ProjectId": "aad53443-2ee2-4650-8a99-97e907265e4e",
"Urls": {
"BaseUrl": "https://localhost:7144",
"ApiBaseUrl": "https://localhost:7144"
},
"Logger": {
"AppType": "Server",
"LogLevel": "Detail",
"LogWriters": [
{
"LogLevel": "Detail",
"LogWriterType": "FruitBank.Common.Server.Services.Loggers.ConsoleLogWriter, FruitBank.Common.Server, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"
}
]
}
}
},
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"WaitForFlush": false,
"FlushTimeout": "00:00:10"
}
}

View File

@ -79,8 +79,9 @@
</PropertyGroup>
<ItemGroup>
<!-- appsettings.json — embedded so MauiAppBuilder can load via GetManifestResourceStream -->
<EmbeddedResource Include="appsettings.json" />
<!-- Shared appsettings.json linked from FruitBankHybrid.Shared, embedded so MauiAppBuilder can load via GetManifestResourceStream.
LogicalName preserves the original manifest resource name so the loader in MauiProgram.cs doesn't need changes. -->
<EmbeddedResource Include="..\FruitBankHybrid.Shared\appsettings.json" Link="appsettings.json" LogicalName="FruitBankHybrid.appsettings.json" />
</ItemGroup>
<ItemGroup>

View File

@ -50,10 +50,10 @@ namespace FruitBankHybrid
#endif
builder.Services.AddSingleton<IAcLogWriterClientBase, SignaRClientLogItemWriter>();
// Logger factory — the Logger itself is a thin wrapper; writers (the real sinks)
// are DI singletons. Each call produces a fresh LoggerClient with the caller's categoryName.
// Mirrors the Microsoft ILoggerFactory / ILogger<T> pattern.
builder.Services.AddSingleton<Func<string, AcLoggerBase>>(sp => categoryName => new LoggerClient(categoryName, sp.GetServices<IAcLogWriterClientBase>().ToArray()));
// Logger options + framework factory. LoggerClient instances are created per caller category,
// with AppType+LogLevel from appsettings and writers resolved from DI via IAcLogWriterClientBase.
builder.Services.Configure<AcLoggerOptions>(builder.Configuration.GetSection("AyCode:Logger"));
builder.Services.AddAcLoggerFactory<LoggerClient, IAcLogWriterClientBase>();
// Bind SignalR options from configuration.
// Precedence: code default → appsettings.json (this line) → any later Configure<T> action.
@ -68,16 +68,19 @@ namespace FruitBankHybrid
// SignalR HubConnectionBuilder — transient so each consumer gets a fresh builder to Build().
// All connection and protocol configuration flows from appsettings.json via IOptions<T>;
// AddFruitBankDefaults only bridges the provided logger into SignalR's internal pipeline.
// AddAcDefaults (framework) applies AcHubConnectionOptions and bridges the provided logger into SignalR's internal pipeline.
// NOTE: AcBinaryHubProtocolOptions is resolved from the OUTER service provider and passed
// explicitly — HubConnectionBuilder's inner DI cannot see outer services.Configure<T>() registrations.
builder.Services.AddTransient<IHubConnectionBuilder>(sp =>
{
var loggerFactory = sp.GetRequiredService<Func<string, AcLoggerBase>>();
var loggerFactory = sp.GetRequiredService<Func<string, LoggerClient>>();
var connectionOpts = sp.GetRequiredService<IOptions<AcHubConnectionOptions>>().Value;
var protocolOpts = sp.GetRequiredService<IOptions<AcBinaryHubProtocolOptions>>().Value;
var logger = loggerFactory(nameof(FruitBankSignalRClient));
var hubBuilder = new HubConnectionBuilder().AddFruitBankDefaults(logger, connectionOpts);
var hubBuilder = new HubConnectionBuilder().AddAcDefaults(logger, connectionOpts);
hubBuilder.AddAcBinaryProtocol(); // IOptions<AcBinaryHubProtocolOptions> from DI
hubBuilder.AddAcBinaryProtocol(protocolOpts);
return hubBuilder;
});

View File

@ -1,20 +0,0 @@
{
"AcHubConnection": {
"Url": "https://localhost:59579/fbHub",
"TransportMaxBufferSize": 30000000,
"ApplicationMaxBufferSize": 30000000,
"CloseTimeout": "00:00:10",
"KeepAliveInterval": "00:01:00",
"ServerTimeout": "00:03:00",
"SkipNegotiation": true,
"Transports": "WebSockets",
"UseAutomaticReconnect": true,
"UseStatefulReconnect": true
},
"AcBinaryHubProtocol": {
"ProtocolMode": "AsyncSegment",
"BufferSize": 4096,
"WaitForFlush": true,
"FlushTimeout": "00:00:10"
}
}