4.4. Системы хранения данных: важные нюансы

В трёх предыдущих параграфах мы рассмотрели важные библиотеки для работы с данными: файлами, парами ключ — значение и SQL/NoSQL БД.

Это — заключительный параграф серии. В нём мы рассмотрим важные нюансы:

  • Как правильно хранить конфиденциальные данные.

  • Как организовать многопоточность при работе с БД.

  • Как выбрать систему для хранения данных.

Хранение секретов

Хранение конфиденциальных данных, таких как API-ключи, требует особой осторожности. Вот несколько распространённых методов для безопасного хранения секретов в приложении:

  • Локальный файл с ключами. Поместите секреты в файл, который исключён из системы контроля версий (например, в файл .gitignore). Этот метод обеспечивает базовую безопасность, но не идеальную.

  • Файлы окружения .env. Используйте файлы окружения для хранения секретных значений. Но и его также нужно не забыть исключить из системы контроля версий, добавив в файл .gitignore. Чтобы проще взаимодействовать с файлом, можно воспользоваться пакетом envied, который в дополнение ко всему «из коробки» предлагает обфусцировать значения.

  • Обфускация. Этот метод заключается в том, чтобы усложнить чтение и понимание вашего кода секретного ключа. В этом может помочь указанный выше envied. Однако это не обеспечивает полной безопасности, так как декомпиляция приложения может деобфусцировать значения.

  • Передача через --dart-define. Вы можете передавать секретные значения в приложение на этапе компиляции. Это позволяет избежать хранения секретов в коде приложения и передавать их динамически. Например, с помощью команды flutter run --dart-define SECRET_KEY_1=a1b2t5b6D --dart-define SECRET_KEY_2=b2l4p5fg1. Если список секретов слишком большой, то начиная с версии Flutter 3.7 используется флаг --dart-define-from-file и появляется возможность хранить все секреты в файле в формате json.

Пример команды: flutter run --dart-define-from-file=my_secrets.json.

Применение правильных методов для хранения и управления секретами — важный аспект обеспечения безопасности приложений. Внимательно выбирайте метод, который соответствует вашим требованиям безопасности. Но стоит отметить всё же, что наиболее предпочтительным вариантом остаётся использование --dart-define.

Мультипоточность при работе с БД

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

Вот как это может выглядеть:

4.1.6

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

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

Для потокобезопасной работы (isolate-safe) с файлом из разных изолятов следует обеспечить синхронизацию и корректное управление доступом. Например, могут встречаться ситуации, когда один изолят пишет в файл, а другой должен из него читать. Dart и Flutter не предоставляют встроенные механизмы синхронизации, которые используются для управления доступом к общим ресурсам в многопоточных программах (Mutex/Semaphore).

Однако их можно создать самостоятельно.

Создайте централизованный менеджер (обычно в основном изоляте), который будет заниматься всеми операциями и синхронизацией с файлами. Все другие изоляты должны отправлять сообщения к этому менеджеру для выполнения операций чтения или записи. Это обеспечит последовательное выполнение всех операций с данными из одного источника, что предотвратит возможные конфликты.

Используйте lock-файл. Один изолят пишет в файл, другой читает из этого файла. Чтобы избежать конфликтов, можно использовать механизмы типа «лока». Например, можно создать дополнительный файл, действующий как флаг (например, lock.file), который будет создаваться перед началом операции записи и удаляться после её завершения. Изолят, предназначенный для чтения, должен проверять наличие этого файла перед началом чтения.

Пример:

1import 'dart:isolate';
2import 'package:flutter/services.dart';
3import 'dart:io';
4import 'package:path_provider/path_provider.dart';
5
6void main() async {
7  RootIsolateToken rootIsolateToken = RootIsolateToken.instance!;
8
9  await Isolate.spawn(
10    _writeFile,
11    _IsolateData(
12      message: "Hello from Isolate 1\n",
13      token: rootIsolateToken,
14    ),
15  );
16  await Isolate.spawn(
17    _writeFile,
18    _IsolateData(
19      message: "Hello from Isolate 2\n",
20      token: rootIsolateToken,
21    ),
22  );
23  // Даём время для завершения записи
24  await Future.delayed(const Duration(seconds: 1));
25  await _readFile(rootIsolateToken);
26}
27
28Future<void> writeFile(IsolateData data) async {
29  BackgroundIsolateBinaryMessenger.ensureInitialized(data.token);
30  final documentsDirectory = await getApplicationDocumentsDirectory();
31  var lockFile = File('${documentsDirectory.path}/file.lock');
32  while (lockFile.existsSync()) {
33    // Небольшая задержка
34    await Future.delayed(const Duration(milliseconds: 50));
35  }
36  await lockFile.create();
37  var file = File('${documentsDirectory.path}/shared_file.txt');
38  await file.writeAsString(data.message, mode: FileMode.append);
39  await lockFile.delete();
40}
41
42Future<void> readFile(RootIsolateToken token) async {
43  BackgroundIsolateBinaryMessenger.ensureInitialized(token);
44  final documentsDirectory = await getApplicationDocumentsDirectory();
45  var file = File('${documentsDirectory.path}/shared_file.txt');
46  if (await file.exists()) {
47    var content = await file.readAsString();
48    print(content);
49  }
50}
51
52class IsolateData {
53  final RootIsolateToken token;
54  final String message;
55
56  IsolateData({
57    required this.token,
58    required this.message,
59  });
60}

