Как оптимизировать процесс миграции данных из разных источников и получить бесшовный список? В этой статье мы расскажем о фронд-способе в кроссплатформенном фреймворке Flutter. Flutter — это мощный и популярный фреймворк для создания мобильных и веб-приложений. Он предлагает широкий спектр функций и инструментов, облегчающих и ускоряющих разработку. Статья особенно полезна тем, кто создает мобильные приложения на языке Dart.

Проблема, которую решает алгоритм

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

Процесс миграции многомерен и длителен:

  • во-первых, существует десяток продуктов, которые могут находиться на разной стадии миграции между архитектурами;

  • во-вторых, клиенты переносятся в несколько этапов: сначала десяток избранных клиентов, затем сотня лояльных клиентов.

Поэтому время, в течение которого разные клиенты находятся в обеих архитектурах, достаточно продолжительно и может занимать месяцы. Но время, деньги и бизнес клиентов не должны останавливаться, а значит, данные должны быть доступны из двух источников одновременно и выглядеть бесшовно.

Точка отправления

Есть 2 подхода к отображению бесшовных данных:

  • сделать на бэке фасад, который будет объединять данные из монолита и микросервисной платформы;

  • поручить это фронту, в данном случае мобильному Flutter приложению.

Рассмотрим второй вариант. Как опорный и базовый продукт возьмём «Платежи», но держим в голове, что это только первая ласточка, на очереди ещё десяток подсистем/модулей/микросервисов.

Для начала пользователь должен видеть список платежей, сгруппированный по датам и отсортированный по дате по убыванию. Нужно иметь ввиду, что клиент, являющийся юридическим лицом, имеет несколько действующих лиц/пользователей, каждый из которых видит операции по конкретному юридическому лицу. При этом разные действующие лица могут быть либо переключены на микросервисы либо же ещё в монолите.

То есть, информацию по платежам до какого-то момента нужно не просто брать из монолита, а после уже из микросервиса, а они так сказать перемешаны и одновременно находятся и там и там (без дублирования).

Алгоритм работы: описание и реализация

 Вот как отобразить стройный список:

  • При первичной загрузке (0 страница) загружаем определённую порцию Size из обоих сервисов Mo и Mc, при скроллинге мы определяем с какой записи стоит продолжить загрузку из Mo, а с какой из Mc;

  • Соединяем списки и сортируем ( Mo + Mc ).sort();

  • Добавляем в результирующий список только первые Size записей
    Res = ( Mo + Mc ).sort().take(Size);

Код пишется максимально обезличенным и понятным, чтоб можно было легко передать коллегам работающим над другими продуктами в том числе над web стеком или чтоб мог воспользоваться любой хабражитель даже не знающий Dart

///Инструментарий обработки записей
class UtilsUnitedRecords<T> {

  ///Получаем список который нужно добавить в результирующий
  List<T> unitedRecord(List<T> setA, List<T> setB, int Function(T a, T b) compare) {
      var union = [...setA, ...setB];
      union.sort(compare);
      return union.take(Environment.sizePage).toList();
  }

  ///Получаем позицию с которой нужно продолжить загрузку
  int getStartPosition(List<T> setA, bool Function(T a) check) {
    return setA.where((element) => check(element)).length;
  }
}


///Репозиторий данных
class Repository<T> {

  Repository({
    required this.api,
    required this.apiMs,
    required this.store,
    required this.permissionRepository,
  });

  final IStore store;
  final IApi api;
  final IApi apiMs;
  final IPermissionRepository permissionRepository;

  final utils = UtilsUnitedRecords<T>

