Одно из двух, — прошелестел он, — или пациент жив, или он умер. Если он жив — он останется жив или он не останется жив. Если он мёртв — его можно оживить или нельзя оживить.
А.Н. Толстой. "Золотой ключик, или Приключения Буратино"

Введение
Недавно в организации, где я тружусь, появилась идея переписать корпоративную систему, которая работает более 20 лет и за этот срок несколько устарела. Если кратко - информационная система в области морских грузовых перевозок и обслуживания судов в порту. В данный момент я занимаюсь НИОКР и хочу поделиться с вами некоторыми проблемами, с которыми может столкнуться разработчик подобной системы и как я планирую их решать. На текущий момент планирую сервер приложений ASP.NET Core под .NET 6. База данных останется старая, так как это даст возможность постепенно переходить на новую систем�� по мере добавления функционала. Основное клиентское ПО предполагается на WPF под .NET 6. Кроме того, возможны небольшие узкопрофильные клиенты на веб-страницах и мобильных устройствах.
Проблема
Пользователю лень фильтровать запрашиваемые данные, а их довольно много. Заставлять пользователя что-то фильтровать - негуманно и попахивает произволом. В текущей системе такой пользователь сидит грустит, ругает разработчиков, давит на техподдержку. В текущей системе клиент идёт прямо в базу данных через BDE, ничего с этим сделать уже нельзя. В новой системе клиент про базу данных ничего не знает, сервер по его запросу шлет JSON через HTTP. Если делать "в лоб", то сильно мало что поменяется: сервер по запросу клиента будет собирать для отправки коллекцию объектов, загружая их из базы или ещё как-то. Потом всю эту махину он пошлёт в ответном HTTP-пакете, предварительно серилизовав в JSON, там это всё будет в один присест десериализовано в клиентские объекты, которые, наконец-то предстанут пред взором потерявшего надежду пользователя.
Идея решения
А давайте, сервер по запросу начнёт грузить в свою коллекцию, как и раньше, но на клиента отправит не всё, а сколько получится загрузить до истечения таймаута или какое-то фиксированное количество объектов или в результате суперпозиции этих условий. При этом клиенту мы будем сообщать в специальном заголовке, уже всё или нет. А если нет, передавать специальный идентификатор, который клиент должен предъявлять при запросе следующей порции. Таким образом, пользователь быстро увидит результат, ��озможно, даже сможет использовать.
Предположим, у нас есть следующий класс:
public enum PartialLoaderState { New, Partial, Full ... }
public class PartialLoader<T> where T: class
{
// Возвращает текущее состояние: новый или данные получены (полностью или
// частично)
PartialLoaderState State { get; }
// Выполняет загрузку очередной партии данных
Task LoadAsync()
{
...
}
// Устанавливает источник данных
public PartialLoader<T> SetDataProvider(IAsyncEnumerable<T> dataProvider)
{
...
}
// Задаёт таймаут, после истечения которого происходит возврат из
// метода LoadAsync()
public PartialLoader<T> SetTimeout(TimeSpan timeout)
{
...
}
// Задаёт размер партии данных, после достижения которого происходит
// возврат метода LoadAsync()
public PartialLoader<T> SetPaging(int paging)
{
...
}
// Добавляет "утилизатор" в цепочку обработчиков каждого элемента
public PartialLoader<T> AddUtilizer(Action<T> utilizer)
{
...
}
...
}
Данный класс может быть также унаследован с предопределёнными "утилизаторами". В этой статье мы будем использовать класс ChunksPartialLoader, который помещает очередную порцию в List<T>, заменяя предыдущую порцию.
public class ChunkPartialLoader<T> : PartialLoader<T> where T : class
{
private readonly List<T> _chunk = new();
public List<T> Chunk
{
get
{
...
return _chunk;
}
}
public override async Task LoadAsync()
{
AddUtilizer(Utilizer);
_chunk.Clear();
await base.LoadAsync();
}
private void Utilizer(T item)
{
_chunk.Add(item);
}
}Посмотрим, как этим можно воспользоваться.
Лабораторная установка
Чтобы испытать, как всё работает, создадим в VisualStudio Studio Community 2022 три проекта. Здесь опишу по возможности кратко, так как все исходники доступны: https://sourceforge.net/p/partialloader/code/ci/v1.1.0/tree/.
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>>();
partialLoader
.SetTimeout(TimeSpan.FromMilliseconds(timeout))
.SetPaging(paging)
.SetDataProvider(GenerateManyCats(count, delay))
;
}
else
{
// Если это последующий запрос, то получаем ключ из заголовка
// запроса, берём PartialLoader из хранилища и продолжаем
// генерацию.
key = context.Request.Headers[Constants.PartialLoaderSessionKey];
partialLoader = loaderStorage.Data[key];
}
await partialLoader.LoadAsync();
// Добавляем заголовок ответа, сигнализирующий, последняя это партия
// или нет.
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 |
О реализации PartialLoader
Ну и несколько слов о реализации PartialLoader, использованной для данной демонстрации. При первом вызове LoadAsync(...) запускается задача, которая читает асинхронный Enumerable и записывает их в очередь. Как первый, так и последующие вызовы LoadAsync() читают данные из этой очереди в течение времени, соответствующего заданным параметрам. Для совместного доступа потоков к очереди используется ManualResetEventSlim. Вначале он сброшен. Когда задача, пишущая в очередь, добавляет объект, она устанавливает ManualResetEventSlim, и происходит чтение из очереди. Когда очередь пустеет, ManualResetEventSlim сбрасывается. Подробнее реализацию можно посмотреть в исходниках.
public async Task StartAsync(IAsyncEnumerable<T> data, PartialLoaderOptions options)
{
...
if (State is PartialLoaderState.New)
{
State = PartialLoaderState.Started;
_manualReset.Reset();
_loadTask = Task.Run(async () =>
{
await foreach (T item in data)
{
if (_cancellationTokenSource.Token.IsCancellationRequested)
{
...
break;
}
_queue.Enqueue(item);
_manualReset.Set();
}
});
...
}
else
{
State = PartialLoaderState.Continued;
}
await ExecuteAsync();
// вычищаем "утизизаторы" после каждого вызова, чтобы избежать
// обращений к объектам, которые могут не сохранять функциональность
// между вызовами, например, использовать HttpContext
_utilizers.Clear();
}
private async Task ExecuteAsync()
{
_start = DateTimeOffset.Now;
_count = 0;
while (!_loadTask.IsCompleted)
{
TimeSpan limeLeft = _timeout.Ticks <= 0 ?
TimeSpan.MaxValue : _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;
State = PartialLoaderState.Canceled;
return;
}
if (UtilizeAndPossiblySetPartialStateAndReturn())
{
return;
}
}
else
{
if (_cancellationTokenSource!.Token.IsCancellationRequested)
{
await _loadTask;
State = PartialLoaderState.Canceled;
return;
}
State = PartialLoaderState.Partial;
return;
}
if (!_loadTask.IsCompleted)
{
_manualReset.Reset();
}
}
if (UtilizeAndPossiblySetPartialStateAndReturn())
{
return;
}
if (_cancellationTokenSource!.Token.IsCancellationRequested)
{
State = PartialLoaderState.Canceled;
return;
}
if (_loadTask.IsFaulted)
{
throw _loadTask.Exception!;
}
State = PartialLoaderState.Full;
}
private bool UtilizeAndPossiblySetPartialStateAndReturn()
{
while (_queue.TryDequeue(out T? item))
{
if (item is not null)
{
foreach (Action<T> utilizer in _utilizers)
{
utilizer.Invoke(item);
}
_count++;
if (_paging > 0 && _count >= _paging || _timeout.Ticks > 0 && (_timeout - (DateTimeOffset.Now - _start)).Ticks <= 0)
{
State = PartialLoaderState.Partial;
return true;
}
}
}
return false;
}Вывод
При необходимости загрузки в пользовательский интерфейс большого количества данных, можно сократить время ожидания пользователя выполняя такую загрузку частями и сразу отображая их.
NuGet: https://www.nuget.org/packages/Net.Leksi.PartialLoader/
