七色花基本权限系统(5) 实体配置的使用和利用T4自动生成实体配置

在前面的章节里,用户表的结构非常简单,没有控制如何映射到数据库。通常,需要对字段的长度、是否可为空甚至特定数据类型进行设置,因为EntityFramework的默认映射规则相对而言比较简单和通用。在这篇日志里,将演示如何对数据实体进行映射配置,并利用T4模板自动创建映射配置类文件。

配置方式

EntityFramework的实体映射配置有2种。

第一种是直接在实体类中以特性的方式进行控制,这些特性有部分是EF实现的,也有部分是非EF实现的。也就是说,在数据实体层不引用EF的情况下,只能使用不全的特性对实体映射进行控制。这种方式有2个明显的弊端:实体类没有尽可能地简单,数据实体层需要引用EF。

第二种是通过EF提供的特定接口进行控制。这种控制方式将完全通过EF提供的配置API,能保证数据实体的干净和独立,并且将映射配置独立出来,便于维护。

映射配置

毫无疑问,这里将采用第二种配置方式。

配置信息是要传递给EF数据上下文的,并且需要引用EF,因此实体配置可以直接建立在数据核心层(S.Framework.DataCore)中。在EntityFramework文件夹下建立新的文件夹,名称Configurations。

需要提醒的是,在数据实体中,我们通过“不同名称的一级文件夹”对不同数据库的实体进行了隔离,在创建EF数据库上下文时,也用同样的名称作为前缀。因此,在实现实体配置时我们也需要进行区分,区分的方式也是文件夹隔离——在Configurations文件夹下创建文件夹,名称Master。如下图:

image

现在开始创建用户实体配置。

  1 using System;
  2 using System.Collections.Generic;
  3 using System.Linq;
  4 using System.Text;
  5 using System.Threading.Tasks;
  6 using System.Data.Entity.ModelConfiguration;
  7 using System.Data.Entity.ModelConfiguration.Configuration;
  8 
  9 using S.Framework.Entity.Master;
 10 
 11 namespace S.Framework.DataCore.EntityFramework.Configurations.Master
 12 {
 13     /// <summary>
 14     /// 数据表映射
 15     /// </summary>
 16     public class SysUserConfiguration : EntityTypeConfiguration<SysUser>
 17     {
 18         /// <summary>
 19         /// 数据表映射构造函数
 20         /// </summary>
 21         public SysUserConfiguration()
 22         {
 23             SysUserConfigurationAppend();
 24         }
 25 
 26         /// <summary>
 27         /// 数据映射
 28         /// </summary>
 29         public void SysUserConfigurationAppend()
 30         {
 31             //设置ID属性非空、最大长度38
 32             //由于EF会自动识别名为ID的属性作为主键(会自动非空),所以其实这里只需要设置最大长度即可
 33             this.Property(p => p.ID).IsRequired().HasMaxLength(38);
 34 
 35             //设置UserName属性非空、最大长度100
 36             this.Property(p => p.UserName).IsRequired().HasMaxLength(100);
 37 
 38             //设置Password属性非空、最大长度100
 39             this.Property(p => p.Password).IsRequired().HasMaxLength(100);
 40 
 41             //设置CreateUser属性非空、最大长度100
 42             this.Property(p => p.CreateUser).IsRequired().HasMaxLength(100);
 43 
 44             //设置LastModifyUser属性最大长度100
 45             this.Property(p => p.LastModifyUser).HasMaxLength(100);
 46         }
 47 
 48         /// <summary>
 49         /// 将映射配置注册给配置注册器
 50         /// </summary>
 51         /// <param name="configurations">配置注册器对象</param>
 52         public void RegistTo(ConfigurationRegistrar configurations)
 53         {
 54             configurations.Add(this);
 55         }
 56     }
 57 }
 58 
用户实体配置类

可配置的内容还有很多,比如数据精度、字段数据类型等等,请查API。

这里稍微提几条常见的EF对实体属性映射的默认规则,这里以Sql server为例。

值类型(int、bool等)的属性映射到数据库后,字段的数据类型是相应的int、bit等,且不允许为空;

引用类型(好像只有string?)的属性映射到数据库后,字段的数据类型是nvarchar(max),允许为空;

属性名为“ID”或者“实体类名ID”的属性,将自动被识别为主键;

注册配置

创建了实体配置类之后,还需要让EF数据库上下文知道有这个配置。

在MasterEntityContext中的OnModelCreating方法中追加,使该方法变成这样:

  1 /// <summary>
  2 /// 模型配置重写
  3 /// </summary>
  4 /// <param name="modelBuilder">数据实体生成器</param>
  5 protected override void OnModelCreating(System.Data.Entity.DbModelBuilder modelBuilder)
  6 {
  7     // 禁用一对多级联删除
  8     modelBuilder.Conventions.Remove<OneToManyCascadeDeleteConvention>();
  9     // 禁用多对多级联删除
 10     modelBuilder.Conventions.Remove<ManyToManyCascadeDeleteConvention>();
 11     // 禁用表名自动复数规则
 12     modelBuilder.Conventions.Remove<PluralizingTableNameConvention>();
 13 
 14     new SysUserConfiguration().RegistTo(modelBuilder.Configurations);
 15 }
注册配置类给数据库上下文

一切就绪,可以再创建一次数据库看看效果。

删除原数据库,开启Home控制器下Index中的“测试访问数据库”的代码,运行。

新生成的数据库结构如下图:

image

可以看到,字段的信息发生了改变。

利用T4自动生成实体配置类

当实体数量过多时,为各个实体创建配置类就变成了体力活。我们可以利用T4模板来偷懒,让它自动创建实体配置里。

VS自带的T4模板,略有不足。请先安装2个关于T4的VS插件。

工具 => 扩展和更新,联机搜索Devart T4 Editor和T4 Toolbox for Visual Studio 2013。

image

image

需要注意的是,针对不同版本的VS,有不同版本的插件,请选择相应的版本。至于VS2015,听说没有这个插件,那我就不清楚了,还没用上VS2015。若有读者发现VS2015下这2个插件的变化还望留言告知。

在EntityFramework文件夹下创建文件夹,名称T4,如下图:

image

装了2个插件之后,添加新项时会增加一个新的类型“T4 Toolbox”,我们将用Template文件创建模板,再用Script文件创建执行器。此处,我们先创建一个用于表示“实体配置类”的模板。如下图:

image

模板代码如下:

  1 <#+
  2 // <copyright file="Configuration.tt" company="">
  3 //  Copyright © . All Rights Reserved.
  4 // </copyright>
  5 public class Configuration : CSharpTemplate
  6 {
  7     private string _modelName; //实体名称
  8     private string _prefixName; //实体前缀名称,表示不同的数据库
  9     public Configuration(string modelName, string prefixName)
 10     {
 11         _modelName = modelName;
 12         _prefixName = prefixName;
 13     }
 14     public override string TransformText()
 15     {
 16     base.TransformText();
 17 #>
 18 using System;
 19 using System.Data.Entity.ModelConfiguration;
 20 using System.Data.Entity.ModelConfiguration.Configuration;
 21 using S.Framework.Entity.<#= _prefixName #>;
 22 namespace S.Framework.DataCore.EntityFramework.Configurations.<#= _prefixName #>
 23 {
 24     /// <summary>
 25     /// 数据表映射
 26     /// </summary>
 27     public class <#= _modelName #>Configuration : EntityTypeConfiguration<<#= _modelName #>>
 28     {
 29         /// <summary>
 30         /// 数据表映射构造函数
 31         /// </summary>
 32         public <#= _modelName #>Configuration()
 33         {
 34             <#= _modelName #>ConfigurationAppend();
 35         }
 36         /// <summary>
 37         /// 数据映射
 38         /// </summary>
 39         public void <#= _modelName #>ConfigurationAppend()
 40         {
 41         }
 42         /// <summary>
 43         /// 将映射配置注册给配置注册器
 44         /// </summary>
 45         /// <param name="configurations">配置注册器对象</param>
 46         public void RegistTo(ConfigurationRegistrar configurations)
 47         {
 48             configurations.Add(this);
 49         }
 50     }
 51 }
 52 <#+
 53     return this.GenerationEnvironment.ToString();
 54     }
 55 }
 56 #>
实体配置模板文件

T4不是重点,此处不做介绍,有兴趣的读者可以自行查阅资料学习。

模板只是表示“目标文件的格式规则是怎样的”,还需要有一个“以一定条件去执行模板”的生成器。上面已经提到过Script执行器文件,创建一个吧。

image

执行器文件与模板文件有个明显可视的区别是:

image

执行器文件可以展开。模板文件的使命就是定义结构和规则,只要定义完成,就没它什么事情了。我们需要关注的是如何去调用模板文件。此处不多展开,直接放出Exec.tt文件代码。

  1 <#@ template language="C#" debug="True" #>
  2 <#@ output extension="cs" #>
  3 <#@ import namespace="System.IO" #>
  4 <#@ import namespace="System.Text" #>
  5 <#@ include file="T4Toolbox.tt" #>
  6 <#@ include file="Configuration.tt" #>
  7 <#
  8 
  9     string coreName = "S.Framework", projectName = coreName + ".DataCore";
 10     //当前完整路径
 11     string currentPath = Path.GetDirectoryName(Host.TemplateFile);
 12     //T4文件夹的父级文件夹路径
 13     string projectPath = currentPath.Substring(0, currentPath.IndexOf(@"\T4"));
 14     //解决方案路径
 15     string solutionFolderPath = currentPath.Substring(0, currentPath.IndexOf(@"\" + projectName));
 16 
 17     //实体名称和实体所在的数据库标识名称
 18     string modelName= "SysUser", prefixName= "Master";
 19     //目标文件的路径和名称(嵌套Generate文件夹是为了标识T4生成的类文件)
 20     string folderName= @"\Configurations\", fileName= prefixName + @"\Generate\" + modelName + "Configuration.cs";
 21     //执行实体配置模板,自动创建文件
 22     Configuration configuration = new Configuration(modelName, prefixName);
 23     configuration.Output.Encoding = Encoding.UTF8;
 24     string path = projectPath + folderName + fileName;
 25     configuration.RenderToFile(path);
 26 #>
 27 
模板执行器文件

注意,这个文件只要保存就会自动执行,也可以通过右键来运行或调试。

image

在T4里写代码会有点不适应,智能提示、dll库等都是缺失得非常厉害,需要自己手动import相应的dll才行,有问题多调试,会很快搞定的。

执行Exec.tt之后,我们会发现一个新文件被自动创建了,如下图:

image

这个自动生成的配置类,按照模板中定义的那样,与底下那个手动创建的配置类是一致的。先不急删除“底下那个手动创建的配置类”,因为此时还有几个问题需要解决。

第一个问题:模板文件既然叫做模板,必然是通用于各实体类的。那么必然无法在模板中指定“每个字段的配置”,因为每个实体类的字段都不同的。这样就导致“自动生成的配置类中并不包含配置”,那怎么解决呢?

第二个问题:在模板执行器文件中,硬编码的方式写死了“要生成的目标实体的名称SysUser”,那岂不是需要在为每个实体生成配置类的时候都需要手动调整代码?

先解决第一个问题。C#中可以通过关键字partial来表示部分的意思,可以用在类中,也可以中在方法中。这个关键字就是解决第一个问题的方式。调整配置类的模板文件,修改2处代码。

把配置类的修饰符public改成partial,再把数据映射方法ConfigurationAppend修改成:

  1 /// <summary>
  2 /// 数据映射
  3 /// </summary>
  4 partial void <#= _modelName #>ConfigurationAppend();
修改后的数据映射方法

重新执行exec.tt。

这样,“T4生成的配置类”和“手动创建的配置类”就存在一部分重复的内容,重复的内容交给T4,在“手动创建的配置类”中只需关注不定的内容。修改“手动创建配置类”,只保留“部分方法即可”,如下:

  1 using System;
  2 using System.Collections.Generic;
  3 using System.Linq;
  4 using System.Text;
  5 using System.Threading.Tasks;
  6 
  7 using S.Framework.Entity.Master;
  8 
  9 namespace S.Framework.DataCore.EntityFramework.Configurations.Master
 10 {
 11     /// <summary>
 12     /// 数据表映射
 13     /// </summary>
 14     partial class SysUserConfiguration
 15     {
 16         /// <summary>
 17         /// 数据映射
 18         /// </summary>
 19         partial void SysUserConfigurationAppend()
 20         {
 21             //设置ID属性非空、最大长度38
 22             //由于EF会自动识别名为ID的属性作为主键(会自动非空),所以其实这里只需要设置最大长度即可
 23             this.Property(p => p.ID).IsRequired().HasMaxLength(38);
 24 
 25             //设置UserName属性非空、最大长度100
 26             this.Property(p => p.UserName).IsRequired().HasMaxLength(100);
 27 
 28             //设置Password属性非空、最大长度100
 29             this.Property(p => p.Password).IsRequired().HasMaxLength(100);
 30 
 31             //设置CreateUser属性非空、最大长度100
 32             this.Property(p => p.CreateUser).IsRequired().HasMaxLength(100);
 33 
 34             //设置LastModifyUser属性最大长度100
 35             this.Property(p => p.LastModifyUser).HasMaxLength(100);
 36         }
 37     }
 38 }
 39 
手动创建的配置类

为了目录结构上的统一,我们把调整文件夹为如下结构:

image

需要注意的是:配置类的命名空间必须是S.Framework.DataCore.EntityFramework.Configurations.Master,否则关键字partial就没有相应的作用。

现在解决第二个问题。

我们用反射来解决这个问题。思路其实很简单,反射数据实体层,获取每个实体类的名称和数据库标识名称,循环执行配置模板文件。

为了能够准确表示“这个类是数据实体类”,我们添加一个“实体类必须要继承的实体基本类”,这样一来,只需要寻找“继承了实体基本类的类”就可以了。

同时再增加一个“实体通用属性公共类”,用来定义通用的属性,比如创建人、创建时间等字段,减少每个实体类的代码量。

这2个类,先定义在数据实体层根目录中,将来会再做调整。

  1 using System;
  2 using System.Collections.Generic;
  3 using System.Linq;
  4 using System.Text;
  5 using System.Threading.Tasks;
  6 
  7 namespace S.Framework.Entity
  8 {
  9     /// <summary>
 10     /// 所有实体模型必须直接或间接继承此类,T4模板中反射 Entity.dll 时将只识别此类的派生类为实体模型
 11     /// </summary>
 12     public abstract class EntityModelBaseForReflection
 13     {
 14     }
 15 }
 16 
实体模型基本类
  1 using System;
  2 using System.Collections.Generic;
  3 using System.Linq;
  4 using System.Text;
  5 using System.Threading.Tasks;
  6 
  7 namespace S.Framework.Entity
  8 {
  9     public abstract class EntityBaseModel : EntityModelBaseForReflection
 10     {
 11         /// <summary>
 12         /// 获取或设置一个 <see cref="string"/> 值,该值表示实体对象的数据创建者。
 13         /// </summary>
 14         public virtual string CreateUser { get; set; }
 15 
 16         /// <summary>
 17         /// 获取或设置一个 <see cref="DateTime"/> 值,该值表示实体对象的数据创建时间。
 18         /// </summary>
 19         public virtual DateTime CreateDate { get; set; }
 20 
 21         /// <summary>
 22         /// 获取或设置一个 <see cref="string"/> 值,该值表示实体对象的数据最后修改者。
 23         /// </summary>
 24         public virtual string LastModifyUser { get; set; }
 25 
 26         /// <summary>
 27         /// 获取或设置一个 <see cref="DateTime"/> 值,该值表示实体对象的数据最后修改时间。
 28         /// </summary>
 29         public virtual DateTime? LastModifyDate { get; set; }
 30     }
 31 }
 32 
实体模型通用类

这2个类都加上了abstract,表示不能被实例化。

现在,让SysUser实体继承EntityBaseModel,并且删除SysUser中的创建人、创建时间、最后修改人、最后修改时间这4个属性。

然后修改Exec.tt,通过反射来获取实体信息。

  1 <#@ template language="C#" debug="True" #>
  2 <#@ assembly name="System.Core" #>
  3 <#@ output extension="cs" #>
  4 <#@ import namespace="System.IO" #>
  5 <#@ import namespace="System.Text" #>
  6 <#@ import namespace="System.Reflection" #>
  7 <#@ import namespace="System.Linq" #>
  8 <#@ import namespace="System.Collections.Generic" #>
  9 <#@ include file="T4Toolbox.tt" #>
 10 <#@ include file="Configuration.tt" #>
 11 <#
 12 
 13     string coreName = "S.Framework", projectName = coreName + ".DataCore", entityProjectName = coreName + ".Entity";
 14     string entityBaseModelName = entityProjectName + ".EntityBaseModel";
 15     string entityBaseModelNameForReflection = entityProjectName + ".EntityModelBaseForReflection";
 16     //当前完整路径
 17     string currentPath = Path.GetDirectoryName(Host.TemplateFile);
 18     //T4文件夹的父级文件夹路径
 19     string projectPath = currentPath.Substring(0, currentPath.IndexOf(@"\T4"));
 20     //解决方案路径
 21     string solutionFolderPath = currentPath.Substring(0, currentPath.IndexOf(@"\" + projectName));
 22 
 23     //加载数据实体.dll
 24     string entityFilePath = string.Concat(solutionFolderPath, ("\\"+ entityProjectName +"\\bin\\Debug\\" + entityProjectName + ".dll"));
 25     byte[] fileData = File.ReadAllBytes(entityFilePath);
 26     Assembly assembly = Assembly.Load(fileData);
 27     //反射出实体类,不知道为啥此处不能成功判定“是否继承EntityModelBaseForReflection类”
 28     //因此只能通过名称比较的方式来判定
 29     IEnumerable<Type> modelTypes = assembly.GetTypes().Where(m => m.IsClass && !m.IsAbstract && (m.BaseType.FullName.Equals(entityBaseModelName) || m.BaseType.FullName.Equals(entityBaseModelNameForReflection)));
 30 
 31     //循环实体类
 32     List<string> prefixNames = new List<string>();
 33     foreach (Type item in modelTypes)
 34     {
 35         //找 实体文件夹 名称
 36         string tempNamespace= item.Namespace, nameSpaceWithoutProjectName = tempNamespace.Substring(entityProjectName.Length);
 37         if(nameSpaceWithoutProjectName.IndexOf(".") != 0 || nameSpaceWithoutProjectName.LastIndexOf(".") > 0)
 38         { continue; }
 39 
 40         //是否直接继承实体基本类
 41         bool purity = item.BaseType.FullName.Equals(entityBaseModelNameForReflection);
 42         //实体所在的数据库标识名称
 43         string targetName = nameSpaceWithoutProjectName.Substring(1);
 44         if(!prefixNames.Any(a => a == targetName)){ prefixNames.Add(targetName); }
 45         //目标文件的路径和名称(嵌套Generate文件夹是为了标识T4生成的类文件)
 46         string fileName= targetName + @"\Generate\" + item.Name + "Configuration.cs";
 47 
 48         //配置文件
 49         string folderName= @"\Configurations\";
 50         Configuration configuration = new Configuration(item.Name, targetName);
 51         configuration.Output.Encoding = Encoding.UTF8;
 52         string path = projectPath + folderName + fileName;
 53         configuration.RenderToFile(path);
 54     }
 55 #>
 56 
执行器文件

这里注意一下,由于是通过反射S.Framework.Entity/bin/下的S.Framework.Entity.dll来获取实体信息,因此当新增或移除实体,请先编译S.Framework.Entity,再来运行模板执行器。

既然有了“实体通用属性类EntityBaseModel”,那么就可以确定:继承于EntityBaseModel的实体,都需要配置“创建人、创建时间、最后修改人、最后修改时间”这4个字段。那也加到模板中吧,减少手动要写的代码。

修改Configuration.tt如下:

  1 <#+
  2 // <copyright file="Configuration.tt" company="">
  3 //  Copyright © . All Rights Reserved.
  4 // </copyright>
  5 
  6 public class Configuration : CSharpTemplate
  7 {
  8     private string _modelName; //实体名称
  9     private string _prefixName; //实体前缀名称,表示不同的数据库
 10     private bool _purity; //是否为纯净的实体,若为纯净则表示无任何额外属性,不纯净则表示包含“创建、最后修改”等额外属性
 11     public Configuration(string modelName, string prefixName, bool purity)
 12     {
 13         _modelName = modelName;
 14         _prefixName = prefixName;
 15         _purity = purity;
 16     }
 17 
 18 	public override string TransformText()
 19 	{
 20 		base.TransformText();
 21 #>
 22 
 23 using System;
 24 using System.Data.Entity.ModelConfiguration;
 25 using System.Data.Entity.ModelConfiguration.Configuration;
 26 
 27 using S.Framework.Entity.<#= _prefixName #>;
 28 
 29 namespace S.Framework.DataCore.EntityFramework.Configurations.<#= _prefixName #>
 30 {
 31 	/// <summary>
 32     /// 数据表映射
 33     /// </summary>
 34     partial class <#= _modelName #>Configuration : EntityTypeConfiguration<<#= _modelName #>>
 35     {
 36         /// <summary>
 37         /// 数据表映射构造函数
 38         /// </summary>
 39         public <#= _modelName #>Configuration()
 40         {
 41             <#= _modelName #>ConfigurationDefault();
 42             <#= _modelName #>ConfigurationAppend();
 43         }
 44 
 45         /// <summary>
 46         /// 默认的数据映射
 47         /// </summary>
 48         public void <#= _modelName #>ConfigurationDefault()
 49         {
 50 <#+
 51         if(!this._purity)
 52 {
 53 #>
 54             this.Property(p => p.CreateUser).IsRequired().HasMaxLength(100);
 55 
 56             this.Property(p => p.LastModifyUser).HasMaxLength(100);
 57 <#+
 58 }
 59 #>
 60         }
 61 
 62         /// <summary>
 63         /// 数据映射
 64         /// </summary>
 65         partial void <#= _modelName #>ConfigurationAppend();
 66 
 67         /// <summary>
 68         /// 将映射配置注册给配置注册器
 69         /// </summary>
 70         /// <param name="configurations">配置注册器对象</param>
 71         public void RegistTo(ConfigurationRegistrar configurations)
 72         {
 73             configurations.Add(this);
 74         }
 75     }
 76 }
 77 <#+
 78         return this.GenerationEnvironment.ToString();
 79 	}
 80 }
 81 #>
 82 
修改后的配置模板文件

同时,需要在Exec.tt中增加参数的传递。

  1 Configuration configuration = new Configuration(item.Name, targetName);

修改为

  1 Configuration configuration = new Configuration(item.Name, targetName, purity);

再运行Exec.tt,打开生成的用户配置类,检查正确与否。

验收成果

为了检验T4功能和配置功能,我们添加一个角色实体类。

  1 using System;
  2 using System.Collections.Generic;
  3 using System.Linq;
  4 using System.Text;
  5 using System.Threading.Tasks;
  6 
  7 namespace S.Framework.Entity.Master
  8 {
  9     /// <summary>
 10     /// 角色
 11     /// </summary>
 12     public class SysRole : EntityBaseModel
 13     {
 14         /// <summary>
 15         /// 主键
 16         /// </summary>
 17         public string ID { get; set; }
 18 
 19         /// <summary>
 20         /// 名称
 21         /// </summary>
 22         public string Name { get; set; }
 23 
 24         /// <summary>
 25         /// 排序号
 26         /// </summary>
 27         public int SortNumber { get; set; }
 28     }
 29 }
 30 
角色实体

注意命名空间!别忘记删除“分类文件夹名System”。

编译S.Framework.Entity,然后去右键运行Exec.tt。得到以下结果:

image

如果要自定义一些配置,请在Customize文件夹中建立同名同命名空间的角色配置类,注意关键字partial。参照自定义用户配置类。

  1 using System;
  2 using System.Collections.Generic;
  3 using System.Linq;
  4 using System.Text;
  5 using System.Threading.Tasks;
  6 
  7 namespace S.Framework.DataCore.EntityFramework.Configurations.Master
  8 {
  9     partial class SysRoleConfiguration
 10     {
 11         partial void SysRoleConfigurationAppend()
 12         {
 13             this.Property(p => p.ID).IsRequired().HasMaxLength(100);
 14 
 15             this.Property(p => p.Name).IsRequired().HasMaxLength(100);
 16         }
 17     }
 18 }
 19 
自定义角色配置类

现在把新实体关联到EF数据库上下文中,在MasterEntityContext中增加属性:

  1 /// <summary>
  2 /// 角色
  3 /// </summary>
  4 public DbSet<SysRole> Roles { get; set; }

同时在OnModelCreating方法中增加对角色配置类的注册:

  1 new SysRoleConfiguration().RegistTo(modelBuilder.Configurations);

删除原数据库,重新跑一下项目,让EF再次创建数据库吧。

下一章节,将实现EntityFramework自动合并/迁移/数据初始化。

截止本章节,项目源码下载:点击下载(存在百度云盘中)

原文地址:https://www.cnblogs.com/matrixkey/p/5561300.html