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

Мой опыт использования LiteDB

.NET *
Перевод
Автор оригинала: Ivan Iakimov

Недавно я искал систему хранения для моей программы. Она представляет собой desktop-приложение, которому нужно хранить множество объектов и осуществлять поиск текста в них. И я подумал: "Почему бы не попробовать что-то новое". Вместо SQL базы данных типа SqLite я мог бы использовать документную базу данных. Но мне хотелось бы, чтобы для неё не требовался отдельный сервер, чтобы она работала с простым файлом. Поиск в Интернет подобных систем для .NET приложений быстро вывел меня на LiteDB. Здесь я хочу поделиться тем, что я нашёл в процессе работы с этой базой данных.


Наследование

Специфика моей программы такова. Я хочу сохранять в базу простые объекты типа:

internal class Item
{
    public string Title { get; set; }

    public string Description { get; set; }

    public List<Field> Fields { get; set; } = new List<Field>();
}

Но вот класс Field у меня абстрактный, имеющий массу наследников:

internal abstract class Field
{
}

internal sealed class TextField : Field
{
    public string Text { get; set; }
}

internal sealed class PasswordField : Field
{
    public string Password { get; set; }
}

internal sealed class DescriptionField : Field
{
    public string Description { get; set; }
}

...

При работе с SQL базами данных мне приходилось настраивать сохранение различных наследников класса Field. Я полагал, что в LiteDB мне придётся писать собственный механизм BSON-сериализации, благо такая возможность предоставляется. Но LiteDB меня приятно удивил. Никаких усилий с моей стороны не потребовалось. Сохранение и восстановление различных типов происходит совершенно без моего участия. Вы создаёте нужные объекты:

var items = new Item[]
{
    new Item
    {
        Title = "item1",
        Description = "description1",
        Fields =
        {
            new TextField
            {
                Text = "text1"
            },
            new PasswordField
            {
                Password = "123"
            }
        }
    },
    new Item
    {
        Title = "item2",
        Description = "description2",
        Fields =
        {
            new TextField
            {
                Text = "text2"
            },
            new DescriptionField
            {
                Description = "description2"
            }
        }
    }
};

... и вставляете их в базу данных:

using (var db = new LiteDatabase(connectionString))
{
    var collection = db.GetCollection<Item>();

    collection.InsertBulk(items);
}

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

{
  "_id": {"$oid": "62bf12ce12a00b0f966e9afa"},
  "Title": "item1",
  "Description": "description1",
  "Fields":
  [
    {
      "_type": "LiteDBSearching.TextField, LiteDBSearching",
      "Text": "text1"
    },
    {
      "_type": "LiteDBSearching.PasswordField, LiteDBSearching",
      "Password": "123"
    }
  ]
}

Оказывается, что для каждого объекта в поле _type сохраняется его тип, что и позволяет правильно восстановить объект при чтении из базы.

Что ж, с сохранением разобрались. Давайте перейдём к чтению.

Поиск текста

Как я уже сказал, мне необходимо искать объекты Item в свойствах Title и Description которых, а так же в свойствах их полей (свойство Fields) содержится определённый текст.

С поиском внутри свойств Title и Description всё ясно. Документация содержит понятные примеры:

var items = collection.Query()
    .Where(i => i.Title.Contains("1") || i.Description.Contains("1"))
    .ToArray();

Но с поиском в полях есть проблема. Дело в том, что абстрактный класс Field не определяет никаких свойств. Поэтому я не могу на них сослаться здесь. К счастью LiteDB позволяет использовать строковую запись запросов:

var items = collection.Query()
    .Where("$.Title LIKE '%1%' OR $.Description LIKE '%1%'")
    .ToArray();

Но как с её помощью искать внутри полей? Документация даёт подсказку, что выражение должно выглядеть примерно так:

$.Title LIKE '%1%' OR $.Description LIKE '%1%' OR $.Fields[@.Text] LIKE '%1%' OR $.Fields[@.Description] LIKE '%1%' OR $.Fields[@.Password] LIKE '%1%'

Но такая запись приводит к ошибке:

Left expression `$.Fields[@.Text]` returns more than one result. Try use ANY or ALL before operant.

Действительно, использование функции ANY решает проблему:

$.Title LIKE '%1%' OR $.Description LIKE '%1%' OR ANY($.Fields[@.Text LIKE '%1%']) OR ANY($.Fields[@.Description LIKE '%1%']) OR ANY($.Fields[@.Password LIKE '%1%'])

По поводу этого выражения хочу сделать несколько замечаний. Во-первых, из него кажется, что можно в качестве фильтра использовать выражение вида:

ANY($.Fields[@.Text LIKE '%1%'])

Но это не так. Попытка отфильтровать записи по этому выражению приводит к ошибке:

Expression 'ANY($.Fields[@.Text LIKE "%1%"])' are not supported as predicate expression.

Странно, не правда ли. Оказывается нужно писать так:

ANY($.Fields[@.Text LIKE '%1%']) = true

Сразу на ум приходят 1 и 0, используемые в предикатах в SQL Server. Ну да Бог им судья.

Во-вторых, меня несколько смущала фраза Try use ANY or ALL before operant. Как-то она не согласовывалась у меня с вызовом функции. Оказывается, что LiteDB поддерживает и следующий синтаксис:

$.Fields[*].Text ANY LIKE '%1%'

К большому сожалению, он не описан в документации, я натолкнулся на него, просматривая исходных код тестов LiteDB на GitHib. Он, в отличии от функции ANY нормально работает в качестве предиката без всяких сравнений с true.

В итоге поисковое выражение можно переписать так:

$.Title LIKE '%1%' OR $.Description LIKE '%1%' OR ($.Fields[*].Text ANY LIKE '%1%') OR ($.Fields[*].Description ANY LIKE '%1%') OR ($.Fields[*].Password ANY LIKE '%1%')

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

LiteDB поддерживает атрибут BsonField, который позволяет задавать имя поля базы данных, в котором будет храниться данное свойство. Можно использовать его так:

internal sealed class TextField : Field
{
    [BsonField("TextField")]
    public string Text { get; set; }
}

internal sealed class PasswordField : Field
{
    [BsonField("TextField")]
    public string Password { get; set; }
}

internal sealed class DescriptionField : Field
{
    [BsonField("TextField")]
    public string Description { get; set; }
}

Теперь можно использовать одно поисковое выражение для любых объектов Field:

$.Title LIKE '%1%' OR $.Description LIKE '%1%' OR $.Fields[*].TextField ANY LIKE '%1%'

Добавляя нового наследника класса Field, я могу просто пометить его свойство атрибутом [BsonField("TextField")]. Тогда мне не придётся вносить никаких изменений в поисковое выражение.

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

По этой причине я пока остановлюсь на старой форме запроса:

$.Title LIKE '%1%' OR $.Description LIKE '%1%' OR ($.Fields[*].Text ANY LIKE '%1%') OR ($.Fields[*].Description ANY LIKE '%1%') OR ($.Fields[*].Password ANY LIKE '%1%')

У неё есть ещё одна проблема. Мне несколько раз пришлось писать искомую строку '%1%'. Да и SQL Injection ещё никто не отменял (хотя не уверен, применимо ли здесь использовать слово SQL). Короче говоря, я веду к тому, что неплохо было бы использовать параметры запроса. И действительно, API позволяет это делать:

Параметры запроса
Параметры запроса

Но как конкретно мне сослаться на параметр в тексте запроса? К сожалению, документация опять подвела меня. Пришлось лезть в код тестов для LiteDB и искать там, как следует использовать параметры:

var items = collection.Query()
    .Where("$.Title LIKE @0 OR $.Description LIKE @0 OR ($.Fields[*].Text ANY LIKE @0) OR ($.Fields[*].Description ANY LIKE @0) OR ($.Fields[*].Password ANY LIKE @0)", "%1%")
    .ToArray();

Что ж, с поиском разобрались. Но насколько быстро он осуществляется?

Индексы

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

Во-первых, как нам узнать, использует ли данный запрос индекс или нет. Для этого в LiteDB есть команда EXPLAIN. В LiteDB.Studio я выполню свой запрос так:

EXPLAIN
SELECT $ FROM Item
WHERE $.Title LIKE '%1%'
    OR $.Description LIKE '%1%'
    OR ($.Fields[*].Text ANY LIKE '%1%')
    OR ($.Fields[*].Description ANY LIKE '%1%')
    OR ($.Fields[*].Password ANY LIKE '%1%')

Результат выполнения этой команды содержит следующую информацию об используемом индексе:

"index":
  {
    "name": "_id",
    "expr": "$._id",
    "order": 1,
    "mode": "FULL INDEX SCAN(_id)",
    "cost": 100
  },

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

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

collection.EnsureIndex("TextIndex", "$.Fields[*].Text");

Теперь этот индекс можно использовать в поиске:

var items = collection.Query()
    .Where("$.Fields[*].Text ANY LIKE @0", "%1%")
    .ToArray();

Команда EXPLAIN в LiteDB.Studio показывает, что этот запрос действительно использует созданный нами индекс:

"index":
  {
    "name": "TextIndex",
    "expr": "MAP($.Fields[*]=>@.Text)",
    "order": 1,
    "mode": "FULL INDEX SCAN(TextIndex LIKE \"%1%\")",
    "cost": 100
  },

Но как нам объединить в один индекс все наши свойства? На помощь приходит команда CONCAT. Она объединяет в один массив несколько свойств. Вот как будет выглядеть создание полного индекса:

collection.EnsureIndex("ItemsIndex", @"CONCAT($.Title,
            CONCAT($.Description,
                CONCAT($.Fields[*].Text,
                    CONCAT($.Fields[*].Password,
                            $.Fields[*].Description
                    )
                )
            )
        )");

Чтобы искать по нему, нам придётся переписать наше поисковое выражение:

var items = collection.Query()
    .Where(
        @"CONCAT($.Title,
            CONCAT($.Description,
                CONCAT($.Fields[*].Text,
                    CONCAT($.Fields[*].Password,
                            $.Fields[*].Description
                    )
                )
            )
        ) ANY LIKE @0",
        "%1%")
    .ToArray();

Теперь наш поиск действительно использует индекс:

"index":
  {
    "name": "ItemsIndex",
    "expr": "CONCAT($.Title,CONCAT($.Description,CONCAT(MAP($.Fields[*]=>@.Text),CONCAT(MAP($.Fields[*]=>@.Password),MAP($.Fields[*]=>@.Description)))))",
    "order": 1,
    "mode": "FULL INDEX SCAN(ItemsIndex LIKE \"%3%\")",
    "cost": 100
  },

К сожалению, оператор LIKE всё равно приводит к FULL INDEX SCAN. Остаётся надеяться, что индекс всё же даёт некоторый выигрыш. Хотя, зачем нам надеяться. Мы же можем всё измерить. У нас же есть BenchmarkDotNet.

Я написал вот такой класс для проведения тестов быстродействия:

[SimpleJob(RuntimeMoniker.Net60)]
public class LiteDBSearchComparison
{
    private LiteDatabase _database;
    private ILiteCollection<Item> _collection;

    [GlobalSetup]
    public void Setup()
    {
        if (File.Exists("compare.dat"))
            File.Delete("compare.dat");

        _database = new LiteDatabase("Filename=compare.dat");

        _collection = _database.GetCollection<Item>();

        _collection.EnsureIndex("ItemIndex", @"CONCAT($.Title,
            CONCAT($.Description,
                CONCAT($.Fields[*].Text,
                    CONCAT($.Fields[*].Password,
                            $.Fields[*].Description
                    )
                )
            )
        )");

        for (int i = 0; i < 100; i++)
        {
            var item = new Item
            {
                Title = "t",
                Description = "d",
                Fields =
                {
                    new TextField { Text = "te" },
                    new PasswordField { Password = "p" },
                    new DescriptionField { Description = "de" }
                }
            };

            _collection.Insert(item);
        }
    }

    [GlobalCleanup]
    public void Cleanup()
    {
        _database.Dispose();
    }

    [Benchmark(Baseline = true)]
    public void WithoutIndex()
    {
        _ = _collection.Query()
            .Where("$.Title LIKE @0 OR $.Description LIKE @0 OR ($.Fields[*].Text ANY LIKE @0) OR ($.Fields[*].Description ANY LIKE @0) OR ($.Fields[*].Password ANY LIKE @0)",
                "%1%")
            .ToArray();
    }

    [Benchmark]
    public void WithIndex()
    {
        _ = _collection.Query()
            .Where(@"CONCAT($.Title,
                        CONCAT($.Description,
                            CONCAT($.Fields[*].Text,
                                CONCAT($.Fields[*].Password,
                                        $.Fields[*].Description
                                )
                            )
                        )
                    ) ANY LIKE @0",
                "%1%")
            .ToArray();
    }
}

Результаты, которые я получил для него, следующие:

Method

Mean

Error

StdDev

Ratio

WithoutIndex

752.7 us

14.71 us

21.56 us

1.00

WithIndex

277.5 us

4.30 us

4.02 us

0.37

Как видите, индекс действительно может давать существенное преимущество в быстродействии.

Заключение

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

Надеюсь, приведённая информация будет вам полезна. Удачи!

Теги:
Хабы:
Всего голосов 9: ↑8 и ↓1 +7
Просмотры 5.6K
Комментарии Комментарии 23