Redis и Asp.Net Core

Redis и Asp.Net Core

Дмитрий Бахтенков

Введение

В этой статье мы разберём СУБД Redis, рассмотрим её различные варианты использования, и напишем приложение на C#, которое будет взаимодействовать с Redis.

Эта статья подойдёт тоем, кто уже пробовал писать API на C# с помощью фреймворка asp.net core. Перед её прочтением рекомендую прочитать предыдущие статьи:

Что такое Redis?

Редис

Redis - это open source система управления базами данных (СУБД) класса NoSQL, работающая со структурами данных “ключ-значение”. Она может использоваться как в качестве основной БД, так и в качестве реализации кэшей, брокеров сообщений и т.д.

БД хранит все данные в оперативной памяти, но при этом снабжена механизмами снимков для обеспечения постоянного хранения. Поддерживает репликацию данных с основных узлов на несколько подчинённых (master → slave replication) и пакетную обработку команд. Предоставляет операции для обмена сообщениями, реализующие паттерн publisher-subscriber: с помощью Redis приложения могут создавать каналы, подписываться на них и помещать туда сообщения, которые будут получены всеми подписчиками..

Redis хранит данные в виде словаря, в котором ключи связаны со своими значениями. Значения этих ключей не ограничиваются строками: поддерживаются строки, списки, множества, хэш-таблицы. Тип данных определяет, какие операции будут для него доступны.

Поднимаем

Redis очень просто установить: в ОС Linux можно воспользоваться пакетными менеджерами, в Windows использовать WSL или порт Redis под Windows. Также можно воспользоваться Docker. В приложении Docker Desktop можно очень удобно загрузить образ редиски и запустить его, или воспользоваться командой docker run redis -p 6379:6379

docker-desktop
Вывод запущенного Redis

У Redis нет официального графического клиента, но есть несколько сторонних, информацию о которых можно посмотреть тут. Также есть интерфейс командной строки redis-cli - я буду использовать его

Я зашёл в терминал развёрнутого контейнера и выполнил команду redis-cli, чтобы войти в интерфейс командной строки. Команда INFO SERVER показывает информацию о текущем сервере:

info server

С помощью команды SELECT мы можем выбрать необходимую нам БД. В аргументе мы должны указать номер необходимой нам БД. По-умолчанию создаётся 16 бд, и мы можем взять любой номер. Посмотреть кол-во баз данных можно с помощью команды CONFIG GET DATABASES.

Базы данных в Redis

Чтобы добавить какое-либо значение, можно воспользоваться командой SET. Чтобы получить значение по ключу - используем GET:

GET & SET

Подробнее про команды можно посмотреть в документации, также есть отличная статья - “Шпаргалка по Redis”.

Кодим

С Redis вроде бы разобрались, теперь можно и покодить. Мы рассмотрим следующие кейсы применения редиски в приложениях:

  • Кэш
  • Rate Limiter
  • Механизм Publisher/Subscriber

Соединяем ASP.NET Core и Redis

Создаём пустой проект asp.net core:

и добавляем библиотеку StackExchange.Redis:

За подключение к Redis отвечает статический метод ConnectionMultiplexer.Connect(). В файле Program.cs добавим коннект к Redis как синглтон сервис в DI-контейнер:

builder.Services.AddSingleton<IConnectionMultiplexer(
                    ConnectionMultiplexer.Connect("localhost"));

Далее добавим класс RedisService, который будет отвечать за работу с редиской:

public class RedisService
{
    // объект IDatabase, описывающий отдельную БД в Redis
    private readonlyIDatabase_database;

    // в конструкторе указана зависимость IConnecctionMultiplexer
    public RedisService(IConnectionMultiplexerconnectionMultiplexer)
    {
        //  из коннекта к редису получаем объект БД для взаимодействия
        _database = connectionMultiplexer.GetDatabase();
    }

    public async Task SetValue<T>(string key, T value)
    {
        // используем метод SetStringAsync для установки значения по ключу.
        // Метод JsonSerializer.Serialize преобразует объект в json-строку
        await _database.StringSetAsync(key, JsonSerializer.Serialize(value));
    }

    public async Task<T?> GetValue<T>(string key)
    {
        // получаем строку из БД. Переменная value имеет тип RedisValue
        var value = await _database.StringGetAsync(key);
        if (value.HasValue)
        {
            // преобразуем  json-строку в объект с помощью JsonSerializer.Deserialize
            return JsonSerializer.Deserialize<T>(value.ToString());
        }

        return default;
    }
}

