forked from gongxuegit/tiku-backend.net
perf: optimize authorization scoreline and workers
This commit is contained in:
14
Tiku.Api/Caching/TenantPublicCacheInvalidator.cs
Normal file
14
Tiku.Api/Caching/TenantPublicCacheInvalidator.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
namespace Tiku.Api.Caching;
|
||||
|
||||
internal sealed class TenantPublicCacheInvalidator(IOutputCacheStore outputCacheStore)
|
||||
: ITenantPublicCacheInvalidator
|
||||
{
|
||||
public ValueTask InvalidateCoreAsync(Guid tenantId, CancellationToken cancellationToken) =>
|
||||
outputCacheStore.EvictByTagAsync($"tenant:{tenantId:N}", cancellationToken);
|
||||
|
||||
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
|
||||
await InvalidateCoreAsync(tenantId, cancellationToken);
|
||||
}
|
||||
48
Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs
Normal file
48
Tiku.Api/Caching/TenantPublicOutputCachePolicy.cs
Normal file
@@ -0,0 +1,48 @@
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Tiku.Application.Security;
|
||||
|
||||
namespace Tiku.Api.Caching;
|
||||
|
||||
internal sealed class TenantPublicOutputCachePolicy : IOutputCachePolicy
|
||||
{
|
||||
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = context.HttpContext.Request;
|
||||
var tenantId = context.HttpContext.RequestServices.GetRequiredService<ITenantContext>().TenantId;
|
||||
var enabled = HttpMethods.IsGet(request.Method) &&
|
||||
tenantId.HasValue &&
|
||||
StringValues.IsNullOrEmpty(request.Headers.Authorization) &&
|
||||
context.HttpContext.User.Identity?.IsAuthenticated != true;
|
||||
context.EnableOutputCaching = enabled;
|
||||
context.AllowCacheLookup = enabled;
|
||||
context.AllowCacheStorage = enabled;
|
||||
context.AllowLocking = true;
|
||||
if (enabled)
|
||||
{
|
||||
var resolvedTenantId = tenantId.GetValueOrDefault();
|
||||
context.CacheVaryByRules.QueryKeys = "*";
|
||||
context.CacheVaryByRules.HeaderNames = new StringValues("Accept-Encoding");
|
||||
context.CacheVaryByRules.VaryByValues["tenant"] = resolvedTenantId.ToString("N");
|
||||
context.Tags.Add($"tenant:{resolvedTenantId:N}");
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) =>
|
||||
ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
|
||||
{
|
||||
var response = context.HttpContext.Response;
|
||||
if (response.StatusCode != StatusCodes.Status200OK ||
|
||||
!StringValues.IsNullOrEmpty(response.Headers.SetCookie) ||
|
||||
context.HttpContext.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
context.AllowCacheStorage = false;
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -22,9 +22,11 @@ public static class ApplicationBuilderExtensions
|
||||
}
|
||||
|
||||
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
|
||||
app.UseMiddleware<DatabaseRequestMetricsMiddleware>();
|
||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
||||
app.UseForwardedHeaders();
|
||||
app.UseHttpsRedirection();
|
||||
app.UseResponseCompression();
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
@@ -37,6 +39,7 @@ public static class ApplicationBuilderExtensions
|
||||
app.UseMiddleware<CurrentPrincipalMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<SaasFeatureAccessMiddleware>();
|
||||
app.UseOutputCache();
|
||||
app.MapControllers();
|
||||
|
||||
return app;
|
||||
|
||||
@@ -209,7 +209,11 @@ internal static class AuthenticationExtensions
|
||||
if (session is null)
|
||||
{
|
||||
context.Fail("Session, identity, membership, tenant or role state is no longer valid.");
|
||||
return;
|
||||
}
|
||||
|
||||
context.HttpContext.RequestServices.GetRequiredService<IRequestSecurityState>()
|
||||
.SetValidatedSession(session);
|
||||
}
|
||||
|
||||
private static async Task WriteTenantConflictChallengeAsync(JwtBearerChallengeContext context)
|
||||
|
||||
@@ -3,6 +3,12 @@ using Tiku.Application;
|
||||
using Tiku.Infrastructure;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Infrastructure.Security;
|
||||
using Tiku.Api.Caching;
|
||||
using Microsoft.AspNetCore.ResponseCompression;
|
||||
using System.IO.Compression;
|
||||
using Tiku.Api.Observability;
|
||||
using Tiku.Application.Tenancy;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
@@ -18,6 +24,9 @@ public static class DependencyInjection
|
||||
|
||||
builder.Services.AddApiPresentation();
|
||||
builder.Services.AddHealthChecks();
|
||||
builder.Services.AddApiObservability(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddSingleton<OutboxBacklogSnapshot>();
|
||||
builder.Services.AddHostedService<OutboxBacklogMonitor>();
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
|
||||
builder.Services.AddApiRateLimiting(builder.Configuration);
|
||||
@@ -42,6 +51,31 @@ public static class DependencyInjection
|
||||
throw new InvalidOperationException(
|
||||
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
|
||||
}
|
||||
|
||||
builder.Services.AddOutputCache(options =>
|
||||
{
|
||||
options.AddPolicy("TenantPublic", new TenantPublicOutputCachePolicy());
|
||||
options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(60);
|
||||
});
|
||||
builder.Services.RemoveAll<ITenantPublicCacheInvalidator>();
|
||||
builder.Services.AddSingleton<ITenantPublicCacheInvalidator, TenantPublicCacheInvalidator>();
|
||||
if (builder.Environment.IsProduction() && !string.IsNullOrWhiteSpace(redisConnectionString))
|
||||
{
|
||||
builder.Services.AddStackExchangeRedisOutputCache(options =>
|
||||
{
|
||||
options.Configuration = redisConnectionString;
|
||||
options.InstanceName = $"tiku:{builder.Environment.EnvironmentName.ToLowerInvariant()}:output:";
|
||||
});
|
||||
}
|
||||
builder.Services.AddResponseCompression(options =>
|
||||
{
|
||||
options.EnableForHttps = true;
|
||||
options.Providers.Add<BrotliCompressionProvider>();
|
||||
options.Providers.Add<GzipCompressionProvider>();
|
||||
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
|
||||
});
|
||||
builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
builder.Services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
|
||||
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
|
||||
builder.Services.AddOptions<MessagingOptions>()
|
||||
.Bind(builder.Configuration.GetSection("RabbitMq"))
|
||||
|
||||
46
Tiku.Api/Configuration/ObservabilityExtensions.cs
Normal file
46
Tiku.Api/Configuration/ObservabilityExtensions.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using OpenTelemetry.Metrics;
|
||||
using OpenTelemetry.Resources;
|
||||
using OpenTelemetry.Trace;
|
||||
using Tiku.Infrastructure.Observability;
|
||||
|
||||
namespace Tiku.Api.Configuration;
|
||||
|
||||
internal static class ObservabilityExtensions
|
||||
{
|
||||
internal static IServiceCollection AddApiObservability(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration,
|
||||
IHostEnvironment environment)
|
||||
{
|
||||
var endpoint = configuration["OpenTelemetry:OtlpEndpoint"];
|
||||
var hasOtlpEndpoint = Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri);
|
||||
|
||||
services.AddOpenTelemetry()
|
||||
.ConfigureResource(resource => resource.AddService(
|
||||
serviceName: environment.ApplicationName,
|
||||
serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString()))
|
||||
.WithTracing(tracing => tracing
|
||||
.AddAspNetCoreInstrumentation(options =>
|
||||
options.Filter = context => !context.Request.Path.StartsWithSegments("/api/health"))
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddSource("Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)))
|
||||
.WithMetrics(metrics => metrics
|
||||
.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Tiku.Messaging", "Npgsql")
|
||||
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static TBuilder ApplyIf<TBuilder>(this TBuilder builder, bool condition, Action<TBuilder> configure)
|
||||
{
|
||||
if (condition)
|
||||
{
|
||||
configure(builder);
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,12 @@ public sealed class ScorelineQueryDto
|
||||
[Range(1, 2000)]
|
||||
public int? Limit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 版本化的不透明游标,仅用于游标分页接口。
|
||||
/// </summary>
|
||||
[StringLength(2000)]
|
||||
public string? Cursor { get; set; }
|
||||
|
||||
public ScorelineFilter ToFilter(
|
||||
Guid tenantId,
|
||||
IReadOnlyCollection<ScorelineDynamicFilter>? dynamicFilters = null)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Application.Assets;
|
||||
using Tiku.Api.Contracts;
|
||||
@@ -18,6 +19,7 @@ namespace Tiku.Api.Controllers;
|
||||
[AllowAnonymous]
|
||||
[Produces("application/json")]
|
||||
[Route("api/catalog")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
public sealed class CatalogController(
|
||||
ICatalogQueryService catalogQueryService,
|
||||
IContentNavigationQueryService contentNavigationQueryService,
|
||||
|
||||
@@ -4,9 +4,9 @@ using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MassTransit.EntityFrameworkCoreIntegration;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
using Tiku.Api.Observability;
|
||||
|
||||
namespace Tiku.Api.Controllers;
|
||||
|
||||
@@ -19,7 +19,8 @@ public sealed class HealthController(
|
||||
TikuDbContext dbContext,
|
||||
IRedisSecurityStore redisSecurityStore,
|
||||
MessagingOptions messagingOptions,
|
||||
HealthCheckService healthCheckService) : ControllerBase
|
||||
HealthCheckService healthCheckService,
|
||||
OutboxBacklogSnapshot outboxSnapshot) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
[EndpointSummary("健康检查")]
|
||||
@@ -43,17 +44,8 @@ public sealed class HealthController(
|
||||
registration => registration.Tags.Contains("ready"),
|
||||
cancellationToken);
|
||||
var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy;
|
||||
var outboxPending = database
|
||||
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
|
||||
: -1;
|
||||
var outboxOldestSentTime = database
|
||||
? await dbContext.Set<OutboxMessage>()
|
||||
.Select(message => (DateTime?)message.SentTime)
|
||||
.MinAsync(cancellationToken)
|
||||
: null;
|
||||
var outboxOldestAgeSeconds = outboxOldestSentTime is null
|
||||
? 0
|
||||
: Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(outboxOldestSentTime.Value)).TotalSeconds);
|
||||
var outboxPending = outboxSnapshot.Pending;
|
||||
var outboxOldestAgeSeconds = outboxSnapshot.OldestAgeSeconds;
|
||||
var outboxAlert = outboxPending >= messagingOptions.OutboxBacklogAlertCount ||
|
||||
outboxOldestAgeSeconds >= messagingOptions.OutboxOldestMessageAlertSeconds;
|
||||
var ready = database && redis && rabbitMq;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Tiku.Application.Security;
|
||||
using Tiku.Application.Tenancy;
|
||||
|
||||
@@ -15,6 +16,7 @@ public sealed class RuntimeController(
|
||||
ITenantFrontendConfigService frontendConfigService) : ControllerBase
|
||||
{
|
||||
[HttpGet("bootstrap")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
[EndpointSummary("获取租户前端运行时配置")]
|
||||
[ProducesResponseType<TenantRuntimeBootstrap>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status304NotModified)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.OutputCaching;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Tiku.Api.Contracts;
|
||||
using Tiku.Application.Catalog;
|
||||
@@ -24,6 +25,7 @@ public sealed class ScorelineController(
|
||||
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];
|
||||
|
||||
[HttpGet("fields")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
[EndpointSummary("查询分数线字段配置")]
|
||||
[ProducesResponseType<CatalogList<ScorelineFieldItem>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
@@ -49,6 +51,20 @@ public sealed class ScorelineController(
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("records/cursor")]
|
||||
[EndpointSummary("游标分页查询分数线记录")]
|
||||
[ProducesResponseType<ScorelineRecordCursorPage>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||
public async Task<ActionResult<ScorelineRecordCursorPage>> GetRecordsCursor(
|
||||
[FromQuery] ScorelineQueryDto query,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await scorelineQueryService.GetRecordsCursorAsync(
|
||||
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), ResolveDynamicFilters()),
|
||||
query.Cursor,
|
||||
cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("trend")]
|
||||
[EndpointSummary("查询历年分数线趋势")]
|
||||
[ProducesResponseType<CatalogList<ScorelineRecordItem>>(StatusCodes.Status200OK)]
|
||||
@@ -63,6 +79,7 @@ public sealed class ScorelineController(
|
||||
}
|
||||
|
||||
[HttpGet("years")]
|
||||
[OutputCache(PolicyName = "TenantPublic")]
|
||||
[EndpointSummary("查询分数线可用年份")]
|
||||
[ProducesResponseType<CatalogList<int>>(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||
|
||||
12
Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs
Normal file
12
Tiku.Api/Middleware/DatabaseRequestMetricsMiddleware.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using Tiku.Infrastructure.Observability;
|
||||
|
||||
namespace Tiku.Api.Middleware;
|
||||
|
||||
public sealed class DatabaseRequestMetricsMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
using var metrics = DatabaseRequestMetrics.Begin();
|
||||
await next(context);
|
||||
}
|
||||
}
|
||||
66
Tiku.Api/Observability/OutboxBacklogMonitor.cs
Normal file
66
Tiku.Api/Observability/OutboxBacklogMonitor.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
using System.Diagnostics.Metrics;
|
||||
using Npgsql;
|
||||
using Tiku.Infrastructure.Messaging;
|
||||
|
||||
namespace Tiku.Api.Observability;
|
||||
|
||||
public sealed class OutboxBacklogSnapshot
|
||||
{
|
||||
private static readonly Meter Meter = new("Tiku.Messaging", "1.0.0");
|
||||
private long pending;
|
||||
private double oldestAgeSeconds;
|
||||
|
||||
public OutboxBacklogSnapshot()
|
||||
{
|
||||
Meter.CreateObservableGauge("tiku.outbox.pending", () => Interlocked.Read(ref pending));
|
||||
Meter.CreateObservableGauge("tiku.outbox.oldest_age", () => Volatile.Read(ref oldestAgeSeconds), "s");
|
||||
}
|
||||
|
||||
internal long Pending => Interlocked.Read(ref pending);
|
||||
internal double OldestAgeSeconds => Volatile.Read(ref oldestAgeSeconds);
|
||||
|
||||
internal void Update(long count, DateTime? oldestSentTime)
|
||||
{
|
||||
Interlocked.Exchange(ref pending, count);
|
||||
Volatile.Write(ref oldestAgeSeconds, oldestSentTime is null
|
||||
? 0
|
||||
: Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(oldestSentTime.Value)).TotalSeconds));
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class OutboxBacklogMonitor(
|
||||
NpgsqlDataSource dataSource,
|
||||
OutboxBacklogSnapshot snapshot,
|
||||
MessagingOptions messagingOptions,
|
||||
ILogger<OutboxBacklogMonitor> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (messagingOptions.IsConfigured)
|
||||
{
|
||||
await using var command = dataSource.CreateCommand(
|
||||
"SELECT count(*), min(sent_time) FROM outbox_message;");
|
||||
await using var reader = await command.ExecuteReaderAsync(stoppingToken);
|
||||
if (await reader.ReadAsync(stoppingToken))
|
||||
{
|
||||
snapshot.Update(reader.GetInt64(0), reader.IsDBNull(1) ? null : reader.GetDateTime(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Outbox backlog metric collection failed.");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,12 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" />
|
||||
<PackageReference Include="Microsoft.OpenApi" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="Scalar.AspNetCore" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
|
||||
@@ -26,6 +26,9 @@
|
||||
"Application": "Tiku.Api"
|
||||
}
|
||||
},
|
||||
"OpenTelemetry": {
|
||||
"OtlpEndpoint": ""
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [],
|
||||
"AllowedHeaders": [
|
||||
|
||||
Reference in New Issue
Block a user