zoukankan      html  css  js  c++  java
  • .NET框架设计(常被忽视的框架设计技巧)

    阅读目录:

    • 1.开篇介绍
    • 2.元数据缓存池模式(在运行时构造元数据缓存池)
      • 2.1.元数据设计模式(抽象出对数据的描述数据)
      • 2.2.借助Dynamic来改变IOC、AOP动态绑定的问题
      • 2.3.元数据和模型绑定、元数据应该隐藏在Model背后、元数据与DSL的关系
    • 3.链式配置Dynamic模式(爱不释手的思维习惯编程)
    • 4.委托工厂模式(要优于常见的 工厂,概念更加准确,减少污染)
    • 5.规则外挂(视委托为特殊的规则对象原型)

    1】开篇介绍

    通过上一篇的“.NET框架设计—常被忽视的C#设计技巧”一文来看,对于框架设计的技巧还是有很多人比较有兴趣的,那么框架设计思想对于我们日常开发来说其实并不是很重要,但是对于我们理解框架背后的运行原理至关重要;当我们使用着LINQ灵活的语法的同时我们是否能理解它的背后运行原理、设计原理更深一点就是它的设计模式及复杂的对象模型;

    从一开始学习.NET我就比较喜欢框架背后的设计模型,框架提供给我们的使用接口是及其简单的,单纯从使用上来看我们不会随着对框架的使用时间而增加我们对框架内部设计的理解,反而会养成一样拿来即用的习惯,我们只有去了解、深挖它的内部设计原理才是我们长久学习的目标;因为框架的内部设计模式是可以提炼出来并被总结的;

    这篇文章总结了几个我最近接触的框架设计思想,可以称他们为模式;由于时间关系,这里只是介绍加一个简单的介绍和示例让我们能基本的了解它并且能在日后设计框架的时候想起来有这么一个模式、设计方式可以借鉴;当然,这每一节都是一个很大主题,用的时候在去细心的分析学习吧;

    2】元数据缓存池模式(在运行时构造元数据缓存池)

    很多框架都有将特性放在属性上面用来标识某种东西,但是这种方式使用不当的话会对性能造成影响;再从框架设计原则来讲也是对DomainModel极大的污染,从EntityFramework5.0之前的版本我们就能体会到了,它原本是将部分Attribute加在了Entity上的,但是这毕竟是业务代码的核心,原则上来说这不能有任何的污染,要绝对的POJO;后来5.0之后就完全独立了DomainModel.Entity,所有的管理都在运行时构造绑定关系,因为它有EDMX元数据描述文件;

    那么这些Attribute其实本质是.NET在运行时的一种元数据,主要的目的是我们想在运行时将它读取出来,用来对某些方面的判断;那么现在的问题是如果我们每次都去读取这个Attribute是必须要走反射机制,当然你可以找一些框架来解决这个问题;(我们这里讨论的是你作为开发框架的设计者!)

    反射影响性能这不用多讲了,那么常规的做法是会在第一次反射之后将这些对象缓存起来,下次再用的时候直接在缓存中读取;这没有问题,这是解决了反射的性能问题,那么你的Attribute是否还要加在DomainModel中呢,如果加的话随着代码量的增加,这些都会成为后面维护的成本开销;那么我们如何将干净的POJO对象提供给程序员用,但是在后台我们也能对POJO进行强大的控制?这是否是一种设计问题?

    2.1】元数据设计模式(抽象出对数据的描述数据)

    我一直比较关注对象与数据之间的关系,面向对象的这种纵横向关系如何平滑的与E-R实体关系模型对接,这一直是复杂软件开发的核心问题;这里就用它来作为本章的示例的基本概要;

    我们有一个基本的DomainModel聚合,如何在不影响本身简洁性的情况下与E-R关系对接,比如我们在对聚合进行一个Add操作如何被映射成对数据库的Insert操作;我们来看一下元数据设计模式思想;

     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ConsoleApplication1.DomainModel
     9 {
    10     /// <summary>
    11     /// Employee.<see cref="DomainModel.Employee"/>
    12     /// </summary>
    13     public class Employee
    14     {
    15         /// <summary>
    16         /// Primary id.
    17         /// </summary>
    18         public string EId { get; set; }
    19 
    20         /// <summary>
    21         /// Name.
    22         /// </summary>
    23         public string Name { get; set; }
    24 
    25         /// <summary>
    26         /// Sex.<see cref="DomainModel.SexType"/>
    27         /// </summary>
    28         public SexType Sex { get; set; }
    29 
    30         /// <summary>
    31         /// Address.
    32         /// </summary>
    33         public Address Address { get; set; }
    34     }
    35 }
    View Code

    这里有一个以Employee实体为聚合根的聚合,里面包含一些基本的属性,特别需要强调的是Sex属性和Address,这两个属性分别是Complex类型的属性;
    Complex类型的属性是符合面向对象的需要的,但是在关系型数据库中是很难实现的,这里就需要我们用元数据将它描述出来并能在一些行为上进行控制;

     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ConsoleApplication1.DomainModel
     9 {
    10     /// <summary>
    11     /// Address .
    12     /// </summary>
    13     public struct Address
    14     {
    15         /// <summary>
    16         /// Address name.
    17         /// </summary>
    18         public string AddressName { get; set; }
    19     }
    20 }
    View Code

    这是Address类型的定义;

     1 namespace ConsoleApplication1.DomainModel
     2 {
     3     /// <summary>
     4     /// Sex type.
     5     /// </summary>
     6     public enum SexType
     7     {
     8         Male,
     9         Female
    10     }
    11 }
    View Code

    这是SexType类型的定义;都比较简单;  

    只有这样我们才能对DomainModel进行大面积的复杂设计,如果我们不能将数据对象化我们无法使用设计模式,也就谈不上扩展性;

    图1:

    这是我们的对象模型,那么我们如何将它与数据库相关的信息提取出来形成独立的元数据信息,对元数据的抽取需要动、静结合才行;

    什么动、静结合,我们是否都会碰见过这样的问题,很多时候我们的代码在编译时是确定的,但是有部分的代码需要在运行时动态的构造,甚至有些时候代码需要根据当前的IDE来生成才行,但是最终在使用的时候这些在不同阶段生成的代码都需要结合起来变成一个完整的元数据对象;

    框架在很多时候需要跟IDE结合才能使使用变的顺手,比如我们在开发自己的ORM框架如果不能直接嵌入到VisualStudio中的话,用起来会很不爽;当我们用自己的插件去连接数据库并且生成代码的时候,有部分的元数据模型已经在代码中实现,但是有部分需要我们动态的去设置才行;

    我们来看一下关于元数据的基础代码;

     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ORM.Meta
     9 {
    10     using System;
    11     using System.Collections.Generic;
    12     using System.Linq;
    13     using System.Text;
    14     using System.Threading.Tasks;
    15 
    16     /// <summary>
    17     /// Data source context.
    18     /// </summary>
    19     public abstract class DataBaseContext : List<MetaTable>, IDisposable
    20     {
    21         /// <summary>
    22         /// Data base name.
    23         /// </summary>
    24         protected string DataBaseName { get; set; }
    25 
    26         /// <summary>
    27         /// Connection string.
    28         /// </summary>
    29         protected string ConnectionString { get; set; }
    30 
    31         /// <summary>
    32         /// Provider child class add table.
    33         /// </summary>
    34         /// <param name="table"></param>
    35         protected virtual void AddTable(MetaTable table)
    36         {
    37             this.Add(table);
    38         }
    39 
    40         /// <summary>
    41         /// Init context.
    42         /// </summary>
    43         protected virtual void InitContext() { }
    44         public void Dispose() { }
    45     }
    46 }
    View Code

    这表示数据源上下文,属于运行时元数据的基础设施;

     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ORM.Meta
     9 {
    10     using System.Collections.Generic;
    11     using System.Linq;
    12 
    13     /// <summary>
    14     /// Database Table meta.
    15     /// </summary>
    16     public class MetaTable : List<MetaColumn>
    17     {
    18         /// <summary>
    19         /// Table name.
    20         /// </summary>
    21         public string Name { get; set; }
    22 
    23         /// <summary>
    24         /// Entity name.
    25         /// </summary>
    26         public string EntityName { get; set; }
    27 
    28         /// <summary>
    29         /// Get column by column name.
    30         /// </summary>
    31         /// <param name="name">Column name.</param>
    32         /// <returns><see cref="ORM.MetaColumn"/></returns>
    33         public MetaColumn GetColumnByName(string name)
    34         {
    35             var column = from item in this.ToList() where item.CoumnName == name select item;
    36             return column.FirstOrDefault();
    37         }
    38     }
    39 }
    View Code

    简单的表示一个Table,里面包含一系列的Columns;要记住在设计元数据基础代码的时候将接口留出来,方便在IDE中植入初始化元数据代码;

    图2:

    到目前为止我们都是在为元数据做基础工作,我们看一下有系统生成的声明的元数据代码;

     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ConsoleApplication1.Repository
     9 {
    10     using System;
    11     using System.Collections.Generic;
    12     using System.Linq;
    13     using System.Text;
    14     using System.Threading.Tasks;
    15 
    16     /// <summary>
    17     /// IDE Builder.
    18     /// </summary>
    19     public class DesignBuilder_DataBaseContext : ORM.Meta.DataBaseContext
    20     {
    21         //this begin IDE builder.
    22         protected override void InitContext()
    23         {
    24             ORM.Meta.MetaTable metaTable = new ORM.Meta.MetaTable() { Name = "TB_Employee", EntityName = "Employee" };
    25             metaTable.Add(new ORM.Meta.MetaColumn()
    26             {
    27                 CoumnName = "EId",
    28                 DataType = ORM.Meta.DataType.NVarchar
    29             });
    30             metaTable.Add(new ORM.Meta.MetaColumn()
    31             {
    32                 CoumnName = "Name",
    33                 DataType = ORM.Meta.DataType.NVarchar
    34             });
    35             metaTable.Add(new ORM.Meta.MetaColumn()
    36             {
    37                 CoumnName = "Sex",
    38                 DataType = ORM.Meta.DataType.Int
    39             });
    40             metaTable.Add(new ORM.Meta.MetaColumn()
    41             {
    42                 CoumnName = "Address",
    43                 DataType = ORM.Meta.DataType.NVarchar
    44             });
    45             this.AddTable(metaTable);
    46         }
    47         //end
    48     }
    49 }
    View Code

    我假设这是我们框架在IDE中生成的部分元数据代码,当然你可以用任何方式来存放这些元数据,但是最后还是要去对象化;

    图3:

    这个目录你可以直接隐藏,在后台属于你的框架需要的一部分,没有必要让它污染项目结构,当然放出来也有理由;如果想让你的LINQ或者表达式能直接穿过你的元数据上下文你需要直接扩展;

    1 static void Main(string[] args)
    2         {
    3             using (Repository.DesignBuilder_DataBaseContext context = new Repository.DesignBuilder_DataBaseContext())
    4             {
    5                 var employee = from emp in context.Employee where emp.EId == "Wqp123" select emp;
    6             }
    7         }
    View Code

    这里所有的代码看上去很简单,没有多高深的技术,这也不是本篇文章的目的,任何代码都需要设计的驱动才能产生价值,我们构建的基础代码都是元数据驱动;当你在运行时把这些元数据放入Cache,既不需要加Attribute也不需要反射反而活动了更大程度上的控制,但是要想构建一个能用的元数据结构需要结合具体的需求才行;

    2.2】借助Dynamic来改变IOC、AOP动态绑定的问题

    要想在运行时完全动态的绑定在编译时定义的对象行为是需要强大的IOC框架支撑的,这样的框架我们是做不来的或者需要很多精力,得不偿失;对于元数据设计需要将AOP通过IOC的方式注入,在使用的时候需要改变一下思路,AOP的所有的切面在编译时无法确定,后期通过IOC的方式将所有的行为注入;这里我们需要使用动态类型特性;

    使用Dynamic之后我们很多以往不能解决问题都可以解决,更向元编程跨进了一步;对于IOC、AOP的使用也将变的很简单,也有可能颠覆以往IOC、AOP的使用方式;而且动态编程将在很大程度上越过设计模式了,也就是设计模式的使用方式在动态编程中将不复存在了;

     1 using (Repository.DesignBuilder_DataBaseContext context = new Repository.DesignBuilder_DataBaseContext())
     2             {
     3                 var employees = from emp in context.Employee where emp.EId == "Wqp123" select emp;
     4 
     5                 Employee employee = new Employee() { EId = "Wqp123" };
     6 
     7                 var entityOpeartion = DynamicBehavior.EntityDymanicBehavior.GetEntityBehavior<Employee>(employee);
     8 
     9                 entityOpeartion.Add();
    10             }
    View Code
     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ConsoleApplication1.DynamicBehavior
     9 {
    10     using System;
    11     using System.Dynamic;
    12 
    13     public class EntityDymanicBehavior
    14     {
    15         public static dynamic GetEntityBehavior<TEntity>(TEntity entity)
    16         {
    17             //auto mark entity behavior
    18             dynamic dy = new ExpandoObject();
    19             //load meta data mark dynamic behavior
    20             dy.Entity = entity;
    21             dy.Add = new Action(() =>
    22             {
    23                 Console.WriteLine("Action Add " + entity.GetType());
    24             });
    25             return dy;
    26         }
    27     }
    28 }
    View Code

    图4:

    画红线的部分是可以抽取来放入扩展方法Add中的,在构造的内部是完全可以进入到元数据缓存池中拿到这些数据然后直接动态生成扩展方法背后的真实方法;

    2.3】元数据和模型绑定、元数据应该隐藏在Model背后、元数据与DSL的关系

    元数据的绑定应该在运行时动态去完成,这点在以往我们需要大费力气,通过CodeDom、Emit才能完成,但是现在可以通过Dynamic、DLR来完成;思维需要转变一下,动态编程我们以往用的最多的地方在JS上,现在可以在C#中使用,当然你也可以使用专门的动态语言来写更强大的元数据框架,IronRuby、IronPython都是很不错的,简单的了解过Ruby的元数据编程,很强大,如果我们.NET程序员眼馋就用Iron…系列;

    在开发复杂的动态行为时尽量使用元数据设计思想,不要把数据和表示数据的数据揉在一起,要把他们分开,在运行时Dynamic绑定;元数据应该在Model的背后应该在DomainModel的背后;

    元数据和DSL有着天然的渊源,如果我们能把所有的语句组件化就可以将其封入.NET组件中,在IDE中进行所见即所得的DSL设计,然后生成可以直接运行的Dynamic代码,这可能也是元编程的思想之一吧;

    图5:

    这可能是未来10年要改变的编程路线吧,我只是猜测;最后软件将进一步被自定义;

    3】链式配置Dynamic模式(爱不释手的思维习惯编程)

    再一次提起链式编程是觉得它的灵活性无话可说,语言特性本身用在哪里完全需求驱动;把链式用来做配置相关的工作非常的合适;我们上面做了元数据配置相关的工作,这里我们试着用链式的方法来改善它;

    Dynamic类型本身的所有行为属性都是可以动态构建的,那么我们把它放入链式的方法中去,根据不同的参数来实现动态的添加行为;

    扩展Dynamic类型需要使用ExpandoObject开始;

     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ConsoleApplication1.DynamicBehavior
     9 {
    10     using System;
    11     using System.Dynamic;
    12 
    13     public static class EntityDynamicBehaviorExtent
    14     {
    15         /// <summary>
    16         /// Add dynamic method.
    17         /// </summary>
    18         /// <param name="entity"></param>
    19         /// <returns></returns>
    20         public static ExpandoObject AddExten(this ExpandoObject entity)
    21         {
    22             dynamic dy = entity as dynamic;
    23             dy.Add = new Func<ExpandoObject>(() => { Console.WriteLine("add " + entity); return entity; });
    24             return entity;
    25         }
    26         /// <summary>
    27         /// where  dynamic method. 
    28         /// </summary>
    29         /// <typeparam name="T"></typeparam>
    30         /// <param name="entity"></param>
    31         /// <param name="where"></param>
    32         /// <returns></returns>
    33         public static ExpandoObject WhereExten<T>(this ExpandoObject entity, Func<T, bool> where)
    34         {
    35             dynamic dy = entity as dynamic;
    36             dy.Where = where;
    37             return entity;
    38         }
    39     }
    40 }
    View Code

    扩展方法需要扩展 ExpandoObject对象,DLR在运行时使用的是ExpandoObject对象实例,所以我们不能够直接扩展Dynamic关键字;

    1 Employee employee1 = new Employee() { EId = "Wqp123" };
    2                 var dynamicEntity = DynamicBehavior.EntityDymanicBehavior.GetEntityBehavior<Employee>(employee1);
    3                 (dynamicEntity as System.Dynamic.ExpandoObject).AddExten().WhereExten<Employee>(emp =>
    4                 {
    5                     Console.WriteLine("Where Method.");
    6                     return emp.EId == "Wqp123";
    7                 });
    8                 dynamicEntity.Add().Where(employee1);
    View Code

     图6:

    红线部分必须要转换才能顺利添加行为;

    4】委托工厂模式(要优于常见的 工厂,概念更加准确,减少污染)

    对于工厂模式我们都会熟悉的一塌糊涂,各种各样的工厂模式我们见的多了,但是这种类型的工厂使用方式你还真的没见过;其实这种委托是想部分的逻辑交给外部来处理;

     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ConsoleApplication1.DomainModel
     9 {
    10     /// <summary>
    11     /// Address factory.
    12     /// </summary>
    13     /// <returns></returns>
    14     public delegate Address Factory();
    15 
    16     /// <summary>
    17     /// Employee.<see cref="DomainModel.Employee"/>
    18     /// </summary>
    19     public class Employee
    20     {
    21         public Employee() { }
    22         /// <summary>
    23         /// Mark employee instance.
    24         /// </summary>
    25         /// <param name="eID"></param>
    26         /// <param name="name"></param>
    27         /// <param name="sex"></param>
    28         /// <param name="addressFactory">address factory.</param>
    29         public Employee(string eID, string name, SexType sex, Factory addressFactory)
    30         {
    31             this.EId = eID;
    32             this.Name = name;
    33             this.Sex = sex;
    34             this.Address = addressFactory();
    35         }
    36         /// <summary>
    37         /// Primary id.
    38         /// </summary>
    39         public string EId { get; set; }
    40 
    41         /// <summary>
    42         /// Name.
    43         /// </summary>
    44         public string Name { get; set; }
    45 
    46         /// <summary>
    47         /// Sex.<see cref="DomainModel.SexType"/>
    48         /// </summary>
    49         public SexType Sex { get; set; }
    50 
    51         /// <summary>
    52         /// Address.
    53         /// </summary>
    54         public Address Address { get; set; }
    55     }
    56 }
    View Code

    我们定义了一个用来创建Employee.Address对象的Factory,然后通过构造函数传入;

    1 Employee employee2 = new Employee("Wqp123", "Wqp", SexType.Male, new Factory(() =>
    2                 {
    3                     return new Address() { AddressName = "Shanghai" };
    4                 }));
    View Code

    这里纯粹为了演示方便,这种功能是不应该在DommianModel中使用的,都是在一些框架、工具中用来做灵活接口用的;

    5】规则外挂(视委托为特殊的规则对象原型)

    规则外挂其实跟上面的委托工厂有点像,但是绝对不一样的设计思想;如何将规则外挂出去,放入Cache中让运行时可以配置这个规则参数;委托是规则的天然宿主,我们只要将委托序列化进Cache就可以对它进行参数的配置;

     1 /*==============================================================================
     2  * Author:深度训练
     3  * Create time: 2013-08-04
     4  * Blog Address:http://www.cnblogs.com/wangiqngpei557/
     5  * Author Description:特定领域软件工程实践;
     6  *==============================================================================*/
     7 
     8 namespace ConsoleApplication1.DomainModel.Specification
     9 {
    10     using System;
    11     using System.Linq.Expressions;
    12 
    13     /// <summary>
    14     /// Employee add specification.
    15     /// </summary>
    16     [Serializable]
    17     public class EmployeeSpecificationAdd : System.Runtime.Serialization.IDeserializationCallback
    18     {
    19         /// <summary>
    20         /// specification.
    21         /// </summary>
    22         [NonSerialized]
    23         private Func<Employee, bool> _specification;
    24         /// <summary>
    25         /// Gets specification.
    26         /// </summary>
    27         public Func<Employee, bool> Specificaion { get { return _specification; } }
    28 
    29         /// <summary>
    30         /// employee.
    31         /// </summary>
    32         private Employee Employee { get; set; }
    33 
    34         /// <summary>
    35         /// Mark employee specificatoin.
    36         /// </summary>
    37         /// <param name="employee"></param>
    38         public EmployeeSpecificationAdd(Employee employee)
    39         {
    40             this.Employee = employee;
    
    41             InitSpecification();
    42         }
    43         /// <summary>
    44         /// Is Check.
    45         /// </summary>
    46         /// <returns></returns>
    47         public bool IsCheck()
    48         {
    49             return _specification(Employee);
    50         }
    51 
    52         public void OnDeserialization(object sender)
    53         {
    54             InitSpecification();
    55         }
    56         private void InitSpecification()
    57         {
    58             this._specification = (emp) =>
    59             {
    60                 return !string.IsNullOrWhiteSpace(emp.EId) && !string.IsNullOrWhiteSpace(emp.Name);
    61             };
    62         }
    63     }
    64 }
    View Code

    图7:

    注意这里的反序列化接口实现,因为Lambda无法进行序列化,也没有必要进行序列化;

     1 EmployeeSpecificationAdd specification = new EmployeeSpecificationAdd(employee2);
     2 
     3                 Stream stream = File.Open("specification.xml", FileMode.Create);
     4                 BinaryFormatter formattter = new BinaryFormatter();
     5                 formattter.Serialize(stream, specification);
     6 
     7                 stream.Seek(0, SeekOrigin.Begin);
     8                 specification = formattter.Deserialize(stream) as EmployeeSpecificationAdd;
     9 
    10                 stream.Close();
    11                 stream.Dispose();
    12                 if (specification.IsCheck())
    13                 {
    14                     Console.WriteLine("Ok...");
    15                 }
    View Code

    既然能将规则序列化了,就可以把它放在任何可以使用的地方了,配置化已经没有问题了;

    示例Demo地址:http://files.cnblogs.com/wangiqngpei557/ConsoleApplication2.zip

  • 相关阅读:
    TypeError: Buffer.alloc is not a function
    node.js服务端程序在Linux上持久运行
    C#中的反射
    群要事日记
    vs2017 自定义生成规则 错误 MSB3721 命令 ”已退出,返回代码为 1。
    VP9 Video Codec
    用户手册是Yasm汇编
    更改Mysql数据库存储位置
    注册表项
    C#开发可以可视化操作的windows服务
  • 原文地址:https://www.cnblogs.com/wangiqngpei557/p/3236680.html
Copyright © 2011-2022 走看看