С помощью этого сервиса мы сможем удобно работать с Redis. В методах указаны generic-параметры T - в них можно указать конкретный тип данных, с которым работаем в БД, например redisService.SetValue<User>(”123”, new User(”username”, “password”))

Добавим этот класс в DI-контейнер в файле Program.cs:

builder.Services.AddSingleton<RedisService>();

Для проверки механизма добавим класc User и напишем UserController, который будет использовать Redis для хранения данных о пользователе.

Класс User:

public class User
{
    public Guid Id { get; set; }
    public string Username { get; set; }
    public string Password { get; set; }
}

Для идентификатора пользователя используем тип данных GUID, который представляет уникальный идентификатор.

Класс UserController:

[ApiController]
[Route("api/user")]
public class UserController : ControllerBase
{
    private readonly RedisService _redisService;

    public UserController(RedisService redisService)
    {
        _redisService = redisService;
    }

    [HttpPost]
    public async Task<User> CreateUser([FromBody] User user)
    {
        await _redisService.SetValue(user.Id.ToString(), user);

        return user;
    }

    [HttpGet("{id}")]
    public async Task<User?> GetUser(Guid id)
    {
        return await _redisService.GetValue<User>(id.ToString());
    }
}

В контроллере мы, используя RedisService, с помощью метода POST добавляем пользователя в БД, а с помощью метода GET - получаем его по ключу-идентификатору. Попробуем запустить и проверить в интерфейсе Swagger:

Создаём пользователя. Приходит ответ 200 Ok:

Пытаемся получить пользователя по идентификатору:

Если у вас возникают какие-то ошибки с подключением к Redis - проверьте пожалуйста, что БД развёрнута на порту 6379.

Давайте посмотрим что у нас в redis-cli:

Наши данные лежат в нашей БД. Всё хорошо. Теперь приступим к конкретным кейсам.

Rate Limiter

Что такое Rate Limiting?

Это установка ограничения количества запросов в определённый период времени. Например при ограничении в 10 запросов в одну секунду, в рамках этой секунды первые 10 запросов будут успешны, а следующие запросы - откинутся сервером с http-кодом 429 (Too many requests).

Существует несколько алгоритмов rate limit:

  • Leaky Bucket - это алгоритм, который обеспечивает наиболее простой, интуитивно понятный подход к ограничению скорости обработки при помощи очереди, которую можно представить в виде «ведра», содержащего запросы. Когда запрос получен, он добавляется в конец очереди. Через равные промежутки времени первый элемент в очереди обрабатывается. Это также известно как очередь FIFO. Если очередь заполнена, то дополнительные запросы отбрасываются (или “утекают”).
  • Fixed Window - в этом алгоритме для отслеживания запросов используется окно, равное n секундам. Обычно используются значения вроде 60 секунд (минута) или 3600 секунд (час). Каждый входящий запрос увеличивает счётчик для этого окна. Если счётчик превышает некое пороговое значение, запрос отбрасывается.
  • Sliding log - данный алгоритм предполагает отслеживание временных меток каждого запроса пользователя. Эти записи сохраняются, например, в hash set или таблицу и сортируются по времени; записи за пределами отслеживаемого интервала отбрасываются. Когда поступает новый запрос, мы вычисляем количество записей, чтобы определить частоту запросов. Если запрос выходит за рамки допустимого количества, то он отбрасывается.
  • Sliding Window - это гибридный подход, который сочетает в себе низкую стоимость обработки Fixed Window и улучшенную обработку граничных ситуаций Sliding Log. Как и в простом Fixed Window, мы отслеживаем счётчик для каждого окна, а затем учитываем взвешенное значение частоты запросов предыдущего окна на основе текущей временной метки, чтобы сгладить всплески трафика.
Подробнее об алгоритмах ограничения скорости можно прочитать в этой статье - с объяснениями, подробностями и визуализацией

Реализация

Мы используем алгоритм Fixed Window, а реализуем его с помощью скрипта на языке Lua - Redis поддерживает его для написания скриптов. Гайд из официальной документации Redis предлагает следующий вариант реализации:

 local requests = redis.call('INCR', @key)
 redis.call('EXPIRE', @key, @expiry)
 if requests < tonumber(@maxRequests) then
     return 0
 else
     return 1
 end

