Как стать автором
Обновить

Как не дать пользователю заснуть во время загрузки большого набора данных

Время на прочтение11 мин
Количество просмотров5.1K

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

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

Введение

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

Теги:
Хабы:
Всего голосов 8: ↑8 и ↓0+8
Комментарии14

Публикации

Истории

Работа

Ближайшие события

19 сентября
CDI Conf 2024
Москва
24 сентября
Конференция Fin.Bot 2024
МоскваОнлайн