Bootstrap

在ASP.NET Core WebAPI 中使用轻量级的方式实现一个支持持久化的缓存组件

前言

在 WebAPI 开发中,缓存是一种常用的优化手段。Redis 是广泛使用的缓存解决方案,但在某些场景下,我们可能不希望引入第三方依赖,而是希望使用轻量级的方式实现一个支持持久化的缓存组件,满足以下需求:

  • 缓存持久化:重启后缓存可以恢复。
  • 过期删除:支持设置缓存过期时间。
  • 基本操作:支持常见的增、删、查操作。

本文将指导如何设计和实现一个符合上述需求的缓存组件。

需求分析与设计思路

要实现这样的缓存组件,我们需要解决以下几个关键问题:

  1. 数据存储
    选择适合持久化的数据存储方式。SQLite 是一个很好的选择,因为它内置于 .NET,性能良好,且无需额外安装服务。

  2. 过期管理
    需要定期清理过期缓存,可以通过后台定时任务扫描和清理。

  3. 高效的操作接口
    提供易用的 SetGetRemove 等接口,并封装为服务供 API 使用。

实现步骤

1. 定义缓存模型

定义缓存的基本结构,包含键、值、过期时间等信息。

public class CacheItem
{
    public string Key { get; set; } = null!;
    public string Value { get; set; } = null!;
    public DateTime Expiration { get; set; }
}

2. 创建 SQLite 数据存储

在项目中配置 SQLite 数据库,用于存储缓存数据。

配置 SQLite 数据库
 <Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.Data.Sqlite.Core" Version="8.0.11" />
    <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
    <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="8.0.1" />
    <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.10" />
    <PackageReference Include="SQLitePCLRaw.core" Version="2.1.10" />
    <PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.10" />
    <PackageReference Include="SQLitePCLRaw.provider.e_sqlite3" Version="2.1.10" />
  </ItemGroup>

</Project>
  
初始化数据库

创建一个帮助类用于初始化和操作 SQLite 数据库。

using Microsoft.Data.Sqlite;
using SQLitePCL;

namespace SqliteCache
{
    public class CacheDbContext
    {
        private readonly string _connectionString = "Data Source=cache.db";

        public CacheDbContext()
        {
            Batteries.Init();
            using var connection = new SqliteConnection(_connectionString);
            connection.Open();

            var command = connection.CreateCommand();
            command.CommandText = @"
            CREATE TABLE IF NOT EXISTS Cache (
                Key TEXT PRIMARY KEY,
                Value TEXT NOT NULL,
                Expiration TEXT NOT NULL
            );";
            command.ExecuteNonQuery();
        }

        public void AddOrUpdate(CacheItem item)
        {
            using var connection = new SqliteConnection(_connectionString);
            connection.Open();

            var command = connection.CreateCommand();
            command.CommandText = @"
            INSERT INTO Cache (Key, Value, Expiration)
            VALUES (@Key, @Value, @Expiration)
            ON CONFLICT(Key) DO UPDATE SET
                Value = excluded.Value,
                Expiration = excluded.Expiration;";
            command.Parameters.AddWithValue("@Key", item.Key);
            command.Parameters.AddWithValue("@Value", item.Value);
            command.Parameters.AddWithValue("@Expiration", item.Expiration.ToString("o"));
            command.ExecuteNonQuery();
        }

        public CacheItem? Get(string key)
        {
            using var connection = new SqliteConnection(_connectionString);
            connection.Open();

            var command = connection.CreateCommand();
            command.CommandText = "SELECT Key, Value, Expiration FROM Cache WHERE Key = @Key";
            command.Parameters.AddWithValue("@Key", key);

            using var reader = command.ExecuteReader();
            if (reader.Read())
            {
                return new CacheItem
                {
                    Key = reader.GetString(0),
                    Value = reader.GetString(1),
                    Expiration = DateTime.Parse(reader.GetString(2))
                };
            }
            return null;
        }

        public void Remove(string key)
        {
            using var connection = new SqliteConnection(_connectionString);
            connection.Open();

            var command = connection.CreateCommand();
            command.CommandText = "DELETE FROM Cache WHERE Key = @Key";
            command.Parameters.AddWithValue("@Key", key);
            command.ExecuteNonQuery();
        }

