Одно из двух, — прошелестел он, — или пациент жив, или он умер. Если он жив — он останется жив или он не останется жив. Если он мёртв — его можно оживить или нельзя оживить.
А.Н. Толстой. "Золотой ключик, или Приключения Буратино"
Введение
Недавно в организации, где я тружусь, появилась идея переписать корпоративную систему, которая работает более 20 лет и за этот срок несколько устарела. Если кратко - информационная система в области морских грузовых перевозок и обслуживания судов в порту. В данный момент я занимаюсь НИОКР и хочу поделиться с вами некоторыми проблемами, с которыми может столкнуться разработчик подобной системы и как я планирую их решать. На текущий момент планирую сервер приложений ASP.NET Core под .NET 6. База данных останется старая, так как это даст возможность постепенно переходить на новую систему по мере добавления функционала. Основное клиентское ПО предполагается на WPF под .NET 6. Кроме того, возможны небольшие узкопрофильные клиенты на веб-страницах и мобильных устройствах.
Проблема
Пользователю лень фильтровать запрашиваемые данные, а их довольно много. Заставлять пользователя что-то фильтровать - негуманно и попахивает произволом. В текущей системе такой пользователь сидит грустит, ругает разработчиков, давит на техподдержку. В текущей системе клиент идёт прямо в базу данных через BDE, ничего с этим сделать уже нельзя. В новой системе клиент про базу данных ничего не знает, сервер по его запросу шлет JSON через HTTP. Если делать "в лоб", то сильно мало что поменяется: сервер по запросу клиента будет собирать для отправки коллекцию объектов, загружая их из базы или ещё как-то. Потом всю эту махину он пошлёт в ответном HTTP-пакете, предварительно серилизовав в JSON, там это всё будет в один присест десериализовано в клиентские объекты, которые, наконец-то предстанут пред взором потерявшего надежду пользователя.
Идея решения
А давайте, сервер по запросу начнёт грузить в свою коллекцию, как и раньше, но на клиента отправит не всё, а сколько получится загрузить до истечения таймаута или какое-то фиксированное количество объектов или в результате суперпозиции этих условий. При этом клиенту мы будем сообщать в специальном заголовке, уже всё или нет. А если нет, передавать специальный идентификатор, который клиент должен предъявлять при запросе следующей порции. Таким образом, пользователь быстро увидит результат, возможно, даже сможет использовать.
Предположим, у нас есть класс, реализующий следующий интерфейс:
public enum PartialLoaderState { New, Partial, Full ... }
public interface IPartialLoader<T>
{
// Возвращает текущее состояние: новый или с данными в Chunk (полными или
// неполными)
PartialLoaderState State { get; }
// Выполняет конфигурацию объекта и загрузку первой партии данных
Task StartAsync(IAsyncEnumerable<T> data, PartialLoaderOptions options);
// Выполняет загрузки последующих партий данных
Task ContinueAsync();
// Возвращает ссылку на все загруженные данные
List<T> Result { get; }
// Возвращает ссылку на партию данных, загруженных в результате
// последнего вызова StartAsync() или ContinueAsync()
List<T> Chunk { get; }
...
}
public class PartialLoaderOptions
{
// Задаёт таймаут, после истечения которого происходит возврат из
// методов StartAsync() или ContinueAsync()
public TimeSpan Timeout { get; set; } = TimeSpan.FromMilliseconds(-1);
// Задаёт размер партии данных, после достижения которого происходит
// возврат из методов StartAsync() или ContinueAsync()
public int Paging { get; set; } = 0;
...
}
О конкретной реализации поговорим позже, а сейчас посмотрим, как этим можно воспользоваться.
Лабораторная установка
Чтобы испытать, как всё работает, создадим в VisualStudio Studio Community 2022 три проекта. Здесь опишу по возможности кратко, так как все исходники доступны тут или тут.
BigCatsDataContract
- библиотека классов, общих для сервера и клиента. Здесь находится класс, описывающий элемент данных, в нашем случае - кошку. Также присутствует класс с некоторыми константами.
public class Cat
{
public string Name { get; set; }
}
public class Constants
{
public const string PartialLoaderStateHeaderName =
"X-CatsPartialLoaderState";
public const string PartialLoaderSessionKey =
"X-CatsPartialLoaderSessionKey";
public const string Partial = "Partial";
public const string Full = "Full";
}
BigCatsDataServer
- проект ASP.NET Core. ВProgram.cs
Мы обрабатываем два маршрута с параметрами:
// Запрашиваем count кошек одной партией с заданной задержкой delay,
// требующейся для загрузки каждой кошки
app.MapGet("/cats/{count=1001}/{delay=0}",
async (HttpContext context, int count, double delay) =>
await Task.Run(() => CatsGenerator.GetCats(context, count, delay))
);
// Запрашиваем count кошек с заданной задержкой delay,
// требующейся для загрузки каждой кошки, партиями, размеры которых
// ограничены таймаутом timeout или фиксированной величиной paging
app.MapGet("/catsChunks/{count=1001}/{timeout=100}/{paging=1000}/{delay=0}",
async (HttpContext context, int count, int timeout, int paging,
double delay) =>
await Task.Run(() => CatsGenerator.GetCatsChunks(context, count, timeout,
paging, delay))
);
В классе CatsGenerator расположены метод, генерирующий данные и методы, обрабатывающие HTTP-запросы:
public class CatsGenerator
{
private const string CatNamePrefix = "Кошка №";
public static async IAsyncEnumerable<Cat> GenerateManyCats(int count,
double delay)
{
...
yield return await Task.Run(() =>
{
if (delay > 0)
{
// Если задали ненулевой delay, имитируем бурную
// деятельность продолжительностью примерно delay миллисекунд.
}
return new Cat { Name = $"{CatNamePrefix}{i + 1}" };
});
}
/// <summary>
/// Метод, возвращающий всех кошек сразу.
/// </summary>
public static async Task GetCats(HttpContext httpContext, int count,
double delay)
{
List<Cat> cats = new();
await foreach(Cat cat in GenerateManyCats(count, delay))
{
cats.Add(cat);
}
await httpContext.Response.WriteAsJsonAsync<List<Cat>>(cats);
}
/// <summary>
/// Метод, возвращающий кошек партиями.
/// </summary>
public static async Task GetCatsChunks(HttpContext context, int count,
int timeout, int paging, double delay)
{
IPartialLoader<Cat> partialLoader;
string key = null!;
// Получаем хранилище через механизм внедрения зависимостей.
// Хранилище зарегистрировано, как Singleton, поэтому живёт вечно.
CatsLoaderStorage loaderStorage =
context.RequestServices.GetRequiredService<CatsLoaderStorage>();
if (!context.Request.Headers.ContainsKey(
Constants.PartialLoaderSessionKey))
{
// Если это первый запрос, то создаём IPartialLoader (В нашем
// случае получаем через механизм внедрения зависимостей, где он
// зарегистрирован как Transient) и стартуем генерацию.
partialLoader = context.RequestServices.
GetRequiredService<IPartialLoader<Cat>>();
await partialLoader.StartAsync(GenerateManyCats(count, delay),
new PartialLoaderOptions {
Timeout = TimeSpan.FromMilliseconds(timeout),
Paging = paging,
});
}
else
{
// Если это последующий запрос, то получаем ключ из заголовка
// запроса, берём PartialLoader из хранилища и продолжаем
// генерацию.
key = context.Request.Headers[Constants.PartialLoaderSessionKey];
partialLoader = loaderStorage.Data[key];
await partialLoader.ContinueAsync();
}
// Добавляем заголовок ответа, сигнализирующий, последняя это партия
// или нет.
context.Response.Headers.Add(Constants.PartialLoaderStateHeaderName,
partialLoader.State.ToString());
if(partialLoader.State == PartialLoaderState.Partial)
{
// Если партия не последняя,
if(key is null)
{
// Если партия первая, придумываем ключ и помещаем
// IPartialLoader в хранилище.
key = Guid.NewGuid().ToString();
loaderStorage.Data[key] = partialLoader;
}
// Добавляем заголовок ответа с ключом.
context.Response.Headers.Add(
Constants.PartialLoaderSessionKey, key);
}
else
{
// Если партия последняя, удаляем IPartialLoader из хранилища.
if (key is not null)
{
loaderStorage.Data.Remove(key);
}
}
await context.Response.WriteAsJsonAsync<List<Cat>>(
partialLoader.Chunk);
}
}
BigCatsDataClient
- проект WPF. Разметку рассматривать не будем, а в code-behind для простоты размещены методы, запрашивающие список кошек полностью и частями. Код для краткости привожу не полностью, он доступен в исходниках.
private const string Server = "https://localhost:7209";
/// <summary xml:lang="ru">
/// Метод загрузки целиком.
/// </summary>
private async Task GetAllCats()
{
...
try
{
...
using HttpClient _client = new HttpClient();
...
_client.BaseAddress = new Uri(Server);
// Передаём запрос серверу в стиле REST
HttpRequestMessage request = new HttpRequestMessage(
HttpMethod.Get,
$"{Constants.AllUri}/{Count}/{Delay.ToString().Replace(',', '.')}");
HttpResponseMessage response = await _client.SendAsync(request);
if(response.StatusCode == System.Net.HttpStatusCode.OK)
{
await Dispatcher.BeginInvoke(async () =>
{
List<Cat>? list = await JsonSerializer.
DeserializeAsync<List<Cat>>(
response.Content.ReadAsStream(),
new JsonSerializerOptions {
PropertyNameCaseInsensitive = true
});
// Добавляем кошек в таблицу с кошками
foreach (Cat cat in list)
{
Cats.Add(cat);
}
...
});
}
...
}
catch (Exception ex)
{
// Что-то пошло вообще не так
...
}
}
/// <summary xml:lang="ru">
/// Метод загрузки частями.
/// </summary>
private async Task GetChunksCats()
{
...
try
{
...
using HttpClient _client = new HttpClient();
...
_client.BaseAddress = new Uri(Server);
// Передаём запрос серверу в стиле REST
HttpRequestMessage request = new HttpRequestMessage(
HttpMethod.Get,
$"{Constants.ChunkslUri}/{Count}/{Timeout}/{Paging}/{Delay.ToString().Replace(',', '.')}");
HttpResponseMessage response = await _client.SendAsync(request);
...
while(response.StatusCode == System.Net.HttpStatusCode.OK
&& IsDataLOading)
{
await Dispatcher.BeginInvoke(async () =>
{
List<Cat>? list = await JsonSerializer.
DeserializeAsync<List<Cat>>(
response.Content.ReadAsStream(),
new JsonSerializerOptions {
PropertyNameCaseInsensitive = true });
// Добавляем кошек в таблицу с кошками
foreach (Cat cat in list)
{
Cats.Add(cat);
}
...
if (response.Headers.GetValues(
Constants.PartialLoaderStateHeaderName).First() ==
Constants.Partial)
{
// Если данные пришли не полностью, повторяем запрос.
// Можно без параметров, так как сервер подставит
// значения по умолчанию,
// но они всё равно не будут использоваться,
// так как мы передаём заголовок с идентификатором запроса,
// который сервер
// вернул нам с неполными данными.
request = new HttpRequestMessage(
HttpMethod.Get, $"{Constants.ChunkslUri}");
request.Headers.Add(
Constants.PartialLoaderSessionKey,
response.Headers.GetValues(
Constants.PartialLoaderSessionKey).First());
response = await _client.SendAsync(request);
}
...
});
}
...
}
catch (Exception ex)
{
// Что-то пошло вообще не так
...
}
}
Запустим сервер и клиент.
Будем запрашивать 100000 кошек с задержкой 0.1 миллисекунд на каждую.
Сначала нажмём "Получить сразу всё".
Как и ожидалось, примерно за 10 секунд получили. В течение этого времени таблица была пуста и пользователь только по часикам мог надеяться, что приложение живое.
Теперь попробуем получить частями. Таймаут поставим 200, меньше нет смысла, так как в PartialLoader
мы используем ManualResetEventSlim.Wait(...)
и меньшую задержку мы не сможем выдержать.
Видим, что уже через десятые доли секунды пользователь увидел первые данные.
Попробуем вместо таймаута задать размер партии.
Как видим, всё прошло по плану.
Ну и оба параметра:
После нескольких попыток удалось подобрать размер партии такой, что timeout
и paging
сработали примерно поровну - 51% в пользу paging
.
Hidden text
1 |
1803 |
00:00:00.1110889 |
1803 |
00:00:00.1110889 |
0 |
2 |
1971 |
00:00:00.2389203 |
3774 |
00:00:00.3500092 |
0 |
3 |
2070 |
00:00:00.2179351 |
5844 |
00:00:00.5679443 |
1 |
4 |
2070 |
00:00:00.2198922 |
7914 |
00:00:00.7878365 |
1 |
5 |
2070 |
00:00:00.2197443 |
9984 |
00:00:01.0075808 |
1 |
6 |
2061 |
00:00:00.2236320 |
12045 |
00:00:01.2312128 |
0 |
7 |
2055 |
00:00:00.2192046 |
14100 |
00:00:01.4504174 |
0 |
8 |
2026 |
00:00:00.2243919 |
16126 |
00:00:01.6748093 |
0 |
9 |
2038 |
00:00:00.2201484 |
18164 |
00:00:01.8949577 |
0 |
10 |
2070 |
00:00:00.2219114 |
20234 |
00:00:02.1168691 |
1 |
11 |
2046 |
00:00:00.2223356 |
22280 |
00:00:02.3392047 |
0 |
12 |
2070 |
00:00:00.2228286 |
24350 |
00:00:02.5620333 |
1 |
13 |
2025 |
00:00:00.2215145 |
26375 |
00:00:02.7835478 |
0 |
14 |
2048 |
00:00:00.2218749 |
28423 |
00:00:03.0054227 |
0 |
15 |
2070 |
00:00:00.2216480 |
30493 |
00:00:03.2270707 |
1 |
16 |
2069 |
00:00:00.2219907 |
32562 |
00:00:03.4490614 |
0 |
17 |
2069 |
00:00:00.2219315 |
34631 |
00:00:03.6709929 |
0 |
18 |
2070 |
00:00:00.2228502 |
36701 |
00:00:03.8938431 |
1 |
19 |
2065 |
00:00:00.2218863 |
38766 |
00:00:04.1157294 |
0 |
20 |
2070 |
00:00:00.2228646 |
40836 |
00:00:04.3385940 |
1 |
21 |
2067 |
00:00:00.2219197 |
42903 |
00:00:04.5605137 |
0 |
22 |
2069 |
00:00:00.2212811 |
44972 |
00:00:04.7817948 |
0 |
23 |
2070 |
00:00:00.2211251 |
47042 |
00:00:05.0029199 |
1 |
24 |
2028 |
00:00:00.2230665 |
49070 |
00:00:05.2259864 |
0 |
25 |
2057 |
00:00:00.2223452 |
51127 |
00:00:05.4483316 |
0 |
26 |
2070 |
00:00:00.2231241 |
53197 |
00:00:05.6714557 |
1 |
27 |
2070 |
00:00:00.2201679 |
55267 |
00:00:05.8916236 |
1 |
28 |
2070 |
00:00:00.2206666 |
57337 |
00:00:06.1122902 |
1 |
29 |
2070 |
00:00:00.2230042 |
59407 |
00:00:06.3352944 |
1 |
30 |
2057 |
00:00:00.2217751 |
61464 |
00:00:06.5570695 |
0 |
31 |
2064 |
00:00:00.2229603 |
63528 |
00:00:06.7800298 |
0 |
32 |
2070 |
00:00:00.2216961 |
65598 |
00:00:07.0017259 |
1 |
33 |
2070 |
00:00:00.2220264 |
67668 |
00:00:07.2237523 |
1 |
34 |
2070 |
00:00:00.2227823 |
69738 |
00:00:07.4465346 |
1 |
35 |
2070 |
00:00:00.2218313 |
71808 |
00:00:07.6683659 |
1 |
36 |
2070 |
00:00:00.2249333 |
73878 |
00:00:07.8932992 |
1 |
37 |
2070 |
00:00:00.2199873 |
75948 |
00:00:08.1132865 |
1 |
38 |
2070 |
00:00:00.2228433 |
78018 |
00:00:08.3361298 |
1 |
39 |
2070 |
00:00:00.1097573 |
80088 |
00:00:08.4458871 |
1 |
40 |
2068 |
00:00:00.2390979 |
82156 |
00:00:08.6849850 |
0 |
41 |
2070 |
00:00:00.2215380 |
84226 |
00:00:08.9065230 |
1 |
42 |
2070 |
00:00:00.2231034 |
86296 |
00:00:09.1296264 |
1 |
43 |
2070 |
00:00:00.2226903 |
88366 |
00:00:09.3523167 |
1 |
44 |
2064 |
00:00:00.2218493 |
90430 |
00:00:09.5741660 |
0 |
45 |
2038 |
00:00:00.2219079 |
92468 |
00:00:09.7960739 |
0 |
46 |
2054 |
00:00:00.2234104 |
94522 |
00:00:10.0194843 |
0 |
47 |
2070 |
00:00:00.2202597 |
96592 |
00:00:10.2397440 |
1 |
48 |
2048 |
00:00:00.2221415 |
98640 |
00:00:10.4618855 |
0 |
49 |
1360 |
00:00:00.1118606 |
100000 |
00:00:10.5737461 |
0 |
0,5102040816 |
О реализации IPartialLoader
Ну и несколько слов о реализации интерфейса IPartialLoader
, использованной для данной демонстрации. При вызове StartAsync(...)
запускается задача, которая читает асинхронный Enumerable
и записывает их в очередь. Как StartAsync(...)
, так и последующие вызовы ContinueAsync()
читают данные из этой очереди в течение времени, соответствующего заданным параметрам. Для совместного доступа потоков к очереди используется ManualResetEventSlim
. Вначале он сброшен. Когда задача, пишущая в очередь, добавляет объект, она устанавливает ManualResetEventSlim
, и происходит чтение из очереди. Когда очередь пустеет, ManualResetEventSlim
сбрасывается. Подробнее реализацию можно посмотреть в исходниках.
public async Task StartAsync(IAsyncEnumerable<T> data, PartialLoaderOptions options)
{
...
_manualReset.Reset();
_loadTask = Task.Run(async () =>
{
await foreach (T item in data)
{
if (_cancellationTokenSource.Token.IsCancellationRequested)
{
...
break;
}
_queue.Enqueue(item);
_manualReset.Set();
}
});
...
}
/// <summary>
/// Метод, общий для StartAsync(...) и ContinueAsync().
/// </summary>
private async Task ExecuteAsync()
{
DateTimeOffset start = DateTimeOffset.Now;
while (!_loadTask.IsCompleted)
{
TimeSpan limeLeft = _options.Timeout.TotalMilliseconds < 0 ?
TimeSpan.MaxValue : _options.Timeout - (DateTimeOffset.Now - start);
if (limeLeft == TimeSpan.MaxValue || limeLeft.TotalMilliseconds > 0)
{
try
{
if (limeLeft == TimeSpan.MaxValue)
{
_manualReset.Wait(_cancellationTokenSource!.Token);
}
else
{
_manualReset.Wait(limeLeft, _cancellationTokenSource!.Token);
}
}
catch (OperationCanceledException)
{
await _loadTask;
...
}
if (_cancellationTokenSource!.Token.IsCancellationRequested)
{
await _loadTask;
...
Output(PartialLoaderState.Canceled);
return;
}
while (_queue.TryDequeue(out T? item))
{
...
_list.Add(item);
if(_options.Paging > 0
&& _list.Count - _offset == _options.Paging)
{
Output(PartialLoaderState.Partial);
return;
}
}
}
else
{
if (_cancellationTokenSource!.Token.IsCancellationRequested)
{
await _loadTask;
...
Output(PartialLoaderState.Canceled);
return;
}
Output(PartialLoaderState.Partial);
return;
}
if (!_loadTask.IsCompleted)
{
_manualReset.Reset();
}
}
while (_queue.TryDequeue(out T? item))
{
...
_list.Add(item);
if (_options.Paging > 0
&& _list.Count - _offset == _options.Paging)
{
Output(PartialLoaderState.Partial);
return;
}
}
if (_cancellationTokenSource!.Token.IsCancellationRequested)
{
...
Output(PartialLoaderState.Canceled);
return;
}
if (_loadTask.IsFaulted)
{
throw _loadTask.Exception!;
}
Output(PartialLoaderState.Full);
}
private void Output(PartialLoaderState state)
{
State = state;
if(State == PartialLoaderState.Partial
|| State == PartialLoaderState.Full)
{
_chunk = _list!.GetRange(_offset, _list.Count - _offset);
_offset = _list.Count;
}
}
Вывод
При необходимости загрузки в пользовательский интерфейс большого количества данных, можно сократить время ожидания пользователя выполняя такую загрузку частями и сразу отображая их.
Комментарии (12)
leksiq Автор
25.02.2022 10:30-1На мой взгляд, ORM всё же завязан на БД, предполагает достаточно прямое соответствие модели и таблиц. Не всегда это возможно. В моём случае используется унаследованная БД, довольно запутанная, но стоит задача совместимости старой и новой системы в течение долгого периода. Также, на мой взгляд, в вашем случае будут происходить отдельные запросы БД вместо одного. Также, данные могут приходить не только из БД. Например, файловая система или учетная запись IMAP...
Android97
25.02.2022 10:31+2Почему нельзя просто по страницам вытаскивать данные из БД и передавать клиенту ?
Если ORM используете то будет примерно так: cats.Skip(pageNumber * pageSize).Take(pageSize)
И каждый раз дополнительно высылать общее количество строк в БД. Итого, клиент сам будет запрашивать нужную порцию данных.leksiq Автор
25.02.2022 10:33На мой взгляд, ORM всё же завязан на БД, предполагает достаточно прямое соответствие модели и таблиц. Не всегда это возможно. В моём случае используется унаследованная БД, довольно запутанная, но стоит задача совместимости старой и новой системы в течение долгого периода. Также, на мой взгляд, в вашем случае будут происходить отдельные запросы БД вместо одного. Также, данные могут приходить не только из БД. Например, файловая система или учетная запись IMAP...
Android97
25.02.2022 13:32Про ORM это только пример :) Можно то же самое сделать SQL запросом (см. LIMIT и OFFSET).Про IMAP сказать ничего не могу, не решал такую задачу.
qw1
25.02.2022 18:46+1stateful-сервер сомнительное удовольствие. Сразу минус масштабирование, а когда это всё обрастёт кодом, не перепишешь уже. Я встречал вариант, когда первый запрос отдаёт только id-шники строк, которые попадают под фильтры, дальше клиент сам разбивает этот список на куски и запросиками по 200-500 id загружает полноценные объекты со всеми внутренностями.
xFFFF
27.02.2022 10:47Зачем загружать сотни тысяч записей? Никто их все не будет просматривать. Загрузить видимые, а остальные подгружать динамически.
qw1
27.02.2022 18:44Например, пользователь нажмёт «сортировать по столбцу» — и приехали, надо загрузить всё. Вообще очень сложно переучивать пользователей, они привыкли в старой системе видеть 100500 записей, и хотят всё то же самое в новой, и не хотят объяснять, зачем им эти 100500 строк для их задачи.
0x131315
28.02.2022 00:40Эм, это же классическая задача вывода листинга. Решается через отдельный интерфейс с пагинацией. Обычно он же поддерживает сортировку, в том числе по нескольким произвольным полям. Работает быстро как раз за счёт того, что лишние данные реально не запрашиваются, даже на уровне БД. Зачем тут велосипед - решительно непонятно.
dimyanchek
Решение не ново и даже очевидно ,тут вы не удивили ни разу, но за детали реализации большое спасибо, весьма полезно.
OkunevPY
Решение не просто не ново, оно старо как мир.
И уже давно на вопрос как грузить данные ответили простым подходом, разделяй и влавствуй. Например разбить бд на кусочки, и не дрюгать всю бд целиком, а выполнить n+1 запрос к разным частям данных? Профит мне кажеться куда больше чем грузить из одного источника кусками.