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 = (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


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


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

    Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.

    На какую тему написать в первую очередь?

    • 62,1%Monaco Editor в Blazor для c# (с автокомплитом)18
    • 17,2%Svg с помощью viz.js в Blazor5
    • 58,6%Blazor Grid с поддержкой IQueryable17
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 7

      +2
      items.Count()

      Не затянули ли вы всю таблицу чтобы подсчитать количество записей?
      Ну в общем то что заметил, разве что сложные отчеты это не к EF :).

        0
        Нет, там будет SELECT COUNT, я проверял (плохо). К тому же, это лишь пример.

        EF нужен, чтобы универсально абстрагироваться от БД. Понятно, что разработчикам удобнее взять LinqPad, или просто написать запрос на SQL. А эту штуку можно сделать один раз, отдать пользователям, и забыть (но это не точно).
          +1
          О нет! Вы правы, надо же к IQueryable привести. Поправил.
          +2
          Визуализировать схему базы

          Я вот активно использую EF, но для такого кейса точно бы выбрал native путь, через служебные таблицы конкретной СУБД, т.к. очевидно, стандартных средств EF для метаинформации может не хватить и все-равно придется лезть «глубже».
            +2
            Это правда. Но как шпаргалка, когда нужно составить отчет, подойдёт, да и выглядит круто. На схеме буду не только таблицы, но и представления.
            +1

            Я сделал подобный велосипед

              0
              Лучше конечно использовать C# Source Generators

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое