♻️ Refactored the cache service

This commit is contained in:
2025-12-02 22:38:47 +08:00
parent c2f72993b7
commit aca28f9318
15 changed files with 308 additions and 333 deletions

View File

@@ -16,9 +16,7 @@ public static class ServiceCollectionExtensions
services.AddLocalization(); services.AddLocalization();
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -12,9 +12,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase services.AddDbContext<AppDatabase>(); // Assuming you'll have an AppDatabase
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>(); // Uncomment if you have CacheServiceRedis
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -14,9 +14,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services) public static IServiceCollection AddAppServices(this IServiceCollection services)
{ {
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -35,9 +35,7 @@ public static class ServiceCollectionExtensions
services.AddLocalization(options => options.ResourcesPath = "Resources"); services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -17,9 +17,7 @@ public static class ServiceCollectionExtensions
public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddAppServices(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -1,396 +1,201 @@
using System.Text.Json; using Microsoft.Extensions.Caching.Distributed;
using System.Text.Json.Serialization; using RedLockNet;
using System.Text.Json.Serialization.Metadata;
using DysonNetwork.Shared.Data;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using StackExchange.Redis; using StackExchange.Redis;
namespace DysonNetwork.Shared.Cache; namespace DysonNetwork.Shared.Cache;
/// <summary> public class CacheServiceRedis(
/// Represents a distributed lock that can be used to synchronize access across multiple processes IDistributedCache cache,
/// </summary> IConnectionMultiplexer redis,
public interface IDistributedLock : IAsyncDisposable ICacheSerializer serializer,
IDistributedLockFactory lockFactory
)
: ICacheService
{ {
/// <summary> private const string GlobalKeyPrefix = "dyson:";
/// The resource identifier this lock is protecting private const string GroupKeyPrefix = GlobalKeyPrefix + "cg:";
/// </summary> private const string LockKeyPrefix = GlobalKeyPrefix + "lock:";
string Resource { get; }
/// <summary> private static string Normalize(string key) => $"{GlobalKeyPrefix}{key}";
/// Unique identifier for this lock instance
/// </summary>
string LockId { get; }
/// <summary> // -----------------------------------------------------
/// Extends the lock's expiration time // BASIC OPERATIONS
/// </summary> // -----------------------------------------------------
Task<bool> ExtendAsync(TimeSpan timeSpan);
/// <summary>
/// Releases the lock immediately
/// </summary>
Task ReleaseAsync();
}
public interface ICacheService
{
/// <summary>
/// Sets a value in the cache with an optional expiration time
/// </summary>
Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
/// <summary>
/// Gets a value from the cache
/// </summary>
Task<T?> GetAsync<T>(string key);
/// <summary>
/// Get a value from the cache with the found status
/// </summary>
Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key);
/// <summary>
/// Removes a specific key from the cache
/// </summary>
Task<bool> RemoveAsync(string key);
/// <summary>
/// Adds a key to a group for group-based operations
/// </summary>
Task AddToGroupAsync(string key, string group);
/// <summary>
/// Removes all keys associated with a specific group
/// </summary>
Task RemoveGroupAsync(string group);
/// <summary>
/// Gets all keys belonging to a specific group
/// </summary>
Task<IEnumerable<string>> GetGroupKeysAsync(string group);
/// <summary>
/// Helper method to set a value in cache and associate it with multiple groups in one operation
/// </summary>
/// <typeparam name="T">The type of value being cached</typeparam>
/// <param name="key">Cache key</param>
/// <param name="value">The value to cache</param>
/// <param name="groups">Optional collection of group names to associate the key with</param>
/// <param name="expiry">Optional expiration time for the cached item</param>
/// <returns>True if the set operation was successful</returns>
Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
/// <summary>
/// Acquires a distributed lock on the specified resource
/// </summary>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>A distributed lock instance if acquired, null otherwise</returns>
Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
TimeSpan? retryInterval = null);
/// <summary>
/// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
/// </summary>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="action">The action to execute while holding the lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null,
TimeSpan? retryInterval = null);
/// <summary>
/// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
/// </summary>
/// <typeparam name="T">The return type of the function</typeparam>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="func">The function to execute while holding the lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry,
TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
}
public class RedisDistributedLock : IDistributedLock
{
private readonly IDatabase _database;
private bool _disposed;
public string Resource { get; }
public string LockId { get; }
internal RedisDistributedLock(IDatabase database, string resource, string lockId)
{
_database = database;
Resource = resource;
LockId = lockId;
}
public async Task<bool> ExtendAsync(TimeSpan timeSpan)
{
if (_disposed)
throw new ObjectDisposedException(nameof(RedisDistributedLock));
var script = @"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
";
var result = await _database.ScriptEvaluateAsync(
script,
[$"{CacheServiceRedis.LockKeyPrefix}{Resource}"],
[LockId, (long)timeSpan.TotalMilliseconds]
);
return (long)result! == 1;
}
public async Task ReleaseAsync()
{
if (_disposed)
return;
var script = @"
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
";
await _database.ScriptEvaluateAsync(
script,
[$"{CacheServiceRedis.LockKeyPrefix}{Resource}"],
[LockId]
);
_disposed = true;
}
public async ValueTask DisposeAsync()
{
await ReleaseAsync();
GC.SuppressFinalize(this);
}
}
public class CacheServiceRedis : ICacheService
{
private readonly IDatabase _database;
private readonly JsonSerializerOptions _jsonOptions;
// Global prefix for all cache keys
public const string GlobalKeyPrefix = "dyson:";
// Using prefixes for different types of keys
public const string GroupKeyPrefix = GlobalKeyPrefix + "cg:";
public const string LockKeyPrefix = GlobalKeyPrefix + "lock:";
public CacheServiceRedis(IConnectionMultiplexer redis)
{
var rds = redis ?? throw new ArgumentNullException(nameof(redis));
_database = rds.GetDatabase();
// Configure System.Text.Json with proper NodaTime serialization
_jsonOptions = new JsonSerializerOptions
{
TypeInfoResolver = new DefaultJsonTypeInfoResolver
{
Modifiers = { JsonExtensions.UnignoreAllProperties() },
},
ReferenceHandler = ReferenceHandler.Preserve,
NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals,
Converters = { new ByteStringConverter() }
};
_jsonOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
_jsonOptions.PropertyNameCaseInsensitive = true;
}
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null)
{ {
key = $"{GlobalKeyPrefix}{key}"; key = Normalize(key);
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
var serializedValue = JsonSerializer.Serialize(value, _jsonOptions); var json = serializer.Serialize(value);
return await _database.StringSetAsync(key, serializedValue, expiry);
var options = new DistributedCacheEntryOptions();
if (expiry.HasValue)
options.SetAbsoluteExpiration(expiry.Value);
await cache.SetStringAsync(key, json, options);
return true;
} }
public async Task<T?> GetAsync<T>(string key) public async Task<T?> GetAsync<T>(string key)
{ {
key = $"{GlobalKeyPrefix}{key}"; key = Normalize(key);
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
var value = await _database.StringGetAsync(key); var json = await cache.GetStringAsync(key);
if (json is null)
return default;
return value.IsNullOrEmpty ? default : return serializer.Deserialize<T>(json);
// For NodaTime serialization, use the configured JSON options
JsonSerializer.Deserialize<T>(value.ToString(), _jsonOptions);
} }
public async Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key) public async Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key)
{ {
key = $"{GlobalKeyPrefix}{key}"; key = Normalize(key);
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
var value = await _database.StringGetAsync(key); var json = await cache.GetStringAsync(key);
if (json is null)
return (false, default);
return value.IsNullOrEmpty ? (false, default) : return (true, serializer.Deserialize<T>(json));
// For NodaTime serialization, use the configured JSON options
(true, JsonSerializer.Deserialize<T>(value!.ToString(), _jsonOptions));
} }
public async Task<bool> RemoveAsync(string key) public async Task<bool> RemoveAsync(string key)
{ {
key = $"{GlobalKeyPrefix}{key}"; key = Normalize(key);
if (string.IsNullOrEmpty(key))
throw new ArgumentException("Key cannot be null or empty", nameof(key));
// Before removing the key, find all groups it belongs to and remove it from them // Remove key from all groups
var script = @" var db = redis.GetDatabase();
local groups = redis.call('KEYS', ARGV[1])
for _, group in ipairs(groups) do
redis.call('SREM', group, ARGV[2])
end
return redis.call('DEL', ARGV[2])
";
var result = await _database.ScriptEvaluateAsync( var groupPattern = $"{GroupKeyPrefix}*";
script, var server = redis.GetServers().First();
values: [$"{GroupKeyPrefix}*", key]
);
return (long)result! > 0; var groups = server.Keys(pattern: groupPattern);
foreach (var group in groups)
{
await db.SetRemoveAsync(group, key);
} }
await cache.RemoveAsync(key);
return true;
}
// -----------------------------------------------------
// GROUP OPERATIONS
// -----------------------------------------------------
public async Task AddToGroupAsync(string key, string group) public async Task AddToGroupAsync(string key, string group)
{ {
if (string.IsNullOrEmpty(key)) key = Normalize(key);
throw new ArgumentException(@"Key cannot be null or empty.", nameof(key)); var db = redis.GetDatabase();
if (string.IsNullOrEmpty(group))
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
var groupKey = $"{GroupKeyPrefix}{group}"; var groupKey = $"{GroupKeyPrefix}{group}";
key = $"{GlobalKeyPrefix}{key}"; await db.SetAddAsync(groupKey, key);
await _database.SetAddAsync(groupKey, key);
} }
public async Task RemoveGroupAsync(string group) public async Task RemoveGroupAsync(string group)
{ {
if (string.IsNullOrEmpty(group))
throw new ArgumentException(@"Group cannot be null or empty.", nameof(group));
var groupKey = $"{GroupKeyPrefix}{group}"; var groupKey = $"{GroupKeyPrefix}{group}";
var db = redis.GetDatabase();
// Get all keys in the group var keys = await db.SetMembersAsync(groupKey);
var keys = await _database.SetMembersAsync(groupKey);
if (keys.Length > 0) if (keys.Length > 0)
{ {
// Delete all the keys foreach (var key in keys)
var keysTasks = keys.Select(key => _database.KeyDeleteAsync(key.ToString())); await cache.RemoveAsync(key.ToString());
await Task.WhenAll(keysTasks);
} }
// Delete the group itself await db.KeyDeleteAsync(groupKey);
await _database.KeyDeleteAsync(groupKey);
} }
public async Task<IEnumerable<string>> GetGroupKeysAsync(string group) public async Task<IEnumerable<string>> GetGroupKeysAsync(string group)
{ {
if (string.IsNullOrEmpty(group)) var groupKey = $"{GroupKeyPrefix}{group}";
throw new ArgumentException("Group cannot be null or empty.", nameof(group)); var db = redis.GetDatabase();
var members = await db.SetMembersAsync(groupKey);
var groupKey = string.Concat(GroupKeyPrefix, group); return members.Select(x => x.ToString());
var members = await _database.SetMembersAsync(groupKey);
return members.Select(m => m.ToString());
} }
public async Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, public async Task<bool> SetWithGroupsAsync<T>(
string key,
T value,
IEnumerable<string>? groups = null,
TimeSpan? expiry = null) TimeSpan? expiry = null)
{ {
// First, set the value in the cache var result = await SetAsync(key, value, expiry);
var setResult = await SetAsync(key, value, expiry); if (!result || groups == null)
return result;
// If successful and there are groups to associate, add the key to each group var tasks = groups.Select(g => AddToGroupAsync(key, g));
if (!setResult || groups == null) return setResult;
var groupsArray = groups.Where(g => !string.IsNullOrEmpty(g)).ToArray();
if (groupsArray.Length <= 0) return setResult;
var tasks = groupsArray.Select(group => AddToGroupAsync(key, group));
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
return true;
return setResult;
} }
public async Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null, // -----------------------------------------------------
// DISTRIBUTED LOCK (RedLock wrapper)
// -----------------------------------------------------
private readonly TimeSpan _defaultRetry = TimeSpan.FromMilliseconds(100);
public async Task<IDistributedLock?> AcquireLockAsync(
string resource,
TimeSpan expiry,
TimeSpan? waitTime = null,
TimeSpan? retryInterval = null) TimeSpan? retryInterval = null)
{ {
if (string.IsNullOrEmpty(resource)) if (string.IsNullOrWhiteSpace(resource))
throw new ArgumentException("Resource cannot be null or empty", nameof(resource)); throw new ArgumentException("Resource cannot be null", nameof(resource));
var lockKey = $"{LockKeyPrefix}{resource}"; var lockKey = $"{LockKeyPrefix}{resource}";
var lockId = Guid.NewGuid().ToString("N"); var redlock = await lockFactory.CreateLockAsync(
var waitTimeSpan = waitTime ?? TimeSpan.Zero; lockKey,
var retryIntervalSpan = retryInterval ?? TimeSpan.FromMilliseconds(100); expiry,
waitTime ?? TimeSpan.Zero,
retryInterval ?? _defaultRetry
);
var startTime = DateTime.UtcNow; return !redlock.IsAcquired ? null : new RedLockAdapter(redlock, resource);
var acquired = false;
// Try to acquire the lock, retry until waitTime is exceeded
while (!acquired && (DateTime.UtcNow - startTime) < waitTimeSpan)
{
acquired = await _database.StringSetAsync(lockKey, lockId, expiry, When.NotExists);
if (!acquired)
{
await Task.Delay(retryIntervalSpan);
}
} }
if (!acquired) public async Task<bool> ExecuteWithLockAsync(
string resource,
Func<Task> action,
TimeSpan expiry,
TimeSpan? waitTime = null,
TimeSpan? retryInterval = null)
{ {
return null; // Could not acquire the lock within the wait time await using var l = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
} if (l is null)
return false;
return new RedisDistributedLock(_database, resource, lockId);
}
public async Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry,
TimeSpan? waitTime = null, TimeSpan? retryInterval = null)
{
await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
if (lockObj == null)
return false; // Could not acquire the lock
await action(); await action();
return true; return true;
} }
public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, public async Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(
TimeSpan expiry, TimeSpan? waitTime = null, TimeSpan? retryInterval = null) string resource,
Func<Task<T>> func,
TimeSpan expiry,
TimeSpan? waitTime = null,
TimeSpan? retryInterval = null)
{ {
await using var lockObj = await AcquireLockAsync(resource, expiry, waitTime, retryInterval); await using var l = await AcquireLockAsync(resource, expiry, waitTime, retryInterval);
if (l is null)
if (lockObj == null) return (false, default);
return (false, default); // Could not acquire the lock
var result = await func(); var result = await func();
return (true, result); return (true, result);
} }
} }
public class RedLockAdapter(IRedLock inner, string resource) : IDistributedLock
{
public string Resource { get; } = resource;
public string LockId => inner.LockId;
public ValueTask ReleaseAsync() => inner.DisposeAsync();
public async ValueTask DisposeAsync()
{
await inner.DisposeAsync();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,7 @@
namespace DysonNetwork.Shared.Cache;
public interface ICacheSerializer
{
string Serialize<T>(T value);
T? Deserialize<T>(string data);
}

View File

@@ -0,0 +1,86 @@
namespace DysonNetwork.Shared.Cache;
public interface ICacheService
{
/// <summary>
/// Sets a value in the cache with an optional expiration time
/// </summary>
Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null);
/// <summary>
/// Gets a value from the cache
/// </summary>
Task<T?> GetAsync<T>(string key);
/// <summary>
/// Get a value from the cache with the found status
/// </summary>
Task<(bool found, T? value)> GetAsyncWithStatus<T>(string key);
/// <summary>
/// Removes a specific key from the cache
/// </summary>
Task<bool> RemoveAsync(string key);
/// <summary>
/// Adds a key to a group for group-based operations
/// </summary>
Task AddToGroupAsync(string key, string group);
/// <summary>
/// Removes all keys associated with a specific group
/// </summary>
Task RemoveGroupAsync(string group);
/// <summary>
/// Gets all keys belonging to a specific group
/// </summary>
Task<IEnumerable<string>> GetGroupKeysAsync(string group);
/// <summary>
/// Helper method to set a value in cache and associate it with multiple groups in one operation
/// </summary>
/// <typeparam name="T">The type of value being cached</typeparam>
/// <param name="key">Cache key</param>
/// <param name="value">The value to cache</param>
/// <param name="groups">Optional collection of group names to associate the key with</param>
/// <param name="expiry">Optional expiration time for the cached item</param>
/// <returns>True if the set operation was successful</returns>
Task<bool> SetWithGroupsAsync<T>(string key, T value, IEnumerable<string>? groups = null, TimeSpan? expiry = null);
/// <summary>
/// Acquires a distributed lock on the specified resource
/// </summary>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>A distributed lock instance if acquired, null otherwise</returns>
Task<IDistributedLock?> AcquireLockAsync(string resource, TimeSpan expiry, TimeSpan? waitTime = null,
TimeSpan? retryInterval = null);
/// <summary>
/// Executes an action with a distributed lock, ensuring the lock is properly released afterwards
/// </summary>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="action">The action to execute while holding the lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>True if the lock was acquired and the action was executed, false otherwise</returns>
Task<bool> ExecuteWithLockAsync(string resource, Func<Task> action, TimeSpan expiry, TimeSpan? waitTime = null,
TimeSpan? retryInterval = null);
/// <summary>
/// Executes a function with a distributed lock, ensuring the lock is properly released afterwards
/// </summary>
/// <typeparam name="T">The return type of the function</typeparam>
/// <param name="resource">The resource identifier to lock</param>
/// <param name="func">The function to execute while holding the lock</param>
/// <param name="expiry">How long the lock should be held before automatically expiring</param>
/// <param name="waitTime">How long to wait for the lock before giving up</param>
/// <param name="retryInterval">How often to retry acquiring the lock during the wait time</param>
/// <returns>The result of the function if the lock was acquired, default(T) otherwise</returns>
Task<(bool Acquired, T? Result)> ExecuteWithLockAsync<T>(string resource, Func<Task<T>> func, TimeSpan expiry,
TimeSpan? waitTime = null, TimeSpan? retryInterval = null);
}

View File

@@ -0,0 +1,22 @@
namespace DysonNetwork.Shared.Cache;
/// <summary>
/// Represents a distributed lock that can be used to synchronize access across multiple processes
/// </summary>
public interface IDistributedLock : IAsyncDisposable
{
/// <summary>
/// The resource identifier this lock is protecting
/// </summary>
string Resource { get; }
/// <summary>
/// Unique identifier for this lock instance
/// </summary>
string LockId { get; }
/// <summary>
/// Releases the lock immediately
/// </summary>
ValueTask ReleaseAsync();
}

View File

@@ -0,0 +1,20 @@
using System.Text.Json;
namespace DysonNetwork.Shared.Cache;
public class JsonCacheSerializer(JsonSerializerOptions? options = null) : ICacheSerializer
{
private readonly JsonSerializerOptions _options = options ?? new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
// Customize as needed (NodaTime, camelCase, converters, etc.)
WriteIndented = false
};
// Customize as needed (NodaTime, camelCase, converters, etc.)
public string Serialize<T>(T value)
=> JsonSerializer.Serialize(value, _options);
public T? Deserialize<T>(string data)
=> JsonSerializer.Deserialize<T>(data, _options);
}

View File

@@ -0,0 +1,21 @@
using MessagePack;
namespace DysonNetwork.Shared.Cache;
public class MessagePackCacheSerializer(MessagePackSerializerOptions? options = null) : ICacheSerializer
{
private readonly MessagePackSerializerOptions _options = options ?? MessagePackSerializerOptions.Standard
.WithCompression(MessagePackCompression.Lz4BlockArray);
public string Serialize<T>(T value)
{
var bytes = MessagePackSerializer.Serialize(value!, _options);
return Convert.ToBase64String(bytes);
}
public T? Deserialize<T>(string data)
{
var bytes = Convert.FromBase64String(data);
return MessagePackSerializer.Deserialize<T>(bytes, _options);
}
}

View File

@@ -22,8 +22,10 @@
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" /> <PackageReference Include="MaxMind.GeoIP2" Version="5.3.0" />
<PackageReference Include="MessagePack" Version="2.5.192" />
<PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" /> <PackageReference Include="Microsoft.AspNetCore.Authentication" Version="2.3.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" /> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="10.0.0" />
<PackageReference Include="NATS.Net" Version="2.6.11" /> <PackageReference Include="NATS.Net" Version="2.6.11" />
<PackageReference Include="NodaTime" Version="3.2.2" /> <PackageReference Include="NodaTime" Version="3.2.2" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" /> <PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.2.0" />
@@ -31,6 +33,8 @@
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" /> <PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.3.0" />
<PackageReference Include="OpenGraph-Net" Version="4.0.1" /> <PackageReference Include="OpenGraph-Net" Version="4.0.1" />
<PackageReference Include="Otp.NET" Version="1.4.0" /> <PackageReference Include="Otp.NET" Version="1.4.0" />
<PackageReference Include="RedLock.net" Version="2.3.2" />
<PackageReference Include="StackExchange.Redis" Version="2.10.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="10.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.1" /> <PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.1" />
<PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" /> <PackageReference Include="Yarp.ReverseProxy" Version="2.3.0" />

