Найти в Дзене
"Мы"-Прогер

Как я многопоточную систему выполнения примеров в БД делал - бэкенд

Сначала объясню, что же имеется в виду. Захотел я получше изучить, как параллельно работающие в базе данных запросы влияют друг на друга. Всякие там уровни изоляции транзакций, дедлуки и прочее. А чтобы изучать было интереснее, я решил реализовать супер-мега-комбайн, позволяющий выполнять параллельно в БД любые примеры, и интегрировать его в свой проект-песочницу. Это не для новичков. Я постарался вырвать из кода отдельные интересные моменты, так как описывать здесь весь код смысла нет. Для изучения программирования будут отдельные статьи. Исходный код доступен для изучения здесь - https://github.com/nea14e/StudyWebNea14 (рекомендуется скачать и смотреть у себя локально). Рассмотрим скриншот того, что у нас должно получиться: Итак, у нас есть пример, который наглядно показывает какую-нибудь особенность работы с базой данных. Как видно, пример состоит из нескольких набросков кода (чёрные блоки; назовём их сниппетами). Сниппеты можно запускать по отдельности. Каждый сниппет может содержа
Оглавление

Сначала объясню, что же имеется в виду. Захотел я получше изучить, как параллельно работающие в базе данных запросы влияют друг на друга. Всякие там уровни изоляции транзакций, дедлуки и прочее. А чтобы изучать было интереснее, я решил реализовать супер-мега-комбайн, позволяющий выполнять параллельно в БД любые примеры, и интегрировать его в свой проект-песочницу.

Это не для новичков. Я постарался вырвать из кода отдельные интересные моменты, так как описывать здесь весь код смысла нет. Для изучения программирования будут отдельные статьи.

Исходный код доступен для изучения здесь - https://github.com/nea14e/StudyWebNea14 (рекомендуется скачать и смотреть у себя локально).

Сущности

Рассмотрим скриншот того, что у нас должно получиться:

Итак, у нас есть пример, который наглядно показывает какую-нибудь особенность работы с базой данных. Как видно, пример состоит из нескольких набросков кода (чёрные блоки; назовём их сниппетами). Сниппеты можно запускать по отдельности. Каждый сниппет может содержать несколько процессов, работающих параллельно (столбцы). Процессы состоят из задач (отдельные клеточки). Каждая предыдущая сущность может содержать несколько последующих, то есть, в коде будут списки (List<...>):

-2

Как видно, сущность "пример" содержит внутри себя список сущностей "сниппет", то есть, в примере есть много сниппетов.

-3

Аналогично, сущность "сниппет" содержит внутри себя список сущностей "процесс", и так далее, всего 4 вида сущностей.

Уровни архитектуры

Каждая сущность представлена на трёх уровнях:

  1. сущность в том виде, как она загружается из базы данных (Entity),
  2. логическая сущность (Le - Logic Entity), с которой происходит вся работа (запуски процессов, выполнение кода в базе данных и т.п.),
  3. сущность для отправки на фронтенд, в том виде, в котором удобно её отображать (Dto - Data Transfer Object).

Например, сущность "пример" уровня Le содержит в себе ссылку на запущенный сейчас сниппет (RunningSnippet). Эта ссылка проставляется в момент запуска сниппета и потому, ясное дело, не загружается из базы данных. Таким образом, сущность "пример" уровня Entity не содержит этой ссылки, она добавляется в уровне Le и далее передаётся на фронт уровнем Dto. Фронтенд использует это значение, чтобы, например, блокировать кнопки запуска других сниппетов, пока один из них работает. Аналогично время начала и завершения каждой задачи не загружаются из базы данных, а проставляются при работе с задачами на уровне Le.

Данные между уровнями перегоняет отважная команда мэпперов. Например, вот мэппер примера:

-4

