Start Debugging

Как заполнять данные с помощью UseSeeding и UseAsyncSeeding в EF Core 11

Заполняйте справочные данные правильно в EF Core 11 с помощью UseSeeding и UseAsyncSeeding: где их настраивать, когда они выполняются, какую проверку идемпотентности нельзя пропускать и почему нужно реализовать обе.

Чтобы заполнить данные в EF Core 11, настройте UseSeeding и UseAsyncSeeding на DbContextOptionsBuilder, напишите проверку существования в начале каждого callback, чтобы вставка выполнялась только при отсутствии строки, и запускайте их вызовом EnsureCreated/EnsureCreatedAsync, Migrate/MigrateAsync или dotnet ef database update. Callback’и выполняются при каждой из этих операций, даже когда ни одна миграция не была применена, поэтому именно проверка существования защищает вас от вставки дубликатов. Реализуйте и синхронную, и асинхронную перегрузку с одинаковой логикой, потому что инструментарий EF Core вызывает только синхронную. В этой статье используются .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0) и C# 14.

UseSeeding и UseAsyncSeeding появились в EF Core 9 и являются рекомендованным механизмом заполнения общего назначения в EF Core 11. Они заменяют старую привычку запихивать всё в HasData, который команда EF с тех пор переименовала в “model managed data” именно потому, что он никогда не предназначался для динамических, зависящих от базы данных данных, которые большинство приложений на самом деле хотят заполнять.

Зачем вообще существует UseSeeding

Годами ответом на вопрос “как поместить начальные данные в мою базу данных” был HasData. Он работает, но у него есть острые края, которые режут, как только ваши данные становятся чем-то иным, чем фиксированная справочная таблица. HasData встроен в модель: EF вычисляет вставки, обновления и удаления, сравнивая данные в снимке вашей миграции, поэтому ему нужен каждый первичный ключ, прописанный вручную, он не может использовать ключи, генерируемые базой данных, и любое значение, которое не детерминировано (DateTime.UtcNow, Guid.NewGuid(), хешированный пароль), заставляет модель выглядеть “изменённой” при каждой сборке. Именно этот последний случай является частым источником PendingModelChangesWarning, который застаёт людей врасплох при миграции с EF Core 6 на EF Core 11.

UseSeeding — это обычный код приложения, который выполняется на живом DbContext. Вы делаете запрос, ветвитесь, вызываете внешние API при необходимости, вызываете SaveChanges. Нет снимка модели, нет сравнения ключей, нет требования детерминизма. Это правильный инструмент всякий раз, когда ваши заполняемые данные относятся к одному из случаев: тестовые фикстуры, данные, зависящие от того, что уже есть в базе данных, большие blob’ы, которые вы не хотите фиксировать в снимках миграций, строки, ключи которых генерируются базой данных, или что угодно, требующее преобразования вроде хеширования паролей. Официальное руководство говорит прямо: UseSeeding и UseAsyncSeeding — рекомендованный способ заполнения в EF Core, а HasData теперь зарезервирован для по-настоящему статических справочных данных, таких как коды стран или почтовые индексы.

Где настраивать callback’и

Методы относятся к DbContextOptionsBuilder, поэтому они располагаются там, где вы строите свои опции. Два распространённых места — это OnConfiguring на самом контексте и регистрация AddDbContext в Program.cs.

Вот форма с OnConfiguring прямо из класса контекста:

// .NET 11, EF Core 11 (Microsoft.EntityFrameworkCore 11.0), C# 14
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    => optionsBuilder
        .UseSqlServer(connectionString)
        .UseSeeding((context, _) =>
        {
            var admin = context.Set<Role>().FirstOrDefault(r => r.Name == "Admin");
            if (admin is null)
            {
                context.Set<Role>().Add(new Role { Name = "Admin" });
                context.SaveChanges();
            }
        })
        .UseAsyncSeeding(async (context, _, cancellationToken) =>
        {
            var admin = await context.Set<Role>()
                .FirstOrDefaultAsync(r => r.Name == "Admin", cancellationToken);
            if (admin is null)
            {
                context.Set<Role>().Add(new Role { Name = "Admin" });
                await context.SaveChangesAsync(cancellationToken);
            }
        });

