前言
终于踏出第一步探索EF Core原理和本质,过程虽然比较漫长且枯燥乏味还得反复论证,其中滋味自知,EF Core的强大想必不用我再过多废话,有时候我们是否思考过背后到底做了些什么,到底怎么实现的呢?比如本节要讲的在命令行简单敲下dotnet ef migrations add initial初始化表完事,如此简洁。激起了我的好奇,下面我们来看看。本节内容可能比较多,请耐心。
EntityFramework Core命令基础拾遗
我们提前创建好.NET Core Web应用程序和实体模型以及上下文,园中例子太多且我们也只是探讨迁移原理,无关乎其他。
如此简单一个命令就初始化了表,是不是很神奇,我们接下来要做的就是化神奇为简单。我们接下来将上述迁移文件夹删除,再次运行如下命令,看看迁移详细过程。
dotnet ef migrations add init -c EFCoreDbContext -p ..\EfCore.Data\ --verbose
通过如上两张图我们可看出EF迁移将会进行两步:第一步则是编译上下文所在项目,编译启动项目。第二步则是通过编译成功后的上下文所在程序集合启动项目程序集最终实现迁移。总结起来就是简单两小步,背后所需要做的很多,请继续往下看。
EntityFramework Core迁移本质
当我们敲写dotnet ef migrations add initial命令后,紧接着会在启动项目obj文件夹会生成如下文件。
这个东西是做什么的呢,我也不知道,我们打开该文件看看。
"1.0" encoding="utf-8"?>"http://schemas.microsoft.com/developer/msbuild/2003"> "GetEFProjectMetadata" Condition=""> " '$(TargetFramework)' == '' " Projects="$(MSBuildProjectFile)" Targets="GetEFProjectMetadata" Properties="TargetFramework=$(TargetFrameworks.Split(';')[0]);EFProjectMetadataFile=$(EFProjectMetadataFile)" /> " '$(TargetFramework)' != '' "> "AssemblyName: $(AssemblyName)" /> "OutputPath: $(OutputPath)" /> "Platform: $(Platform)" /> "PlatformTarget: $(PlatformTarget)" /> "ProjectAssetsFile: $(ProjectAssetsFile)" /> "ProjectDir: $(ProjectDir)" /> "RootNamespace: $(RootNamespace)" /> "RuntimeFrameworkVersion: $(RuntimeFrameworkVersion)" /> "TargetFileName: $(TargetFileName)" /> "TargetFrameworkMoniker: $(TargetFrameworkMoniker)" /> " '$(TargetFramework)' != '' " File="$(EFProjectMetadataFile)" Lines="@(EFProjectMetadata)" />
一堆的如上东西,什么鬼玩意,刚看到这东西时我懵逼了,于是开始了探索之路。在.NET Core CLI 1.0.0有了称为“项目工具扩展”的功能,我们称之为“CLI工具”。 这些是项目特定的命令行工具,也就是说扩展了dotnet命令。比如我们安装Microsoft.DotNet.Watcher.Tools包则可以使用dotnet watch命令,就是这么个意思。在.NET Core尚未完善时,项目文件采用JSON格式,紧接着改为了以扩展名为.xproj结尾的项目文件,格式也就转换为了XML格式,最后项目文件定型为以.proj结尾,当然数据格式依然是XML,我猜测估计和MSBuild有关,因为微软对XML数据格式的操作已经有非常成熟的库,相比较而言JSON我们使用起来当然更加方便,可能微软需要多做额外的工作,纯属猜测。了解和知道MSBuild的童鞋看到上述数据格式想必格外亲切,再熟悉不过了,我们若仔细看到上述数据参数,就能够明白上述参数是存放的项目参数。在.NET Core中都是利用MSBuild和CLI工具来读取项目信息以用于其他目的。那么问题就来了,如何读取项目信息呢?
利用MSBuild和CLI工具读取项目信息
首先我们需要找到项目中以扩展名为.proj结尾的文件,其次我们需要注入MSBuild Target,最后则启动进程是调用Target,代码如下:
public static void Main(string[] args) { var projectFile = @"D:\Visual Studio 2015\Projects\EFCore2Example\EFCore2Example\EFCore2Example.csproj"; var targetFileName = Path.GetFileName(projectFile) + ".EntityFrameworkCore.targets"; var projectExePath = Path.Combine(@"D:\Visual Studio 2015\Projects\EFCore2Example\EFCore2Example", "obj"); Directory.CreateDirectory(projectExePath); var targetFile = Path.Combine(projectExePath, targetFileName); File.WriteAllText(targetFile, @""); var psi = new ProcessStartInfo { FileName = "dotnet", Arguments = $"msbuild \"{projectFile}\" /t:GetEFProjectMetadata /nologo" }; var process = Process.Start(psi); process.WaitForExit(); if (process.ExitCode != 0) { Console.Error.WriteLine("Invoking MSBuild target failed"); } Console.ReadKey(); } AssemblyName: $(AssemblyName) OutputPath: $(OutputPath) Platform: $(Platform)
默认情况下MSBuildProjectExtensionsPath路径在项目中obj文件夹下如上我们迁移的WebApplication1.csproj.EntityFrameworkCore.targets,我们对targets文件命名一般约定为$(MSBuildProjectExtensionsPath)$(MSBuildProjectFile).
上述只是作为简单的显示信息而使用,利用CLI工具在我们项目内部创建了一个MSBuild目标。这个目标可以完成MSBuild所能做的任何事情,EF Core则是将加载目标读取临时文件的形式来获取项目信息。
var projectFile = @"D:\Visual Studio 2015\Projects\EFCore2Example\EFCore2Example\EFCore2Example.csproj"; var targetFileName = Path.GetFileName(projectFile) + ".EntityFrameworkCore.targets"; var projectExePath = Path.Combine(@"D:\Visual Studio 2015\Projects\EFCore2Example\EFCore2Example", "obj"); Directory.CreateDirectory(projectExePath); var targetFile = Path.Combine(projectExePath, targetFileName); File.WriteAllText(targetFile, @""); var tmpFile = Path.GetTempFileName(); var psi = new ProcessStartInfo { FileName = "dotnet", Arguments = $"msbuild \"{projectFile}\" /t:GetEFProjectMetadata /nologo \"/p:EFProjectMetadataFile={tmpFile}\"" }; var process = Process.Start(psi); process.WaitForExit(); if (process.ExitCode != 0) { Console.Error.WriteLine("Invoking MSBuild target failed"); } var lines = File.ReadAllLines(tmpFile); File.Delete(tmpFile); var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); foreach (var line in lines) { var idx = line.IndexOf(':'); if (idx <= 0) continue; var name = line.Substring(0, idx)?.Trim(); var value = line.Substring(idx + 1)?.Trim(); properties.Add(name, value); } Console.WriteLine("........................................"); Console.WriteLine($"EFCore2Example project has {properties.Count()} properties"); Console.WriteLine($"AssemblyName = { properties["AssemblyName"] }"); Console.WriteLine($"OutputPath = { properties["OutputPath"] }"); Console.WriteLine($"Platform = { properties["Platform"] }"); Console.WriteLine($"PlatformTarget = { properties["PlatformTarget"] }"); Console.WriteLine($"ProjectAssetsFile = { properties["ProjectAssetsFile"] }"); Console.WriteLine($"ProjectDir = { properties["ProjectDir"] }"); Console.WriteLine($"RootNamespace = { properties["RootNamespace"] }"); Console.WriteLine($"RuntimeFrameworkVersion = { properties["RuntimeFrameworkVersion"] }"); Console.WriteLine($"TargetFileName = { properties["TargetFileName"] }"); Console.WriteLine($"TargetFrameworkMoniker = { properties["TargetFrameworkMoniker"] }"); Console.WriteLine("........................................");
上述是控制台中示例,若我们在.NET Core Web应用程序中,此时我们完全可以获取到项目文件而无需如控制台写死项目文件路径,如下:
var projectFiles = Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "*.*proj", SearchOption.TopDirectoryOnly) .Where(f => !string.Equals(Path.GetExtension(f), ".xproj", StringComparison.OrdinalIgnoreCase)) .Take(2).ToList(); var projectFile = projectFiles[0]; var targetFileName = Path.GetFileName(projectFile) + ".EntityFrameworkCore.targets"; .......
此时获取到启动项目信息,如下:
到了这里我们探索完了EF Core如何进行迁移的第一步,同时我们也明白为何要将执行命令路径切换到启动项目项目文件所在目录,因为需要获取到项目信息,然后进行Build也就是生成,如果执行生成错误则返回,否则返回项目详细信息。到这里我们了解了利用MSBuild和CLI工具来获取上下文所在项目详细信息和启动项目详细信息。我们继续往下探讨。
执行.NET Core必需文件和调用ef.exe或者ef.x86.exe应用程序或者ef.dll程序集执行迁移
通过上述MSBuild和CLI工具我们获取到上下文和启动项目详细信息,接下来则是进行迁移,如开头第四张图片所示,所执行命令大致如下:
dotnet exec --depsfile [.deps.json] --addtionalprobingpath [nugetpackage] --runtimeconfig [.runtimeconfig.json] ef.dll migrations add
init -c [DbContext] --assembly [DbContextAssmbly] --startup-assembly [StartupProjectAssembly]
一波刚平息 一波又起,首先我们得明白上述命令,比如通过读取扩展名为.deps.json文件来执行--depsfile命令,以及读取扩展名为.runtimeconfig.json文件执行--runtimeconfig命令,那么这两个文件是做什么的呢,我们又得花费一点功夫来讲解。接下来我们利用dotnet命令来创建控制台程序来初识上述两个命令的作用。首先我们运行如下命令创建控制台程序,在此需要特别说明的是在.NET Core 2.0后当通过dotnet build后直接包含了执行dotnet restore命令:
dotnet new Console
此时同时也会在obj文件夹下生成project.assets.json文件,这个文件是做什么的呢?别着急,我们先讲完.deps.json和.runtimeconfig.json继续话题会讲到这个文件的作用,我们继续。
此时我们继续运行生成命令,如下则会生成bin文件夹,同时在如下.netcoreapp2.1文件夹会生成我们需要讲到的两个json文件。
dotnet build
{ "runtimeOptions": { "tfm": "netcoreapp2.1", "framework": { "name": "Microsoft.NETCore.App", "version": "2.1.0-preview1-26216-03" } } }
运行.NET Core应用程序必须要runtimeconfig.json文件,意为“运行时”,我们也可以翻译为共享框架,且运行时和共享框架概念可任意转换。此json文件为运行时配置选项,如果没有runtimeconfig.json文件,将抛出异常,我们删除该文件看看。
通过运行时json文件当运行时指示dotnet运行Microsoft.NETCore.App 2.0.0共享框架 此框架是最常用的框架,但也存在其他框架,例如Microsoft.AspNetCore.App。 与.NET Framework不同,可能会在计算机上安装多个.NET Core共享框架。dotnet读取json文件,并在C:\Program Files\dotnet\shared中查找运行该应用程序所需的文件,如下存在多个运行时框架。当然如果我们安装了更高版本的.net core如2.1.0-preview1-final,此时dotnet将自动选择最高的版本。
好了,我们算是明白.runtimeconfig.json文件主要是用来指示dotnet在运行时使用哪个框架。我们再来看看.deps.json文件,如下:
{ "runtimeTarget": { "name": ".NETCoreApp,Version=v2.1", "signature": "da39a3ee5e6b4b0d3255bfef95601890afd80709" }, "compilationOptions": {}, "targets": { ".NETCoreApp,Version=v2.1": { "认识.NET Core/1.0.0": { "runtime": { "认识.NET Core.dll": {} } } } }, "libraries": { "认识.NET Core/1.0.0": { "type": "project", "serviceable": false, "sha512": "" } } }
deps.json文件是一个依赖关系清单。它可以用来配置来自包的组件的动态链接。NET Core可以配置为从多个位置动态加载程序集,这些位置包括:
应用程序基目录(与入口点应用程序位于同一文件夹中,不需要配置)
- 包缓存文件夹(NuGet恢复缓存或NuGet后备文件夹)
- 优化的包缓存或运行时包存储。
- 共享框架(通过runtimeconfig.json配置)。
好了,对于.deps.json和runtimeconfig.json文件暂时先讲到这里,后续有可能再详细讲解,我们弄明白了这两个文件的大致作用即可。回到我们的话题,那么这两个文件是如何找到的呢?那就得结合我们第一步获取到的项目信息了,在第一部分获取项目信息最后给出的图片里面根据ProjectDir和OutputPath就可以获取到.deps.json和.runtimeconfig.json文件。最后则需要获取ef.dll程序集从而执行相关迁移命令,那么ef.dll程序集是怎么获取到的呢?这个时候就需要获取项目中的信息ProjectAssetsFile即读取project.assets.json文件,获取packageFolders节点下数据,如下:
"packageFolders": { "C:\\Users\\JeffckyWang\\.nuget\\packages\\": {}, "C:\\Program Files (x86)\\Microsoft SDKs\\NuGetPackagesFallback\\": {}, "C:\\Program Files\\dotnet\\sdk\\NuGetFallbackFolder": {} },
我们从开头第四张图片可看出对于--addtionalprobingpath有三个路径也就是如上三个路径,我们看看如上三个路径是否存在ef.dll程序集。
如上只有nuget和sdk中有ef.dll程序集,我们依然看看开头第四张图片最终执行的却是sdk中的ef.dll程序集,难道是如果nuget和skd目录在project.assets.json中都存在,那么优先从sdk中查找么,也就是sdk中程序集优先级比nuget程序集高吗,如果sdk中存在对应程序集则直接执行吗。当移除该文件中nuget路径,重新生成会覆盖。所以猜测可能优先查找sdk中是否存在ef.dll程序集。这里还需额外说明一点的是我们在第一节获取到了项目详细信息,其中有一项是TargetFrameworkMoniker,若我们创建的项目是.NET Framework,此时根据TargetFrameworkMoniker来判断,若为.NETCoreApp则执行上述ef.dll程序集否则执行如下路径应用程序来迁移。
手动执行命令迁移
上述我们完整讲述了在命令行中执行dotnet ef命令背后的本质是什么,那么我们接下来利用代码手动来迁移。如下第一个类为解析进程所需的参数类【从dotnet ef源码拷贝而来】
public static class Common { public static string ToArguments(IReadOnlyList<string> args) { var builder = new StringBuilder(); for (var i = 0; i < args.Count; i++) { if (i != 0) { builder.Append(" "); } if (args[i].IndexOf(' ') == -1) { builder.Append(args[i]); continue; } builder.Append("\""); var pendingBackslashs = 0; for (var j = 0; j < args[i].Length; j++) { switch (args[i][j]) { case '\"': if (pendingBackslashs != 0) { builder.Append('\\', pendingBackslashs * 2); pendingBackslashs = 0; } builder.Append("\\\""); break; case '\\': pendingBackslashs++; break; default: if (pendingBackslashs != 0) { if (pendingBackslashs == 1) { builder.Append("\\"); } else { builder.Append('\\', pendingBackslashs * 2); } pendingBackslashs = 0; } builder.Append(args[i][j]); break; } } if (pendingBackslashs != 0) { builder.Append('\\', pendingBackslashs * 2); } builder.Append("\""); } return builder.ToString(); } }
项目所需的详细信息,我们封装成一个类且其中包含执行build命令的方法,如下:
public class Project { public string AssemblyName { get; set; } public string Language { get; set; } public string OutputPath { get; set; } public string PlatformTarget { get; set; } public string ProjectAssetsFile { get; set; } public string ProjectDir { get; set; } public string RootNamespace { get; set; } public string RuntimeFrameworkVersion { get; set; } public string TargetFileName { get; set; } public string TargetFrameworkMoniker { get; set; } public void Build() { var args = new List<string> { "build" }; args.Add("/p:GenerateRuntimeConfigurationFiles=True"); args.Add("/verbosity:quiet"); args.Add("/nologo"); var arg = Common.ToArguments(args); var psi = new ProcessStartInfo { FileName = "dotnet", Arguments = arg }; var process = Process.Start(psi); process.WaitForExit(); } }
接下来则是获取项目详细信息、生成、迁移,如下三个方法以及对应方法实现。
//获取项目详细信息 var projectMedata = GetProjectMedata(); //生成 projectMedata.Build(); //执行EF迁移命令 ExecuteEFCommand(projectMedata);
public Project GetProjectMedata() { var projectFiles = Directory.EnumerateFiles(Directory.GetCurrentDirectory(), "*.*proj", SearchOption.TopDirectoryOnly) .Where(f => !string.Equals(Path.GetExtension(f), ".xproj", StringComparison.OrdinalIgnoreCase)) .Take(2).ToList(); var projectFile = projectFiles[0]; var targetFileName = Path.GetFileName(projectFile) + ".EntityFrameworkCore.targets"; var projectExePath = Path.Combine(Path.GetDirectoryName(projectFile), "obj"); Directory.CreateDirectory(projectExePath); var targetFile = Path.Combine(projectExePath, targetFileName); System.IO.File.WriteAllText(targetFile, @""); var tmpFile = Path.GetTempFileName(); var psi = new ProcessStartInfo { FileName = "dotnet", Arguments = $"msbuild \"{projectFile}\" /t:GetEFProjectMetadata /nologo \"/p:EFProjectMetadataFile={tmpFile}\"" }; var process = Process.Start(psi); process.WaitForExit(); if (process.ExitCode != 0) { Console.Error.WriteLine("Invoking MSBuild target failed"); } var lines = System.IO.File.ReadAllLines(tmpFile); System.IO.File.Delete(tmpFile); var properties = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); foreach (var line in lines) { var idx = line.IndexOf(':'); if (idx <= 0) continue; var name = line.Substring(0, idx)?.Trim(); var value = line.Substring(idx + 1)?.Trim(); properties.Add(name, value); } var project = new Project() { AssemblyName = properties["AssemblyName"], OutputPath = properties["OutputPath"], ProjectDir = properties["ProjectDir"], ProjectAssetsFile = properties["ProjectAssetsFile"], TargetFileName = properties["TargetFileName"], TargetFrameworkMoniker = properties["TargetFrameworkMoniker"], RuntimeFrameworkVersion = properties["RuntimeFrameworkVersion"], PlatformTarget = properties["PlatformTarget"], RootNamespace = properties["RootNamespace"] }; return project; }
public void ExecuteEFCommand(Project project) {var depsFile = Path.Combine( project.ProjectDir, project.OutputPath, project.AssemblyName + ".deps.json"); var runtimeConfig = Path.Combine( project.ProjectDir, project.OutputPath, project.AssemblyName + ".runtimeconfig.json"); var projectAssetsFile = project.ProjectAssetsFile; var args = new List<string> { "exec", "--depsfile" }; args.Add(depsFile); var packageSDKFolder = string.Empty; if (!string.IsNullOrEmpty(projectAssetsFile)) { using (var reader = new JsonTextReader(System.IO.File.OpenText(projectAssetsFile))) { var projectAssets = JToken.ReadFrom(reader); var packageFolders = projectAssets["packageFolders"].Children().Select(p => p.Name); foreach (var packageFolder in packageFolders) { packageSDKFolder = packageFolder; args.Add("--additionalprobingpath"); args.Add(packageFolder.TrimEnd(Path.DirectorySeparatorChar)); } } } if (System.IO.File.Exists(runtimeConfig)) { args.Add("--runtimeconfig"); args.Add(runtimeConfig); } else if (project.RuntimeFrameworkVersion.Length != 0) { args.Add("--fx-version"); args.Add(project.RuntimeFrameworkVersion); } args.Add(Path.Combine(@"C:\Program Files\dotnet\sdk\NuGetFallbackFolder\microsoft.entityframeworkcore.tools.dotnet\2.0.2\tools\netcoreapp2.0", "ef.dll")); args.AddRange(new List<string>() { "migrations", "add", "initial", "-c", "EFCoreDbContext" }); args.Add("--assembly"); args.Add(Path.Combine(project.ProjectDir, project.OutputPath, project.TargetFileName)); args.Add("--startup-assembly"); args.Add(Path.Combine(project.ProjectDir, project.OutputPath, project.TargetFileName));if (!string.IsNullOrEmpty(project.Language)) { args.Add("--language"); args.Add(project.Language); } var arg = Common.ToArguments(args); var psi = new ProcessStartInfo { FileName = "dotnet", Arguments = arg, UseShellExecute = false }; var process = Process.Start(psi); process.WaitForExit(); if (process.ExitCode != 0) { Console.WriteLine("Migration failed"); } }
请注意在上述ExecuteEFCommand方法中已明确标注此时目标迁移目录就是上述当前项目,需要迁移到上下文所在类库中,我们在命令行就可以得到上下文所在项目,此时只需要将上述ExecuteEFCommand方法中标注改为从命令行获取到的项目参数即可,如下我们直接写死:
args.Add("--assembly"); args.Add(Path.Combine(project.ProjectDir, project.OutputPath, "EFCore.Data.dll"));
同时还需添加上下文项目目录参数,如下:
args.Add("--project-dir");
args.Add(@"C:\Users\JeffckyWang\Source\Repos\WebApplication1\EFCore.Data\"); if (!string.IsNullOrEmpty(project.Language)) { args.Add("--language"); args.Add(project.Language); } .......
最后将启动项目中生成的迁移目录修改为上下文所在项目,如下:
var sqlStr = @"data source=WANGPENG;User Id=sa;Pwd=sa123;initial catalog=EFCore2xDb;
integrated security=True;MultipleActiveResultSets=True;"; services.AddDbContextPool(options => { options.UseSqlServer(sqlStr, d => d.MigrationsAssembly("EFCore.Data")); }, 256);
此时我们再来手动迁移那么将在上下文所在项目中生成迁移文件夹,如下:
了解了执行dotnet ef背后实现的原理,Jeff要说了【那么问题来了】,对于我们而言有何帮助没有呢,当然有而且马上能实现,我们可以写一个批处理文件在发布时直接执行生成数据库表,说完就开干。我们在上述WebApplication1启动项目中创建名为deploy-efcore.bat批处理文件,代码如下:
set EFCoreMigrationsNamespace=%WebApplication1 set EFCoreMigrationsDllName=%WebApplication1.dll set EFCoreMigrationsDllDepsJson=%bin\debug\netcoreapp2.0\WebApplication1.deps.json
set PathToNuGetPackages=%USERPROFILE%\.nuget\packages set PathToEfDll=%PathToNuGetPackages%\microsoft.entityframeworkcore.tools.dotnet\2.0.0\tools\netcoreapp2.0\ef.dll dotnet exec --depsfile .\%EFCoreMigrationsDllDepsJson% --additionalprobingpath %PathToNuGetPackages% %PathToEfDll% database update --assembly .\%EFCoreMigrationsDllName% --startup-assembly .\%EFCoreMigrationsDllName% --project-dir . --verbose --root-namespace %EFCoreMigrationsNamespace% pause
总结
本节我们详细讲解了执行dotnet ef命令背后究竟发生了什么,同时也大概讨论了下.NET Core几个配置文件的作用,足够了解这些,当出现问题才不至于手足无措,耗时一天多才写完,不过收获颇多,下面我们给出背后实现大致原理【后面可能会更详细探讨,到时继续更新】图来解释执行dotnet ef命令背后的本质以此来加深印象,希望对阅读的您也能有所帮助,我们下节再会。