Разберём этот пример:

  • В первой строке мы создаём переменную requests с помощью вызова функции INCR (increment) у redis. Так как при первом запуске скрипта значение в redis ещё не определено, оно будет равняться единице.
  • Во второй строке вызываем функцию EXPIRE, которая устанавливает временное “окно” - по истечении этого времени значение в БД будет удалено.
  • Далее происходит проверка - если кол-во запросов меньше максимального - успешное завершение функции с возвращаемым значением 0.

Параметры, указанные через символ “@”, например @key - это динамические параметры, в которые можно подставить значения при выполнении скрипта через .net.

Добавим статический класс Scripts, в котором укажем наш скрипт на Lua:

public static class Scripts
{
    public static LuaScript RateLimitScript => LuaScript.Prepare(RateLimiter);

    private const string RateLimiter = @"
            local requests = redis.call('INCR',@key)
            redis.call('EXPIRE', @key, @expiry)
            if requests < tonumber(@maxRequests) then
                return 0
            else
                return 1
            end
            ";
}

В класс RedisService добавим метод для выполнения скрипта:

public async Task<int> ExecuteScript(LuaScript script, object? parameters = null)
    {
        return (int)await _database.ScriptEvaluateAsync(script, parameters);
    }

Теперь добавим контроллер RateLimitController, в котором проверим ограничение трафика:

[Route("api/rate-limit")]
[ApiController]
public class RateLimitController : ControllerBase
{
    private readonly RedisService _redisService;

    public RateLimitController(RedisService redisService)
    {
        _redisService = redisService;
    }

    [HttpGet]
    public async Task<IActionResult> Get()
    {
        // создаём ключ для хранения в Redis
        var key = $"{Request.Path.Value}:{DateTime.UtcNow:hh:mm}";
        // выполняем скрипт. В параметрах указываем время жизни записи (60 секунд) и максимальное кол-во запросов
        var res = await _redisService.ExecuteScript(Scripts.RateLimitScript, new
        {
            key = new RedisKey(key),
            expiry = 60,
            maxRequests = 10
        });

        return res == 0 ? Ok() : StatusCode(429);
    }
}

Для проверки проще всего выполнить скрипт на Bash, который в цикле отправляет запросы на сервер:

for n in {1..21}; do echo $(curl -s -w " HTTP %{http_code}, %{time_total} s" -X GET -H "Content-Length: 0" <http://localhost:5229/api/rate-limit>); sleep 0.5;
Будьте внимательны, возможно у вас другой порт в приложении.

Результат следующий:

Для удобства можно было бы сделать middleware или использовать готовые библиотеки. Ну а мы идём дальше.

Redis Cache

Кэширование данных - это процесс сохранения данных локально, который позволяет быстрее получить к ним доступ при будущих запросах.

Redis в первую очередь ассоциируется именно с кэшированием данных - он быстрый, надёжный, реализовать кэширование с ним очень просто.

Мы попробуем реализовать эндпоинт для получения информации о пользователях. Исходную информацию будем читать из файла, и на некоторое время кэшируем её в Redis. Мы сравним показатели скорости первого и второго запроса.

Кодим

В RedisService реализуем метод GetOrAdd(), который будет принимать ключ для кэширования и функцию обратного вызова, возвращающую данные:

public async Task<TData> GetOrAdd<TData>(
                                string key, Func<Task<TData>> callback)
    {
        // пытаемся получить данные из кэша
        var existingData = await _database.StringGetAsync(key);
        // если данные существуют, возвращаем их в виде 
        // необходимого объекта
        if (existingData.HasValue)
        {
          return JsonSerializer.Deserialize<TData(
                                             existingData.ToString())!;
        }

        // получаем данные из колбэк-функции
        var data = await callback();
        // сохраняем данные с помощью метода, написанного ранее, 
     // с истечением через минуту
        await _database.StringSetAsync(key,
                                      JsonSerializer.Serialize(data),
                                      expiry: TimeSpan.FromMinutes(1));
        return data;
    }

Теперь сделаем json-файл с информацией о пользователях users.json:

[
  {
  "Id": "a4fff1d7-b3b5-4d98-a02a-33b6a5c3f000",
  "Username": "firstuser",
  "Password": "12345"
  },
  {
    "Id": "a7d63511-1e0c-4250-82d3-1bca4523f433",
    "Username": "seconduser",
    "Password": "12345"
  },
  {
    "Id": "984ae84e-6065-4eaa-b598-2921da469a97",
    "Username": "thirduser",
    "Password": "12345"
  }
]

Добавим в UserController новые методы. С помощью класса Stopwatch мы также будем измерять скорость выполнения запроса:

    [HttpGet]
    public async Task<User[]> GetUsers()
    {
        // запускаем отсчёт времени выполнения кода
        var stopwatch = Stopwatch.StartNew();
        // получаем пользователей. В качестве параметра GetOrAdd 
        // мы передаём функцию
        // GetUsersFromFile, результат которой будет кеширован
        var result = await _redisService.GetOrAdd("users.json",
                                                GetUsersFromFile);
        // сохраняем время выполнения коде
        var time = stopwatch.ElapsedMilliseconds;
        Console.WriteLine("Result time: " + time);
        // останавливаем таймер
        stopwatch.Stop();
        return result;
    }

    private async Task<User[]> GetUsersFromFile()
    {
        // читаем файл users.json и сериализуем его в объект User[]
        using (var reader = new StreamReader("users.json"))
        {
            return JsonSerializer.Deserialize<User[]>(await
                                        reader.ReadToEndAsync())!;
        }
    }

Запустим наш проект и посмотрим на результат:

Выполним два запроса подряд:

Первый результат - чтение данных из файла, второй - из кэша Redis. Redis действительно быстр!

Publisher/Subscriber

С помощью редис можно реализовать паттерн издатель-подписчик. Давайте разберёмся, что это за паттерн.

Описание паттерна

У нас есть некий объект, который издаёт события. На факт того, что это событие произошло, можно подписаться с помощью функции обратного вызова. Когда происходит это событие, все функции обратного вызова вызываются.

Чтобы было проще понять, можно провести аналогию. Представим YouTube - там есть каналы, у которых можно подписаться на уведомления. Далее происходит событие - публикация нового видео. Мы подписаны на это событие и получаем уведомление о том, что видео вышло.

Примерно таким образом работают events в C#. Но существуют реализации, когда между издателем и подписчиками добавляется некоторый посредник - брокер сообщений. Это позволяет реализовывать паттерн не только в рамках одной системы, но и в рамках нескольких систем - когда одна публикует сообщения в брокер, а другая принимает сообщения и как-то реагирует на них. 

Redis может выступать в роли брокера сообщений.

Реализация

Мы будем подписываться на название определённого класса, который будет описывать информацию о событии. Добавим несколько методов в RedisService:

public async Task Publish<TEvent>(TEvent @event)
{
    // выполняем метод Publish для публикации сообщения в Redis
    // первым параметром методя является название канала, 
  // в который мы публикуем сообщение
    // в данном случае это название типа события, например "CreateUserEvent"
    await _database.PublishAsync(typeof(TEvent).Name, JsonSerializer.Serialize(@event));
}

public async Task Subscribe<TEvent>(Action<TEvent> callback)
{
    // Из объекта ConnectionMultiplexer Redis позволяяет получить объект ISubscriber
    // с помощью которого можно подписаться на события в определённом канале
    await _database.Multiplexer
        .GetSubscriber()
        .SubscribeAsync(
            typeof(TEvent).Name,
            // выполняем callback-функцию с десериализованным сообщением в параметрах
            (channel, value) => callback(JsonSerializer.Deserialize<TEvent>(value!)!));
}

Теперь определим событие. Пусть это будет событие создания пользователя. Определим тип события:

public class CreateUserEvent
{
    public Guid UserId { get; set; }
}

Опубликуем событие после создания пользователя в UserController.CreateUser:

[HttpPost]
public async Task<User> CreateUser([FromBody] User user)
{
    // сохраняем пользователя
    await _redisService.SetValue(user.Id.ToString(), user);
    // публикуем событие
    await _redisService.Publish<CreateUserEvent>(new CreateUserEvent
    {
        UserId = user.Id
    });

    return user;
}

Теперь нам нужно подписаться на событие. Делать это удобнее всего в файле Program.cs, так как в нём происходит инициализация и запуск приложения.

var redisService = app.Services.GetRequiredService<RedisService>();
await redisService.Subscribe<CreateUserEvent>((@event =>
{
    // выполнение какой-то полезной логики, например отправка email
    // новому пользователю
    Console.WriteLine($"User with id {@event.UserId} created");
}));

app.Run();

Запускаем приложение и пробуем создать пользователя:

В консоли мы увидим реакцию на это событие:

С помощью брокера сообщений можно настраивать взаимодействие не только в рамках одной системы, но и в рамках нескольких систем. Это взаимодействие будет асинхронным (неблокирующим). Такой подход часто используют в микросервисной архитектуре.

Заключение

В этой статье мы разобрали СУБД Redis и её различные варианты использования. Код приложения доступен на гитхаб.

С вами Flex Code

Report Page