context, переданный в callback, — это полностью функциональный DbContext, поэтому context.Set<T>() даёт вам ту же поверхность запросов и отслеживания, которую вы используете повсюду. Отброшенный параметр _ — это bool, сообщающий, создал ли EF базу данных во время этой операции; большинство заполнителей его игнорируют.

В типичном приложении ASP.NET Core вы настраиваете то же самое при регистрации внедрения зависимостей. Обратите внимание, что сигнатуры идентичны; отличается только хост:

// Program.cs -- .NET 11, ASP.NET Core 11, EF Core 11
builder.Services.AddDbContext<AppDbContext>(options =>
    options
        .UseSqlServer(builder.Configuration.GetConnectionString("Default"))
        .UseSeeding((context, _) => SeedRoles(context))
        .UseAsyncSeeding(async (context, _, ct) => await SeedRolesAsync(context, ct)));

Вынесение тела в именованные методы (SeedRoles, SeedRolesAsync) сохраняет читаемость регистрации и даёт вам очевидное место, где живёт вся логика заполнения, что было значительной частью смысла этой возможности.

Когда callback’и действительно выполняются

Это та деталь, на которой люди спотыкаются, поэтому стоит сформулировать её точно. Callback’и заполнения вызываются в составе:

Критически важно: они выполняются при каждом вызове этих операций, даже когда не было изменений модели и не было применено ни одной миграции. Вызов Migrate() на уже актуальной базе данных всё равно запускает callback заполнения. Это сделано намеренно, и это самое важное, что нужно усвоить: фреймворк не помнит, что заполнял в прошлый раз, чтобы пропустить вас. Ваш callback отвечает за решение, есть ли что делать.

Вот почему каждый пример выше начинается с запроса. Форма всегда одна и та же: найдите строку, вставляйте только если она отсутствует. Пропустите проверку — и вы будете вставлять “Admin” при каждом запуске, вызывающем Migrate, и через неделю у вас будет таблица, полная дублирующихся ролей администратора.

Проверка идемпотентности не опциональна

Поскольку callback выполняется повторно, ваша логика заполнения должна быть идемпотентной: выполнить её один раз и выполнить десять раз должно оставлять базу данных в одном и том же состоянии. Защита FirstOrDefault/if (x is null) из примеров — это минимальная форма. Для пакета строк запросите набор, который у вас уже есть, и вставьте только разницу:

// .NET 11, EF Core 11, C# 14 -- idempotent batch seed
static void SeedRoles(DbContext context)
{
    string[] required = ["Admin", "Editor", "Viewer"];

    var existing = context.Set<Role>()
        .Where(r => required.Contains(r.Name))
        .Select(r => r.Name)
        .ToHashSet();

    var missing = required
        .Where(name => !existing.Contains(name))
        .Select(name => new Role { Name = name })
        .ToList();

    if (missing.Count > 0)
    {
        context.Set<Role>().AddRange(missing);
        context.SaveChanges();
    }
}

Один обмен с базой, чтобы прочитать, что существует, один — чтобы записать только новое, и вообще ничего не происходит, как только таблица полностью заполнена. Это последнее свойство важно: заполнитель, выполняющий SaveChanges при каждом запуске, даже вхолостую, расточителен и засоряет ваши журналы. Сначала вычислите разницу, записывайте только когда missing.Count > 0.

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

Почему нужно реализовать обе перегрузки

Заметку в документации легко пропустить и дорого проигнорировать: инструментарий EF Core в настоящее время полагается на синхронный метод UseSeeding и не заполнит данные корректно, если реализован только UseAsyncSeeding. Поэтому когда вы запускаете dotnet ef database update, срабатывает именно синхронный callback, независимо от того, насколько асинхронен код вашего приложения.

Обратное тоже верно. Если ваше приложение запускается с await context.Database.MigrateAsync() (идиоматический асинхронный запуск), этот путь вызывает UseAsyncSeeding, а не UseSeeding. Реализуйте только синхронный — и заполнение при собственном запуске вашего приложения тихо ничего не делает, пока ваше заполнение через CLI работает, или наоборот.

Безопасное правило: реализуйте оба, с идентичной логикой. Вынесите тело в общий метод, чтобы два callback’а не могли разойтись:

// .NET 11, EF Core 11, C# 14
options
    .UseSeeding((context, _) => SeedRoles(context))
    .UseAsyncSeeding((context, _, ct) => SeedRolesAsync(context, ct));