Он умеет выполнять два действия, то есть, имеет два метода: EntityToLe() и LeToDto(), названия которых говорят сами за себя. Когда пример перегоняется между какими-либо двумя уровнями, то находящиеся внутри него сниппеты тоже перегоняются. Здесь мы видим Select(), который превращает каждый сниппет в сниппет другого уровня, вызывая методы EntityToLe() и LeToDto() мэппера сниппета. То есть, мэппер примера вызывает внутри себя мэппер сниппета. Аналогично, мэппер сниппета вызывает внутри себя мэппер процесса:

-5

И так далее. Получается, что чтобы вызывать всю эту цепочку превращений, достаточно написать лишь одну строчку:

-6

И всё, у нас уже вместо примера на слое Le получился пример на слое Dto со всеми внутренностями. Этот dto мы тут же с радостью и возвращаем на фронтенд.

Сервис (основная логика)

Как и во всех веб-приложениях, основная логика содержится в классе, который называется "сервис". А точнее, DbTaskRunnerService. Файл доступен тут - https://github.com/nea14e/StudyWebNea14/blob/master/Backend/Backend/Services/DbTaskRunner/DbTaskRunnerService.cs

API сервиса

Как фронтенд будет использовать сервис? Можно выделить 3 действия, которые должен уметь выполнять сервис:

  1. Загрузить пример (в момент открытия примера пользователем);
  2. Запустить какой-либо сниппет примера;
  3. Узнать текущее состояние примера.

Это значит, что сервис будет иметь 3 публичных метода:

-7

Состояние примера

Сервис должен хранить состояние примера: какой сниппет запущен, когда запущена каждая из задач, с каким результатом она завершилась. Фронтенд запрашивает эти данные из сервиса в течение некоторого промежутка времени и между запросами нужно где-то это хранить. Поскольку пользователь может открыть несколько примеров на разных вкладках браузера (а пользователей тоже может быть несколько), то сервис должен хранить не один пример, а сразу много. Решается это легко: каждая вкладка фронтенда генерирует свой уникальный идентификатор (Guid) и передаёт его с каждым своим запросом, а бэкенд, создав новый объект примера, кладёт его в словарь, где ключом является этот идентификатор. Короче, вот:

-8

Созданный пример теперь хранится в словаре _examples под ключом instanceId.

Теперь, когда фронтенд желает узнать текущее состояние примера, сервис просто достаёт пример из словаря:

-9

Фронтенд никогда не будет запрашивать состояние примера, который ещё не создан, так как создание примера происходит при открытии вкладки браузера, но на всякий случай тут стоит кидание ошибки. Это нужно, чтобы в случае чего ошибка была сразу понятна программисту, а не пришлось долго выискивать, из какого это словаря и что мы пытались достать, когда программа упала. Код должен предусматривать как можно больше разных ошибок, даже если вы уверены, что другие части программы никогда эту ошибку не вызовут. Дело в том, что все иногда ошибаются: другие части программы могут содержать ошибку, которая не предусмотрена в нашей части.

Далее, так как сервис должен хранить состояние примера, то он не должен пересоздаваться между разными запросами с фронтенда. Для этого надо пометить его как AddSingleton() при конфигурировании системы Dependency Injection:

-10

Многопоточность

Для того, чтобы выполнить какую-либо команду в базе данных, мы создаём объект класса DbCommand и вызываем у него метод ExecuteReaderAsync() / прочее. Отличие от обычного вызова состоит в том, что мы не ждём, пока команда выполнится. Поэтому у нас не будет await. Вместо этого у нас будет ContinueWith(), позволяющий сделать что-то после завершения команды. А именно, мы помечаем задачу как завершённую успешно/с ошибкой, запоминаем результат и переходим к следующей задаче:

-11

Условно говоря, запуск первых задач каждого процесса происходит в цикле по процессам, и если бы там был await, то мы бы ждали, пока первая задача завершится, и только потом переходили бы к второму процессу. А так мы не ждём.

В то же время переход от первой задачи ко второй в пределах одного и того же процесса может подождать, поэтому внутри ContinueWith() мы используем await.