From a3d6efb98a58da65d828c62fcb7095e302f70fb2 Mon Sep 17 00:00:00 2001 From: Farshad DASHTI Date: Tue, 1 Oct 2024 17:02:55 +0100 Subject: [PATCH] Added Caching --- .github/workflows/build-test-caching.yml | 19 +++++ .github/workflows/built-test-template.yml | 85 +++++++++++++++++++ DfE.CoreLibs.sln | 12 +++ .../DfE.CoreLibs.Caching.csproj | 18 ++++ .../Helpers/CacheKeyHelper.cs | 43 ++++++++++ .../Interfaces/ICacheService.cs | 8 ++ .../ServiceCollectionExtensions.cs | 19 +++++ .../Services/MemoryCacheService.cs | 53 ++++++++++++ .../Settings/CacheSettings.cs | 8 ++ 9 files changed, 265 insertions(+) create mode 100644 .github/workflows/build-test-caching.yml create mode 100644 .github/workflows/built-test-template.yml create mode 100644 src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj create mode 100644 src/DfE.CoreLibs.Caching/Helpers/CacheKeyHelper.cs create mode 100644 src/DfE.CoreLibs.Caching/Interfaces/ICacheService.cs create mode 100644 src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs create mode 100644 src/DfE.CoreLibs.Caching/Services/MemoryCacheService.cs create mode 100644 src/DfE.CoreLibs.Caching/Settings/CacheSettings.cs diff --git a/.github/workflows/build-test-caching.yml b/.github/workflows/build-test-caching.yml new file mode 100644 index 0000000..9576ca7 --- /dev/null +++ b/.github/workflows/build-test-caching.yml @@ -0,0 +1,19 @@ +name: .NET Build and Test for DfE.CoreLibs.Caching + +on: + push: + branches: + - main + paths: + - 'src/DfE.CoreLibs.Caching/**' + +jobs: + build-and-test: + uses: ./.github/workflows/build-test-template.yml + with: + project_name: DfE.CoreLibs.Caching + project_path: src/DfE.CoreLibs.Caching + sonar_project_key: DFE-Digital_corelibs-caching + secrets: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/built-test-template.yml b/.github/workflows/built-test-template.yml new file mode 100644 index 0000000..3907781 --- /dev/null +++ b/.github/workflows/built-test-template.yml @@ -0,0 +1,85 @@ +name: .NET Build and Test Template + +on: + workflow_call: + inputs: + project_name: + required: true + type: string + description: "The name of the project" + project_path: + required: true + type: string + description: "The relative path to the project directory" + sonar_project_key: + required: true + type: string + description: "SonarCloud project key" + secrets: + GITHUB_TOKEN: + required: true + SONAR_TOKEN: + required: true + +env: + DOTNET_VERSION: '8.0.x' + EF_VERSION: '6.0.5' + JAVA_VERSION: '17' + CONNECTION_STRING: 'Server=localhost,1433;Database=sip;TrustServerCertificate=True;User Id=sa;Password=StrongPassword905' + +jobs: + build-and-test: + runs-on: ubuntu-latest + permissions: + packages: read + contents: read + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} + + - name: Setup JDK + uses: actions/setup-java@v4 + with: + distribution: 'microsoft' + java-version: ${{ env.JAVA_VERSION }} + + - name: Cache SonarCloud packages + uses: actions/cache@v4 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: Install SonarCloud scanners + run: dotnet tool install --global dotnet-sonarscanner + + - name: Install EF for tests + run: dotnet tool install --global dotnet-ef --version ${{ env.EF_VERSION }} + + - name: Install dotnet reportgenerator + run: dotnet tool install --global dotnet-reportgenerator-globaltool + + - name: Add nuget package source + run: dotnet nuget add source --username USERNAME --password ${{ secrets.GITHUB_TOKEN }} --store-password-in-clear-text --name github "https://nuget.pkg.github.com/DFE-Digital/index.json" + + - name: Restore dependencies + run: dotnet restore ${{ inputs.project_path }} + + - name: Build, Test and Analyze + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + ConnectionStrings__DefaultConnection: ${{ env.CONNECTION_STRING }} + CI: true + run: | + dotnet-sonarscanner begin /k:"${{ inputs.sonar_project_key }}" /o:"dfe-digital" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" /d:sonar.coverageReportPaths=CoverageReport/SonarQube.xml + dotnet build --no-restore -p:CI=${CI} ${{ inputs.project_path }} + dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" ${{ inputs.project_path }} + reportgenerator -reports:./**/coverage.cobertura.xml -targetdir:./CoverageReport -reporttypes:SonarQube + dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/DfE.CoreLibs.sln b/DfE.CoreLibs.sln index 6105196..5273f06 100644 --- a/DfE.CoreLibs.sln +++ b/DfE.CoreLibs.sln @@ -3,7 +3,19 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.10.35122.118 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DfE.CoreLibs.Caching", "src\DfE.CoreLibs.Caching\DfE.CoreLibs.Caching.csproj", "{D88D58F0-18C4-4C3B-805B-8A483500E73E}" +EndProject Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {D88D58F0-18C4-4C3B-805B-8A483500E73E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D88D58F0-18C4-4C3B-805B-8A483500E73E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D88D58F0-18C4-4C3B-805B-8A483500E73E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D88D58F0-18C4-4C3B-805B-8A483500E73E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection diff --git a/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj b/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj new file mode 100644 index 0000000..e4ae1de --- /dev/null +++ b/src/DfE.CoreLibs.Caching/DfE.CoreLibs.Caching.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/src/DfE.CoreLibs.Caching/Helpers/CacheKeyHelper.cs b/src/DfE.CoreLibs.Caching/Helpers/CacheKeyHelper.cs new file mode 100644 index 0000000..f78450e --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Helpers/CacheKeyHelper.cs @@ -0,0 +1,43 @@ +using System.Security.Cryptography; +using System.Text; + +namespace DfE.CoreLibs.Caching.Helpers +{ + public static class CacheKeyHelper + { + /// + /// Generates a hashed cache key for any given input string. + /// + /// The input string to be hashed. + /// A hashed string that can be used as a cache key. + public static string GenerateHashedCacheKey(string input) + { + if (string.IsNullOrWhiteSpace(input)) + { + throw new ArgumentException("Input cannot be null or empty", nameof(input)); + } + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(input)); + + return BitConverter.ToString(bytes).Replace("-", "").ToLower(); + } + + /// + /// Generates a hashed cache key for a collection of strings by concatenating them. + /// + /// A collection of strings to be concatenated and hashed. + /// A hashed string that can be used as a cache key. + public static string GenerateHashedCacheKey(IEnumerable inputs) + { + if (inputs == null || !inputs.Any()) + { + throw new ArgumentException("Input collection cannot be null or empty", nameof(inputs)); + } + + var concatenatedInput = string.Join(",", inputs); + + return GenerateHashedCacheKey(concatenatedInput); + } + } + +} diff --git a/src/DfE.CoreLibs.Caching/Interfaces/ICacheService.cs b/src/DfE.CoreLibs.Caching/Interfaces/ICacheService.cs new file mode 100644 index 0000000..58a5020 --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Interfaces/ICacheService.cs @@ -0,0 +1,8 @@ +namespace DfE.CoreLibs.Caching.Interfaces +{ + public interface ICacheService + { + Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName); + void Remove(string cacheKey); + } +} diff --git a/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs b/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..cea55bf --- /dev/null +++ b/src/DfE.CoreLibs.Caching/ServiceCollectionExtensions.cs @@ -0,0 +1,19 @@ +using DfE.CoreLibs.Caching.Interfaces; +using DfE.CoreLibs.Caching.Services; +using DfE.CoreLibs.Caching.Settings; +using Microsoft.Extensions.Configuration; + +namespace Microsoft.Extensions.DependencyInjection +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddServiceCaching( + this IServiceCollection services, IConfiguration config) + { + services.Configure(config.GetSection("CacheSettings")); + services.AddSingleton(); + + return services; + } + } +} diff --git a/src/DfE.CoreLibs.Caching/Services/MemoryCacheService.cs b/src/DfE.CoreLibs.Caching/Services/MemoryCacheService.cs new file mode 100644 index 0000000..ef5c343 --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Services/MemoryCacheService.cs @@ -0,0 +1,53 @@ +using System.Diagnostics.CodeAnalysis; +using DfE.CoreLibs.Caching.Interfaces; +using DfE.CoreLibs.Caching.Settings; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DfE.CoreLibs.Caching.Services +{ + [ExcludeFromCodeCoverage] + public class MemoryCacheService( + IMemoryCache memoryCache, + ILogger logger, + IOptions cacheSettings) + : ICacheService + { + private readonly CacheSettings _cacheSettings = cacheSettings.Value; + + public async Task GetOrAddAsync(string cacheKey, Func> fetchFunction, string methodName) + { + if (memoryCache.TryGetValue(cacheKey, out T? cachedValue)) + { + logger.LogInformation("Cache hit for key: {CacheKey}", cacheKey); + return cachedValue!; + } + + logger.LogInformation("Cache miss for key: {CacheKey}. Fetching from source...", cacheKey); + var result = await fetchFunction(); + + if (Equals(result, default(T))) return result; + var cacheDuration = GetCacheDurationForMethod(methodName); + memoryCache.Set(cacheKey, result, cacheDuration); + logger.LogInformation("Cached result for key: {CacheKey} for duration: {CacheDuration}", cacheKey, cacheDuration); + + return result; + } + + public void Remove(string cacheKey) + { + memoryCache.Remove(cacheKey); + logger.LogInformation("Cache removed for key: {CacheKey}", cacheKey); + } + + private TimeSpan GetCacheDurationForMethod(string methodName) + { + if (_cacheSettings.Durations.TryGetValue(methodName, out int durationInSeconds)) + { + return TimeSpan.FromSeconds(durationInSeconds); + } + return TimeSpan.FromSeconds(_cacheSettings.DefaultDurationInSeconds); + } + } +} diff --git a/src/DfE.CoreLibs.Caching/Settings/CacheSettings.cs b/src/DfE.CoreLibs.Caching/Settings/CacheSettings.cs new file mode 100644 index 0000000..a0acb3e --- /dev/null +++ b/src/DfE.CoreLibs.Caching/Settings/CacheSettings.cs @@ -0,0 +1,8 @@ +namespace DfE.CoreLibs.Caching.Settings +{ + public class CacheSettings + { + public int DefaultDurationInSeconds { get; set; } = 5; + public Dictionary Durations { get; set; } = new(); + } +}