// sync and async bodies kept in lockstep
static void SeedRoles(DbContext context) { /* query, branch, SaveChanges */ }

static async Task SeedRolesAsync(DbContext context, CancellationToken ct)
{
    // same query, same branch, SaveChangesAsync(ct)
}

Сопротивляйтесь искушению реализовать один через блокирующий вызов другого (SeedRolesAsync(context, ct).GetAwaiter().GetResult() внутри синхронного callback или Task.Run вокруг синхронного тела в асинхронном). Sync-over-async приглашает к взаимным блокировкам в некоторых контекстах синхронизации, а async-over-sync просто лжёт о своей асинхронности. Напишите оба тела целиком; они короткие.

Конкурентность учтена, но только для тела заполнения

По-настоящему приятное свойство: код внутри UseSeeding и UseAsyncSeeding защищён механизмом блокировки миграций EF Core. Когда два экземпляра вашего приложения запускаются в один и тот же момент и оба вызывают Migrate, блокировка сериализует их, так что они не проскакивают оба мимо проверки существования и не делают двойную вставку. Это реальное преимущество перед самописным заполнением при запуске, где вам пришлось бы строить эту координацию самостоятельно.

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

Когда UseSeeding — неправильный выбор

UseSeeding — не молоток для каждого гвоздя. Два случая толкают вас в другое место.

Во-первых, по-настоящему статические справочные данные, которые никогда не меняются вне миграции схемы — канонический пример — таблица почтовых индексов или кодов стран ISO — по-прежнему лучше обслуживаются HasData. Они путешествуют вместе с миграцией, версионируются вместе со схемой и не требуют запроса во время выполнения при каждом запуске. Прибегайте к HasData, когда данные фиксированы, детерминированы, малы и вы согласны, чтобы ими владели миграции.

Во-вторых, заполнение, которому нужны два разных экземпляра DbContext внутри одной транзакции, нельзя чисто выразить в одном callback UseSeeding, которому передаётся один контекст. Для этого документация направляет вас обратно к обычной пользовательской логике инициализации: откройте контексты сами, выполните работу и, что особенно важно, держите её вне обычного пути запросов, чтобы не столкнуться с проблемами конкурентности и не требовать, чтобы у работающего приложения были права на изменение схемы.

// .NET 11, EF Core 11 -- custom initialization, run once at deploy time
await using var context = new AppDbContext();
await context.Database.MigrateAsync();

if (!await context.Roles.AnyAsync())
{
    context.Roles.AddRange(new Role { Name = "Admin" }, new Role { Name = "Viewer" });
    await context.SaveChangesAsync();
}

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

Собираем всё вместе

Ментальная модель коротка. UseSeeding и UseAsyncSeeding — это код приложения, который EF Core вызывает при EnsureCreated, Migrate и dotnet ef database update. Они выполняются каждый раз, поэтому ваша первая строка — всегда проверка существования, а запись происходит только для отсутствующих строк. Вы реализуете обе перегрузки, потому что инструментарий и ваш асинхронный путь запуска вызывают разные. Тело заполнения защищено блокировкой, чтобы конкурентные запуски не сталкивались. А HasData по-прежнему здесь для узкого случая статических, детерминированных, управляемых миграциями справочных данных.

Если вы подтягиваете остальную часть вашего слоя данных EF Core 11, та же забота о том, что и когда выполняется, появляется и в других местах: посмотрите, как интерсепторы EF Core 11 обрабатывают аудит в узком месте SaveChanges, когда предпочесть ExecuteUpdate вместо загрузки сущностей и вызова SaveChanges для массовых записей и почему AsNoTracking против AsNoTrackingWithIdentityResolution важно для запросов с большим количеством чтений. Если ваша вставка при заполнении когда-нибудь споткнётся о тип сущности требует определения первичного ключа, это проблема моделирования, которую нужно исправить, прежде чем заполнитель сможет выполниться.

Источники: документация по заполнению данных EF Core на Microsoft Learn охватывает API UseSeeding/UseAsyncSeeding, время выполнения, требование обеих перегрузок и гарантию блокировки миграций; справочник API DbContextOptionsBuilder.UseSeeding документирует точные сигнатуры.

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад