Почему я разработал Couchbase Lite для Dart + Flutter, как с ним работать и как реализовать полнотекстовый поиск локально
Воу, прошел уже почти год. Время летит невероятно быстро. В январе я работал над приложением Flutter в качестве дополнительного проекта и сначала хотел сделать его автономным. На мой взгляд, многие функции должны работать как можно лучше даже при плохом или полном отсутствии связи. Учитывая постоянное улучшение доступности мобильных сетей, можно подумать, что подход offline-first потерял актуальность, но локальное выполнение задач также полезно, помимо прочего, для отзывчивости и конфиденциальности пользователей.
Я начал работу с использования Firebase Firestore в качестве решения для хранения данных, которое может работать в автономном режиме. Однако оно имеет ограниченный и слабый контроль над соответствующими функциями. Единственным параметром, определяющим, что хранится локально, является размер кэша в байтах. Вы все еще можете сохранять данные в хранилище в автономном режиме, но механизм разрешения конфликтов отсутствует. Насколько я могу судить, преимущество имеет последняя запись на сервере.
Далее следует сказать о полнотекстовом поиске (FTS), вернее, о его отсутствии. Документация Firestore в основном говорит вам проверить Elastic, Algolia или что-то еще... просто разберитесь с этим. 🤪 Хм, хорошо, а что насчет SQLite? Он поставляется предустановленным на многих системах и имеет множество расширений FTS. Изучив его, я обнаружил, что, хотя это и возможно, заставить его работать не совсем просто. Не во всех предустановленных версиях SQLite включено одно и то же расширение FTS или оно просто отсутствует. Конечно, версии самого SQLite различаются между платформами и версиями этих платформ, так что это тоже нужно учитывать. Как и в любой реляционной базе данных, вам придется управлять схемой, и настройка FTS-индекса в SQLite тоже не является супер простой задачей.
Долгое время я был в поиске мобильного решения для хранения данных, которое давало бы мне полный контроль над данными, хранящимися локально, и поддерживало бы разрешение конфликтов и FTS. В итоге, я нашел Couchbase Lite, встроенную NoSQL базу данных, которая отвечала всем моим требованиям и даже больше.
В то время на pub.dev было доступно множество пакетов, реализующих Coubhase Lite для Flutter, с различными уровнями проработки, поддержки платформы и функциональной полноты. Ни один из них не подходил мне идеально, поэтому, естественно, я забросил свой дополнительный проект и начал брить яка размером с Couchbase Lite. 🙊
Я решил начать новый проект, потому что чувствовал, что для поддержки автономного Dart и Flutter необходима особая архитектура, но спасибо создателям этих пакетов за то, что дали мне ориентиры.
Примерно год спустя cbl-dart находится в довольно стабильной и пригодной для использования бета-версии, с полной поддержкой всех платформ, которые поддерживаются автономным Dart и Flutter (кроме web). Тот же Dart API можно использовать для сохранения данных в приложении Flutter, проверки баз данных в CLI-инструменте или настройки готовых баз данных в серверном приложении.
Если вас интересует архитектура, как начать работу с Couchbase Lite и как реализовать с его помощью полнотекстовый поиск, продолжайте читать.
Архитектура
В этом разделе мы рассмотрим компоненты проекта и объясним, как они сочетаются друг с другом. Если вас не интересует архитектура проекта, вы можете перейти к следующему разделу.
И cblite, и cblitedart являются нативными динамическими библиотеками, которые открывают API на языке C. cblite поставляется как часть Couchbase Lite C SDK. Именно здесь реализована основная функциональность Couchbase Lite. dart:ffi позволяет коду Dart вызывать нативные C API, но имеет некоторые ограничения, из-за которых необходим слой поддержки. Этот слой обеспечивается cblitedart.
Внутренний пакет Dart cbl_ffi инкапсулирует весь код, необходимый для вызова cblite и cblitedart из Dart.
cbl реализован поверх cbl_ffi и предоставляет Dart API для Couchbase Lite. Именно с этим пакетом в основном взаимодействует пользовательский код.
cbl нуждается в инициализации, включающей такие моменты, как загрузка и инициализация собственных библиотек. Это предназначение cbl_dart для приложений на чистом Dart и cbl_flutter для приложений на Flutter.
Начало работы
Приступить к работе очень просто. Я покажу, как подготовить приложение Flutter к использованию Couchbase Lite, в том числе с помощью модульных тестов. Итак, сперва вам необходимо:
- добавить необходимые зависимости,
- инициализировать Couchbase Lite в приложении
- инициализировать Couchbase Lite в модульных тестах.
Далее, добавьте следующие зависимости в ваш pubspec.yaml:
1. dependencies:
2. cbl: ^1.0.0
3. cbl_flutter: ^1.0.0
4. # This dependency selects the Community Edition of Couchbase Lite. 5. # For the Enterprise Edition add `cbl_flutter_ee` instead.
6. cbl_flutter_ce: ^1.0.0
7. dev_dependencies:
8. # This dependency allows you to use Couchbase Lite in Flutter
9. # unit tests and not just in integration tests. 10. cbl_dart: ^1.0.0
Инициализируйте Couchbase Lite на ранних этапах жизненного цикла приложения:
1. import 'package:cbl_flutter/cbl_flutter.dart';
2.
3. Future<void> main() async {
4. // If you're initializing Couchbase Lite in your `main` function
5. // make sure to initialize Flutter before Couchbase Lite.
6. WidgetsFlutterBinding.ensureInitialized();
7.
8. // Now initialize Couchbase Lite.
9. await CouchbaseLiteFlutter.init();
10.
11. // Finally, start running the app.
12. runApp(MyApp());
13. }
Аналогичным образом инициализируйте Couchbase Lite в setupAll hook в ваших модульных тестах Flutter:
1. import 'dart:io';
2.
3. import 'package:cbl/cbl.dart';
4. import 'package:cbl_dart/cbl_dart.dart';
5. import 'package:flutter_test/flutter_test.dart';
6.
7. void main() {
8. // Each test file needs to initialize Couchbase Lite.
9. // It's a good idea to encapsulate that in a util function,
10. // that all tests use.
11. setUpAll(() async {
12. // If no `filesDir` is specified when initializing CouchbaseLiteDart,
13. // the working directory is used as the default database directory.
14. // By specifying a `filesDir` here, we can ensure that the tests don't 15. // create databases in the project directory.
16. final tempFilesDir = await Directory.systemTemp.createTemp();
17.
18. // Now initialize Couchbase Lite.
19. await CouchbaseLiteDart.init(
20. edition: Edition.community,
21. filesDir: tempFilesDir.path,
22. );
23. });
24.}
Возможность использовать Couchbase Lite в модульных тестах Flutter очень удобна, поскольку они запускаются гораздо быстрее, чем интеграционные тесты на устройствах или симуляторах. Короткий цикл обратной связи позволяет проводить исследования и разработку на основе тестирования, что может стать утомительным при использовании полномасштабных интеграционных тестов. Однако следует помнить, что модульные тесты запускаются в безголовой версии Flutter на хосте разработки. Они не могут заменить тестирование кода базы данных в интеграционных тестах на реальных устройствах или даже симуляторах.
Реализация полнотекстового поиска
Чтобы дать вам представление о том, как это - работать с Couchbase Lite, и продемонстрировать, насколько просто использовать функцию FTS, я покажу вам, как реализовать FTS для простой модели заметок. Итак, вам понадобится:
- открыть базу данных,
- создать новые заметки,
- создать FTS-индекс для заметок,
- использовать этот индекс FTS в запросе,
- извлечь данные из результатов запроса.
Сначала рассмотрим, как открыть базу данных:
1. // For this example, we'll put the database into a global variable.
2. late final AsyncDatabase database;
3.
4. Future<void> openDatabase() async {
5. // The database name will be used as the part of the file name
6. // of the database.
7. database = await Database.openAsync('notes-app');
8. }
Обратите внимание, что мы открываем AsyncDatabase через Database.openAsync. Пока вам достаточно знать, что весь Dart API имеет синхронную и асинхронную версию, и вам стоит использовать асинхронный API, если вы не уверены.
При открытии базы данных без указания каталога используется каталог по умолчанию. Для приложений Flutter это каталог, возвращаемый из path_provider's getApplicationSupportDirectory. Если база данных уже существует, она будет открыта, в противном случае, сначала будет создана новая.
Открыв базу данных, мы можем создавать заметки. В данном примере заметки имеют ID, заголовок и тело:
1. Future<MutableDocument> createNote({
2. required String title,
3. required String body,
4. }) async {
5. // In Couchbase Lite, data is stored in JSON like documents. The default 6. // constructor of MutableDocument creates a new document with a randomly
7. // generated id.
8. final doc = MutableDocument({
9. // Since documents of different types are all stored in the same database,
10. // it is customary to store the type of the document in the `type` field. 11. 'type': 'note',
12. 'title': title,
13. 'body': body,
14. });
15.
16. // Now save the new note in the database.
17. await database.saveDocument(doc);
18.
19. return doc;
20. }
Прежде чем выполнять запросы, использующие индекс FTS, его необходимо настроить, а также определить, какие поля документов будут индексироваться:
1. Future<void> createNoteFtsIndex() async {
2. // Existing documents will be indexed when an index is created.
3. await database.createIndex(
4. // Any existing index, with the same name, will be replaced with
5. // a new index, with the new configuration.
6. 'note-fts',
7. FullTextIndexConfiguration(
8. // We want both the title and body of the note to be indexed.
9. ['title', 'body'],
10. // By selecting the language, that is primarily used in the
11. // indexed fields, users will get better search results.
12. language: FullTextLanguage.english,
13. ),
14. );
15.}
Мы собираемся инкапсулировать каждый результат поиска в простой класс данных. Запрос должны будут получить только те поля, которые нам нужны для этого класса:
1. class NoteSearchResult {
2. NoteSearchResult({required this.id, required this.title});
3.
4. /// This method creates a NoteSearchResult from a query result.
5. static NoteSearchResult fromResult(Result result) => NoteSearchResult( 6. // The Result type has typed getters, to extract values from a result. 7. id: result.string('id')!,
8. title: result.string('title')!,
9. );
10.
11. final String id;
12. final String title;
13. }
Наконец, мы можем использовать индекс FTS в запросе:
1. Future<List<NoteSearchResult>> searchNotes(Query queryString) async { 2. // Creating a query has some overhead and if a query is executed
3. // many times, it should be created once and reused. For simplicity
4. // we don't do that here.
5. final query = await Query.fromN1ql(
6. database,
7. r'''
8. SELECT META().id AS id, title
9. FROM _
10. WHERE type = 'note' AND match(note-fts, $query)
11. ORDER BY rank(note-fts)
12. LIMIT 10
13. ''',
14. );
15.
16. // Query parameters are defined by prefixing an identifier with `$`.
17. await query.setParameters(Parameters({'query': '$queryString*'}));
18.
19. // Each time a query is executed, its results are returned in a ResultSet. 20. final resultSet = await query.execute();
21.
22. // To create the NoteSearchResults, we turn the ResultSet into a stream 23. // and collect the results into a List, after transforming them into
24. // NoteSearchResults.
25. return resultSet.asStream().map(NoteSearchResult.fromResult).toList(); 26. }
Запрос пишется на языке запросов, который называется N1QL (произносится как никель, как металл). Это как SQL для JSON. Существует также API конструктора безопасных запросов, если вы предпочитаете именно его.
Если вы знакомы с SQL, вы узнаете типичную структуру оператора SELECT. Новинкой будет функция META(). Каждый документ имеет метаданные, которые управляются базой данных и могут быть доступны через эту функцию. Уникальный идентификатор документа является частью этих метаданных. Что касается пункта FROM, просто знайте, что он вам нужен и вам нужно выбрать значение из _ .
Часть запроса, относящаяся к FTS, - это функции match и rank. Обе в качестве первого аргумента принимают имя индекса FTS, который должен быть использован. match дополнительно принимает запрос FTS в качестве второго аргумента.
Обратите внимание, что мы добавили * к queryString перед инициализацией параметров запроса, чтобы выполнить префиксное совпадение по последнему слову. При выполнении запроса каждый раз, когда пользователь набирает символ, он будет получать результаты поиска до того, как закончит вводить слово. Подробнее о поддерживаемых выражениях запроса FTS можно узнать здесь.
Функция rank возвращает ранг результата поиска по отношению ко всем результатам.
Теперь вы знаете все, что необходимо для создания полностью локального, всегда доступного полнотекстового поиска.
Я буду рад услышать ваши отзывы и ответить на любые вопросы.
Для тех, кто хочет знать больше
Если вы хотите узнать больше о том, как использовать Couchbase Lite с Dart и Flutter, ознакомьтесь с этими ресурсами:
- README пакета cbl: Подробнее о ключевых концепциях API и примерах использования.
https://twitter.com/FlutterComm
Переведено на русский язык с сайта: https://medium.com/flutter-community/how-to-make-your-flutter-app-offline-first-with-couchbase-lite-86bb23780f74