0
0
mirror of https://github.com/alex289/CleanArchitecture.git synced 2025-08-22 19:28:34 +00:00

Merge pull request #23 from alex289/feature/redis

feat: Add redis cache
This commit is contained in:
Alex 2023-09-01 22:22:15 +02:00 committed by GitHub
commit 864d1e812c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 245 additions and 42 deletions

View File

@ -7,26 +7,28 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AspNetCore.HealthChecks.ApplicationStatus" Version="7.0.0"/> <PackageReference Include="AspNetCore.HealthChecks.ApplicationStatus" Version="7.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="7.0.0"/> <PackageReference Include="AspNetCore.HealthChecks.Redis" Version="7.0.0" />
<PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="7.1.0"/> <PackageReference Include="AspNetCore.HealthChecks.SqlServer" Version="7.0.0" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.10"/> <PackageReference Include="AspNetCore.HealthChecks.UI.Client" Version="7.1.0" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.10"/> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="7.0.10" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="7.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.10"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.10">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.10"/> <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.10" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.10"/> <PackageReference Include="Microsoft.Extensions.Caching.StackExchangeRedis" Version="7.0.10" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0"/> <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.10" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="6.5.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CleanArchitecture.Application\CleanArchitecture.Application.csproj"/> <ProjectReference Include="..\CleanArchitecture.Application\CleanArchitecture.Application.csproj" />
<ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj"/> <ProjectReference Include="..\CleanArchitecture.Domain\CleanArchitecture.Domain.csproj" />
<ProjectReference Include="..\CleanArchitecture.gRPC\CleanArchitecture.gRPC.csproj"/> <ProjectReference Include="..\CleanArchitecture.gRPC\CleanArchitecture.gRPC.csproj" />
<ProjectReference Include="..\CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj"/> <ProjectReference Include="..\CleanArchitecture.Infrastructure\CleanArchitecture.Infrastructure.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@ -14,6 +14,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using StackExchange.Redis;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@ -30,7 +31,8 @@ if (builder.Environment.IsProduction())
{ {
builder.Services builder.Services
.AddHealthChecks() .AddHealthChecks()
.AddSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")!); .AddSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")!)
.AddRedis(builder.Configuration["RedisHostName"]!, "Redis");
} }
builder.Services.AddDbContext<ApplicationDbContext>(options => builder.Services.AddDbContext<ApplicationDbContext>(options =>
@ -59,6 +61,19 @@ builder.Services.AddLogging(x => x.AddSimpleConsole(console =>
console.IncludeScopes = true; console.IncludeScopes = true;
})); }));
if (builder.Environment.IsProduction())
{
builder.Services.AddStackExchangeRedisCache(options =>
{
options.Configuration = builder.Configuration["RedisHostName"];
options.InstanceName = "clean-architecture";
});
}
else
{
builder.Services.AddDistributedMemoryCache();
}
var app = builder.Build(); var app = builder.Build();
using (var scope = app.Services.CreateScope()) using (var scope = app.Services.CreateScope())

View File

@ -13,5 +13,6 @@
"Issuer": "CleanArchitectureServer", "Issuer": "CleanArchitectureServer",
"Audience": "CleanArchitectureClient", "Audience": "CleanArchitectureClient",
"Secret": "sD3v061gf8BxXgmxcHssasjdlkasjd87439284)@#(*" "Secret": "sD3v061gf8BxXgmxcHssasjdlkasjd87439284)@#(*"
} },
"RedisHostName": "redis"
} }

View File

@ -6,20 +6,26 @@ using CleanArchitecture.Application.Queries.Tenants.GetAll;
using CleanArchitecture.Application.Queries.Tenants.GetTenantById; using CleanArchitecture.Application.Queries.Tenants.GetTenantById;
using CleanArchitecture.Application.ViewModels; using CleanArchitecture.Application.ViewModels;
using CleanArchitecture.Application.ViewModels.Tenants; using CleanArchitecture.Application.ViewModels.Tenants;
using CleanArchitecture.Domain;
using CleanArchitecture.Domain.Commands.Tenants.CreateTenant; using CleanArchitecture.Domain.Commands.Tenants.CreateTenant;
using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant; using CleanArchitecture.Domain.Commands.Tenants.DeleteTenant;
using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant; using CleanArchitecture.Domain.Commands.Tenants.UpdateTenant;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Extensions;
using CleanArchitecture.Domain.Interfaces; using CleanArchitecture.Domain.Interfaces;
using Microsoft.Extensions.Caching.Distributed;
namespace CleanArchitecture.Application.Services; namespace CleanArchitecture.Application.Services;
public sealed class TenantService : ITenantService public sealed class TenantService : ITenantService
{ {
private readonly IMediatorHandler _bus; private readonly IMediatorHandler _bus;
private readonly IDistributedCache _distributedCache;
public TenantService(IMediatorHandler bus) public TenantService(IMediatorHandler bus, IDistributedCache distributedCache)
{ {
_bus = bus; _bus = bus;
_distributedCache = distributedCache;
} }
public async Task<Guid> CreateTenantAsync(CreateTenantViewModel tenant) public async Task<Guid> CreateTenantAsync(CreateTenantViewModel tenant)
@ -47,7 +53,16 @@ public sealed class TenantService : ITenantService
public async Task<TenantViewModel?> GetTenantByIdAsync(Guid tenantId, bool deleted) public async Task<TenantViewModel?> GetTenantByIdAsync(Guid tenantId, bool deleted)
{ {
return await _bus.QueryAsync(new GetTenantByIdQuery(tenantId, deleted)); var cachedTenant = await _distributedCache.GetOrCreateJsonAsync(
CacheKeyGenerator.GetEntityCacheKey<Tenant>(tenantId),
async () => await _bus.QueryAsync(new GetTenantByIdQuery(tenantId, deleted)),
new DistributedCacheEntryOptions
{
SlidingExpiration = TimeSpan.FromDays(3),
AbsoluteExpiration = DateTimeOffset.Now.AddDays(30)
});
return cachedTenant;
} }
public async Task<PagedResult<TenantViewModel>> GetAllTenantsAsync(PageQuery query, string searchTerm = "") public async Task<PagedResult<TenantViewModel>> GetAllTenantsAsync(PageQuery query, string searchTerm = "")

View File

@ -18,7 +18,7 @@ public sealed class TenantViewModel
{ {
Id = tenant.Id, Id = tenant.Id,
Name = tenant.Name, Name = tenant.Name,
Users = tenant.Users.Select(UserViewModel.FromUser) Users = tenant.Users.Select(UserViewModel.FromUser).ToList()
}; };
} }
} }

View File

@ -7,6 +7,7 @@ namespace CleanArchitecture.Application.ViewModels.Users;
public sealed class UserViewModel public sealed class UserViewModel
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid TenantId { get; set; }
public string Email { get; set; } = string.Empty; public string Email { get; set; } = string.Empty;
public string FirstName { get; set; } = string.Empty; public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty; public string LastName { get; set; } = string.Empty;
@ -18,6 +19,7 @@ public sealed class UserViewModel
return new UserViewModel return new UserViewModel
{ {
Id = user.Id, Id = user.Id,
TenantId = user.TenantId,
Email = user.Email, Email = user.Email,
FirstName = user.FirstName, FirstName = user.FirstName,
LastName = user.LastName, LastName = user.LastName,

View File

@ -0,0 +1,13 @@
using System;
using CleanArchitecture.Domain.Entities;
namespace CleanArchitecture.Domain;
public static class CacheKeyGenerator
{
public static string GetEntityCacheKey<TEntity>(TEntity entity) where TEntity : Entity =>
$"{typeof(TEntity)}-{entity.Id}";
public static string GetEntityCacheKey<TEntity>(Guid id) where TEntity : Entity =>
$"{typeof(TEntity)}-{id}";
}

View File

@ -6,11 +6,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
<PackageReference Include="FluentValidation" Version="11.7.1" /> <PackageReference Include="FluentValidation" Version="11.7.1"/>
<PackageReference Include="MediatR" Version="12.1.1" /> <PackageReference Include="MediatR" Version="12.1.1"/>
<PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1" /> <PackageReference Include="Microsoft.Extensions.Options" Version="7.0.1"/>
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.32.2" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="6.32.1"/>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -100,7 +100,7 @@ public sealed class CreateUserCommandHandler : CommandHandlerBase,
if (await CommitAsync()) if (await CommitAsync())
{ {
await Bus.RaiseEventAsync(new UserCreatedEvent(user.Id)); await Bus.RaiseEventAsync(new UserCreatedEvent(user.Id, user.TenantId));
} }
} }
} }

View File

@ -62,7 +62,7 @@ public sealed class DeleteUserCommandHandler : CommandHandlerBase,
if (await CommitAsync()) if (await CommitAsync())
{ {
await Bus.RaiseEventAsync(new UserDeletedEvent(request.UserId)); await Bus.RaiseEventAsync(new UserDeletedEvent(request.UserId, user.TenantId));
} }
} }
} }

View File

@ -100,7 +100,7 @@ public sealed class UpdateUserCommandHandler : CommandHandlerBase,
if (await CommitAsync()) if (await CommitAsync())
{ {
await Bus.RaiseEventAsync(new UserUpdatedEvent(user.Id)); await Bus.RaiseEventAsync(new UserUpdatedEvent(user.Id, user.TenantId));
} }
} }
} }

