Одну из форм форм кэширования представляет распределенное кэширование. Распределенный кэш — это кэш, общий для нескольких серверов приложений. Распределенный кэш обладает такими преимуществами, как согласованность данных между запросами нескольких приложений с разных серверов, независимость от приложения, его запуска, перезапуска и процесса развертывания, отсутствия влияния на локальную память. Соответственно применение распределенного кэша может повысить производительность и масштабируемость приложения ASP.NET Core
В .NET распределенный кэш представлен интерфейсом IDistributedCache. Для управления кэшем этот тип предоставляет ряд методов. Основные из них:
Get(string key) / GetAsync(string key, CancellationToken token)
: возвращает значение с указанным ключом. значение возвращается в виде массива байтов byte[]?
Refresh(string key) / RefreshAsync(string key, CancellationToken token)
: обновляет значение в кэше по ключу, сбрасывая его срок кэширования.
Remove(string key) / RemoveAsync(string key, CancellationToken token)
: удаляет значение с указанным ключом.
Set(string key, byte[] value, DistributedCacheEntryOptions options) / SetAsync(string key, byte[] value, DistributedCacheEntryOptions options, CancellationToken token)
:
устанавливает значение с указанным ключом. В качестве значения выступает массив байтов
SetString(string key, string value) / SetStringAsync(string key, string value, CancellationToken token)
:
устанавливает значение с указанным ключом. В качестве значения выступает строка
GetString(string key) / GetStringAsync(string key, CancellationToken token)
: возвращает значение с указанным ключом. Значение возвращается в виде строки
Из перечисления методов видно, что мы можем сохранить в кэш данные либо в виде массива байт byte[], либо в виде строки string. И обратно получить данные из кэша также можно только в
виде объектов byte[]
и string
. Соответственно если наши данные представляют другой тип, то нам надо предусмотреть механиз сериализации/десериализации данные в/из byte[]
и string
.
Есть разные реализации распределенного кэша. Рассмотрим одну из наиболее популярных, которую представляет Redis.
Redis представляет популярное хранилище данных в памяти, которое часто используется и в качестве базы данных, и в качестве кэша, и которое отличается высоким быстродействием. Рассмотрим, как использовать Redis в качестве кэша в ASP.NET.
Прежде всего для работы нам нужен сам сервер Redis. В рамках данной статьи будем использовать локальный сервер Redis, который установден на текущем компьютере. Однако на данный момент Redis работает только на Linux и на MacOS.
На MacOS для установки Redis применяется пакетный менеджер homebrew :
brew install redis
Для запуска на MacOS применяется команда
sudo service redis-server start
На Linux для установки Redis надо выполнить следующие команды:
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list sudo apt-get update sudo apt-get install redis
Для запуска на Linux применяется команда
sudo service redis-server start
Для Windows Redis не доступен, однако можно использовать Windows Subsystem for Linux или WSL. Для этого сначала надо установить wsl командой
wsl --install
Затем откроем консоль WSL и выполним все те же команды, что указаны выше для Linux.
Для проверки установки Redis можно посмотреть версию командой redis-server --version
Фреймворк ASP.NET Core предоставляет встроенные инструменты для работы с Redis. Так, создадим проект ASP.NET Core. Для работы с Redis добавим в проект Nuget-пакет Microsoft.Extensions.Caching.StackExchangeRedis
Для рассмотрения механизма кэширования возьмем простенькую задачу: закэшируем некоторую информацию о пользователе, которая может не изменяться в течение более долгого периода времени, и поэтому эту информацию мы можем кэшировать, чтобы в будущем избежать лишних обращений к бд.
Для простоты и демонстрации в качестве базы данных будем использовать базу данных SQLite, с которой будем работать через Entity Framework. Поэтому добавим в проект через Nuget пакет Microsoft.EntityFrameworkCore.Sqlite.
Далее определим в файле Program.cs следующий код приложения:
using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; using System.Text.Json; var builder = WebApplication.CreateBuilder(args); // внедрение зависимости Entity Framework builder.Services.AddDbContext<ApplicationContext>(options => options.UseSqlite("Data Source=usercacheapp.db")); // внедрение зависимости UserService builder.Services.AddTransient<UserService>(); // добавление кэширования builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "localhost"; options.InstanceName = "local"; }); var app = builder.Build(); app.MapGet("/user/{id}", async (int id, UserService userService) => { User? user = await userService.GetUser(id); if (user != null) return $"User {user.Name} Id={user.Id} Age={user.Age}"; return "User not found"; }); app.MapGet("/", () => "Hello World!"); app.Run(); public class User { public int Id { get; set; } public string Name { get; set; } = ""; public int Age { get; set; } } public class ApplicationContext : DbContext { public DbSet<User> Users { get; set; } = null!; public ApplicationContext(DbContextOptions<ApplicationContext> options) : base(options) => Database.EnsureCreated(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // инициализация БД начальными данными modelBuilder.Entity<User>().HasData( new User { Id = 1, Name = "Tom", Age = 23 }, new User { Id = 2, Name = "Alice", Age = 26 }, new User { Id = 3, Name = "Sam", Age = 28 } ); } } public class UserService { ApplicationContext db; IDistributedCache cache; public UserService(ApplicationContext context, IDistributedCache distributedCache) { db = context; cache = distributedCache; } public async Task<User?> GetUser(int id) { User? user = null; // пытаемся получить данные из кэша по id var userString = await cache.GetStringAsync(id.ToString()); //десериализируем из строки в объект User if (userString != null) user = JsonSerializer.Deserialize<User>(userString); // если данные не найдены в кэше if (user == null) { // обращаемся к базе данных user = await db.Users.FindAsync(id); // если пользователь найден, то добавляем в кэш if (user != null) { Console.WriteLine($"{user.Name} извлечен из базы данных"); // сериализуем данные в строку в формате json userString = JsonSerializer.Serialize(user); // сохраняем строковое представление объекта в формате json в кэш на 2 минуты await cache.SetStringAsync(user.Id.ToString(), userString, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) }); } } else { Console.WriteLine($"{user.Name} извлечен из кэша"); } return user; } }
Вкратце рассмотрим кода. Прежде всего определяем класс User, который будет описывать используемые данные и объекты которого будут храниться в базе данных SQLite:
public class User { public int Id { get; set; } public string Name { get; set; } = ""; public int Age { get; set; } }
Для взаимодействия с базой данных применяется класс контекста данных ApplicationContext:
public class ApplicationContext : DbContext { public DbSet<User> Users { get; set; } = null!; public ApplicationContext(DbContextOptions<ApplicationContext> options) : base(options) => Database.EnsureCreated(); protected override void OnModelCreating(ModelBuilder modelBuilder) { // инициализация БД начальными данными modelBuilder.Entity<User>().HasData( new User { Id = 1, Name = "Tom", Age = 23 }, new User { Id = 2, Name = "Alice", Age = 26 }, new User { Id = 3, Name = "Sam", Age = 28 } ); } }
Для тестирования в методе OnModelCreating()
инициализируем базу данных тремя объектами.
Для взаимодействия с контекстом данных и кэшем определен класс UserService:
public class UserService { ApplicationContext db; IDistributedCache cache; public UserService(ApplicationContext context, IDistributedCache distributedCache) { db = context; cache = distributedCache; } public async Task<User?> GetUser(int id) { User? user = null; // пытаемся получить данные из кэша по id var userString = await cache.GetStringAsync(id.ToString()); //десериализируем из строки в объект User if (userString != null) user = JsonSerializer.Deserialize<User>(userString); // если данные не найдены в кэше if (user == null) { // обращаемся к базе данных user = await db.Users.FindAsync(id); // если пользователь найден, то добавляем в кэш if (user != null) { Console.WriteLine($"{user.Name} извлечен из базы данных"); // сериализуем данные в строку в формате json userString = JsonSerializer.Serialize(user); // сохраняем строковое представление объекта в формате json в кэш на 2 минуты await cache.SetStringAsync(user.Id.ToString(), userString, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) }); } } else { Console.WriteLine($"{user.Name} извлечен из кэша"); } return user; } }
Данный сервис через встроенный механизм внедрения зависимостей будет получать контекст данных - объект ApplicationContext и использовать его для взаимодействия с бд.
Также через механизм внедрения зависимостей сервис в конструкторе получает сервис IDistributedCache, через который приложение будет взаимодействовать с сервером Redis.
В методе GetUser()
сервис UserService получает объект User по id. При получении объекта вначале пытаемся найти этот объект в кэше.
Поскольку IDistributedCache позволяет сохранять данные только в виде массива байтов и строки, то в данном случае мы будем хранить в кэше объект User в формате json в виде строки. И вначале
получаем по id строковое представление объекта из кэша:
var userString = await cache.GetStringAsync(id.ToString());
Здесь ключами элементов в кэше являются значения id. Если ключ в кэше был найден, то десериализуем полученное значение с помощью метода JsonSerializer.Deserialize()
if (userString != null) user = JsonSerializer.Deserialize<User>(userString);
Если в кэше не оказалось объекта, то извлекаем его и бд, сериализуем его в строку и затем добавляем в кэш на 2 минуты:
user = await db.Users.FindAsync(id); if (user != null) { Console.WriteLine($"{user.Name} извлечен из базы данных"); userString = JsonSerializer.Serialize(user); await cache.SetStringAsync(user.Id.ToString(), userString, new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(2) }); }
Для добавления в кэш в метод SetStringAsync()
передаем ключ объекта - его Id, затем передаем само кэшируемое значение - сериализованный объект User. И далее
устанавливаем опции кэширования с помощью объекта DistributedCacheEntryOptions. В частности, с помощью свойства AbsoluteExpirationRelativeToNow
задаем время кэширования относительно текущего момента в виде 2 минут.
Чтобы получить сервис IDistributedCache, который взаимодействует с локальным сервером Redis, необходимо добавить сервисы кэширования с помощью вызова:
builder.Services.AddStackExchangeRedisCache(options => { options.Configuration = "localhost"; options.InstanceName = "local"; });
Метод AddStackExchangeRedisCache()
в качестве параметра принимает делегат Action<Microsoft.Extensions.Caching.StackExchangeRedis.RedisCacheOptions>
.
Его параметр - объект RedisCacheOptions позволяет установить настройки подключения с помощью своих свойств:
Configuration: конфигурация подключения к серверу Redis. Для подключения к локально запущенному серверу можно указать, как в примере выше, строку "localhost". Для подключения к внешним серверам Redis на других хостах указываются адрес хоста и порт, соответственно точную конфигурацию надо узнавать у хостера.
InstanceName: имя экземпляра Redis. Для локального экземпляра можно указать "local". Это имя будет использоваться в качестве префикса для сохраняемых в кэше данных.
И для тестирования определим конечную точку, где клиент передает id через параметр маршрута, и сервис UserService по этому id пытается найти в базе данных и кэше нужный объект User:
app.MapGet("/user/{id}", async (int id, UserService userService) => { User? user = await userService.GetUser(id); if (user != null) return $"User {user.Name} Id={user.Id} Age={user.Age}"; return "User not found"; });
Запустим сервер Redis и затем наше приложение и обратимся по адресу https://localhost:xxxx/user/1
(то есть для получения объекта User с id=1). В итоге при первом обращении к приложению данные будут извлекаться из базы данных и сохраняться в кэш. При всех последующих обращениях в пределах времени кэширования
(в данном случае в течение 5 минут) данные будут извлекаться из кэша:
А в кэше Redis мы можем увидеть сохраненный объект. Для этого нам надо в консоли обратиться к redis-cli с помощью команды
redis-cli
Затем получить все ключи с помощью команды
keys *
Поскольку в настройках конфигурации подключения Redis в коде C# в качестве имени экземпляра задана строка "local", а ключ сохраняенного объекта - это его id, то есть число id, то в самом Redis сохраненный объект будет храниться по ключу "local1". И для получения данных по записи с этим ключом выполним команду
hgetall local1
То есть команде hgetall
передается ключ записи
Для установки параметров кэширования в метод Set/SetAsync/SetString/SetStringAsync в качестве третьего параметра можно передать объект DistributedCacheEntryOptions, который устанавливает настройки кэширования объекта с помощью ряда свойств:
AbsoluteExpiration
: возвращает или задает абсолютную дату окончания кэширования
AbsoluteExpirationRelativeToNow
: возвращает или задает абсолютную дату окончания кэширования относительно текущего момента
SlidingExpiration
: возвращает или задает время, в течение которого запись кэша может быть неактивной (то есть к ней нет обращений), прежде чем она будет удалена.