Использование библиотек, которые поддерживают транзакции и/или блокировки «из коробки». Пройдёмся по основным библиотекам и посмотрим, какие из них могут предложить функционал работы с мультиизолятами.

В Hive нет полноценной поддержки работы mutli-isolate с БД. Так что лучше отказаться от этого плагина, если есть необходимость в мультипоточности, или использовать централизованный менеджер (см. выше).

В Isar поддержка multi-isolate доступна «из коробки» и описана в документации к пакету. Именно в этом случае и будет использоваться транзакция записи.

Хотя SQFLite и выполняет операции с базой данных в фоновом потоке (что технически изолирует их от потока пользовательского интерфейса), все обращения к базе данных должны исходить из главного изолята. Механизмы транзакции в SQFLite не поддерживают многопоточный доступ или доступ из разных изолятов. Подробнее про это можно почитать в комментарии от разработчика пакета.

В Drift можно использовать статические методы NativeDatabase.createInBackground или NativeDatabase.createBackgroundConnection (разные способы создания БД в фоновом изоляте), и тогда создастся изолят, в котором будут исполняться SQL-запросы. Это можно сделать без дополнительных настроек. Если всё же требуется работать с БД из разных изолятов, то глобально есть два варианта.

Самый простой — использовать метод computeWithDatabase (аналогия с compute) или serializableConnection для ручной инициализации изолята.

1import 'package:drift/isolate.dart';
2
3...
4
5Future<void> insertSomeData(AppDatabase database) async {
6  await database.computeWithDatabase(
7    computation: (database) async {
8      // Затратная операция выполняется в отдельном изоляте, но взаимодействует с основной базой данных.
9      await database.batch((batch) {
10        batch.insertAll(database.dogs, []);
11      });
12    },
13    connect: (connection) {
14      // Эта функция отвечает за создание второго экземпляра вашего класса базы данных с краткосрочным [connection].
15      // Для работы необходим конструктор класса базы данных, принимающий connection.
16      return AppDatabase(connection);
17    },
18  );

Варианты посложнее с полным контролем жизненного цикла и с кастомизацией поведения используют сущность DriftIsolate, которая помогает запускать Drift в фоновом изоляте и является ссылкой на внутренний сервер базы данных (RunningDriftServer), к которому вы можете получить доступ из других изолятов.

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

Также стоит учитывать, что работа с данными в изоляте помогает добиться лучшего перформанса по UI, но скорость работы в некоторых случаях может быть медленнее, чем без изолятов, так как результат выполнения операций не доступен напрямую, а копируется из изолята, работающего с БД. Однако благодаря недавним улучшениям, таким как isolated groups в Dart VM, эти накладные расходы довольно малы, и там, где это возможно, рекомендуется использовать изоляты для выполнения запросов.

Как выбрать систему для хранения данных

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

1. Покрытие тестами

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

2. Шифрование

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

3. Популярность

Насколько стандартно и широко используется выбранное решение? Стандартизированные и популярные инструменты обычно имеют более широкую поддержку сообщества и документацию.

4. Реляционность и запросы

Если вам нужно хранить связанные данные и выполнять сложные запросы, рассмотрите реляционные базы данных. Для неструктурированных данных и простых операций выберите NoSQL-решения.

5. Транзакции

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

6. Документация

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

7. Использование оперативной памяти

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

8. Миграции

Если формат ваших данных будет меняться в будущем, убедитесь, что выбранное решение поддерживает механизмы миграции данных.

9. Изоляты

Рассмотрите, как выбранное решение взаимодействует с изолятами. Некоторые решения могут быть не приспособлены для работы с изолятами.

Для удобства мы подготовили сводную таблицу, в которой сравнили все решения из трёх предыдущих параграфов:

Шифрование

Покрытие тестами

Популярность

Транзакции

Документация

Использование оперативной памяти

Изоляты

Миграции

path_provider

-

+

+

-

+

-

-

-

shared_preferences

-

+

+

-

+

+

-

-

Hive

+

+

+-

+

+

+

+

+-

Isar

-

+

+-

+

+

+

+

+-

SQFLite

+-
(с доп. плагином sqflite_sqlcipher)

+

+

+

+

-

+-
(обращение к БД через нативный бэкграунд поток)

+

Drift

+

+

+-

+

+

-

+

+

flutter_cache_manager

-

+

+-

-

+

-

-

-

flutter_secure_storage

+

+

+

-

+

-

-

-

get_storage

-

+

+-

-

+

+

-

-

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

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


Квиз

Чтобы добавить в заметки выделенный текст, нажмите Ctrl + E

Отмечайте параграфы как прочитанные, чтобы видеть свой прогресс обучения

Предыдущий параграф4.3. Системы хранения данных: библиотеки для работы с БД
Следующий параграф4.5. Продвинутые библиотеки для получения данных