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
Спасибо за уделённое время!
У меня есть еще несколько тем, на которые хотелось бы написать. Буду признателен, если укажете в опросе темы, которые были бы вам интересны.