View File

@ -1,7 +1,9 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Events.Tenant; using CleanArchitecture.Domain.Events.Tenant;
using MediatR; using MediatR;
using Microsoft.Extensions.Caching.Distributed;
namespace CleanArchitecture.Domain.EventHandler; namespace CleanArchitecture.Domain.EventHandler;
@ -10,18 +12,29 @@ public sealed class TenantEventHandler :
INotificationHandler<TenantDeletedEvent>, INotificationHandler<TenantDeletedEvent>,
INotificationHandler<TenantUpdatedEvent> INotificationHandler<TenantUpdatedEvent>
{ {
private readonly IDistributedCache _distributedCache;
public TenantEventHandler(IDistributedCache distributedCache)
{
_distributedCache = distributedCache;
}
public Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken) public Task Handle(TenantCreatedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task Handle(TenantDeletedEvent notification, CancellationToken cancellationToken) public async Task Handle(TenantDeletedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; await _distributedCache.RemoveAsync(
CacheKeyGenerator.GetEntityCacheKey<Tenant>(notification.AggregateId),
cancellationToken);
} }
public Task Handle(TenantUpdatedEvent notification, CancellationToken cancellationToken) public async Task Handle(TenantUpdatedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; await _distributedCache.RemoveAsync(
CacheKeyGenerator.GetEntityCacheKey<Tenant>(notification.AggregateId),
cancellationToken);
} }
} }

View File

@ -1,7 +1,9 @@
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CleanArchitecture.Domain.Entities;
using CleanArchitecture.Domain.Events.User; using CleanArchitecture.Domain.Events.User;
using MediatR; using MediatR;
using Microsoft.Extensions.Caching.Distributed;
namespace CleanArchitecture.Domain.EventHandler; namespace CleanArchitecture.Domain.EventHandler;
@ -11,23 +13,36 @@ public sealed class UserEventHandler :
INotificationHandler<UserUpdatedEvent>, INotificationHandler<UserUpdatedEvent>,
INotificationHandler<PasswordChangedEvent> INotificationHandler<PasswordChangedEvent>
{ {
private readonly IDistributedCache _distributedCache;
public UserEventHandler(IDistributedCache distributedCache)
{
_distributedCache = distributedCache;
}
public Task Handle(PasswordChangedEvent notification, CancellationToken cancellationToken) public Task Handle(PasswordChangedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken) public async Task Handle(UserCreatedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; await _distributedCache.RemoveAsync(
CacheKeyGenerator.GetEntityCacheKey<Tenant>(notification.TenantId),
cancellationToken);
} }
public Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken) public async Task Handle(UserDeletedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; await _distributedCache.RemoveAsync(
CacheKeyGenerator.GetEntityCacheKey<Tenant>(notification.TenantId),
cancellationToken);
} }
public Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken) public async Task Handle(UserUpdatedEvent notification, CancellationToken cancellationToken)
{ {
return Task.CompletedTask; await _distributedCache.RemoveAsync(
CacheKeyGenerator.GetEntityCacheKey<Tenant>(notification.TenantId),
cancellationToken);
} }
} }

View File

@ -5,7 +5,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserCreatedEvent : DomainEvent public sealed class UserCreatedEvent : DomainEvent
{ {
public UserCreatedEvent(Guid userId) : base(userId) public Guid TenantId { get; }
public UserCreatedEvent(Guid userId, Guid tenantId) : base(userId)
{ {
TenantId = tenantId;
} }
} }

View File

@ -5,7 +5,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserDeletedEvent : DomainEvent public sealed class UserDeletedEvent : DomainEvent
{ {
public UserDeletedEvent(Guid userId) : base(userId) public Guid TenantId { get; }
public UserDeletedEvent(Guid userId, Guid tenantId) : base(userId)
{ {
TenantId = tenantId;
} }
} }

View File

@ -5,7 +5,10 @@ namespace CleanArchitecture.Domain.Events.User;
public sealed class UserUpdatedEvent : DomainEvent public sealed class UserUpdatedEvent : DomainEvent
{ {
public UserUpdatedEvent(Guid userId) : base(userId) public Guid TenantId { get; }
public UserUpdatedEvent(Guid userId, Guid tenantId) : base(userId)
{ {
TenantId = tenantId;
} }
} }

View File

