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


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


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


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


Делать сложные отчеты с помощью 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


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


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