CLR 完全介绍-反射之反思

清晰的组件化目标是否因在库间共享过多类型信息而落空?或许您需要高效的强类型化数据存储,但如果每次对象模型发展后都需要更新您的数据库架构,那会耗费很大成本,所以您更愿意在运行时推断出其类型架构吗?您需要交付能接受任意用户对象的组件,并以某种智能化的方式处理它们吗?您希望库的调方者能以编程方式向您说明它们的类型吗?

如果您发现自己在苦苦维持强类型化数据结构的同时,又冀望于最大化运行时灵活性,那么您大概会愿意考虑反射,以及它如何改善您的软件。在本专栏中,我将探讨 Microsoft® .NET Framework 中的 System.Reflection 命名空间,以及它如何为您的开发体验提供助益。我将从一些简单的示例开始,最后将讲述如何处理现实世界中的序列化情形。在此过程中,我会展示反射和 CodeDom 如何配合工作,以有效处理运行时数据。

在深入探究 System.Reflection 之前,我想先讨论一下一般的反射编程。首先,反射可定义为由一个编程系统提供的任何功能,此功能使程序员可以在无需提前了解其标识或正式结构的情况下检查和操作代码实体。这部分内容很多,我将逐一展开说明。

首先,反射提供了什么呢?您能用它做些什么呢?我倾向于将典型的以反射为中心的任务分为两类:检查和操作。检查需要分析对象和类型,以收集有关其定义和行为的结构化信息。除了一些基本规定之外,通常这是在事先不了解它们的情况下进行的。(例如,在 .NET Framework 中,任何东西都继承自 System.Object,并且一个对象类型的引用通常是反射的一般起点。)

操作利用通过检查收集到的信息动态地调用代码,创建已发现类型的新实例,或者甚至可以轻松地动态重新结构化类型和对象。需要指出的一个要点是,对于大多数系统,在运行时操作类型和对象,较之在源代码中静态地进行同等操作,会导致性能降低。由于反射的动态特性,因此这是个必要的取舍,不过有很多技巧和最佳做法可以优化反射的性能(有关优化使用反射的更多深入信息,请参见 msdn.microsoft.com/msdnmag/issues/05/07/Reflection)。

那么,什么是反射的目标呢?程序员实际检查和操作什么呢?在我对反射的定义中,我用了“代码实体”这个新术语,以强调一个事实:从程序员的角度来说,反射技术有时会使传统对象和类型之间的界限变得模糊。例如,一个典型的以反射为中心的任务可能是:

  1. 从对象 O 的句柄开始,并使用反射获得其相关定义(类型 T)的句柄。
  2. 检查类型 T,获得它的方法 M 的句柄。
  3. 调用另一个对象 O’(同样是类型 T)的方法 M。

请注意,我在从一个实例穿梭到它的底层类型,从这一类型到一个方法,之后又使用此方法的句柄在另一个实例上调用它 — 显然这是在源代码中使用传统的 C# 编程技术无法实现的。在下文中探讨 .NET Framework 的 System.Reflection 之后,我会再次通过一个具体的例子来解释这一情形。

某些编程语言本身可以通过语法提供反射,而另一些平台和框架(如 .NET Framework)则将其作为系统库。不管以何种方式提供反射,在给定情形下使用反射技术的可能性相当复杂。编程系统提供反射的能力取决于诸多因素:程序员很好地利用了编程语言的功能表达了他的概念吗?编译器是否在输出中嵌入足够的结构化信息(元数据),以方便日后的解读?有没有一个运行时子系统或主机解释器来消化这些元数据?平台库是否以对程序员有用的方式,展示此解释结果?

如果您头脑中想象的是一个复杂的、面向对象类型的系统,但在代码中却表现为简单的、C 语言风格的函数,而且没有正式的数据结构,那么显然您的程序不可能动态地推断出,某变量 v1 的指针指向某种类型 T 的对象实例。因为毕竟类型 T 是您头脑中的概念,它从未在您的编程语句中明确地出现。但如果您使用一种更为灵活的面向对象语言(如 C#)来表达程序的抽象结构,并直接引入类型 T 的概念,那么编译器就会把您的想法转换成某种日后可以通过合适的逻辑来理解的形式,就象公共语言运行时 (CLR) 或某种动态语言解释器所提供的一样。

反射完全是动态、运行时的技术吗?简单的说,不是这样。整个开发和执行周期中,很多时候反射对开发人员都可用且有用。一些编程语言通过独立编译器实现,这些编译器将高级代码直接转换成机器能够识别的指令。输出文件只包括编译过的输入,并且运行时没有用于接受不透明对象并动态分析其定义的支持逻辑。这正是许多传统 C 编译器的情形。因为在目标可执行文件中几乎没有支持逻辑,因此您无法完成太多动态反射,然而编译器会不时提供静态反射 — 例如,普遍运用的 typeof 运算符允许程序员在编译时检查类型标识。

另一种完全不同的情况是,解释性编程语言总是通过主进程获得执行(脚本语言通常属于此类)。由于程序的完整定义是可用的(作为输入源代码),并跟完整的语言实现结合在一起(作为解释器本身),因此所有支持自我分析所需的技术都到位了。这种动态语言频繁地提供全面反射功能,以及一组用于动态分析和操作程序的丰富工具。

.NET Framework CLR 和它的承载语言如 C# 属于中间形态。编译器用来把源代码转换成 IL 和元数据,后者与源代码相比虽属于较低级别或者较低“逻辑性”,但仍然保留了很多抽象结构和类型信息。一旦 CLR 启动和承载了此程序,基类库 (BCL) 的 System.Reflection 库便可以使用此信息,并返回关于对象类型、类型成员、成员签名等的信息。此外,它也可以支持调用,包括后期绑定调用。

.NET 中的反射

要在用 .NET Framework 编程时利用反射,您可以使用 System.Reflection 命名空间。此命名空间提供封装了很多运行时概念的类,例如程序集、模块、类型、方法、构造函数、字段和属性。图 1 中的表显示,System.Reflection 中的类如何与概念上运行时的对应项对应起来。

Figure 1 System.Reflection 类

语言组件 相应的 .NET 类
程序集 System.Reflection.Assembly
模块 System.Reflection.Module
抽象成员 System.Reflection.MemberInfo(以下所有的基类)
类型 System.Type
属性 System.Reflection.PropertyInfo
字段 System.Reflection.FieldInfo
事件 System.Reflection.EventInfo
抽象方法 System.Reflection.MethodBase(以下所有的基类)
方法 System.Reflection.MethodInfo
构造函数 System.Reflection.ConstructorInfo

尽管很重要,不过 System.Reflection.Assembly 和 System.Reflection.Module 主要用于定位新代码并将其加载到运行时。本专栏中,我暂不讨论这些部分,并且假定所有相关代码都已经加载。

要检查和操作已加载代码,典型模式主要是 System.Type。通常,您从获得一个所关注运行时类别的 System.Type 实例开始(通过 Object.GetType)。接着您可以使用 System.Type 的各种方法,在 System.Reflection 中探索类型的定义并获得其它类的实例。例如,如果您对某特定方法感兴趣,并希望获得此方法的一个 System.Reflection.MethodInfo 实例(可能通过 Type.GetMethod)。同样,如果您对某字段感兴趣,并希望获得此字段的一个 System.Reflection.FieldInfo 实例(可能通过 Type.GetField)。

一旦获得所有必要的反射实例对象,即可根据需要遵循检查或操作的步骤继续。检查时,您在反射类中使用各种描述性属性,获得您需要的信息(这是通用类型吗?这是实例方法吗?)。操作时,您可以动态地调用并执行方法,通过调用构造函数创建新对象,等等。

检查类型和成员

让我们跳转到一些代码中,探索如何运用基本反射进行检查。我将集中讨论类型分析。从一个对象开始,我将检索它的类型,而后考察几个有意思的成员(请参见图 2)。

Figure 2 检索对象类型和成员

代码
using System;
using System.Reflection;

// use Reflection to enumerate some basic properties of a type...

namespace Example1
{
    class MyClass
    {
        private int MyField = 0;
        public void MyMethod1() { return; }
        public int MyMethod2(int i) { return i; }
        public int MyProperty { get { return MyField; } }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Reflection Demo Example 1");

            MyClass mc = new MyClass();
            Type t = mc.GetType();
            Console.WriteLine("Type Name: {0}", t.Name);
    
            foreach(MethodInfo m in t.GetMethods())
                Console.WriteLine("Method Name: {0}", m.Name);

            foreach (PropertyInfo p in t.GetProperties())
                Console.WriteLine("Property Name: {0}", p.Name);
        }
    }
}
输出

Reflection Demo Example 1
Type Name: MyClass
Method Name: MyMethod1
Method Name: MyMethod2
Method Name: get_MyProperty
Method Name: GetType
Method Name: ToString
Method Name: Equals
Method Name: GetHashCode
Property Name: MyProperty 

 

首先需要注意的是,在类定义中,乍看起来说明方法的篇幅比我预期的要多很多。这些额外的方法是从哪里来的呢?任何精通 .NET Framework 对象层次结构的人,都会识别从通用基类 Object 自身继承的这些方法。(事实上,我首先使用了 Object.GetType 检索其类型。)此外,您可以看到属性的 getter 函数。现在,如果您只需要 MyClass 自身显式定义的函数,该怎么办呢?换句话说,您如何隐藏继承的函数?或者您可能只需要显式定义的实例函数?

随便在线看看 MSDN®,就会发现大家都愿意使用 GetMethods 第二个重载方法,它接受 BindingFlags 参数。通过结合来自 BindingFlags 枚举中不同的值,您可以让函数仅返回所需的方法子集。替换 GetMethods 调用,代之以:

GetMethods(BindingFlags.Instance | BindingFlags.DeclaredOnly | 
           BindingFlags.Public)

结果是,您得到以下输出(注意这里不存在静态帮助器函数和继承自 System.Object 的函数)。

Reflection Demo Example 1
Type Name: MyClass
Method Name: MyMethod1
Method Name: MyMethod2
Method Name: get_MyProperty
Property Name: MyProperty

如果您事先知道类型名称(完全限定)和成员,又该如何?您如何完成从枚举类型向检索类型的转换?图 3 中的示例显示了如何通过 Object.GetType 和 Type.GetMethod,使用描述类型信息的字符串文字,检索实际代码对应项。有了前两个示例中的代码,您已经有了能够实现基元类浏览器的基本组件。通过名称您可以找到一个运行时实体,然后枚举其各种相关属性。

Figure 3 通过字符串检索类型和 MethodInfo

using System;
using System.Reflection;

// use Reflection to retrieve references to a type and method via name

namespace Example2
{
    class MyClass
    {
        public void MyMethod1() { return; }
        public void MyMethod2() { return; }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(“Reflection Demo Example 2”);

            // note that we must use the fully qualified name...
            Type t = Type.GetType(“Example2.MyClass”);
            MethodInfo m = t.GetMethod(“MyMethod1”);

            Console.WriteLine(“Type Name: {0}”, t.Name);
            Console.WriteLine(“Method Name: {0}”, m.Name);
        }
    }
}

 

动态调用代码

迄今为止,我已经获得运行时对象的句柄(如类型和方法),仅作描述用,例如输出它们的名称。但是如何做得更多呢?如何实际调用某个方法呢?图 4 显示了如何获得某类型成员的 MethodInfo,然后使用 MethodInfo.Invoke 实际动态调用此方法。

Figure 4 动态调用方法

using System;
using System.Reflection;

// use Reflection to retrieve a MethodInfo for an 
// instance method and invoke it upon many object instances

namespace Example3
{
    class MyClass
    {
        private int id = -1;

        public MyClass(int id) { this.id = id; }

        public void MyMethod2(object p)
        {
            Console.WriteLine(
                “MyMethod2 is being invoked on object with “ +
                “id {0} with parameter {1}...”, 
                    id.ToString(), p.ToString());
        }
    }

    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine(“Reflection Demo Example 3”);

            MyClass mc1 = new MyClass(1);
            MyClass mc2 = new MyClass(2);
    
            Type t = mc1.GetType();
            MethodInfo method = t.GetMethod(“MyMethod2”);

            for(int i = 1; i <= 5; i++)
                method.Invoke(mc2, new object[]{i});
        }
    }
}

此例的几个要点是:首先,从一个 MyClass, mc1 实例检索一个 System.Type 实例,然后,从该类型检索一个 MethodInfo 实例。最后,当调用 MethodInfo 时,通过把它作为调用的第一个参数来传递,将其绑定到另一个 MyClass (mc2) 实例中。

前面讲过,对于您预期在源代码中见到的类型和对象使用之间的区别,这个示例使这种区别变得模糊。逻辑上,您检索了一个方法的句柄,然后调用该方法,就象它属于一个不同的对象一样。对于熟悉函数式编程语言的程序员来说,这可能轻而易举;但对于只熟悉 C# 的程序员来说,要分离对象实现和对象实例化,可能就不是那么直观了。

组合在一起

至此我已经探讨过检查和调用的基本原理,接下来我会用具体的例子把它们组合在一起。设想您希望交付一个库,带有必须处理对象的静态帮助器函数。但在设计的时候,您对这些对象的类型没有任何概念!这要看函数调用方的指示,看他希望如何从这些对象中提取有意义的信息。函数将接受一个对象集合,和一个方法的字符串描述符。然后它将遍历该集合,调用每个对象的方法,用一些函数聚合返回值(请参见图 5)。

Figure 5 从对象中提取信息

using System;
using System.Collections.Generic;
using System.Reflection;

namespace Example4
{
    class Program
    {
        static void Main(string[] args)
        {
            // prepare some objects for our function to process
            object[] objs = new object[] {
                new IntReturner(1), new IntReturner(2), 
                new IntReturner(3), new SonOfIntReturner(4), 
                new SonOfIntReturner(5), new SonOfIntReturner(6),
            };

            Console.WriteLine(
                “Attempting to compute average, “ + 
                “passing in array with {0} elements.”, objs.Length);

            int average = ComputeAverage(objs, “GetInteger”);

            Console.WriteLine(“Found an average of {0}!”, average);
        }

        public static int ComputeAverage( 
            IEnumerable<object> objs, string methodname)
        {
            int sum = 0, count = 0;

            Type firstType = null;
            MethodInfo firstMethod = null;

            foreach (object o in objs)
            {
                if (firstMethod == null)
                {
                    firstType = o.GetType();
                    firstMethod = firstType.GetMethod(methodname);
                }

                sum += (int)firstMethod.Invoke(o, null);
                count++;
            }

            // note that we use integer division here (not floating point)
            if (count == 0) return 0;
            return sum / count; 
        }
    }

    class IntReturner
    {
        protected int value = -1;
        public IntReturner(int i) { value = i; }
        public virtual int GetInteger()
        {
            Console.WriteLine(
                “GetInteger called on instance of IntReturner, “
                “I’m returning {0}!”, value);
            return value;
        }
    }

    class SonOfIntReturner : IntReturner
    {
        public SonOfIntReturner(int i) : base(i) { }
        public override int GetInteger()
        {
            Console.WriteLine(
                “GetInteger called on instance of SonOfIntReturner, “
                “I’m returning {0}!”, this.value);
            return value;
        }
    }

    class EnemyOfIntReturner
    {
        protected int value = -1;
        public EnemyOfIntReturner(int i) { value = i; }
        public virtual int GetInteger()
        {
            Console.WriteLine(
                “GetInteger called on instance of EnemyOfIntReturner, “
                “I’m returning {0}!”, value);
            return value;
        }
    }
}

 

就此例而言,我要声明一些约束条件。首先,字符串参数描述的方法(必须由每个对象的底层类型实现)不会接受任何参数,并将返回一个整数。代码将遍历对象集合,调用指定的方法,逐步计算出所有值的平均值。最后,因为这不是生产代码,在求和的时候我不用担心参数验证或整数溢出。

在浏览示例代码时,可以看到主函数与静态帮助器 ComputeAverage 之间的协议除了对象自身的通用基类之外,并不依赖任何类型信息。换句话说,您可以彻底改变正在传送的对象的类型和结构,但只要总是能使用字符串描述一个方法,且该方法返回整数,ComputeAverage 就可以正常工作!

需要注意的一个关键问题跟隐藏在最后这个例子中的 MethodInfo(一般反射)有关。注意,在 ComputeAverage 的 foreach 循环中,代码只从集合中的第一个对象中抓取一个 MethodInfo,然后绑定用于所有后续对象的调用。正如编码所示,它运行良好 — 这是 MethodInfo 缓存的一个简单例子。但此处有一个根本性的局限。MethodInfo 实例仅能由其检索对象同等层级类型的实例调用。因为传入了 IntReturner 和 SonOfIntReturner(继承自 IntReturner)的实例,才能这样运行。

