Одно из двух, — прошелестел он, — или пациент жив, или он умер. Если он жив — он останется жив или он не останется жив. Если он мёртв — его можно оживить или нельзя оживить.

А.Н. Толстой. "Золотой ключик, или Приключения Буратино"

Введение

Недавно в организации, где я тружусь, появилась идея переписать корпоративную систему, которая работает более 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/