diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet.sln b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet.sln new file mode 100644 index 000000000..c731660b7 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35527.113 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HybridCachingDotNet", "HybridCachingDotNet\HybridCachingDotNet.csproj", "{FF8DF36E-D891-4831-B5D1-99DD21423E2E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {FF8DF36E-D891-4831-B5D1-99DD21423E2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FF8DF36E-D891-4831-B5D1-99DD21423E2E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FF8DF36E-D891-4831-B5D1-99DD21423E2E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FF8DF36E-D891-4831-B5D1-99DD21423E2E}.Release|Any CPU.Build.0 = Release|Any CPU + {F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F1A99A78-F1F9-4E2E-ABAB-D7E679DB053C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection +EndGlobal diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.csproj b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.csproj new file mode 100644 index 000000000..b1ef77fcb --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.http b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.http new file mode 100644 index 000000000..41f9be2a6 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/HybridCachingDotNet.http @@ -0,0 +1,29 @@ +@HybridCachingDotNet_HostAddress = http://localhost:5009 + +GET {{HybridCachingDotNet_HostAddress}}/cm-courses/4 +Accept: application/json + +### + +POST {{HybridCachingDotNet_HostAddress}}/cm-courses +Accept: application/json +Content-Type: application/json + +{ + "id": 4, + "name": "NewCourse", + "category": "NewCategory" +} + +### + +DELETE {{HybridCachingDotNet_HostAddress}}/cm-courses/4 +Accept: application/json + +### + + +DELETE {{HybridCachingDotNet_HostAddress}}/cm-courses/category/NewCategory +Accept: application/json + +### diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Models/CmCourse.cs b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Models/CmCourse.cs new file mode 100644 index 000000000..1284ebc2d --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Models/CmCourse.cs @@ -0,0 +1,10 @@ +namespace HybridCachingDotNet.Models; + +public class CmCourse +{ + public int Id { get; set; } + + public required string Name { get; set; } + + public required string Category { get; set; } +} diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Program.cs b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Program.cs new file mode 100644 index 000000000..51f133645 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Program.cs @@ -0,0 +1,68 @@ +using HybridCachingDotNet.Models; +using HybridCachingDotNet.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Hybrid; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddHybridCache(options => +{ + options.MaximumPayloadBytes = 1024 * 10 * 10; + options.MaximumKeyLength = 256; + + options.DefaultEntryOptions = new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromMinutes(30), + LocalCacheExpiration = TimeSpan.FromMinutes(30) + }; + + options.ReportTagMetrics = true; + options.DisableCompression = true; +}); + +builder.Services.AddScoped(); + +var app = builder.Build(); + +app.MapGet("/cm-courses/{id}", async ( + ICmCourseService cmCourseService, + int id, + CancellationToken cancellationToken) => + { + var result = await cmCourseService.GetCourseAsync(id, cancellationToken); + return Results.Ok(result); + } +); + +app.MapPost("/cm-courses", async ( + ICmCourseService cmCourseService, + [FromBody]CmCourse course, + CancellationToken cancellationToken) => +{ + await cmCourseService.PostCourseAsync(course, cancellationToken); + return Results.Ok(); +} +); + +app.MapDelete("/cm-courses/{id}", async ( + ICmCourseService cmCourseService, + int id, + CancellationToken cancellationToken) => +{ + await cmCourseService.InvalidateByCourseIdAsync(id, cancellationToken); + return Results.Ok(); +} +); + +app.MapDelete("/cm-courses/category/{tag}", async ( + ICmCourseService cmCourseService, + string tag, + CancellationToken cancellationToken) => +{ + await cmCourseService.InvalidateByCategoryAsync(tag, cancellationToken); + return Results.Ok(); +} +); + +app.Run(); + diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Properties/launchSettings.json b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Properties/launchSettings.json new file mode 100644 index 000000000..d5d88575a --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5009" + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:60033/", + "sslPort": 44312 + } + } +} \ No newline at end of file diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/CmCourseService.cs b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/CmCourseService.cs new file mode 100644 index 000000000..775336854 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/CmCourseService.cs @@ -0,0 +1,73 @@ +namespace HybridCachingDotNet.Services; + +using HybridCachingDotNet.Models; +using Microsoft.Extensions.Caching.Hybrid; + +public class CmCourseService(HybridCache cache) : ICmCourseService +{ + public static readonly List courseList = [ + new CmCourse + { + Id = 1, + Name = "WebAPI", + Category = "Backend" + }, + new CmCourse + { + Id = 2, + Name = "Microservices", + Category = "Backend" + }, + new CmCourse + { + Id = 3, + Name = "Blazer", + Category = "Frontend" + }, + ]; + + public async Task GetCourseAsync(int id, CancellationToken cancellationToken = default) + { + return await cache.GetOrCreateAsync( + $"course-{id}", + async token => + { + await Task.Delay(1000, token); + var course = courseList.FirstOrDefault(course => course.Id == id); + return course; + }, + options: new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromMinutes(30), + LocalCacheExpiration = TimeSpan.FromMinutes(30) + }, + tags: ["course"], + cancellationToken: cancellationToken + ); + } + + public async Task PostCourseAsync(CmCourse course, CancellationToken cancellationToken = default) + { + courseList.Add(course); + + await cache.SetAsync($"course-{course.Id}", + course, + options: new HybridCacheEntryOptions + { + Expiration = TimeSpan.FromMinutes(30), + LocalCacheExpiration = TimeSpan.FromMinutes(30) + }, + tags: [$"cat-{course.Category}"], + cancellationToken: cancellationToken); + } + + public async Task InvalidateByCourseIdAsync(int id, CancellationToken cancellationToken = default) + { + await cache.RemoveAsync($"course-{id}", cancellationToken); + } + + public async Task InvalidateByCategoryAsync(string tag, CancellationToken cancellationToken = default) + { + await cache.RemoveByTagAsync($"cat-{tag}", cancellationToken); + } +} diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/ICmCourseService.cs b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/ICmCourseService.cs new file mode 100644 index 000000000..137ae1348 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/Services/ICmCourseService.cs @@ -0,0 +1,14 @@ +namespace HybridCachingDotNet.Services; + +using HybridCachingDotNet.Models; + +public interface ICmCourseService +{ + Task GetCourseAsync(int id, CancellationToken cancellationToken = default); + + Task PostCourseAsync(CmCourse course, CancellationToken cancellationToken = default); + + Task InvalidateByCourseIdAsync(int id, CancellationToken cancellationToken = default); + + Task InvalidateByCategoryAsync(string tag, CancellationToken cancellationToken = default); +} diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.Development.json b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.Development.json new file mode 100644 index 000000000..0c208ae91 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.json b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.json new file mode 100644 index 000000000..10f68b8c8 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/HybridCachingDotNet/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/aspnetcore-features/HybridCachingDotNet/Tests/CmCourseServiceTests.cs b/aspnetcore-features/HybridCachingDotNet/Tests/CmCourseServiceTests.cs new file mode 100644 index 000000000..9df2c6308 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/Tests/CmCourseServiceTests.cs @@ -0,0 +1,80 @@ +namespace Tests; + +using HybridCachingDotNet.Models; +using HybridCachingDotNet.Services; +using Microsoft.Extensions.Caching.Hybrid; +using NSubstitute; + +[TestClass] +public class CmCourseServiceTests +{ + [TestMethod] + public async Task GivenHybridCache_WhenGetCourseAsync_GetOrCreateAsyncIsCalled() + { + var hybridCache = Substitute.For(); + + var sut = new CmCourseService(hybridCache); + + var actual = await sut.GetCourseAsync(1); + + await hybridCache.Received().GetOrCreateAsync("course-1", + Arg.Any>>(), + Arg.Any(), + Arg.Any>(), + Arg.Any() + ); + } + + [TestMethod] + public async Task GivenHybridCache_WhenPostCourseAsync_SetAsyncIsCalled() + { + var hybridCache = Substitute.For(); + + var sut = new CmCourseService(hybridCache); + + var course = new CmCourse + { + Id = 4, + Name = "NewCourse", + Category = "NewCategory", + }; + + await sut.PostCourseAsync(course); + + await hybridCache.Received().SetAsync("course-4", + Arg.Any(), + Arg.Any(), + Arg.Any>(), + Arg.Any() + ); + } + + [TestMethod] + public async Task GivenHybridCache_WhenInvalidateCourseByIdAsync_RemoveAsyncIsCalled() + { + var hybridCache = Substitute.For(); + + var sut = new CmCourseService(hybridCache); + + + await sut.InvalidateByCourseIdAsync(1); + + await hybridCache.RemoveAsync("course-1", + Arg.Any() + ); + } + + [TestMethod] + public async Task GivenHybridCache_WhenInvalidateByCategoryAsync_RemoveByTagAsyncIsCalled() + { + var hybridCache = Substitute.For(); + + var sut = new CmCourseService(hybridCache); + + await sut.InvalidateByCategoryAsync("NewCategory"); + + await hybridCache.RemoveByTagAsync("cat-NewCategory", + Arg.Any() + ); + } +} \ No newline at end of file diff --git a/aspnetcore-features/HybridCachingDotNet/Tests/MSTestSettings.cs b/aspnetcore-features/HybridCachingDotNet/Tests/MSTestSettings.cs new file mode 100644 index 000000000..aaf278c84 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/Tests/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/aspnetcore-features/HybridCachingDotNet/Tests/Tests.csproj b/aspnetcore-features/HybridCachingDotNet/Tests/Tests.csproj new file mode 100644 index 000000000..8e23fec74 --- /dev/null +++ b/aspnetcore-features/HybridCachingDotNet/Tests/Tests.csproj @@ -0,0 +1,25 @@ + + + + net9.0 + latest + enable + enable + + + + + + + + + + + + + + + + + +