Что такое скомпилированная модель в EF Core 11 и когда стоит её включать
Скомпилированная модель - это сгенерированный C#-код, который воссоздаёт вашу модель EF Core без запуска OnModelCreating. Замеры на EF Core 11 RC1: загрузка модели сокращается с 714 мс до 215 мс при 500 сущностях, но путь через MSBuild (заменивший EFOptimizeContext) медленнее, устаревшие модели ломаются молча, а фильтры запросов блокируют генерацию.
Короткий ответ: скомпилированная модель - это исходный код на C#, который генерирует dotnet ef dbcontext optimize (или MSBuild-пакет Microsoft.EntityFrameworkCore.Tasks). Он строит вашу модель EF Core напрямую, вместо того чтобы при первом использовании DbContext запускать конвенции и OnModelCreating. Включать её стоит, когда первый запрос в свежем процессе стоит вам реальных денег или задержки (serverless, автомасштабируемые контейнеры, CLI-утилиты, десктопные приложения) и в модели порядка 100 типов сущностей или больше, а также при публикации с Native AOT, где она обязательна. В EF Core 11 свойство EFOptimizeContext удалено: включение теперь делается через EFScaffoldModelStage. Для веб-API на 20 таблиц, который запускается раз в неделю, поддержка скомпилированной модели того не стоит.
Все числа и сообщения об ошибках ниже получены на Microsoft.EntityFrameworkCore.Sqlite 11.0.0-rc.1.26425.128 и dotnet-ef 11.0.0-rc.1.26425.128 с .NET 11 RC1 SDK (11.0.100-rc.1.26425.128), в Release-сборках, на MacBook с процессором Apple silicon. Одна перекрёстная проверка выполнена на EF Core 10.0.12 с SDK 10.0.302.
Что делает EF Core при первом использовании DbContext
Создание DbContext обходится дёшево. Дорогая часть происходит при первом обращении к DbContext.Model: запрос, Add, SaveChanges или чтение Model напрямую. В этот момент EF Core прогоняет конвейер конвенций по каждому достижимому типу сущности, находит свойства и связи через рефлексию, применяет вашу конфигурацию из OnModelCreating, проверяет результат и затем преобразует изменяемую модель в RuntimeModel только для чтения. Результат кешируется для каждого типа контекста (точнее, для каждого ключа кеша модели) на всё время жизни процесса, так что платить приходится один раз на процесс.
Скомпилированная модель пропускает этот конвейер. Вместо обнаружения модели EF Core создаёт экземпляры сгенерированных классов, которые вызывают RuntimeModel.AddEntityType, AddProperty, AddKey, AddForeignKey и так далее с уже готовыми ответами. Вот фрагмент того, что dotnet ef dbcontext optimize выдал для одной сущности в моём тестовом проекте:
// <auto-generated /> by dotnet-ef 11.0.0-rc.1.26425.128
var runtimeEntityType = model.AddEntityType(
"Bench.Entity1",
typeof(Entity1),
baseEntityType,
propertyCount: 8,
navigationCount: 1,
foreignKeyCount: 1,
unnamedIndexCount: 2,
keyCount: 1);
var name = runtimeEntityType.AddProperty(
"Name",
typeof(string),
propertyInfo: typeof(Entity1).GetProperty("Name", BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly),
fieldInfo: typeof(Entity1).GetField("<Name>k__BackingField", BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.DeclaredOnly),
maxLength: 200);
Значение maxLength: 200 пришло из HasMaxLength(200) в OnModelCreating. Эта конфигурация теперь зашита в сгенерированный код. В этом весь смысл подхода, и это же источник всех подводных камней, о которых речь пойдёт дальше.
Команда записывает по одному файлу <Entity>EntityType.cs на каждый тип сущности и ещё три файла: <Context>Model.cs (наследник RuntimeModel со статическим Instance), <Context>ModelBuilder.cs и <Context>AssemblyAttributes.cs. Последний содержит строку, благодаря которой EF Core находит модель без каких-либо изменений в вашем коде:
[assembly: DbContextModel(typeof(BenchContext), typeof(BenchContextModel), ProviderName = "Microsoft.EntityFrameworkCore.Sqlite")]
Начиная с EF Core 9, скомпилированная модель в той же сборке, что и контекст, обнаруживается автоматически через этот атрибут. Вызов optionsBuilder.UseModel(BenchContextModel.Instance) нужен только тогда, когда скомпилированная модель лежит в другой сборке или когда вы хотите выбирать между несколькими скомпилированными моделями во время выполнения.
Замеряем, что это даёт
Страница Microsoft Learn о скомпилированных моделях говорит, что они помогают “applications with large models”, то есть “hundreds to thousands of entity types”. Для принятия решения это слишком расплывчато, поэтому я замерил сам. Python-скрипт сгенерировал модели с 10, 100 и 500 типами сущностей. У каждой сущности по восемь скалярных свойств, допускающий null внешний ключ на предыдущую сущность, уникальный индекс и вызов HasMaxLength, чтобы у конвенций была реальная работа. Программа измеряет время первого обращения к модели и первого запроса в свежем процессе:
// .NET 11, C# 14, EF Core 11.0.0-rc.1.26425.128, Microsoft.EntityFrameworkCore.Sqlite
using System.Diagnostics;
using Bench;
using Microsoft.EntityFrameworkCore;
var sw = Stopwatch.StartNew();
using var ctx = new BenchContext();
var model = ctx.Model; // first touch: build or load the model
var modelMs = sw.Elapsed.TotalMilliseconds;
var n = ctx.Set<Entity1>().Where(e => e.IsActive).Count(); // first query
var firstQueryMs = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($"{model.GetType().Name},{model.GetEntityTypes().Count()}," +
$"model={modelMs:F0}ms,firstQuery={firstQueryMs:F0}ms," +
$"OnModelCreating={BenchContext.ModelCreatingCalls}");
BenchContext.ModelCreatingCalls - статический счётчик, который увеличивается внутри OnModelCreating, так что каждый запуск доказывает, каким путём он пошёл. База данных - заранее созданный файл SQLite. В таблице медианы по семи холодным запускам процесса на каждую строку:
| Типов сущностей | Загрузка модели, построена во время выполнения | Загрузка модели, скомпилированной | Первый запрос, во время выполнения | Первый запрос, скомпилированная | Размер сборки, во время выполнения / скомпилированная |
|---|---|---|---|---|---|
| 10 | 185 ms | 65 ms | 277 ms | 171 ms | 23 KB / 43 KB |
| 100 | 280 ms | 92 ms | 383 ms | 213 ms | 158 KB / 342 KB |
| 500 | 714 ms | 215 ms | 868 ms | 401 ms | 888 KB / 1.8 MB |
Выделяются две вещи. Во-первых, выигрыш реален уже при 10 типах сущностей, около 110 мс: заметная доля затрат во время выполнения приходится на JIT-компиляцию самого конвейера конвенций, а не только на его прогон для каждой сущности. Во-вторых, выигрыш масштабируется: при 500 типах сущностей скомпилированная модель экономит полсекунды на каждом холодном старте. OnModelCreating вызывался ноль раз в каждом запуске со скомпилированной моделью и один раз в каждом запуске без неё.
Важны ли 110 мс - вопрос к продукту. Для приложения ASP.NET Core за балансировщиком нагрузки, которое прогревается до приёма трафика, это неважно. Это важно для AWS Lambda или тарифа Azure Functions Consumption, где холодный старт виден пользователю, для dotnet tool, который работает две секунды, и для десктопного или MAUI-приложения, где первый экран ждёт запроса.
Куда делся EFOptimizeContext в EF Core 11
В EF Core 9 появилась интеграция с MSBuild в пакете Microsoft.EntityFrameworkCore.Tasks, которая заново генерирует скомпилированную модель во время сборки или публикации, так что она не может разойтись с кодом. В EF Core 9 и 10 её включали через EFOptimizeContext=true, а затем выбирали этап через EFScaffoldModelStage и EFPrecompileQueriesStage.
EF Core 11 удалил EFOptimizeContext (dotnet/efcore#35079). Два свойства этапов теперь работают сами по себе, а установка старого свойства ломает сборку. При PublishAot, равном true, генерация во время публикации включена по умолчанию. Без AOT вот эквивалент прежнего явного включения в EF Core 11:
<!-- .NET 11, EF Core 11.0.0-rc.1.26425.128 -->
<PropertyGroup>
<EFScaffoldModelStage>build</EFScaffoldModelStage>
<EFPrecompileQueriesStage>none</EFPrecompileQueriesStage>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128" PrivateAssets="all" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tasks" Version="11.0.0-rc.1.26425.128" PrivateAssets="all" />
</ItemGroup>
Сгенерированные файлы попадают в obj/<Configuration>/<TFM>/ как *.g.cs и добавляются в компиляцию, так что в систему контроля версий ничего не попадает. Допустимые значения этапа: build, publish и любое другое (по соглашению none) для отключения. Справочник по задачам MSBuild перечисляет DbContextName, EFTargetNamespace, EFOutputDir и EFNullable для более тонкой настройки. Если вы обновили проект со старым свойством и сборка взорвалась, эту ошибку и предшествовавшую ей ошибку исчерпания памяти разбирает статья про PublishAot и EFOptimizeContext.
Так стоит ли включать путь через MSBuild? Для Native AOT да: скомпилированная модель и предкомпилированные запросы вам всё равно нужны, а их перегенерация при публикации - самый надёжный способ держать их синхронными. Для обычного JIT-приложения мои замеры говорят нет, по двум причинам.
Путь через MSBuild генерирует более медленную модель в стиле AOT
Документация MSBuild отмечает, что интеграция “will always generate additional code in the compiled model that’s required for NativeAOT”. На практике это означает дополнительный файл <Entity>UnsafeAccessors.g.cs на каждый тип сущности: 203 сгенерированных файла для моей модели из 100 сущностей вместо 103. Этот код не бесплатен на старте. Та же модель на 100 сущностей, та же машина:
| Источник скомпилированной модели | Загрузка модели | Первый запрос |
|---|---|---|
dotnet ef dbcontext optimize | 92 ms | 213 ms |
dotnet ef dbcontext optimize --nativeaot | 231 ms | 394 ms |
EFScaffoldModelStage=build | 235 ms | 393 ms |
| Без скомпилированной модели | 280 ms | 383 ms |
Модель, сгенерированная через MSBuild, загружается всего примерно на 45 мс быстрее, чем вообще без скомпилированной модели, а первый запрос быстрее не стал. Результат обычного CLI загружается в 2.5 раза быстрее. Если вы не публикуете с AOT, код для NativeAOT - чистые накладные расходы.
Чистая сборка падает, а повтор молча пропускает генерацию
Вторая причина - проблема порядка сборки, с которой я столкнулся и на 11.0.0-rc.1, и на 10.0.12. Пакет Tasks подключает генерацию к TargetsTriggeredByCompilation, который выполняется сразу после CoreCompile, но задача OptimizeDbContext загружает сборку из bin/, а эта папка заполняется позже в процессе сборки. Из чистой копии репозитория, без папки bin, первая сборка падает:
Optimizing DbContext...
Microsoft.EntityFrameworkCore.Tasks.targets(105,5): error : File '.../bin/Debug/net11.0/Bench.dll' not found.
Build FAILED.
Повторный dotnet build затем сообщает Build succeeded, потому что шаг компиляции актуален и генерация пропускается. Получившееся приложение работает без скомпилированной модели: мой тест вывел RuntimeModel и OnModelCreating=1. Только после изменения исходного файла следующая сборка вывела Optimizing DbContext... и создала BenchContextModel. В CI, где каждая сборка начинается с нуля, это красная сборка при каждом запуске. На момент написания я не нашёл в dotnet/efcore задачи, которая отслеживала бы эту проблему, так что проверьте заметки о выпуске, когда выйдет 11.0 GA.
Путь через CLI: сгенерировать один раз и закоммитить
Для JIT-приложения лучше прежняя схема: запустить CLI, закоммитить результат и перегенерировать при изменении модели.
# .NET 11 SDK, dotnet-ef 11.0.0-rc.1.26425.128
dotnet tool install --global dotnet-ef --version 11.0.0-rc.1.26425.128
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels
На уже собранном проекте генерация заняла от 1.3 до 2.3 секунды для моих трёх размеров модели. Стоит знать две детали: проект должен быть предварительно восстановлен (иначе будет Unable to retrieve project metadata), а если контекст настраивается в другом стартовом проекте, передайте --startup-project или добавьте IDesignTimeDbContextFactory<T>.
Риск пути через CLI - расхождение (drift), и оно хуже, чем можно подумать по документации. В статье о прогреве от апреля говорилось, что EF Core обнаруживает устаревшую скомпилированную модель и выбрасывает исключение. Проверка на EF Core 11 RC1 показывает обратное. Я сгенерировал скомпилированную модель, затем добавил свойство Sku в одну сущность и переименовал столбец через HasColumnName("DisplayName"), не перегенерируя модель:
// .NET 11, EF Core 11.0.0-rc.1.26425.128, compiled model generated BEFORE these changes
Console.WriteLine(ctx.Set<Entity2>().Select(e => e.Name).ToQueryString());
// With the stale compiled model: SELECT "t"."Name" FROM "T2" AS "t"
// Without any compiled model: SELECT "t"."DisplayName" FROM "T2" AS "t"
Ни исключения, ни предупреждения: устаревшая модель сгенерировала SQL со старым именем столбца. Новое свойство Sku действительно вызвало ошибку, но только когда запрос его использовал, и с общим сообщением “The LINQ expression could not be translated … Translation of member ‘Sku’ on entity type ‘Entity0’ failed”, которое указывает совсем не на настоящую причину.
Решение - сделать расхождение падением CI. Генератор детерминирован, кроме одной строки: GUID modelId в <Context>ModelBuilder.cs, который меняется при каждом запуске. Git может игнорировать эту строку:
# .NET 11 SDK, dotnet-ef 11.0.0-rc.1.26425.128, git 2.30+
dotnet ef dbcontext optimize --output-dir CompiledModels --namespace MyApp.CompiledModels
git diff --exit-code -I 'modelId:' -- CompiledModels
Если кто-то изменил модель, не перегенерировав её, diff будет непустым и задача упадёт. Это та же идея, что и проверка на неприменённые миграции, и она укладывается в тот же шаг CI.
Чего скомпилированная модель не умеет
На странице Learn есть список ограничений, но он частично устарел. Проверено на EF Core 11 RC1:
- Глобальные фильтры запросов полностью блокируют генерацию. Достаточно одного
HasQueryFilterв любом месте модели, иdotnet ef dbcontext optimizeостанавливается с сообщениемThe entity type 'Entity1' has a query filter configured. Compiled model can't be generated, because query filters are not supported.Задача для отслеживания, dotnet/efcore#24897, всё ещё открыта в бэклоге. Если вы используете мягкое удаление или мультитенантность через фильтры запросов, скомпилированные модели сегодня исключены. - Модель нужно перегенерировать вручную (путь через CLI) при любом изменении сущности, вызова конфигурации или конвенции. См. раздел про расхождение выше.
- Пользовательские реализации
IModelCacheKeyFactoryне поддерживаются. Если ваша модель различается по арендаторам или схемам, сгенерируйте по одной скомпилированной модели на вариант в отдельные папки и пространства имён и выбирайте нужную черезUseModel(...)по состоянию времени выполнения, как показано на странице Learn. - Конвертеры значений, ссылающиеся на приватные методы, сгенерировать нельзя; сделайте методы
internalилиpublic. EnsureCreatedи миграции по-прежнему запускаютOnModelCreating. ВызовDatabase.EnsureCreated()при наличии скомпилированной модели увеличил мой счётчикOnModelCreatingдо 1, потому что операциям со схемой нужна полная модель времени разработки, а не урезанная модель времени выполнения. Скомпилированная модель ускоряет запросы иSaveChanges, но не создание схемы при старте. Если набор тестов вызываетEnsureCreatedв каждой фикстуре, не ждите, что он станет быстрее.- Прокси ленивой загрузки и отслеживания изменений всё ещё есть в списке на Learn, но задача для отслеживания, dotnet/efcore#24902, закрыта для EF Core 7.0. Прокси для этой статьи я повторно не проверял, так что считайте этот пункт устаревшим, а не подтверждённой поддержкой.
- Частичные методы
Customize()в сгенерированной модели работают на пути через CLI, но не работают на пути через MSBuild, потому что проект должен скомпилироваться до появления модели.
Правило принятия решения, которое работает
Если свести замеры и ограничения вместе:
- Публикуете с Native AOT? Она вам нужна. Задайте
PublishAot, подключитеMicrosoft.EntityFrameworkCore.Tasksи позвольте публикации сгенерировать модель и предкомпилированные запросы. Без неё первый запрос выбросит “Model building is not supported when publishing with NativeAOT”, как разобрано в версии этой ошибки для MAUI на iOS. - Холодные старты видны пользователю (serverless, контейнеры со scale-to-zero, CLI-утилиты, десктопные приложения) и нет фильтров запросов? Используйте путь через CLI, закоммитьте результат и добавьте проверку расхождения в CI. Ожидайте экономию примерно 100 мс на малых моделях и 500 мс на 500 типах сущностей. Хорошо сочетается с другими приёмами из статьи о сокращении времени холодного старта для AWS Lambda на .NET 11.
- Долгоживущий сервер, который прогревается до приёма трафика? Пропустите. Прогрев при старте, обращающийся к
DbContext.Model, даёт тот же результат для пользователя без сгенерированного кода, который нужно поддерживать. - Использовали
EFOptimizeContextиз EF Core 9 или 10 для JIT-приложения? При переходе на EF Core 11 подумайте о том, чтобы удалить интеграцию с MSBuild, а не переводить её вEFScaffoldModelStage=build. Вы получите более быструю модель из CLI и избежите падения на чистой сборке.
Модель - лишь половина стоимости первого запроса. Каждая новая форма LINQ-запроса тоже транслируется и компилируется один раз на процесс; если доминирует несколько горячих запросов, вторую половину атакуют скомпилированные запросы EF Core для горячих путей.
Связанные материалы
- Как прогреть модель EF Core перед первым запросом, альтернатива без поддержки для долгоживущих серверов.
- Исправление: PublishAot плюс EFOptimizeContext исчерпывают память при сборке EF Core, про ошибку сборки в EF Core 10 и ошибку удаления в EF Core 11.
- Как использовать скомпилированные запросы с EF Core на горячих путях, аналог скомпилированной модели на стороне запросов.
- Что такое теневое свойство в EF Core 11 и почему оно появилось в моей миграции, пригодится, когда вы читаете сгенерированный файл
EntityType.csи гадаете, откуда взялось лишнее свойство.
Источники
- Compiled models, документация EF Core, включая список ограничений.
- EF Core MSBuild tasks, документация EF Core, про
EFScaffoldModelStage,EFPrecompileQueriesStageи примечание о коде NativeAOT. - EF Core 11 breaking changes:
EFOptimizeContextMSBuild property has been removed. - What’s new in EF Core 9: auto-compiled models, про автоматическое обнаружение и исходную интеграцию с MSBuild.
dotnet ef dbcontext optimize, справочник CLI по--output-dir,--namespace,--nativeaotи--precompile-queries.- dotnet/efcore#24897 (фильтры запросов в скомпилированных моделях, открыта) и dotnet/efcore#35079 (удаление
EFOptimizeContext).
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.