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

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

Введение

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


  1. dimyanchek
    25.02.2022 10:20
    +1

    Решение не ново и даже очевидно ,тут вы не удивили ни разу, но за детали реализации большое спасибо, весьма полезно.


    1. OkunevPY
      25.02.2022 11:42

      Решение не просто не ново, оно старо как мир.

      И уже давно на вопрос как грузить данные ответили простым подходом, разделяй и влавствуй. Например разбить бд на кусочки, и не дрюгать всю бд целиком, а выполнить n+1 запрос к разным частям данных? Профит мне кажеться куда больше чем грузить из одного источника кусками.


  1. leksiq Автор
    25.02.2022 10:30
    -1

    На мой взгляд, ORM всё же завязан на БД, предполагает достаточно прямое соответствие модели и таблиц. Не всегда это возможно. В моём случае используется унаследованная БД, довольно запутанная, но стоит задача совместимости старой и новой системы в течение долгого периода. Также, на мой взгляд, в вашем случае будут происходить отдельные запросы БД вместо одного. Также, данные могут приходить не только из БД. Например, файловая система или учетная запись IMAP...


  1. Android97
    25.02.2022 10:31
    +2

    Почему нельзя просто по страницам вытаскивать данные из БД и передавать клиенту ?
    Если ORM используете то будет примерно так: cats.Skip(pageNumber * pageSize).Take(pageSize)
    И каждый раз дополнительно высылать общее количество строк в БД. Итого, клиент сам будет запрашивать нужную порцию данных.


    1. leksiq Автор
      25.02.2022 10:33

      На мой взгляд, ORM всё же завязан на БД, предполагает достаточно прямое соответствие модели и таблиц. Не всегда это возможно. В моём случае используется унаследованная БД, довольно запутанная, но стоит задача совместимости старой и новой системы в течение долгого периода. Также, на мой взгляд, в вашем случае будут происходить отдельные запросы БД вместо одного. Также, данные могут приходить не только из БД. Например, файловая система или учетная запись IMAP...


      1. Android97
        25.02.2022 13:32

        Про ORM это только пример :) Можно то же самое сделать SQL запросом (см. LIMIT и OFFSET).Про IMAP сказать ничего не могу, не решал такую задачу.


      1. trueMoRoZ
        25.02.2022 14:05

        >Также, на мой взгляд, в вашем случае будут происходить отдельные запросы БД вместо одного

        Если сделать ленивые вычисления, то будет один запрос


        1. leksiq Автор
          25.02.2022 15:15

          Хорошо, спасибо.


  1. qw1
    25.02.2022 18:46
    +1

    stateful-сервер сомнительное удовольствие. Сразу минус масштабирование, а когда это всё обрастёт кодом, не перепишешь уже. Я встречал вариант, когда первый запрос отдаёт только id-шники строк, которые попадают под фильтры, дальше клиент сам разбивает этот список на куски и запросиками по 200-500 id загружает полноценные объекты со всеми внутренностями.


  1. xFFFF
    27.02.2022 10:47

    Зачем загружать сотни тысяч записей? Никто их все не будет просматривать. Загрузить видимые, а остальные подгружать динамически.


    1. qw1
      27.02.2022 18:44

      Например, пользователь нажмёт «сортировать по столбцу» — и приехали, надо загрузить всё. Вообще очень сложно переучивать пользователей, они привыкли в старой системе видеть 100500 записей, и хотят всё то же самое в новой, и не хотят объяснять, зачем им эти 100500 строк для их задачи.


  1. 0x131315
    28.02.2022 00:40

    Эм, это же классическая задача вывода листинга. Решается через отдельный интерфейс с пагинацией. Обычно он же поддерживает сортировку, в том числе по нескольким произвольным полям. Работает быстро как раз за счёт того, что лишние данные реально не запрашиваются, даже на уровне БД. Зачем тут велосипед - решительно непонятно.