Сравнение 2х нишевых библиотек для написания миграций в монго

от автора

В работе веб-разработчика (в частности бекенд-разработчика) встречается много разных интересных и уникальных задач. Не так много, как в работе игрового программиста, но всё же. В этой статье речь пойдёт о такой теме как написание миграций документно-ориентированной БД mongo. Как и в любой задаче у нас имеется несколько вариантов решения проблемы. Мы подробно разберём примеры использования 2х разных c#-библиотек, не углубляясь в детали реализации. Посмотрим их плюсы и минусы и выберем 1 из них для выполнения поставленной задачи. В конце нас ждёт небольшое сравнение производительности, так что пристегнитесь, ведь будет интересно.

Ведение

Если вы уже занимались написанием миграций для sql баз данных, то вы уже знаете, что это довольно просто делается при помощи ef core. Возьмём к примеру postgresql, там невозможно положить в БД запись не соответствующую схеме. Условно, если ваша модель содержит 3 поля данных: Guid Id, string Name & int Age, то записать данные { Guid Id, string Name, int Age, string NameWithAge } у вас не получится, ведь в таблице нету столбца, соответствующего новому полю NameWithAge. Чтобы этот столбец появился, разработчики пишут миграции, которые мигрируют имеющуюся на боевой базе схему на новую. Старые данные не теряются, при этом появляется возможность дополнять базу новыми данными с обновлённой схемой.

Для сохранения информации о текущей версии схемы достаточно отдельной таблицы. Примерно так работает ef core, сохраняя информацию о всех применённых миграциях в таблице __EFMigrationsHistory. Если была применена миграция, то средства БД отследят, чтобы все записи были в формате обновлённой схемы. Теперь вы не найдёте ни одну запись, в которой не было бы добавленных данных NameWithAge, а БД в свою очередь не позволит добавлять новые записи без поля NameWithAge.

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

[     {         "id": 1,         "age": 47     },     {         "id": 1,         "name": "name"     } ] // Каждый документ хранит в себе свои данные. 

А MongoDb документ может содержать в себе даже другой документ. Средства БД не отслеживают, чтобы все записи соответствовали одной схеме (см. сниппет выше). Такой подход имеет свои плюсы и недостатки.

Например, если у вас добавляется 1 новое поле, то миграции писать не нужно. Ведь драйвер монги умеет автоматически выполнять маппинг документа в поля c#-класса:

BsonClassMap.RegisterClassMap<SimpleDataModel>(cm =>         {             cm.MapProperty(x => x.Id);             cm.MapProperty(x => x.Name);             cm.MapProperty(x => x.Age);             cm.MapProperty(x => x.NameWithAge).SetDefaultValue(string.Empty);         });

В примере выше мы настроили маппинг таким образом, что если поля NameWithAge в документе нету, то оно добавляется со значением пустой строки. Это удобно до тех пор, пока не возникает необходимость выполнять более сложные действия. Представим например ситуацию, когда значение нового добавляемого поля должно быть аггрегатом других значений из документа. Пусть NameWithAge будет всегда равен конкатенации строки с именем и int-32 значением поля Age.

Решить эту проблему можно несколькими способами. Например, можно руками выполнить update-операции над боевой БД, чтобы поле добавилось и действительно равнялось конкатенации 2х других полей.

db.SimpleDataModels.updateMany( // SimpleDataModels - название коллекции     {}, // Фильтр для всех документов     [{         $set: {             "NameWithAge": {                 $concat: [                     "$Name",                     { $toString: "$Age" }                 ]             }         }     }] )

Это примерно то же самое, как если бы мы, пользуясь ef core, не доверяли бы механизму миграций, а сами бы писали sql-скрипты под каждое изменение схемы. Не очень удобно.

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

В отличие от ef core у нас есть проблема. При использовании mongoDb приложение не сможет понять, какая текущая версия у схемы БД, чтобы начать применять миграции для обновления этой версии. Ведь средства mongo не контролируют схемы данных. Иначе говоря, мы не можем доверять данным о каждой применённой миграции из __MigrationsHistory, потому что в коллекции документов могут храниться как документы с обновлённой схемой, так и документы старой схемы. В большинстве случаев поступают так: хранят версию каждого документа в самом документе.

[     {         "id": 1,         "name": "имя",         "age": 47,         "version": "0.0.0"     },     {         "id": 2,         "name": "другое имя",         "age": 105,         "nameWithAge": "другое имя105"         "version": "0.0.1"     } ]

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

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

perlem-ai/mongo-migrations

Источник: https://github.com/perlem-ai/mongo-migrations

Что это?

Представляет собой hand-write библиотеку по написанию миграций для mongo. Поддерживает replica-set, поддерживает возможность выполнять операции конкурентно, написана нашими слонами из Уфы или Пензы, была представлена на одном из докладов, касающихся как раз таки миграций в mongo.

Она не поставляется как nuget-пакет, поэтому, чтобы ею воспользоваться вам надо будет скопипастить её код в своё приложение (см. https://github.com/Prikalel/mongo-migrations-comparasion/tree/perlem-ai/mongo-migrations/Source). Библиотека разбита на 2 отдельных csproj-проекта:

  • Mongo.Migrations — проект с непосредственно реализацией

  • Mongo.Migrations.Versioning — только интерфейсы для версионирования ваших доменных моделей

Таким образом, если вы, как и разработчики данной библиотеки, в своём приложении придерживаетесь слоёной/слоистой архитектуры, то:

  • Ваш слой инфрастуктуры зависит от Mongo.Driver и Mongo.Migrations

  • Ваш слой доменных моделей зависит только от Mongo.Migrations.Versioning

Внедрение

Чтобы начать пользоваться этой библиотекой, вам нужно будет зарегистрировать пару вещей в вашем Di-контейнере. Это достигается 3мя шагами:

  1. Написать реализацию IDocumentSettingsProvider

  2. Написать реализацию ICollectionNameResolver

  3. Зарегистрировать сервисы.

IDocumentSettingsProvider — это интерфейс, благодаря которому библиотека получает информацию о том, какое поле отвечает за идентификацию документа в коллекции.

namespace VelikiyPrikalel.SIMPLE.Web.MigrationStuff;  /// <inheritdoc /> public class DefaultDocumentSettingsProvider : IDocumentSettingsProvider {     /// <inheritdoc />     public DocumentSettings? Get<TDocument>() =>         null; }

Достаточно возвращать null для настроек по умолчанию (за идентификацию документа отвечает поле _id).

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

namespace VelikiyPrikalel.SIMPLE.Web.MigrationStuff;  /// <inheritdoc /> public class SimpleCollectionNameResolver : ICollectionNameResolver {     private static readonly IDictionary<Type, string> collectionNames = new Dictionary<Type, string>     {         { typeof(SimpleDataModel), "SimpleDataModels" }         /// добавить новые если появятся     };      private readonly ILogger<SimpleCollectionNameResolver> logger;      /// <summary>     /// ctor.     /// </summary>     /// <param name="logger">Логгер.</param>     public SimpleCollectionNameResolver(ILogger<SimpleCollectionNameResolver> logger)     {         this.logger = logger;     }      /// <inheritdoc />     public string GetName(Type type)     {         logger.LogTrace("Getting collection name for {type}", type.FullName);         return collectionNames[type];     } }

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

Последним шагом для нас будет регистрация наших реализаций, как и сервисов библиотеки, в Di:

namespace VelikiyPrikalel.SIMPLE.Web.MigrationStuff;  /// <summary> /// Методы расширения <see cref="IServiceCollection"/>. /// </summary> public static class ServiceCollectionExtensions {     /// <summary>     /// Зарегистрировать миграции для монго в приложении.     /// </summary>     /// <param name="services"><see cref="IServiceCollection"/>.</param>     public static void AddMigrations(this IServiceCollection services) =>         services.AddMongoBackgroundJobMigrations<SimpleCollectionNameResolver, DefaultDocumentSettingsProvider>(backgroundMigrationsSettings =>         {             backgroundMigrationsSettings.ShutdownMode = ServiceShutdownMode.Continue;         }); }

Здесь можно указать, что делать после выполнения миграций — продолжать логику приложения, или завершить работу. Эти настройки при желании можно вынести в appsettings.

Фиксация нулевой версии документа

Чтобы сказать библиотеке, что мы намерены выполнять миграции над коллекцией mongo, выполняем следующие действия:

using VelikiyPrikalel.Mongo.Migrations.Versioning; // импортируем зависимость от проекта Mongo.Migrations.Versioning  namespace VelikiyPrikalel.SIMPLE.Data;  // слой доменных моделей   [BsonIgnoreExtraElements] public class SimpleDataModel : IVersioned // Добавляем интерфейс IVersioned {     [BsonId]     public Guid Id { get; set; }      public required string Name { get; set; }      public int Age { get; set; }      [BsonElement(PropertyNames.Version)] // Говорим драйверу, что информация о версии документа хранится в поле _migration_version     public DataVersion MigrationVersion { get; set; } = new(0, 0, 0); // фиксируем нулевую версию документа } 
  1. Импортируем зависимость от проекта Mongo.Migrations.Versioning библиотеки

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

  3. Добавляем новое поле MigrationVersion, сразу говорим драйверу, чтобы он маппил его в БД с конкретным именем, которое будет использовано в библиотеке.

  4. Устанавливаем значение поля по умолчанию.

Добавление миграции

Во-первых добавляем новое поле, или удаляем старое. Одним словом, обновляем нашу модель данных. Во-вторых, обновляем версию документа по умолчанию.

[BsonIgnoreExtraElements] public class SimpleDataModel : IVersioned {     [BsonId]     public Guid Id { get; set; }      public required string Name { get; set; }      public int Age { get; set; }      public required string NameWithAge { get; set; } // добавили новое поле      [BsonElement(PropertyNames.Version)]     public DataVersion MigrationVersion { get; set; } = new(0, 0, 1); /// подняли версию }

И пишем саму миграцию:

using MongoDB.Bson; using VelikiyPrikalel.Mongo.Migrations.Migrations.Document; using VelikiyPrikalel.Mongo.Migrations.Versioning;  namespace VelikiyPrikalel.SIMPLE.Web.MigrationStuff;  public class AddNewComposeField : DocumentBsonMigration<SimpleDataModel> {     public override DataVersion GetVersion() => new(0, 0, 1);      public override BsonDocument Up(BsonDocument document)     {         var name = document.GetValue("Name").AsString;         var age = document.GetValue("Age").AsInt32;         document.Set("NameWithAge", name + age.ToString());         return document;     }      public override BsonDocument Down(BsonDocument document)     {         return document;     } }
  • Наследуемся от абстрактного класса DocumentBsonMigration

  • В методе GetVersion() говорим, миграцию на какую версию мы хотим написать

  • В методе Up() пишем код миграции.

Под капотом библиотека выполнит все миграции в фоновом сервисе BackgroundMigrationService. В отличие от ef core, здесь мы можем заинджектить любые сервисы (например, логирование или маппинг).

Метод Down() я оставил без реализации, так как в текущей версии библиотеки неясно, как выполнять откат и возможно ли это делать вообще.

После запуска приложения, все документы, сколько бы у вас их в коллекции не было, будут переведены на новую версию:

Скриншот из интерфейса MongoDbCompass. Как видно, появилось новое поле и сохранилась версия документа. Это было выполнено для каждого из 10к документов в коллекции.

Скриншот из интерфейса MongoDbCompass. Как видно, появилось новое поле и сохранилась версия документа. Это было выполнено для каждого из 10к документов в коллекции.

SRoddis/Mongo.Migration

Источник: https://github.com/SRoddis/Mongo.Migration

Что это?

Библиотека, поставляемая в виде Nuget-пакета. Умеет в runtime-миграции, что называется on-fly. Это когда миграции над документом выполняются не при старте приложения, а при обращении к нему. Достигается это при помощи специального Interceptor-а.

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

Внедрение

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

using Mongo.Migration.Startup.DotNetCore;  namespace VelikiyPrikalel.SIMPLE.Infrastructure;  /// <summary> /// Extension методы <see cref="IServiceCollection"/>. /// </summary> public static class ServiceCollectionExtensions {     /// <summary>     /// Регистрирует конфиги инфраструктуры.     /// </summary>     /// <param name="services"><see cref="IServiceCollection"/>.</param>     /// <param name="configuration">><see cref="IConfiguration"/>.</param>     public static void RegisterInfrastructure(this IServiceCollection services, IConfiguration configuration)     {         services.AddSingleton<IMongoContext, MongoDbContext>();         services.AddSingleton<ISimpleDataModelRepository, SimpleDataModelRepository>();         services.RegisterHangfire(configuration);          var client = new MongoClient(configuration.GetConnectionString("SIMPLE")); // регистрируем mongo клиент         services.AddSingleton<IMongoClient>(client);         services.AddMigration(); // добавляем миграции     } }

Вся магия происходит на 21-23 строчках. С классом MongoClient знакомы все те, кто хоть раз пользовался Mongo.Driver-ом. От нас лишь требуется зарегистрировать его как singleton, а затем добавить сервисы библиотечки.

На github-странице библиотеки заведён issue, который касается запуска миграций с AspNetCore. Поэтому я добавил непосредственно доставание интерфейса библиотеки из serviceProvider и запустил миграции (см. https://github.com/Prikalel/mongo-migrations-comparasion/blob/d8ca95d32065cea86024c80eeb3b6de538f6c2c8/Source/SIMPLE.Web/Program.cs#L74C5-L78C6):

/// файл Programm.cs ...      public static async Task Main(string[] args)     {         Logger logger = NLogBuilder             .ConfigureNLog("nlog.config")             .GetCurrentClassLogger();          try         {             logger.Debug("Starting up webhost.");             IHost build = CreateWebHostBuilder(args)                 .Build();             ForceMigrationExecution(build); // Форсированный запуск миграций             await build                 .RunAsync();         }         catch (Exception ex)         {             logger.Error(ex, "Stopped service due to exception");             throw;         }         finally         {             LogManager.Shutdown();         }     }      private static void ForceMigrationExecution(IHost build)     {         var migration = build.Services.GetRequiredService<IMongoMigration>();         migration.Run();     }

Фиксация нулевой версии документа

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

using Mongo.Migration.Documents; using Mongo.Migration.Documents.Attributes;  // импортируем библиотеку  namespace VelikiyPrikalel.SIMPLE.Data;  [BsonIgnoreExtraElements] [StartUpVersion("0.0.0")] // добавляем аттрибут с указанием текущей версии [CollectionLocation("SimpleDataModels", "SIMPLE")]  // Говорим название коллекции и название БД public class SimpleDataModel : IDocument {     [BsonId]     public Guid Id { get; set; }      public required string Name { get; set; }      public int Age { get; set; }      public DocumentVersion Version { get; set; } // Добавляем поле где будет храниться версия документа } 
  1. StartUpVersion — это аттрибут, который говорит библиотеке, что конкретно эту модель нужно мигрировать при старте приложения, а не в runtme.

  2. CollectionLocation — альтернатива ICollectionNameResolver из предыдущей библиотеки.

  3. DocumentVersion Version { get; set; } — здесь будет храниться версия документа.

Добавление миграций

Чтобы написать нашу первую миграцию первым делом обновляем нашу модель.

  1. Добавляем поле NameWithAge, или делаем другие изменения, относящиеся к обновлению схемы

  2. Повышаем версию в аттрибуте.

После чего пишем саму миграцию.

using Mongo.Migration.Migrations.Document; using MongoDB.Bson;  namespace VelikiyPrikalel.SIMPLE.Infrastructure.Migrations;  public class M001_AddNewField : DocumentMigration<SimpleDataModel> {     public M001_AddNewField()         : base("0.0.1") // на какую версию мигрируем     {     }      public override void Up(BsonDocument document)     {         var name = document.GetValue("Name").AsString;         var age = document.GetValue("Age").AsInt32;         document.Add("NameWithAge", name + age.ToString());     }      public override void Down(BsonDocument document)     {         document.Remove("NameWithAge");     } }

Стоит отметить, что здесь, как и в предыдущем случае, мы оперируем над классом BsonDocument, который находится в namespace-е MongoDB.Bson. У этого класса, как вы могли заметить, есть удобные средства по добавлению и удалению полей, а также для извлечения из него данных разных типов (коллекции/primary типы данных/другие документы для случая, когда документ вложен в другой документ). Вокруг него построен удобный синтаксический сахар для типизации (AsString/AsInt32 и проч, методов очень много).

Отмечу, что здесь также можно заинджектить любые сервисы, а саму миграцию M001_AddNewField в servicesCollection добавлять не надо, так как библиотека это сама за вас сделает. То есть вот подобная строка будет лишней services.AddSingleton<M001_AddNewField>().

Я реализовал метод Down, так как в отличие от предыдущей библиотеки, в этой мы можем выполнить откат и это даже будет работать.

После применения миграции документы в БД обновятся. Это скриншот из MongoDbCompass.

После применения миграции документы в БД обновятся. Это скриншот из MongoDbCompass.

Сравнение

Что ж, мы посмотрели 2 примера реализации фунеционала написания миграций для БД mongo. С полным кодом вы можете ознакомиться по ссылке: https://github.com/Prikalel/mongo-migrations-comparasion. Там доступно 3 ветки: master (без миграций, простое web-приложение которое достаёт модельки из mongo и выдаёт пользователю по api без какой-либо авторизации) и 2 ветки каждая соответствует своей библиотечке.

Поддержка версий Mongo.Driver

Интуитивность api

Обработка ошибок

perlem-ai/mongo-migrations

Текущая версия 2.22.0. Если вам надо новее, то обновляете сами. Последний коммит 2 года назад для момента публикации статьи.

Интуитивно 8/10

Слабая/нету. Откат миграций невозможен.

SRoddis/Mongo.Migration

Версия 2.26.0. Не поддерживает 3.0. Сообществом открыт pull requst для обновления версии используемого драйвера. Последний коммит 2 года назад для момента публикации статьи.

10/10

Есть откат миграций.

Как видно, библиотека SRoddis-а лучше hand-write решения во всём. Там можно писать runtime-миграции, можно откатывать документы до предыдущей версии (достаточно в аннотации к доменной модели понизить версию), есть поддержка сообщества. Есть открытые pull-request-ы, откуда можно взять новые фичи для самой свежей версии драйвера Mongo.

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

При необходимости обе библиотеки позволяют вам писать миграции не только по обновлению конкретных документов но и по обновлению всей БД (на случай, если данные для одного документа хранятся в другой коллекции или, например, коллекция изменила название). На самом деле, очень удобно. Идея с использованием аннотаций выглядит удобнее идеи с ручной имплементацией интерфейсов.

Производительность

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

Сравнение производительности 2х библиотек

Сравнение производительности 2х библиотек

Произведём анализ полученных результатов:

  • После добавления миграций старт приложения будет занимать немного больше времени. Для первой библиотеки perlem-ai/mongo-migrations задержка будет 1/10 (но она выполняется в фоновом backgroundService, по сути в отдельном потоке) секунды. Для SRoddis/Mongo.Migration чуть чуть побольше.

  • После добавления первой миграции на её выполнение первая библиотечка perlem-ai/mongo-migrations затратила 3.5 секунды, в то время как вторая SRoddis/Mongo.Migration не изменила задержку. Причина этому — разная реализация алгоритма. Если в первом случае формируется огроменный список с ReplaceOne-структурами после чего выполняется bulkWrite, то во втором случае используется cursor с последовательным проходом по нему и применениям изменений.

  • После применения миграций обе библиотечки не меняют скорости доставания данных из БД. Возможно, для кого-то это было очевидно, но эти замеры были добавлены по той причине, что вторая библиотека SRoddis/Mongo.Migration, как уже говорилось ранее, регистрирует свой interceptor для runtime-преобразований. Так как в примере runtime-преобразования задействованы не были, а были испробованы StartUp-миграции, то и значительных изменений в производительности запросов не произошли.

Код для сравнительного анализа также доступен в виде python-скриптов в репозитории https://github.com/Prikalel/mongo-migrations-comparasion

  • Скрипт по наполнению БД через post-api запросы к серверу развёрнотому на Localhost

  • Скрипт для выполнения нагрузочных тестов

  • openapi написанного web-приложения

  • data.json — экспортированные данные 10к документов, которые вы можете быстро импортировать в свою коллекцию через интерфейс MongoDbCompass.

Таким образом, при желании, вы можете повторить те же эксперименты у себя.

Результат

Если вы хотите удобный c#-инструмент по написанию миграций в mongo не пользуйтесь велосипедами, а пользуйтесь готовыми решениями.

Полезные ссылки


Всем спасибо за внимание.


ссылка на оригинал статьи https://habr.com/ru/articles/897840/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *