• WCF 已知类型和泛型解析程序 KnownType


    数据协定继承

    已知类型和泛型解析程序

    Juval Lowy

    下载代码示例

    自首次发布以来,Windows Communication Foundation (WCF) 开发人员便必须处理数据协定继承方面的麻烦(一个称为“已知类型”的问题)。 在本文中,我首先说明这个问题的来源,讨论 Microsoft .NET Framework 3.0 和 .NET Framework 4 中的可用缓解方法,然后演示我用来完全解决这个问题的方法。 除此之外,还会介绍一些高级 WCF 编程技术。

    按值与 按引用

    在传统的面向对象语言(如 C++ 和 C#)中,派生类维护与其基类之间的包含 关系。 也就是说,进行如下声明后,每个 B 对象仍然是 A 对象:

     
    class A {...}
    class B : A {...}
    
    

    如果以图形表示,这类似于图 1 中的维恩图,每个 B 实例仍然是 A 实例(但不一定每个 A 都是 B)。

    图 1 包含关系

    从传统的面向对象域建模角度,包含关系可用于针对基类设计代码,同时与子类进行交互。这意味着可以逐步发展域实体的建模,同时尽量减小对应用程序的影响。

    例如,考虑一个业务联系人管理应用程序,按这种建模方式声明一个基类 Contact 和一个派生类 Customer,派生类通过添加客户的特性来细化联系人:

     
    class Contact {
      public string FirstName;
      public string LastName;
    }
    
    class Customer : Contact {
      public int OrderNumber;
    }
    
    

    应用程序中原为 Contact 类型编写的所有方法都可以接受 Customer 对象,如图 2 所示。

    图 2 交换基类和子类引用

     
    interface IContactManager {
      void AddContact(Contact contact);
      Contact[] GetContacts();
    }
    
    class AddressBook : IContactManager {
      public void AddContact(Contact contact)
      {...}
      ...
    }
    
    IContactManager contacts = new AddressBook();
    
    Contact  contact1 = new Contact();
    Contact  contact2 = new Customer();
    Customer customer = new Customer();
    
    contacts.AddContact(contact1);
    contacts.AddContact(contact2);
    contacts.AddContact(customer);
    
    

    图 2 中的代码能够正常运行,是因为编译器在内存中表示对象状态的方式。为了支持基类与其子类之间的包含关系,在分配新子类实例时,编译器首先分配对象状态的基类部分,然后直接在其后追加子类部分,如图 3 所示。

    图 3 内存中的对象状态层次结构

    对于需要引用 Contact 的方法,如果实际提供的是对 Customer 的引用,该方法仍可正常运行,这是因为 Customer 引用也是对 Contact 的引用。 

    很遗憾,在 WCF 中,不能采用这种复杂的方式。与传统的面向对象或经典的 CLR 编程模型不同,WCF 按值传递所有操作参数,而不是按引用。即使代码类似于按引用传递参数(如在常规 C# 中),WCF 代理实际上是将参数序列化到消息中。参数在 WCF 消息中打包并传输给服务,然后在服务中反序列化为本地引用,供服务操作使用。

    服务操作向客户端返回结果时,也会进行这一系列操作:结果(或传出参数或异常)首先序列化到回复消息中,然后在客户端重新反序列化。

    序列化的确切形式通常取决于编写服务协定所依据的数据协定。例如,考虑以下数据协定:

     
    [DataContract]
    class Contact {...}
    
    [DataContract]
    class Customer : Contact {...}
    
    

    使用这些数据协定可以定义以下服务协定:

     
    [ServiceContract]
    interface IContactManager {
      [OperationContract]
      void AddContact(Contact contact);
    
      [OperationContract]
      Contact[] GetContacts();
    }
    
    

    对于多层应用程序,按值封送参数优于按引用封送,因为体系结构中的任何层都可自由地向数据协定后的行为提供自己的解释。 按值封送还可实现远程调用、互操作性、排队调用和长时间运行的工作流。

    但是与传统的面向对象不同,针对 Contact 类编写的服务操作默认情况下无法使用 Customer 子类。 原因很简单:如果确实向需要基类引用的服务操作传递了子类引用,WCF 如何知道要将派生类部分序列化到消息中呢?

    因此,根据定义,下面的 WCF 代码会失败:

     
    class ContactManagerClient : ClientBase<IContactManager> : 
      IContactManager{
      ...
    }
    
    IContactManager proxy = new ContactManagerClient();
    Contact contact = new Customer();
    
    // This will fail: 
    contacts.AddContact(contact);
    
    

    已知类型支持

    对于 .NET Framework 3.0,WCF 能够使用 KnownTypeAttribute 解决将基类引用替换为子类引用的问题,KnownTypeAttribute 定义为:

     
    [AttributeUsage(AttributeTargets.Struct|AttributeTargets.Class,
      AllowMultiple = true)]
    public sealed class KnownTypeAttribute : Attribute {
      public KnownTypeAttribute(Type type);
      //More members
    }
    
    

    通过 KnownType 特性可以为数据协定指定可接受的子类:

     
    [DataContract]
      [KnownType(typeof(Customer))]
      class Contact {...}
    
      [DataContract]
      class Customer : Contact {...}
    
    

    当客户端传递使用已知类型声明的数据协定时,WCF 消息格式化程序会测试类型(类似使用 is 运算符)并查看其是否为所需已知类型。 如果是,则将参数序列化为子类而不是基类。

    KnownType 特性对所有服务和终结点中所有使用基类的协定和操作产生影响,因此可接受子类而不是基类。此外,它还在元数据中包含子类,以便客户端拥有自己的子类定义并能够传递子类而不是基类。

    如果需要多个子类,开发人员必须列出所有子类:

     
    [DataContract]
    [KnownType(typeof(Customer))]
    [KnownType(typeof(Person))]
    class Contact {...}
    
    [DataContract]
    class Person : Contact {...}
    
    

    WCF 格式化程序使用反射收集数据协定的所有已知类型,然后检查提供的参数是否为任何已知类型。

    请注意,必须在数据协定类层次结构中显式添加所有级别。 添加子类不会添加其基类:

     
    [DataContract]
    [KnownType(typeof(Customer))]
    [KnownType(typeof(Person))]
    class Contact {...}
    
    [DataContract]
    class Customer : Contact {...}
    
    [DataContract]
    class Person : Customer {...}
    
    

    KnownType 特性可能涉及范围太大,因此,WCF 还提供了 ServiceKnownTypeAttribute,可应用于特定操作或特定协定。

    最后,在 .NET Framework 3.0 中,WCF 还允许在应用程序配置文件的 system.runtime.serialization 节中列出所需已知类型。 

    尽管从技术上来讲,使用已知类型可以解决问题,您可能还是会感觉到些许担心。 在传统的面向对象建模中,从不需要将基类与任何特定子类耦合。 准确地说,好基类的特点在于:好基类是所有可能子类的良好基础,但应用已知类型只能够解决已知的子类。 如果在设计系统时提前进行所有建模工作,这可能不是问题。实际上,随着应用程序建模的逐步进行,可能遇到目前未知的类型,这样,至少必须重新部署应用程序,更可能还要修改基类。

    数据协定解析程序

    若要缓解这个问题,.NET Framework 4 WCF 引入了一种在运行时解析已知类型的方式。 这种编程方法(称为数据协定解析程序)是功能最强的方法,这样可以通过扩展完全自动处理已知类型问题。 实际上,这样可以截获操作序列化和反序列化参数的尝试,在客户端和服务端都在运行时解析已知类型。

    实现编程解析的第一步是从抽象类 DataContractResolver 派生,DataContractResolver 定义为:

     
    public abstract class DataContractResolver {
      protected DataContractResolver();
      
      public abstract bool TryResolveType(
        Type type,Type declaredType,
        DataContractResolver knownTypeResolver, 
        out XmlDictionaryString typeName,
        out XmlDictionaryString typeNamespace);
    
      public abstract Type ResolveName(
        string typeName,string typeNamespace, 
        Type declaredType,
        DataContractResolver knownTypeResolver);
    }
    
    

    TryResolveType 的实现会在 WCF 尝试将类型序列化到消息中时调用,提供的类型(type 参数)与操作协定中声明的类型(declaredType 参数)不同。 如果要序列化类型,需要提供一些唯一标识符以用作字典中的键,而字典将标识符映射到类型。 WCF 在反序列化过程中提供这些键,以便与类型进行绑定。

    请注意,命名空间键不能为空字符串或 null。 尽管几乎任何唯一字符串值都可以作为标识符,我还是建议只使用 CLR 类型名称和命名空间。 将类型名称和命名空间设置为 typeName 和 typeNamespace 输出参数。

    如果从 TryResolveType 返回 true,则将类型视为已解析,就像应用了 KnownType 特性一样。 如果返回 false,则 WCF 调用失败。 请注意,TryResolveType 必须解析所有已知类型,即使是使用 KnownType 特性修饰或在配置文件中列出的类型也是如此。 这样存在以下潜在风险:这需要解析程序与应用程序中的所有已知类型耦合,并且以后对其他类型的操作调用也会失败。 因此,最好是使用默认已知类型解析程序(未使用您的解析程序时,WCF 使用该解析程序)解析类型。 这正是 knownTypeResolver 参数的用途。 如果 TryResolveType 的实现无法解析类型,则应委托给 knownTypeResolver。

    ResolveName 会在 WCF 尝试从消息反序列化出类型时调用,提供的类型(type 参数)与操作协定中声明的类型(declaredType 参数)不同。 这种情况下,WCF 提供类型名称和命名空间标识符,以便将其映射回已知类型。

    例如,再次考虑这两个数据协定:

     
    [DataContract]
    class Contact {...}
    
    [DataContract]
    class Customer : Contact {...}
    
    

    图 4 列出了用于 Customer 类型的简单解析程序。

    图 4 CustomerResolver

     
    class CustomerResolver : DataContractResolver {
      string Namespace {
        get {
          return typeof(Customer).Namespace ?? "
    global";
        }   
      }
    
      string Name {
        get {
          return typeof(Customer).Name;
        }   
      }
    
      public override Type ResolveName(
        string typeName,string typeNamespace,
        Type declaredType,
        DataContractResolver knownTypeResolver) {
    
        if(typeName == Name && typeNamespace == Namespace) {
          return typeof(Customer);
        }
        else {
          return knownTypeResolver.ResolveName(
            typeName,typeNamespace,declaredType,null);
        }
      }
    
      public override bool TryResolveType(
        Type type,Type declaredType,
        DataContractResolver knownTypeResolver,
        out XmlDictionaryString typeName,
        out XmlDictionaryString typeNamespace) {
    
        if(type == typeof(Customer)) {
          XmlDictionary dictionary = new XmlDictionary();
          typeName      = dictionary.Add(Name);
          typeNamespace = dictionary.Add(Namespace);
          return true;
        }
        else {
          return knownTypeResolver.TryResolveType(
            type,declaredType,null,out typeName,out typeNamespace);
        }
      }
    }
    
    

    该解析程序必须附加为代理或服务终结点上的每个操作的行为。 ServiceEndpoint 类有一个名为 Contract 且类型为 ContractDescription 的属性:

     
    public class ServiceEndpoint {
      public ContractDescription Contract
      {get;set;}
    
      // More members
    }
    
    

    ContractDescription 有一个操作描述集合,协定上每个操作都有一个 OperationDescription 实例:

     
    public class ContractDescription {
      public OperationDescriptionCollection Operations
      {get;}
    
      // More members
    }
    public class OperationDescriptionCollection : 
      Collection<OperationDescription>
    {...}
    
    

    每个 OperationDescription 都有一个类型为 IOperationBehavior 的操作行为集合:

     
    public class OperationDescription {
      public KeyedByTypeCollection<IOperationBehavior> Behaviors
      {get;}
      // More members
    }
    
    

    在其行为集合中,每个操作始终有一个名为 DataContractSerializerOperationBehavior,该行为有一个 DataContractResolver 属性:

     
    public class DataContractSerializerOperationBehavior : 
      IOperationBehavior,...
    {
      public DataContractResolver DataContractResolver
      {get;set}
      // More members
    }
    
    

    DataContractResolver 属性的默认值为 null,不过,可以设置为自定义解析程序。 若要在主机端安装解析程序,必须循环访问主机维护的服务描述中的终结点集合:

     
    public class ServiceHost : ServiceHostBase {...}
    
    public abstract class ServiceHostBase : ...
    {
      public ServiceDescription Description
      {get;}
      // More members
    }
    
    public class ServiceDescription {   
      public ServiceEndpointCollection Endpoints
      {get;}
      // More members
    }
    
    public class ServiceEndpointCollection : 
      Collection<ServiceEndpoint> {...}
    
    

    假设有以下服务定义并使用图 4 中的解析程序:

     
    [ServiceContract]
    interface IContactManager {
      [OperationContract]
      void AddContact(Contact contact);
      ...
    }
    class AddressBookService : IContactManager {...}
    
    

    图 5 演示如何在 AddressBookService 的主机上安装解析程序。

    图 5 在主机上安装解析程序

     
    ServiceHost host = 
      new ServiceHost(typeof(AddressBookService));
    
    foreach(ServiceEndpoint endpoint in 
      host.Description.Endpoints) {
      foreach(OperationDescription operation in 
        endpoint.Contract.Operations) {
    
        DataContractSerializerOperationBehavior behavior = 
          operation.Behaviors.Find<
            DataContractSerializerOperationBehavior>();
          behavior.DataContractResolver = new CustomerResolver();
      }
    }
    host.Open();
    
    

    在客户端,可按照类似步骤执行,只是需要在代理或通道工厂的单个终结点上设置解析程序。 例如,如果给定以下代理类定义:

     
    class ContactManagerClient : ClientBase<IContactManager>,IContactManager
    {...}
    
    

    图 6 演示如何在代理上安装解析程序,以使用已知类型调用图 5 的服务。

    图 6 在代理上安装解析程序

     
    ContactManagerClient proxy = new ContactManagerClient();
    
    foreach(OperationDescription operation in 
      proxy.Endpoint.Contract.Operations) {
    
      DataContractSerializerOperationBehavior behavior = 
        operation.Behaviors.Find<
        DataContractSerializerOperationBehavior>();
       
      behavior.DataContractResolver = new CustomerResolver();
    }
    
    Customer customer = new Customer();
    ...
    proxy.AddContact(customer);
    
    

    泛型解析程序

    为每个类型编写和安装解析程序显然工作量非常巨大,这需要细心跟踪所有已知类型(这样很容易出错,如果系统向前发展,很快会变得无法控制)。 为了自动实现解析程序,我编写了类 GenericResolver,其定义如下:

     
    public class GenericResolver : DataContractResolver {
      public Type[] KnownTypes
      {get;}
    
      public GenericResolver();
      public GenericResolver(Type[] typesToResolve);
    
      public static GenericResolver Merge(
        GenericResolver resolver1,
        GenericResolver resolver2);
    }
    
    

    GenericResolver 提供两个构造函数。 一个构造函数可以接受要解析的已知类型的数组。 这个无参数构造函数自动将调用程序集中的所有类和结构,以及调用程序集引用的程序集中的所有公共类和结构添加为已知类型。 它不会添加源自 .NET Framework 引用的程序集中的类型。

    此外,GenericResolver 还提供了一个 Merge 静态方法,可用于合并两个解析程序的已知类型,返回可解析这两个解析程序并集的 GenericResolver。 图 7 演示 GenericResolver 的相关部分,不反映程序集中的类型(与 WCF 无关)。

    图 7 实现 GenericResolver(部分)

     
    public class GenericResolver : DataContractResolver {
      const string DefaultNamespace = "global";
       
      readonly Dictionary<Type,Tuple<string,string>> m_TypeToNames;
      readonly Dictionary<string,Dictionary<string,Type>> m_NamesToType;
    
      public Type[] KnownTypes {
        get {
          return m_TypeToNames.Keys.ToArray();
        }
      }
    
      // Get all types in calling assembly and referenced assemblies
      static Type[] ReflectTypes() {...}
    
      public GenericResolver() : this(ReflectTypes()) {}
    
      public GenericResolver(Type[] typesToResolve) {
        m_TypeToNames = new Dictionary<Type,Tuple<string,string>>();
        m_NamesToType = new Dictionary<string,Dictionary<string,Type>>();
    
        foreach(Type type in typesToResolve) {
          string typeNamespace = GetNamespace(type);
          string typeName = GetName(type);
    
          m_TypeToNames[type] = new Tuple<string,string>(typeNamespace,typeName);
    
          if(m_NamesToType.ContainsKey(typeNamespace) == false) {
            m_NamesToType[typeNamespace] = new Dictionary<string,Type>();
          }
    
          m_NamesToType[typeNamespace][typeName] = type;
        }
      }
    
      static string GetNamespace(Type type) {
        return type.Namespace ??
    DefaultNamespace;
      }
    
      static string GetName(Type type) {
        return type.Name;
      }
    
      public static GenericResolver Merge(
        GenericResolver resolver1, GenericResolver resolver2) {
    
        if(resolver1 == null) {
          return resolver2;
        }
    
        if(resolver2 == null) {
          return resolver1;
        }
    
        List<Type> types = new List<Type>();
    
        types.AddRange(resolver1.KnownTypes);
        types.AddRange(resolver2.KnownTypes);
    
        return new GenericResolver(types.ToArray());
      }
    
      public override Type ResolveName(
        string typeName,string typeNamespace,
        Type declaredType,
        DataContractResolver knownTypeResolver) {
    
        if(m_NamesToType.ContainsKey(typeNamespace)) {
          if(m_NamesToType[typeNamespace].ContainsKey(typeName)) {
            return m_NamesToType[typeNamespace][typeName];
          }
        }
    
        return knownTypeResolver.ResolveName(
          typeName,typeNamespace,declaredType,null);
      }
    
      public override bool TryResolveType(
        Type type,Type declaredType,
        DataContractResolver knownTypeResolver,
        out XmlDictionaryString typeName,
        out XmlDictionaryString typeNamespace) {
    
        if(m_TypeToNames.ContainsKey(type)) {
          XmlDictionary dictionary = new XmlDictionary();
          typeNamespace = dictionary.Add(m_TypeToNames[type].Item1);
          typeName      = dictionary.Add(m_TypeToNames[type].Item2);
          return true;
        }
        else {
          return knownTypeResolver.TryResolveType(
          type,declaredType,null,out typeName,
          out typeNamespace);
        }
      }
    }
    
    

    GenericResolver 最重要的成员是 m_TypeToNames 和 m_NamesToType 字典。 m_TypeToNames 将类型映射到其名称和命名空间的元组。 m_NamesToType 将类型命名空间和名称映射到实际类型。 以类型数组为参数的构造函数序列化这两个字典。 TryResolveType 方法使用提供的类型作为 m_TypeToNames 字典中的键,以读取类型的名称和命名空间。 ResolveName 方法使用提供的命名空间和名称作为 m_NamesToType 字典中的键,以返回解析后的类型。

    尽管可以使用类似于图 5 和图 6 的冗长代码来安装 GenericResolver,最好还是使用扩展方法进行简化。 为此,可以使用 GenericResolverInstaller 的 AddGenericResolver 方法,这些方法定义如下:

     
    public static class GenericResolverInstaller {
      public static void AddGenericResolver(
        this ServiceHost host, params Type[] typesToResolve);
    
      public static void AddGenericResolver<T>(
        this ClientBase<T> proxy, 
        params Type[] typesToResolve) where T : class;
    
      public static void AddGenericResolver<T>(
        this ChannelFactory<T> factory,
        params Type[] typesToResolve) where T : class;
    }
    
    

    AddGenericResolver 方法接受类型的参数数组,也就是开放的、以逗号分隔的类型列表。 如果未指定类型,则 AddGenericResolver 将调用程序集中的所有类和结构,以及引用的程序集中的所有公共类和结构添加为已知类型。 例如,考虑以下已知类型:

     
    [DataContract]
    class Contact {...}
    
    [DataContract]
    class Customer : Contact {...}
    
    [DataContract]
    class Employee : Contact {...}
    
    

    图 8 演示几个示例说明如何使用 AddGenericResolver 扩展方法处理这些类型。

    图 8 安装 GenericResolver

     
    // Host side
    
    ServiceHost host1 = new ServiceHost(typeof(AddressBookService));
    // Resolve all types in this and referenced assemblies
    host1.AddGenericResolver();
    host1.Open();
    
    ServiceHost host2 = new ServiceHost(typeof(AddressBookService));
    // Resolve only Customer and Employee
    host2.AddGenericResolver(typeof(Customer),typeof(Employee));
    host2.Open();
    
    ServiceHost host3 = new ServiceHost(typeof(AddressBookService));
    // Can call AddGenericResolver() multiple times
    host3.AddGenericResolver(typeof(Customer));
    host3.AddGenericResolver(typeof(Employee));
    host3.Open();
    
    // Client side
    
    ContactManagerClient proxy = new ContactManagerClient();
    // Resolve all types in this and referenced assemblies
    proxy.AddGenericResolver();
    
    Customer customer = new Customer();
    ...
    proxy.AddContact(customer);
    
    

    GenericResolverInstaller 不仅安装 GenericResolver,还尝试将其与旧的泛型解析程序(如果存在)合并。这意味着,可以多次调用 AddGenericResolver 方法。 这在添加受限的泛型类型时十分方便:

     
    [DataContract]
    class Customer<T> : Contact {...}
    
    ServiceHost host = new ServiceHost(typeof(AddressBookService));
    
    // Add all non-generic known types
    host.AddGenericResolver();
    
    // Add the generic types 
    host.AddGenericResolver(typeof(Customer<int>,Customer<string>));
    
    host.Open();
    
    

    图 9 演示 GenericResolverInstaller 的部分实现。

    图 9 实现 GenericResolverInstaller

     
    public static class GenericResolverInstaller {
      public static void AddGenericResolver(
        this ServiceHost host, params Type[] typesToResolve) {
    
        foreach(ServiceEndpoint endpoint in 
          host.Description.Endpoints) {
    
          AddGenericResolver(endpoint,typesToResolve);
        }
      }
    
      static void AddGenericResolver(
        ServiceEndpoint endpoint,Type[] typesToResolve) {
    
        foreach(OperationDescription operation in 
          endpoint.Contract.Operations) {
    
          DataContractSerializerOperationBehavior behavior = 
            operation.Behaviors.Find<
            DataContractSerializerOperationBehavior>();
    
          GenericResolver newResolver;
    
          if(typesToResolve == null || 
            typesToResolve.Any() == false) {
    
            newResolver = new GenericResolver();
          }
          else {
            newResolver = new GenericResolver(typesToResolve);
          }
    
          GenericResolver oldResolver = 
            behavior.DataContractResolver as GenericResolver;
          behavior.DataContractResolver = 
            GenericResolver.Merge(oldResolver,newResolver);
        }
      }
    }
    
    

    如果未提供类型,AddGenericResolver 会使用 GenericResolver 的无参数构造函数。 否则,它通过调用另一个构造函数,仅使用指定类型。 请注意与旧解析程序(如果存在)的合并。

    泛型解析程序特性

    如果设计的服务需要泛型解析程序,最好不要受主机控制,在设计时就声明需要泛型解析程序。 为此,我编写了 GenericResolverBehaviorAttribute:

     
    [AttributeUsage(AttributeTargets.Class)]
    public class GenericResolverBehaviorAttribute : 
      Attribute,IServiceBehavior {
    
      void IServiceBehavior.Validate(
        ServiceDescription serviceDescription,
        ServiceHostBase serviceHostBase) {
    
        ServiceHost host = serviceHostBase as ServiceHost;
        host.AddGenericResolver();
      }
      // More members
    }
    
    

    这一简洁的特性使服务独立于主机:

     

    GenericResolverBehaviorAttribute 派生自 IServiceBehavior,后者是特殊的 WCF 接口,也是 WCF 中最常使用的扩展。 当主机加载服务时,主机会调用 IServiceBehavior 方法(具体地说就是 Validate 方法),这样,该特性可以与主机交互。 对于 GenericResolverBehaviorAttribute,它会将泛型解析程序添加到主机。

    至此您已实现了您的目的:通过相对简单灵活的方式避免数据协定继承的麻烦。 您可以在下一个 WCF 项目中采用这种方法。

     

    Juval Lowy 是 IDesign 的一名软件架构师,该公司提供 .NET 和体系结构培训和咨询服务。本文节选自他最近撰写的《Programming WCF Services 3rd Edition》一书(O'Reilly,2010 年)。另外,他也是 Microsoft 硅谷地区的区域总监。您可以通过 idesign.net 与 Lowy 联系。

    衷心感谢以下技术专家对本文的审阅:Glenn Block 和 Amadeo Casas Cuadrado

  • 相关阅读:
    KMP算法的理解和代码实现
    关于线程死锁
    PAT1018
    PAT1059
    PAT1009
    PAT1006
    PAT1005
    PAT1004
    PAT1002
    PAT
  • 原文地址:https://www.cnblogs.com/taomylife/p/4754525.html
Copyright © 2020-2023  润新知