在示例代码中,已经包含了名为 EnemyOfIntReturner 的类,它实现了与其他两个类相同的基本协议,但并没有共享任何常见共享类型。换句话说,该接口逻辑上等同,但在类型层级上没有重叠。要探讨 MethodInfo 在该情形下的使用,请尝试向集合添加其他对象,通过“new EnemyOfIntReturner(10)”得到一个实例,再次运行示例。您会遇到一个异常,指出 MethodInfo 不能用于调用指定的对象,因为它和获得 MethodInfo 时的原始类型完全无关(即使方法名称和基本协议是等同的)。要使您的代码达到生产水准,您需要做好遇到这一情形的准备。

一个可能的解决方案可以是通过自己分析所有传入对象的类型,保留对其共享的类型层级(如果有)的解释。如果下一对象的类型与任意已知类型层级相异,就需要获取和存储一个新的 MethodInfo。另一解决方案是捕获 TargetException,并重新获取一个 MethodInfo 实例。这里提到的两种解决方案都各有其优缺点。Joel Pobar 为本杂志 2007 五月期写过一篇优秀的文章,内容关于 MethodInfo 缓冲和我所极力推荐的反射性能。

希望此示例演示的向应用程序或框架中添加反射,可以为日后的自定义或可扩展性增加更多的灵活性。不可否认,较之本机编程语言中的同等逻辑,使用反射可能会有些繁琐。如果您感到对您或您的客户来说,向代码中添加基于反射的后期绑定过于麻烦(毕竟他们需要以某种方式在您的框架中说明他们的类型和代码),那么可能仅需要适度的灵活性以取得某种平衡。

序列化的高效类型处理

至此我们已通过若干示例讲述了 .NET 反射的基本原理,接下来让我们看一下现实世界中的情形。如果您的软件通过 Web 服务或其他进程外远程技术与其他系统进行交互,那么您很可能已经遇到序列化问题。序列化本质上是将活动的、占用内存的对象,转变成适合线上传输或磁盘存储的数据格式。

.NET Framework 中的 System.Xml.Serialization 命名空间提供了拥有 XmlSerializer 的强大序列化引擎,它可以使用任意托管对象,并将其转换成 XML(日后也可将 XML 数据转换回类型化的对象实例,这一过程称之为反序列化)。XmlSerializer 类是一种强大的、企业就绪的软件片断,如果您在项目中面临序列化问题,它将是您的首选。但为了教学目的,我们来探讨如何实现序列化(或者其他类似的运行时类型处理实例)。

设想情形:您正在交付一个框架,需要使用任意用户类型的对象实例,并将其转换成某种智能型数据格式。例如,假定有一个驻留内存的对象,类型为如下所示的 Address:

(pseudocode)
class Address
{
    AddressID id;
    String Street, City;
    StateType State;
    ZipCodeType ZipCode;
}

如何生成适当的数据表示形式以方便日后使用?或许一个简单的文本呈现将解决这一问题:

Address: 123
    Street: 1 Microsoft Way
    City: Redmond
    State: WA
    Zip: 98052

如果事先完全了解需要转换的正式数据类型(例如自己编写代码时),事情就变得非常简单:

foreach(Address a in AddressList)
{
    Console.WriteLine(“Address:{0}”, a.ID);
    Console.WriteLine(“\tStreet:{0}”, a.Street);
    ... // and so on
}

然而,如果预先不知道在运行时会遇到的数据类型,情况会变得十分有趣。您如何编写象这样的一般框架代码?

MyFramework.TranslateObject(object input, MyOutputWriter output)

首先,您需要决定哪些类型成员对序列化有用。可能的情况包括仅捕获特定类型的成员,例如基元系统类型,或提供一种机制以供类型作者说明哪些成员需要被序列化,例如在类型成员上使用自定义属性作为标记)。您仅可以捕获特定类型的成员,例如基元系统类型,或类型作者能够说明哪些成员需要被序列化(可能的方法是在类型成员上使用自定义属性作为标记)。

一旦记录清楚需要转换的数据结构成员,您接着需要做的是编写逻辑,从传入的对象枚举和检索它们。反射在这里担负了繁重的任务,让您既可以查询数据结构又可以查询数据值。

