197 lines
6.2 KiB
C#
197 lines
6.2 KiB
C#
using Microsoft.Extensions.Caching.Hybrid;
|
|
using StackExchange.Redis;
|
|
|
|
namespace Jiaowu.Api.Infrastructure.Caching;
|
|
|
|
public enum AppCacheProfile
|
|
{
|
|
ReferenceData,
|
|
PublishedTimetable,
|
|
Analytics
|
|
}
|
|
|
|
public interface IAppCache
|
|
{
|
|
Task<T> GetOrCreateAsync<T>(
|
|
string key,
|
|
Func<CancellationToken, Task<T>> factory,
|
|
AppCacheProfile profile,
|
|
IReadOnlyCollection<string> tags,
|
|
CancellationToken cancellationToken);
|
|
|
|
ValueTask RemoveByTagAsync(
|
|
string tag,
|
|
CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
public sealed class HybridAppCache(
|
|
HybridCache cache,
|
|
AppCacheOptions options,
|
|
IHostEnvironment environment,
|
|
ILogger<HybridAppCache> logger) : IAppCache
|
|
{
|
|
private readonly string prefix = BuildPrefix(options.KeyPrefix, environment.EnvironmentName);
|
|
|
|
public async Task<T> GetOrCreateAsync<T>(
|
|
string key,
|
|
Func<CancellationToken, Task<T>> factory,
|
|
AppCacheProfile profile,
|
|
IReadOnlyCollection<string> tags,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!options.Enabled)
|
|
return await factory(cancellationToken);
|
|
|
|
var sourceCompleted = false;
|
|
T? sourceValue = default;
|
|
try
|
|
{
|
|
return await cache.GetOrCreateAsync(
|
|
$"{prefix}:{key}",
|
|
async token =>
|
|
{
|
|
sourceValue = await factory(token);
|
|
sourceCompleted = true;
|
|
return sourceValue;
|
|
},
|
|
GetEntryOptions(profile),
|
|
tags.Select(tag => $"{prefix}:tag:{tag}"),
|
|
cancellationToken);
|
|
}
|
|
catch (RedisException exception)
|
|
{
|
|
logger.LogWarning(
|
|
exception,
|
|
"Redis cache operation failed for {CacheKey}; using the source directly.",
|
|
key);
|
|
if (sourceCompleted)
|
|
return sourceValue!;
|
|
return await factory(cancellationToken);
|
|
}
|
|
}
|
|
|
|
public async ValueTask RemoveByTagAsync(
|
|
string tag,
|
|
CancellationToken cancellationToken = default)
|
|
{
|
|
if (!options.Enabled)
|
|
return;
|
|
|
|
try
|
|
{
|
|
await cache.RemoveByTagAsync(
|
|
$"{prefix}:tag:{tag}",
|
|
cancellationToken);
|
|
}
|
|
catch (RedisException exception)
|
|
{
|
|
logger.LogWarning(
|
|
exception,
|
|
"Redis cache invalidation failed for tag {CacheTag}; TTL will bound staleness.",
|
|
tag);
|
|
}
|
|
}
|
|
|
|
private HybridCacheEntryOptions GetEntryOptions(AppCacheProfile profile) =>
|
|
profile switch
|
|
{
|
|
AppCacheProfile.ReferenceData => new HybridCacheEntryOptions
|
|
{
|
|
Expiration = TimeSpan.FromMinutes(options.ReferenceExpirationMinutes),
|
|
LocalCacheExpiration =
|
|
TimeSpan.FromSeconds(options.ReferenceLocalExpirationSeconds)
|
|
},
|
|
AppCacheProfile.PublishedTimetable => new HybridCacheEntryOptions
|
|
{
|
|
Expiration = TimeSpan.FromMinutes(options.TimetableExpirationMinutes),
|
|
LocalCacheExpiration =
|
|
TimeSpan.FromSeconds(options.TimetableLocalExpirationSeconds)
|
|
},
|
|
AppCacheProfile.Analytics => new HybridCacheEntryOptions
|
|
{
|
|
Expiration = TimeSpan.FromMinutes(options.AnalyticsExpirationMinutes),
|
|
LocalCacheExpiration =
|
|
TimeSpan.FromSeconds(options.AnalyticsLocalExpirationSeconds)
|
|
},
|
|
_ => throw new ArgumentOutOfRangeException(nameof(profile), profile, null)
|
|
};
|
|
|
|
private static string BuildPrefix(string configuredPrefix, string environmentName)
|
|
{
|
|
var prefixValue = string.IsNullOrWhiteSpace(configuredPrefix)
|
|
? "jiaowu:v1"
|
|
: configuredPrefix.Trim().Trim(':');
|
|
var environmentValue = string.Concat(
|
|
environmentName.Trim().ToLowerInvariant()
|
|
.Select(character => char.IsLetterOrDigit(character) ? character : '-'));
|
|
return $"{prefixValue}:{environmentValue}";
|
|
}
|
|
}
|
|
|
|
public sealed class NoOpAppCache : IAppCache
|
|
{
|
|
public static NoOpAppCache Instance { get; } = new();
|
|
|
|
private NoOpAppCache()
|
|
{
|
|
}
|
|
|
|
public Task<T> GetOrCreateAsync<T>(
|
|
string key,
|
|
Func<CancellationToken, Task<T>> factory,
|
|
AppCacheProfile profile,
|
|
IReadOnlyCollection<string> tags,
|
|
CancellationToken cancellationToken) =>
|
|
factory(cancellationToken);
|
|
|
|
public ValueTask RemoveByTagAsync(
|
|
string tag,
|
|
CancellationToken cancellationToken = default) =>
|
|
ValueTask.CompletedTask;
|
|
}
|
|
|
|
public static class AppCacheKeys
|
|
{
|
|
public const string ActivationOptions = "auth:activation-options";
|
|
public const string Dashboard = "dashboard:summary";
|
|
public const string TimetableOptions = "timetable:options";
|
|
|
|
public static string BaseData(string kind) => $"base-data:{kind}";
|
|
|
|
public static string PublishedTimetable(
|
|
string resourceType,
|
|
Guid resourceId,
|
|
Guid? academicTermId) =>
|
|
$"timetable:published:{academicTermId?.ToString("N") ?? "current"}:" +
|
|
$"{resourceType}:{resourceId:N}";
|
|
|
|
public static string Statistics(
|
|
string area,
|
|
string dataScope,
|
|
Guid? effectiveCollegeId,
|
|
params string?[] filters)
|
|
{
|
|
static string Normalize(string? value) =>
|
|
string.IsNullOrWhiteSpace(value)
|
|
? "-"
|
|
: value.Trim().ToLowerInvariant();
|
|
|
|
var filterPart = filters.Length == 0
|
|
? "all"
|
|
: string.Join(':', filters.Select(Normalize));
|
|
return $"statistics:v2:{Normalize(area)}:scope:{Normalize(dataScope)}:" +
|
|
$"college:{effectiveCollegeId?.ToString("N") ?? "all"}:{filterPart}";
|
|
}
|
|
}
|
|
|
|
public static class AppCacheTags
|
|
{
|
|
public const string BaseData = "base-data";
|
|
public const string Analytics = "analytics";
|
|
public const string Timetables = "timetables";
|
|
public const string TimetableOptions = "timetable:options";
|
|
|
|
public static string Timetable(Guid academicTermId) =>
|
|
$"timetable:term:{academicTermId:N}";
|
|
}
|