@ -0,0 +1,43 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
using Newtonsoft.Json;
namespace CleanArchitecture.Domain.Extensions;
public static class DistributedCacheExtensions
{
private static readonly JsonSerializerSettings s_jsonSerializerSettings = new()
{
TypeNameHandling = TypeNameHandling.All
};
public static async Task<T?> GetOrCreateJsonAsync<T>(
this IDistributedCache cache,
string key,
Func<Task<T?>> factory,
DistributedCacheEntryOptions options,
CancellationToken cancellationToken = default) where T : class
{
var json = await cache.GetStringAsync(key, cancellationToken);
if (!string.IsNullOrWhiteSpace(json))
{
return JsonConvert.DeserializeObject<T>(json, s_jsonSerializerSettings)!;
}
var value = await factory();
if (value == default)
{
return value;
}
json = JsonConvert.SerializeObject(value, s_jsonSerializerSettings);
await cache.SetStringAsync(key, json, options, cancellationToken);
return value;
}
}

View File

@ -39,7 +39,7 @@ public sealed class InMemoryBusTests
var inMemoryBus = new InMemoryBus(mediator, domainEventStore); var inMemoryBus = new InMemoryBus(mediator, domainEventStore);
var userDeletedEvent = new UserDeletedEvent(Guid.NewGuid()); var userDeletedEvent = new UserDeletedEvent(Guid.NewGuid(), Guid.NewGuid());
await inMemoryBus.RaiseEventAsync(userDeletedEvent); await inMemoryBus.RaiseEventAsync(userDeletedEvent);

View File

@ -38,6 +38,19 @@ To run the project, follow these steps:
### Using docker ### Using docker
Requirements
> This is only needed if running the API locally or only the docker image
1. Redis: `docker run --name redis -d -p 6379:6379 -e ALLOW_EMPTY_PASSWORD=yes redis:latest`
2. Add this to the redis configuration in the Program.cs
```csharp
options.ConfigurationOptions = new ConfigurationOptions
{
AbortOnConnectFail = false,
EndPoints = { "localhost", "6379" }
};
```
Running the container
1. Build the Dockerfile: `docker build -t clean-architecture .` 1. Build the Dockerfile: `docker build -t clean-architecture .`
2. Run the Container: `docker run -p 80:80 clean-architecture` 2. Run the Container: `docker run -p 80:80 clean-architecture`
@ -50,7 +63,8 @@ To run the project, follow these steps:
### Using Kubernetes ### Using Kubernetes
1. Change the ConnectionString in the appsettings.json to `Server=clean-architecture-db-service;Database=clean-architecture;Trusted_Connection=False;MultipleActiveResultSets=true;TrustServerCertificate=True;User Id=SA;Password=Password123!#` 1. Change the ConnectionString in the appsettings.json to `Server=clean-architecture-db-service;Database=clean-architecture;Trusted_Connection=False;MultipleActiveResultSets=true;TrustServerCertificate=True;User Id=SA;Password=Password123!#`
2. Build the docker image and push it to the docker hub (Change the image name in the `k8s-deployment.yml` to your own) 2. Change the RedisHostName in the appsettings.json to `redis-service`
3. Build the docker image and push it to the docker hub (Change the image name in the `k8s-deployment.yml` to your own)
Apply the deployment file: `kubectl apply -f k8s-deployment.yml` (Delete: `kubectl delete -f k8s-deployment.yml`) Apply the deployment file: `kubectl apply -f k8s-deployment.yml` (Delete: `kubectl delete -f k8s-deployment.yml`)

View File

@ -22,3 +22,16 @@ services:
- SA_PASSWORD=Password123!# - SA_PASSWORD=Password123!#
ports: ports:
- 1433:1433 - 1433:1433
redis:
image: docker.io/bitnami/redis:7.2
environment:
# ALLOW_EMPTY_PASSWORD is recommended only for development.
- ALLOW_EMPTY_PASSWORD=yes
- REDIS_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
ports:
- '6379:6379'
volumes:
- 'redis_data:/bitnami/redis/data'
volumes:
redis_data:
driver: local

View File

@ -14,6 +14,7 @@ spec:
spec: spec:
containers: containers:
- name: clean-architecture-app - name: clean-architecture-app
# Replace this with the path to your built image
image: alexdev28/clean-architecture image: alexdev28/clean-architecture
ports: ports:
- containerPort: 80 - containerPort: 80
@ -70,4 +71,50 @@ spec:
- protocol: TCP - protocol: TCP
port: 1433 port: 1433
targetPort: 1433 targetPort: 1433
type: ClusterIP type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis-deployment
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: docker.io/bitnami/redis:7.2
env:
# ALLOW_EMPTY_PASSWORD is recommended only for development.
- name: ALLOW_EMPTY_PASSWORD
value: "yes"
- name: REDIS_DISABLE_COMMANDS
value: "FLUSHDB,FLUSHALL"
ports:
- containerPort: 6379
volumeMounts:
- name: redis-data
mountPath: /bitnami/redis/data
volumes:
- name: redis-data
emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
name: redis-service
spec:
selector:
app: redis
ports:
- protocol: TCP
port: 6379
targetPort: 6379