View File

@@ -1,11 +1,18 @@
using DysonNetwork.Shared.Cache;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics.HealthChecks; using Microsoft.AspNetCore.Diagnostics.HealthChecks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using NodaTime;
using OpenTelemetry; using OpenTelemetry;
using OpenTelemetry.Metrics; using OpenTelemetry.Metrics;
using OpenTelemetry.Trace; using OpenTelemetry.Trace;
using RedLockNet;
using RedLockNet.SERedis;
using RedLockNet.SERedis.Configuration;
using StackExchange.Redis;
namespace Microsoft.Extensions.Hosting; namespace Microsoft.Extensions.Hosting;
@@ -43,12 +50,29 @@ public static class Extensions
// options.AllowedSchemes = ["https"]; // options.AllowedSchemes = ["https"];
// }); // });
builder.Services.AddSingleton<IClock>(SystemClock.Instance);
builder.AddNatsClient("queue"); builder.AddNatsClient("queue");
builder.AddRedisClient("cache", configureOptions: opts => builder.AddRedisClient("cache", configureOptions: opts =>
{ {
opts.AbortOnConnectFail = false; opts.AbortOnConnectFail = false;
}); });
// Setup cache service
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration.GetConnectionString("cache");
options.InstanceName = "dyson:";
});
builder.Services.AddSingleton<RedLockFactory>(sp =>
{
var mux = sp.GetRequiredService<IConnectionMultiplexer>();
return RedLockFactory.Create(new List<RedLockMultiplexer> { new(mux) });
});
builder.Services.AddSingleton<ICacheService, CacheServiceRedis>();
// Using message pack for now
builder.Services.AddSingleton<ICacheSerializer, MessagePackCacheSerializer>();
return builder; return builder;
} }

View File

@@ -27,9 +27,7 @@ public static class ServiceCollectionExtensions
services.AddLocalization(options => options.ResourcesPath = "Resources"); services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddHttpClient(); services.AddHttpClient();

View File

@@ -18,9 +18,7 @@ public static class ServiceCollectionExtensions
services.AddLocalization(options => options.ResourcesPath = "Resources"); services.AddLocalization(options => options.ResourcesPath = "Resources");
services.AddDbContext<AppDatabase>(); services.AddDbContext<AppDatabase>();
services.AddSingleton<IClock>(SystemClock.Instance);
services.AddHttpContextAccessor(); services.AddHttpContextAccessor();
services.AddSingleton<ICacheService, CacheServiceRedis>();
services.AddSingleton<MarkdownConverter>(); services.AddSingleton<MarkdownConverter>();
services.AddHttpClient(); services.AddHttpClient();