Roslyn & EF Core: конструируем DbContext в runtime

—

от автора

Entity Framework Core может генерировать код моделей и DbContext для существующей базы данных с помощью консольной команды dotnet ef dbcontext scaffold. Почему бы нам не попробовать сгенерировать DbContext в runtime?

В статье я расскажу как в runtime в своём приложении:

  1. Сгенерировать код DbContext с помощью EF Core.
  2. Скомпилировать его в памяти с помощью Roslyn.
  3. Загрузить полученную сборку.
  4. Создать экземпляр сгенерированного DbContext.
  5. Работать с базой данных через полученный DbContext.

Пример доступен на github.

Подготовка к работе

Платформой для приложения станет 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 добавлять не обязательно. Я использую его, чтобы имена для коллекций генерировались во множественном числе.

PostgreSQL

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>();

sqlite

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 = (IEnumerable<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, чтобы

Строить отчеты в конструкторе

Если нужно делать сложные отчеты

И получать их результаты

Делать сложные отчеты с помощью c#

Визуализировать схему базы

Вывод

Используя небольшое количество кода, можно динамически создавать 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

Спасибо за уделённое время!

У меня есть еще несколько тем, на которые хотелось бы написать. Буду признателен, если укажете в опросе темы, которые были бы вам интересны.

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


Комментарии

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

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