Entity Framework Core может генерировать код моделей и DbContext для существующей базы данных с помощью консольной команды dotnet ef dbcontext scaffold
. Почему бы нам не попробовать сгенерировать DbContext в runtime?
В статье я расскажу как в runtime в своём приложении:
- Сгенерировать код DbContext с помощью EF Core.
- Скомпилировать его в памяти с помощью Roslyn.
- Загрузить полученную сборку.
- Создать экземпляр сгенерированного DbContext.
- Работать с базой данных через полученный DbContext.
Подготовка к работе
Платформой для приложения станет NET Core 3.1.3.
Для примера я буду использовать базу данных MS SQL, нам понадобится строка подключения к ней. Однако, сам подход работает для любого движка базы данных, поддерживаемого EF Core (я протестировал sqlite и postregs).
Создадим консольное приложение, добавим в него необходимые пакеты:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>netcoreapp3.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis" Version="3.5.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="3.1.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="3.1.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.1.3" />
<PackageReference Include="Bricelam.EntityFrameworkCore.Pluralizer" Version="1.0.0" />
</ItemGroup>
</Project>
Генератор кода находится в пакете Microsoft.EntityFrameworkCore.Design
. Если установить этот пакет через package manager console, в ваш *.csproj будет добавлен такой код:
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
Этот код говорит [1], что пакет нужен только в процессе разработки, и не используется в runtime. Нам он понадобится в runtime, поэтому надо импортировать пакет так:
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.1.3" />
1. Сгенерируем код
В Entity Framework Core код генерирует сервис IReverseEngineerScaffolder
:
interface IReverseEngineerScaffolder
{
ScaffoldedModel ScaffoldModel(
string connectionString,
// Выбор объектов базы
DatabaseModelFactoryOptions databaseOptions,
// Настройки именования моделей
ModelReverseEngineerOptions modelOptions,
// Название контекста, пространств имён
ModelCodeGenerationOptions codeOptions);
}
Проще всего создать экземпляр этого сервиса, создав для него свой Dependency Injection Container.
В контейнер поместим необходимые для генератора зависимости:
IReverseEngineerScaffolder CreateMssqlScaffolder() =>
new ServiceCollection()
.AddEntityFrameworkSqlServer()
.AddLogging()
.AddEntityFrameworkDesignTimeServices()
.AddSingleton<LoggingDefinitions, SqlServerLoggingDefinitions>()
.AddSingleton<IRelationalTypeMappingSource, SqlServerTypeMappingSource>()
.AddSingleton<IAnnotationCodeGenerator, AnnotationCodeGenerator>()
.AddSingleton<IDatabaseModelFactory, SqlServerDatabaseModelFactory>()
.AddSingleton<IProviderConfigurationCodeGenerator, SqlServerCodeGenerator>()
.AddSingleton<IScaffoldingModelFactory, RelationalScaffoldingModelFactory>()
.AddSingleton<IPluralizer, Bricelam.EntityFrameworkCore.Design.Pluralizer>()
.BuildServiceProvider()
.GetRequiredService<IReverseEngineerScaffolder>();
IPluralizer
добавлять не обязательно. Я использую его, чтобы имена для коллекций генерировались во множественном числе.
private IReverseEngineerScaffolder CreatePostgreScaffolder() =>
new ServiceCollection()
.AddEntityFrameworkNpgsql()
.AddLogging()
.AddEntityFrameworkDesignTimeServices()
.AddSingleton<LoggingDefinitions, NpgsqlLoggingDefinitions>()
.AddSingleton<IRelationalTypeMappingSource, NpgsqlTypeMappingSource>()
.AddSingleton<IAnnotationCodeGenerator, AnnotationCodeGenerator>()
.AddSingleton<IDatabaseModelFactory, NpgsqlDatabaseModelFactory>()
.AddSingleton<IProviderConfigurationCodeGenerator, NpgsqlCodeGenerator>()
.AddSingleton<IScaffoldingModelFactory, RelationalScaffoldingModelFactory>()
.AddSingleton<IPluralizer, Bricelam.EntityFrameworkCore.Design.Pluralizer>()
.BuildServiceProvider()
.GetRequiredService<IReverseEngineerScaffolder>();
private IReverseEngineerScaffolder CreateSqliteScaffolder() =>
new ServiceCollection()
.AddEntityFrameworkSqlite()
.AddLogging()
.AddEntityFrameworkDesignTimeServices()
.AddSingleton<LoggingDefinitions, SqliteLoggingDefinitions>()
.AddSingleton<IRelationalTypeMappingSource, SqliteTypeMappingSource>()
.AddSingleton<IAnnotationCodeGenerator, AnnotationCodeGenerator>()
.AddSingleton<IDatabaseModelFactory, SqliteDatabaseModelFactory>()
.AddSingleton<IProviderConfigurationCodeGenerator, SqliteCodeGenerator>()
.AddSingleton<IScaffoldingModelFactory, RelationalScaffoldingModelFactory>()
.AddSingleton<IPluralizer, Bricelam.EntityFrameworkCore.Design.Pluralizer>()
.BuildServiceProvider()
.GetRequiredService<IReverseEngineerScaffolder>();
Теперь можно получить экземпляр генератора кода:
var scaffolder = CreateMssqlScaffolder();
Используем для него следующие настройки:
// Используем все схемы и таблицы
var dbOpts = new DatabaseModelFactoryOptions();
//Имена моделей как у сущностей в базе данных
var modelOpts = new ModelReverseEngineerOptions();
var codeGenOpts = new ModelCodeGenerationOptions()
{
// Зададим пространства имён
RootNamespace = "TypedDataContext",
ContextName = "DataContext",
ContextNamespace = "TypedDataContext.Context",
ModelNamespace = "TypedDataContext.Models",
// Нас не пугает строка подключения в исходном коде,
// ведь он будет существовать только в runtime
SuppressConnectionStringWarning = true
};
Всё готово, сгенерируем код базы данных
ScaffoldedModel scaffoldedModelSources =
scaffolder.ScaffoldModel(сonnectionString, dbOpts, modelOpts, codeGenOpts);
Результат выполнения:
// Сгенерированная модель
class ScaffoldedModel
{
// Код файла DbContext
public virtual ScaffoldedFile ContextFile { get; set; }
// Коллекция элементов с кодом моделей
public virtual IList<ScaffoldedFile> AdditionalFiles { get; }
}
Чтобы использовать Lazy Loading, нужно добавить UseLazyLoadingProxies()
в файл контекста:
var contextFile = scaffoldedModelSources.ContextFile.Code
.Replace(".UseSqlServer", ".UseLazyLoadingProxies().UseSqlServer");
Теперь, когда исходный код готов, скомпилируем его.
2. Компилируем код с помощью Roslyn
С Roslyn, скомпилировать код очень просто:
CSharpCompilation GenerateCode(List<string> sourceFiles)
{
var options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp8);
var parsedSyntaxTrees = sourceFiles
.Select(f => SyntaxFactory.ParseSyntaxTree(f, options));
return CSharpCompilation.Create($"DataContext.dll",
parsedSyntaxTrees,
references: GetCompilationReferences(),
options: new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary,
optimizationLevel: OptimizationLevel.Release));
}
Для успешной компиляции, необходимо указать компилятору ссылки на используемые сборки:
List<MetadataReference> CompilationReferences()
{
var refs = new List<MetadataReference>();
// Для краткости, и чтобы не повторяться сошлемся на все сборки,
// которые подключены к текущей сборке
var referencedAssemblies = Assembly.GetExecutingAssembly().GetReferencedAssemblies();
refs.AddRange(referencedAssemblies.Select(a =>
MetadataReference.CreateFromFile(Assembly.Load(a).Location)));
// Добавим недостающие, необходимые для компиляции сборки:
refs.Add(MetadataReference.CreateFromFile(
typeof(object).Assembly.Location));
refs.Add(MetadataReference.CreateFromFile(
Assembly.Load("netstandard, Version=2.0.0.0").Location));
refs.Add(MetadataReference.CreateFromFile(
typeof(System.Data.Common.DbConnection).Assembly.Location));
refs.Add(MetadataReference.CreateFromFile(
typeof(System.Linq.Expressions.Expression).Assembly.Location))
// Если мы решили использовать LazyLoading, нужно добавить еще одну сборку:
// refs.Add(MetadataReference.CreateFromFile(
// typeof(ProxiesExtensions).Assembly.Location));
return refs;
}
Скомпилируем наши файлы:
MemoryStream peStream = new MemoryStream();
EmitResult emitResult = GenerateCode(sourceFiles).Emit(peStream);
В случае успеха, emitResult.Success
будет равен true
, а в peStream
будет записана наша сборка.
Если что-то пойдет не так, будет легко найти проблему. В emitResult
попадут все ошибки компиляции и предупреждения.
3. Загружаем сборку
Теперь, когда сборка готова, загрузим её:
var assemblyLoadContext = new AssemblyLoadContext("DbContext", isCollectible);
var assembly = assemblyLoadContext.LoadFromStream(peStream);
Хочу обратить внимание на параметр isCollectible
. Он указывает, может ли сборка быть выгружена и очищена сборщиком мусора. Эта полезная возможность появилась в NET Core 3 [2].
В нашем сценарии, будет полезно выгрузить сборку из памяти, когда закончим работу с базой данных. Сделать это просто [5]:
assemblyLoadContext.Unload();
Если используется LazyLoading, то EF Core будет генерировать Proxy-объекты для наших сущностей, они будут загружены с помощью DefaultLoadContext, а он не помечен как collectible
. Так как NonCollectible-сборка не может ссылаться на collectible-сборку, мы не сможем сделать нашу сборку collectible
одновременно с использованием LazyLoading. О проблеме знают разработчики [3][4], возможно в будущем это изменится.
4. Используем наш сгенерированный DbContext
Найдем в сборке конструктор, и создадим экземпляр нашего DbContext.
var type = assembly.GetType("TypedDataContext.Context.DataContext");
var constructor = type.GetConstructor(Type.EmptyTypes);
DbContext dynamicContext = (DbContext)constructor.Invoke(null);
Для динамического доступа, удобно использовать такие расширения:
public static class DynamicContextExtensions
{
public static IQueryable Query(this DbContext context, string entityName) =>
context.Query(context.Model.FindEntityType(entityName).ClrType);
static readonly MethodInfo SetMethod =
typeof(DbContext).GetMethod(nameof(DbContext.Set), 1, Array.Empty<Type>()) ??
throw new Exception($"Type not found: DbContext.Set");
public static IQueryable Query(this DbContext context, Type entityType) =>
(IQueryable)SetMethod.MakeGenericMethod(entityType)?.Invoke(context, null) ??
throw new Exception($"Type not found: {entityType.FullName}");
}
В этих расширениях с помощью Reflection мы получаем доступ к типизованному методу Set<>
нашего DbContext.
Теперь выведем в консоль названия таблиц из базы данных, и количество записей в каждой из них:
foreach (var entityType in dynamicContext.Model.GetEntityTypes())
{
var items = (IQueryable<object>)dynamicContext.Query(entityType.Name);
Console.Write($"Entity type: {entityType.ClrType.Name} ");
Console.WriteLine($"contains {items.Count()} items");
}
Сценарии применения
Такой подход удобно использовать при создании вспомогательных утилит в проектах, в которых схема базы данных продолжает изменяться, чтобы избежать необходимости ручного пересоздания моделей, и повторной компиляции кода.
Приложение ASP.NET Core Blazor для пользователей, не знакомых с программированием, или SQL, которое может подключиться к базе данных MS SQL, PostrgreSQL, sqlite, чтобы
Вывод
Используя небольшое количество кода, можно динамически создавать EF Core DbContext в runtime. С помощью новой возможности NET Core — collectible assemblies
, можно выгружать сборку из памяти, что помогает избежать утечек памяти и проблем с производительностью.
Ссылки
Полный код примера доступен на github.
[1] Ссылки на пакеты (PackageReference) в файлах проектов
[2] Collectible assemblies in .NET Core 3.0
[3] Lazy loading proxy doesn't support entity inside collectible assembly #18272
[4] Support Collectible Dynamic Assemblies #473
[5] How to use and debug assembly unloadability in .NET Core
Спасибо за уделённое время!
У меня есть еще несколько тем, на которые хотелось бы написать. Буду признателен, если укажете в опросе темы, которые были бы вам интересны.
Dansoid
Не затянули ли вы всю таблицу чтобы подсчитать количество записей?
Ну в общем то что заметил, разве что сложные отчеты это не к EF :).
sergeyZ Автор
Нет, там будет SELECT COUNT, я проверял (плохо). К тому же, это лишь пример.
EF нужен, чтобы универсально абстрагироваться от БД. Понятно, что разработчикам удобнее взять LinqPad, или просто написать запрос на SQL. А эту штуку можно сделать один раз, отдать пользователям, и забыть (но это не точно).
sergeyZ Автор
О нет! Вы правы, надо же к IQueryable привести. Поправил.