  search({
    S searchParams,
    bool isFirst = true,
    int pageSize = Environment.sizePage,
  }) async {
	final clientCanUseMS = await permissionRepository.getMicroservicePermission();

    //Проверяем сколько уже выкачано из МС и МОНО, т.о. определяем стартовую позицию загрузки для каждого сервиса
    var startPositionMono = isFirst
		? 0
		: clientCanUseMS 
			? utils.getStartPosition(store, _isMs);
    
    var startPositionMS = isFirst
		? 0
		: utils.getStartPosition(store, _isMono);
    
    //Заменяем стартовую позицию поиска в параметрах и ищем
    searchParams = searchParams.copyWith(startPosition: startPositionMS);
    final responseMono = await search(api: api, searchParams: searchParams);

    searchParams = searchParams.copyWith(startPosition: startPositionMS);
    final responseMs = clientCanUseMS? await search(api: apiMs, searchParams: searchParams): [];

    //Соединяем списки, сортируем получившийся и возвращаем ту часть, коорую нужно добавить в стор
    final insertList = utils.unitedRecords(responseMs, responseMono, _compare)

    //В зависимости от того первая это загрузка или нет, вызывается установка или дополнение списка в стор
    final action = isFirst 
		? store.set 
		: store.add
    action(insertList);
  }
}

Процесс тестирования нового алгоритма на реальных данных, оценка его эффективности и точности

Проверим в Excel тестовый набор данных. Предположим, что страница при прокрутке списка будет подгружать по две записи.

Пример бесшовного списка
Пример бесшовного списка

Действительно видим бесшовный список из разных источников.

Но во время тестирования можно заметить — как происходит избыточная выборка. Иногда, если в результирующий insertList попадают данные только из одного источника, приходится повторно запрашивать из второго. Самый плохой вариант, когда всех пользователей клиента перенесли на микросервис, после некоторого времени данные из монолита уже не видны даже при прокрутке нескольких экранов, а они продолжают запрашиваться и не попадать в результирующий набор данных.

Это приводит к пониманию необходимости кешировать данный, если они не попали в выборку.

enum ServerDef { MONO, MS }

///Инструментарий обработки записей
class UtilsUnitedRecords<T> {
  ///Получаем список который нужно добавить в результирующий 
  List<T> unitedRecord(List<T> setA, List<T> setB, int Function(T a, T b) compare) {
    var union = [...setA, ...setB];
    union.sort(compare);
    return union.take(Environment.sizePage).toList();
  }

  ///Получаем позицию с которой нужно продолжить загрузку
  int getStartPosition(List<T> setA, bool Function(T a) check) {
    return setA.where((element) => check(element)).length;
  }

  ///Возвращает данные для кэширования, если ни одна запись одного из наборов не попала в результирующий список
  MapEntry<ServerDef, List<T>>? needCaching(
      {required List<T> union,
      required MapEntry<ServerDef, List<T>> setA,
      required MapEntry<ServerDef, List<T>> setB}) {
    final minSet = (setA.value.length < setB.value.length) ? setA : setB;

    final isContains = union.any((element) => minSet.value.contains(element));
    return (isContains) ? null : minSet;
  }
}

///Репозиторий данных
class Repository<T> {

  Repository({
    required this.api,
    required this.apiMs,
    required this.store,
    required this.permissionRepository,
  });

  final IStore store;
  final IApi api;
  final IApi apiMs;
  final IPermissionRepository permissionRepository;

  final utils = UtilsUnitedRecords<T>
  MapEntry<ServerDef, List<T>>? _cacheData;

  search({
    S searchParams,
    bool isFirst = true,
    int pageSize = Environment.sizePage,
  }) async {
	final clientCanUseMS = await permissionRepository.getMicroservicePermission();

    //Проверяем сколько уже выкачано из МС и МОНО,
    //  то есть, определяем стартовую позицию загрузки для каждого сервиса
    var startPositionMono = isFirst
		? 0
		: clientCanUseMS 
			? utils.getStartPosition(store, _isMs) 
			: 0;
    var startPositionMS = isFirst
		? 0
		: utils.getStartPosition(store, _isMono);
	if (isFirst){
      cacheData = null;
    }

    //Заменяем стартовую позицию поиска в параметрах и ищем
    searchParams = (_cacheData != null && _cacheData!.key == ServerDef.MONO)
        ? _cacheData!.value
    	: searchParams.copyWith(startPosition: startPositionMS);

    final responseMono = (_cacheData != null && _cacheData!.key == ServerDef.MS)
        ? _cacheData!.value
    	: await search(api: api, searchParams: searchParams);

    searchParams = searchParams.copyWith(startPosition: startPositionMS);
    final responseMs = clientCanUseMS? await search(api: apiMs, searchParams: searchParams): [];

    //Соединяем списки, сортируем получившийся и возвращаем ту часть, которую нужно добавить в стор
    final insertList = utils.unitedRecords(responseMs, responseMono, _compare);

    //В зависимости от того первая это загрузка или нет, вызывается установка или дополнение списка в стор
    final action = isFirst 
		? store.set 
		: store.add;
    action(insertList);

    //Определяем, нужно ли кэшировать данные
    _cacheData = utils.needCaching(
      union: insertList,
      setA: CacheData(ServerDef.MONO, responseMono),
      setB: CacheData(ServerDef.MS, responseMs),
    );
  }
}

Итоги и выводы о значимости алгоритма и дальнейших перспективах его развития

Алгоритм решает проблему получения бесшовных данных из разных источников со следующими последствиями:

  • решение сделать на фронте (в данном случае мобильном приложении) позволяет реализовать локальный (в разрезе пользователя на устройстве) кэш на уровне репозитория. При реализации на бэке пришлось бы использовать дополнительные хранилища Redis или аналоги с отслеживанием сессий пользователя и принудительной чисткой;

  • в данном случае у нас имеется только 2 источника, но репозиторий и функция запросто переписывается под массив источников;

  • впереди ещё десяток продуктов ждущих переезда на микросервисы, вспомогательные функции/utils — это дженерики, а значит, ничего заново переписывать не придётся;

  • функция поиска и функции определения, откуда объект _isMs и _isMono в репозитории достаточно универсальны, нужно будет обновить функцию сравнения объектов других продуктов для сортировки;

Есть ещё пара идей для улучшения, например:

  • использовать параллельный запрос данных (в dart это реализуется с помощью изолятов);

  • оптимизировать вычисление стартовой позиции;

  • кешировать неиспользованные данные, сейчас они кэшируются только если ни одна запись из ответа не использовалась.

Таким образом, с помощью несложных манипуляций можно оптимизировать процесс и решить проблему получения бесшовных данных из разных источников.

Комментарии (7)


  1. nin-jin
    21.06.2023 08:48
    +1

    Рассмотрим второй вариант.

    А первый вариант вы не рассмотрели, потому что бэкенд у вас гвоздями прибит к одному единственному клиенту? Или предлагается на каждом клиенте переизобретать "алгоритм" каждый раз, когда меняется способ хранения данных на бэкенде?


    1. FantasyOR Автор
      21.06.2023 08:48
      +2

      *прошу прощения за сбои в форматировании, подправил статью
      Не понял про клиента, если имеется ввиду фронт, то их 2 web и мобильный Flutter.

      Алгоритм не нужно изобретать каждый раз, т.к.: utils и repo реализована как дженерик и выделяется в библиотеку.
      Поэтому к новому модулю подключается библиотека и далее используется как:
      Repository<Pays> payList

      Repository<Mail> mailList

      На web перенести данный код не составит труда, тоже 1 раз.


      1. nin-jin
        21.06.2023 08:48
        -3

        А флаттер уже научился не тянуть на фронт 2 мегабайта рантайма?


      1. avdosev
        21.06.2023 08:48

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


  1. Ivan22
    21.06.2023 08:48

    "логика но фронте - за и против", собрание из 10 томов


  1. avdosev
    21.06.2023 08:48
    +1

    использовать параллельный запрос данных (в dart это реализуется с помощью изолятов);

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

    Изоляты обычно применяют, чтобы разгрузить основной поток, но это не связано только с запросами и в целом базово не обязательно, но если и есть, то должно быть архитектурно хорошо организовано.

    Я это к тому, что данное улучшение находится в совершенно в другой плоскости и напрямую не является улучшением алгоритма.


    1. FantasyOR Автор
      21.06.2023 08:48

      асинхронно и параллельно не одно и то же, особенно в рамках Flutter, но согласен пачки данных не такие большие чтоб уделять этому внимание.
      Однако есть такой показатель как "H" вместо "4G" на телефоне, не все наши клиенты работают в крупных населённых пунктах.