        public void ClearExpired()
        {
            using var connection = new SqliteConnection(_connectionString);
            connection.Open();

            var command = connection.CreateCommand();
            command.CommandText = "DELETE FROM Cache WHERE Expiration < @Now";
            command.Parameters.AddWithValue("@Now", DateTime.UtcNow.ToString("o"));
            command.ExecuteNonQuery();
        }
    }
}

3. 创建缓存服务

封装数据库操作,提供易用的缓存接口。

namespace SqliteCache
{
    public class PersistentCacheService
    {
        private readonly CacheDbContext _dbContext;

        public PersistentCacheService()
        {
            _dbContext = new CacheDbContext();
        }

        public void Set(string key, string value, TimeSpan expiration)
        {
            var cacheItem = new CacheItem
            {
                Key = key,
                Value = value,
                Expiration = DateTime.UtcNow.Add(expiration)
            };
            _dbContext.AddOrUpdate(cacheItem);
        }

        public string? Get(string key)
        {
            var item = _dbContext.Get(key);
            if (item == null || item.Expiration <= DateTime.UtcNow)
            {
                _dbContext.Remove(key); // 自动删除过期项
                return null;
            }
            return item.Value;
        }

        public void Remove(string key)
        {
            _dbContext.Remove(key);
        }
    }
}

4. 定期清理过期缓存

利用 ASP.NET Core 的后台任务机制清理过期缓存。

配置后台服务
using Microsoft.Extensions.Hosting;

namespace SqliteCache
{
    public class CacheCleanupService : BackgroundService
    {
        private readonly CacheDbContext _dbContext = new();

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                _dbContext.ClearExpired();
                await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
            }
        }
    }
}
注册服务

SqliteCacheServiceCollectionExtensions.cs 中注册缓存服务和后台任务。

using SqliteCache;
namespace Microsoft.Extensions.DependencyInjection
{
    public static class SqliteCacheServiceCollectionExtensions
    {
        public static IServiceCollection AddSqliteCache(this IServiceCollection services)
        {
            services.AddSingleton<PersistentCacheService>();
            services.AddHostedService<CacheCleanupService>();
            return services;
        }
    }
}

5. 使用缓存服务

新增一个asp.net core webapi项目 CacheProject,添加项目引用SqliteCache。在Main函数中添加服务引用。

namespace CacheProject
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);
            builder.Services.AddControllers();
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();
            //引入Sqlitecache缓存组件
            builder.Services.AddSqliteCache();
            var app = builder.Build();
            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }
            app.UseHttpsRedirection();
            app.UseAuthorization();
            app.MapControllers();
            app.Run();
        }
    }
}

在控制器中注入并使用缓存服务。

[ApiController]
[Route("api/[controller]")]
public class CacheController : ControllerBase
{
    private readonly PersistentCacheService _cacheService;

    public CacheController(PersistentCacheService cacheService)
    {
        _cacheService = cacheService;
    }

    [HttpPost("set")]
    public IActionResult Set(string key, string value, int expirationMinutes)
    {
        _cacheService.Set(key, value, TimeSpan.FromMinutes(expirationMinutes));
        return Ok("Cached successfully.");
    }

    [HttpGet("get")]
    public IActionResult Get(string key)
    {
        var value = _cacheService.Get(key);
        if (value == null)
            return NotFound("Key not found or expired.");
        return Ok(value);
    }

    [HttpDelete("remove")]
    public IActionResult Remove(string key)
    {
        _cacheService.Remove(key);
        return Ok("Removed successfully.");
    }
}

6.运行

启动WebApi项目

info: Microsoft.Hosting.Lifetime[14]
      Now listening on: https://localhost:7193
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5217
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
      Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
      Content root path: E:\F\Projects\CSharp\CacheProject\CacheProject
  1. 浏览器查看
    在这里插入图片描述
  2. 写入缓存
    在这里插入图片描述
  3. 读缓存和重启程序
    在这里插入图片描述
  4. 缓存已过期
    在这里插入图片描述

总结

本文展示了如何在 ASP.NET Core WebAPI 中,使用 SQLite 构建一个支持持久化和过期管理的缓存组件。通过以上步骤,我们实现了一个轻量级、易扩展的缓存系统,无需引入 Redis 等第三方工具,同时满足了性能和持久化的需求。

扩展思路

  • 使用 JSON 或 Protobuf 对值进行序列化,支持更复杂的数据类型。
  • 提供缓存命中率统计等高级功能。
  • 增加分布式缓存支持。
;