出于简单性考虑,我们来设计一个轻型转换引擎,得到一个对象,获取所有其公共属性值,通过直接调用 ToString 将它们转换成字符串,然后将这些值序列化。对于一个名为“input”的给定对象,算法大致如下:

  1. 调用 input.GetType 以检索 System.Type 实例,该实例描述了 input 的底层结构。
  2. 用 Type.GetProperties 和适当的 BindingFlags 参数,将公共属性作为 PropertyInfo 实例检索。
  3. 使用 PropertyInfo.Name 和 PropertyInfo.GetValue,将属性作为键-值对检索。
  4. 在每个值上调用 Object.ToString 将其(通过基本方式)转化为字符串格式。
  5. 将对象类型的名称和属性名称、字符串值的集合打包成正确的序列化格式。

这一算法明显简化了事情,同时也抓住了得到运行时数据结构,并将其转化为自描述型数据的要旨。但这里有一个问题:性能。之前提到,反射对于类型处理和值检索的成本都很高。本示例中,我在每个提供类型的实例中执行了完整的类型分析。

如果以某种方式可以捕获或保留您对于类型结构的理解,以便日后不费力地检索它,并有效处理该类型的新实例;换句话说,就是往前跳到示例算法中的步骤 #3?好消息是,利用 .NET Framework 中的功能,完全可能做到这一点。一旦您理解了类型的数据结构,便可以使用 CodeDom 动态生成绑定到该数据结构的代码。您可以生成一个帮助器程序集,其中包含帮助器类和引用了传入类型并直接访问其属性的方法(类似托管代码中的任何其他属性),因此类型检查只会对性能产生一次影响。

现在我将修正这一算法。新类型:

  1. 获得对应于该类型的 System.Type 实例。
  2. 使用各种 System.Type 访问器检索架构(或至少检索对序列化有用的架构子集),例如属性名称、字段名称等。
  3. 使用架构信息生成帮助器程序集(通过 CodeDom),该程序集与新类型相链接,并有效地处理实例。
  4. 在帮助器程序集中使用代码,提取实例数据。
  5. 根据需要序列化数据。

对于给定类型的所有传入数据,可以往前跳到步骤 #4,较之显式检查每一实例,这么做可以获得巨大的性能提升。

我开发了一个名为 SimpleSerialization 的基本序列化库,它用反射和 CodeDom(本专栏中可下载)实现了这一算法。主要组件是一个名为 SimpleSerializer 的类,是用户用一个 System.Type 实例构造所得。在构造函数中,新的 SimpleSerializer 实例会分析给定的类型,利用帮助器类生成一个临时程序集。该帮助器类会紧密绑定到给定的数据类型,而且对实例的处理方式就象自己在完全事先了解类型的情况下编写代码那样。

SimpleSerializer 类有如下布局:

class SimpleSerializer
{
    public class SimpleSerializer(Type dataType);

    public void Serialize(object input, SimpleDataWriter writer);
}

简单地令人惊叹!构造函数承担了最繁重的任务:它使用反射来分析类型结构,然后用 CodeDom 生成帮助器程序集。SimpleDataWriter 类只是用来阐明常见序列化模式的数据接收器。

要序列化一个简单的 Address 类实例,用下面的伪代码即可完成任务: 

SimpleSerializer mySerializer = new SimpleSerializer(typeof(Address));
SimpleDataWriter writer = new SimpleDataWriter();
mySerializer.Serialize(addressInstance, writer);

结束

强烈建议您亲自试用一下示例代码,尤其是 SimpleSerialization 库。我在 SimpleSerializer 一些有趣的部分都添加了注释,希望能够有所帮助。当然,如果您需要在产品代码中进行严格的序列化,那么确实要依靠 .NET Framework 中提供的技术(例如 XmlSerializer)。但如果您发现在运行时需要使用任意类型并能高效处理它们,我希望您采用我的 SimpleSerialization 库作为自己的方案。

对 CLR 开发人员 Weitao Su(反射)和 Pete Sheill (CodeDom) 所提供的指导和反馈,在此深表谢意。

posted @ 2010-02-24 10:52  文明的天空  阅读(288)  评论(0编辑  收藏  举报