Время от времени у меня возникает ощущение, что я хочу чему-то научиться. Я считаю, что настоящий процесс обучения кроется в решении проблем, когда вы пытаетесь достичь какой-то цели. Учебники и курсы по Pluralsight/Udemy никогда не работали для меня. Это не предложение прекратить использование этих платформ, потому что каждому нужно найти свой способ обучения.

На этот раз я решил создать клон Redis на C#. Реальной потребности в этом инструменте в моей повседневной работе не было, и он не предназначен для дальнейшего развития, поэтому на надежность я особо не обращал внимания. Но если вы хотите поиграть с ним и показать мне, как/когда он сломается, это будет здорово.

mes1234/cachy: игровая площадка для Redis, похожая на магазин KV (github.com)

Цель достижения

Я хотел создать хранилище Key-Value с выделенным клиентом для C#. Вы можете добавить элемент для хранения/удаления/получения. Вы можете получить последнюю версию, а также вернуться к более старой версии. Он также должен позволять устанавливать Time To Live, по истечении которого система автоматически удалит его.

Я решил заняться параллельным программированием, хотя у меня редко есть возможность бороться с какими-либо проблемами параллелизма (живя в мире микросервисов).

Общая архитектура

Основой системы является Очередь, которая использует

using System.Collections.Concurrent;
ConcurrentQueue<IEntity> _queue;

Это стандартная параллельная коллекция .NET, которая позволяет безопасно использовать одну и ту же очередь несколькими потоками.

Существует четыре размещенных службы:

  • Получатель — связь с внешним миром
  • Оркестратор — посредник для обработки различных типов действий.
  • Снимок — репозиторий элементов без поддержки версий.
  • Долгосрочное хранение — добавление только реестра элементов, что позволяет поддерживать версии

Получатель

В службе Receiver размещаются три службы gRPC:

  • Инсертитемсервис
  • GetItemService
  • УдалитьItemService

Каждый из них имеет доступ к Queue. Интересна только служба GetItemService, которая добавляет запрос в Queue и ожидает, пока любая другая часть Cachy предоставит запрошенный элемент.

Первый шаг — запустить таймер, чтобы в случае слишком долгой обработки он мог замкнуть его накоротко:

var start = DateTime.Now;
var timeout = TimeSpan.FromSeconds(2000);

Затем он создает Waiter, который на самом деле представляет собой простую Action Task:

item.Waiter = Task.Run(async () =>{
     while (item.Result == null && ((DateTime.Now - start) < timeout))
     { await Task.Delay(10);}});

Единственное, что мы можем сделать, это подождать, пока Официант закончит:

  • что-то установило результаты, чтобы они больше не были null
  • Время истекло.
_queue.Enqueue(item);
await item.Waiter;

Наконец, обработчик должен проверить, что на самом деле произошло, и предоставить клиенту соответствующий ответ:

if (item.Result == null)
       return FailedResponse();
else
       return HandleResponse(item.Result);

Оркестратор

Цель оркестратора — отделить части Cachy друг от друга. Его главная достопримечательность:

ConcurrentBag<IHandler> handlers

и

ConcurrentQueue<IEntity> _queue;

В начале каждого срока службы он регистрируется в обработчиках, поэтому Orchestrator может их использовать. например, служба Snapshot:

public SnapshotStorage(ConcurrentBag<IHandler> handlers,....)
{
    handlers.Add(this);
    ...
}

Теперь Orchestrator имеет доступ ко всем обработчикам из службы Snapshot.

Работа с оркестратором довольно проста:

while (stoppingToken.IsCancellationRequested != true)
{
       IEntity item;
       if (_queue.TryDequeue(out item))
       {
              await Schedule(item);
        }
       else
       {
             await Task.Delay(10);
       }
}

Он пытается получить один элемент из Queue, а затем пытается использовать один из обработчиков:

public async Task Schedule<T>(T item)
    where T : IEntity
 {
     foreach (var handler in _handlers)
      {
          try
            {
               await handler.Handle(item);
            }
           catch (Exception ex)
          {
            System.Console.WriteLine(ex);
          }
     }
}

Greedy catch позволяет сервису выжить даже в случае ошибки одного из обработчиков.

Моментальные снимки и долгосрочное хранение

Обе эти службы служат контейнером для данных. Они поддерживают следующие операции:

public void Add(T item);
public void Remove(string name);
public T Get(string name, int revison);
public void CheckTtl();

Единственное отличие состоит в операции Get, которая в случае снимка не поддерживает ревизии, в то время как снимок отслеживает только последнюю ревизию.

CheckTtl используется в основном цикле обслуживания. Его цель — удалить элементы, у которых Time To Live Expired. Эта операция выполняется один раз. Элемент выбирается случайным образом и проверяется, следует ли его удалить или в случае долговременного хранения он должен изменить свое состояние на

Active = false

Заключение и что я узнал

параллелизм

Я попытался сделать каждую службу независимой и общаться с помощью контейнеров Concurrent:

ConcurrentBag<IHandler> _handlers;
ConcurrentQueue<IEntity> _queue;

Я считаю, что этот подход в порядке, но имейте в виду, что при параллельной обработке могут возникать условия гонки и все неприятные параллельные проблемы. Это не способ «если», а скорее «когда» это происходит.

Каждый объект, который является общим, должен обрабатываться безопасным способом:

public void Add(T item)
{
     lock (_registry)
     {
            if (_registry.ContainsKey(item.Name))
                  _registry[item.Name].Add(item);
            else
               _registry[item.Name] = new Events<T> { item };
       }
}

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

Записи C#9

Записи отличные. Они неизменны, просты в использовании и хорошо подготовлены. Синтаксис для создания глубокой копии с измененным значением:

var newObj = obj with { Active = false };

Но есть еще что улучшать. Вы не можете указать, что обобщение является записью. Поэтому, как только вы решили использовать универсальный формат, вы в основном теряете все полезные функции записей.

Есть еще возможность взломать это, например. добавьте в общий интерфейс этот метод:

interface: public IStoredEntity CopyAndDeactivate();
implementation: public IStoredEntity CopyAndDeactivate() => this with { Active = false };

но это все еще хакерский способ, и я бы не советовал его использовать.

IDE

Мой повседневный набор инструментов — Visual Studio + Reshaper. В этом проекте я использовал расширение VS Code + Microsoft C#.

Это хорошо? Да.

Он работает все время и никогда не было проблем. Очевидно, что в VS&Reshaper гораздо больше автоматизации, но в VS Code нет ничего невозможного.

Пожалуйста, проверьте код на:

mes1234/cachy: игровая площадка для Redis, похожая на магазин KV (github.com)

Существует простой демонстрационный проект, демонстрирующий все функции. Пожалуйста, дайте мне знать, что вы думаете, что бы вы сделали по-другому?