深入Atlas系列:Web Sevices Access in Atlas(5) - 对于复杂数据类型的支持(中)

  在前一片文章里,我们已经得到了从客户端获得的字符串反序列化以后的Dictionary,我们知道它的形式和JSON非常的相像,只是存放了一些基本类型的数据还有IDictionary已经IList对象。它们在被应用到真正用于调用Web Services方法之前,还需要将这个Dictionary里的数转化为强类型的参数对象。在Atlas中,是使用了WebServiceMethodData.StrongTypeParameters方法来获得一个包含强类型参数对象的字典的。因此我们直接开始分析这个方法吧。

一个包含着JSON信息的Dictionary已经作为rawParams参数被传入该方法,代码如下:
29174506_gjie.gif 29174506_M0Qs.gif StrongTypeParameters方法
 1 private IDictionary<stringobject> StrongTypeParameters(IDictionary<stringobject> rawParams)
 2 {
 3     // 从Web Service方法的参数获得存放参数名和WebServiceParameterData的字典
 4     // WebServiceParameterData对象存放了参数的ParameterInfo和参数的编号,从0开始。
 5     IDictionary<string, WebServiceParameterData> dictParamData = this.ParameterDataDictionary;
 6 
 7     // 建立一个存放参数名和调用方法所用的参数的字典,
 8     // 这就是最终使用的参数字典。
 9     IDictionary<stringobject> dictTypedParam = new Dictionary<stringobject>(rawParams.Count);
10 
11     // 枚举rawParams里的每个Pair,每一个Pair的Value将被
12     // 被转变为调用Web Service方法所使用的参数。
13     foreach (KeyValuePair<stringobject> pair in rawParams)
14     {
15         // 获得参数名
16         string paramName = pair.Key;
17         if (dictParamData.ContainsKey(paramName))
18         {
19             // 获得参数类型
20             Type type = dictParamData[paramName].ParameterInfo.ParameterType;
21             // 通过调用ObjectConvertier.ConvertToType方法,
22             // 将pair.Value转化为类型为type的对象。
23             // this.Owner是表示当前Web Service类的WebServiceData对象。
24             dictTypedParam[paramName] = ObjectConverter.ConvertToType(pair.Value, type, this.Owner);
25         }
26     }
27     return dictTypedParam;
28 }

我们可以看到,真正起作用的方法是ObjectConverter.ConvertToType静态方法,它通过传入的对象信息,目标类型来获得最终我们需要的参数对象。那么this.Owner,这个WebServiceData对象的作用是什么呢?我们先来简单看一下ObjectConverter.ConvertToType静态方法的代码:
29174506_gjie.gif 29174506_M0Qs.gif ConvertToType静态方法
 1 internal static object ConvertToType(object o, Type type, IJavaScriptSerializationContext context)
 2 {
 3     // 构造一个ObjectConvertier,它需要得到一个类型为
 4     // IJavaScriptSerializationContext的对象作为工作的上下文
 5     ObjectConverter converter = new ObjectConverter(context);
 6 
 7     // 调用ConvertObjectToType方法来获得将o转变为
 8     // type类型的对象,以用作Web Services方法的参数
 9     return converter.ConvertObjectToType(o, type);
10 }

可以发现,WebServiceData对象被作为了IJavaScriptSerializationContext类型的对象来使用。Microsoft.Web.Script.Serialization.IJavaScriptSerializationContext接口中最重要的方法就是GetType(string),它通过给定一个字符串形式的类型名,来获得这个类型对象Type。很自然,这个类型首先需要被IJavaScriptSerializationContext所识别。还记得在我的前一篇文章《 深入Atlas系列:Web Sevices Access in Atlas(4) - 对于复杂数据类型的支持(上)》中“问题4”的错误吗?它的产生原因就是因为那个WebServiceData对象无法识别字符串“Jeffz.ComplexType.Employee”所表示的类型。既然无法识别,那么即使使用了“__serverType”来表示一个客户端对象的服务器端类型,也是无法转化的。那么为什么会无法识别这个对象呢?我们继续追踪,分析ConvertObjectToType方法的实现,代码如下:
29174506_gjie.gif 29174506_M0Qs.gif ConvertObjectToType方法
 1 private object ConvertObjectToType(object o, Type type)
 2 {
 3     // 如果传入的o为null的话,
 4     // 那么不管type是什么都要返回null了
 5     if (o == null)
 6     {
 7         return null;
 8     }
 9 
10     // 查看有没有在配置中加载Converter
11     if (JavaScriptConverter.ConverterExistsForType(type))
12     {
13         // 如果存在的话则现将o进行序列化得到字符串
14         string serializedObj = JavaScriptObjectSerializer.Serialize(o);
15         // 然后再通过type对应的Converter来反序列化对象
16         return JavaScriptConverter.DeserializeString(serializedObj, type);
17     }
18 
19     // 否则则使用内部的转换方式(相对于在配置文件
20     // 中加载的“外部”转换)转换对象。
21     return this.ConvertObjectToTypeInternal(o, type);
22 }

从这里开始,Atlas对于支持复杂类型的扩展性一点点地被揭开了。在进行转换时,首先会调用JavaScriptConverter.ConverterExistsForType来检验是否有支持该Type的JavaScriptConverter。这里的JavaScriptConverter是指一个继承了Microsoft.Web.Script.Serialization.JavaScriptConverter抽象类的对象。在Atlas中可以可以根据配置文件来加载需要的JavaScriptConverter。事实上,在默认的Web.config文件中,已经为DataSet、DataTable和DataRow三个类写好了JavaScriptConverter。我们在Web.config类中可以发现这样的配置元素:
< microsoft .web >
    
< converters >
        
< add  type ="Microsoft.Web.Script.Serialization.Converters.DataSetConverter" />
        
< add  type ="Microsoft.Web.Script.Serialization.Converters.DataRowConverter" />
        
< add  type ="Microsoft.Web.Script.Serialization.Converters.DataTableConverter" />
    
</ converters >
    ……
</ microsoft.web >

虽然没有在配置文件内将对象类型与JavaScriptConverter对应起来,但是在Microsoft.Web.Script.Serialization.JavaScriptConverter抽象类中有个virtual属性SupportedTypes,它返回了这个JavaScriptConverter能够支持的Type数组,默认为null。JavaScriptConverter就是依靠这个把具体的JavaScriptConverter子类与某个特定的类型关联起来。

在调用ObjectConverter.ConvertObjectToType方法时,首先会查找是不是已经有了目标type所对应的JavaScriptConverter(第11行),如果有的话,则会通过JavaScriptObjectSerializer.Serialize静态方法将需要转换的对象o序列化成字符串,然后再调用JavaScriptConverter.DeserializeString静态方法,将该字符串反序列化成Web Services方法所需要的参数对象。值得注意的是,在这两个静态方法中,都会再次查看是否有对应的JavaScriptConverter与所操作的对象相对应,如果有,则使用JavaScriptConverter进行操作,反之则使用Atlas内定的操作方法。

可是,是不是有朋友对于ObjectConverter.ConvertObjectToType方法中第13-16行的逻辑表示不解呢?为什么需要将传入的参数o通过它对应的JavaScriptConverter重新序列化成字符串,再交给目标类型的JavaScriptConverter再反序列化一次呢?其实这个思路似乎有点被JavaScriptObjectSerializer.Serialize静态方法的实现所误导了(确切的说是被在这里对于该静态方法的使用所误导)。这个静态方法的确会检查是否有与o的类型对应的JavaScriptConverter存在,但是我们仔细思考一下,这个o的类型会是什么呢?

这里的参数o是刚从JSON字符串转变过来的表示参数的对象之一,它的作用是“表现参数”。它的类型其实只是Int32,Double,String,DateTime等基本数据类型或者IDictionary以及IList对象中的一种。我想,开发人员应该不太会为这写类型写JavaScriptConverter吧?对于这两种对象的转换,Atlas已经支持地相当出色了。事实上,在Atlas中的确也没有为基础类型或者IDictionary以及IList这写类型准备JavaScriptConverter。在ObjectConverter.ConvertObjectToType方法中第14行,使用JavaScriptObjectSerializer.Serialize静态方法序列化o对象,其实只是将这个对象再转变回字符串,转变为客户端通过Request Body发送过来的原始字符串。

这样的话,逻辑就顺利了。将一个类型与JavaScriptConverter所绑定的目的,是为了能够自定义关于这个对象的序列化已经反序列化的操作。这两个操作应该是一体的。这个类需要负责将某个类型的对象输出成特别的JSON代码,也需要输出一段Javascript来告知客户端该如何序列化一个客户端对象(通过提供对象一个serialize函数,这一点在上一篇文章中提到过),在以后分析Atlas为某个Web Services生成客户端代理的原理时会详细说明这一点。可以想象,如果(仅仅是如果)在这之间,对象o找到了对应的JavaScriptConverter,得到的序列化字符串并不是客户端的原始输入,那么为目标Type设计的JavaScriptConverter在反序列化一个字符串时就会失败,或者导致不可预料的结果。

因此,其实JavaScriptConverter.DeserializeString方法接收到的字符串,应该(或者说在正常的情况下)就是客户端表示该对象的原始输入字符串。这样,我们就能做到对一个类型自定义一组扩展。这个方法常常被用于序列化于反序列化一组相互引用的对象,在下一片文章中,我将以范例来具体说明这一点。事实上,Atlas里已有的三个JavaScriptConverter,就是为了处理三个复杂类型DataSet,DataTable以及DataRow的序列化与反序列化操作,它们就是存在着相互引用的对象,Atlas也通过了添加JavaScriptConverter来处理这个问题。

如果没有找到目标type所对应的JavaScriptConverter,那么ObjectConverter.ConvertObjectToType方法就会使用Atlas的默认操作——或者说内部操作:ObjectConverter.ConvertObjectToTypeInternal方法来将o转换为目标type的对象,以便Web Services方法使用。接下来,我们来看一下它的实现:
29174506_gjie.gif 29174506_M0Qs.gif ConvertObjectToTypeInternal方法
 1 private object ConvertObjectToTypeInternal(object o, Type type)
 2 {
 3     // 查看对象o是否为IDictionary。
 4     IDictionary<stringobject> dictObj = o as IDictionary<stringobject>;
 5     if (dictObj != null)
 6     {
 7         // 如果是,则调用ConvertDictionaryToObject方法转换对象o
 8         return this.ConvertDictionaryToObject(dictObj, type);
 9     }
10 
11     // 同样的,查看对象o是否为IList对象
12     IList listObj = o as IList;
13     if (listObj != null)
14     {
15         // 如果是,则调用ConvertListToObject方法转换对象o
16         return this.ConvertListToObject(listObj, type);
17     }
18 
19     // 如果type为null或者o已经是type类型了
20     if ((type == null|| (o.GetType() == type))
21     {
22         // 那么直接返回对象即可
23         return o;
24     }
25 
26     // 又是可扩展的一点!
27     // 获得使用TypeConverterAttribute标注的TypeConverter
28     TypeConverter converter = TypeDescriptor.GetConverter(type);
29 
30     // 如果该TypeConverter不能够从o这个Type转化
31     if (!converter.CanConvertFrom(o.GetType()))
32     {
33         // 那么如果该TypeConverter能够从string进行转换
34         if (converter.CanConvertFrom(typeof(string)))
35         {
36             // 获得o的Converter,将其转换成string。
37             string text = TypeDescriptor.GetConverter(o).ConvertToInvariantString(o);
38             // 再使用目标type的Converter从text转换
39             return converter.ConvertFromInvariantString(text);
40         }
41 
42         // 如果目标对象无法从o进行直接赋值
43         // 可以简单理解为o不是type类型、type的子类类型
44         // (包括接口实现)等,也无法隐式转换为type类型。
45         if (!type.IsAssignableFrom(o.GetType()))
46         {
47             // 无法转换了,抛出异常
48             throw new InvalidOperationException(
49                 string.Format(
50                     CultureInfo.CurrentCulture,
51                     AtlasWeb.CannotConvertObjectToType,
52                     new object[] { o.GetType(), type }
53                 )
54             );
55         }
56 
57         // 由于能购把o直接转换成目标类型,那么直接返回
58         return o;
59     }
60 
61     // 到了这一步,说明该TypeConverter能够
62     // 从o的类型这里进行转换,于是转换。
63     return converter.ConvertFrom(o);
64 }

如果当前需要转换的对象为IDictionary类型或者IList类型,那么则调用相应的方法进行转换,这点稍后再作分析。如果这些o不是这两种类型,则说明它们是些基本类型了,例如Int32,Double,DateTime,Boolean等。在这里我们看到Atlas允许我们进行另外一种可以使用的扩展方式:使用自定义的System.ComponentModel.TypeConverter子类并使用System.ComponentModel.TypeConverterAttribute为类型的转换进行标注。熟悉ASP.NET控件开发的朋友可能会比较熟悉这个做法,为控件属性提供设计期支持时经常会使用这种做法。在Atlas中,如果我们的类型会从那些基本类型转换过来,那么我们就可以提供自定义TypeConverter的方式进行扩展。在今后的文章中,我将提供示例供大家参考。

在通过TypeDescriptor.GetConverter方法获得目标类型的TypeConverter后,会查看该TypeConverter是否能够从目前o对象的类型进行转换。如果可以,则返回转换后的结果。否则的话,则设法先将o对象转化为字符串,再使用TypeConverter的ConvertFromInvariantString方法进行转换。请注意,这里使用的方法都是XXXInvariantString,也就是说,它们是与系统的Culture相关的。举个例子,如果我们需要自定一个DateTime到一个特定类型的转换,传入TypeConverter中ConvertFromInvariantString方法的字符串参数,它的值会根据系统当前Culture环境的不同而产生变化。

事实上,对于目标类型本身就是参数类型(例如Int32)的情况,这段代码一般就会执行第36-39行的逻辑。例如对象o的类型是long,而目标类型为int,则这段代码就会先使用Int64Converter将o转变为字符串,再通过Int32Converter将这个字符串转化为我们需要的参数类型:Int32。

另外,通过理解这个逻辑,我们也能够解释下面的行为的原因:

如果参数为整型,那么Atlas会自动将客户端传入的字符串参数转化为整型以保证Web Services方法调用成功。例如,在我之前的文章《 深入Atlas系列:Web Sevices Access in Atlas - 示例一 - 特别的访问方式》的范例中,我将txtAge的text属性绑定到serviceMethod的参数age上。这就相当于将一个字符串的值赋于Web Services方法的整型参数,通过使用Fiddler查看HTTP传输的数据也能发现,在Request Body中传递的数据也类似与“{ age : "20", ... }”。显然,在服务器端得到的age参数输入,其类型是一个字符串。但是,就是因为之前描述的逻辑,在真正使用这个字符串的值去进行Web Services方法调用之前,会被Int32Converter转换成为整型。这样,方法调用就成功了。

如果无法使用TypeConverter将对象o转换为目标类型的话,就尝试着直接将o使用在方法调用上了。在上面的代码中可以发现,Atlas使用了目标type的IsAssignableFrom方法来检测“直接赋值”的合法性。最后,如果发现对象o,不管使用什么方式都无法将其转换为目标type的话,则抛出了InvalidOperationException,表示类型转换的失败。

刚才详细讨论的逻辑是在最后对于一个“值”的转换。但是一般来说,在Atlas转换数据类型时难免会遇到IDictionary和IList对象的嵌套。在这时候,就要使用代码所看到的ConvertDictionaryToObject方法和ConvertListToObject方法了。首先我们来分析一下ConvertListToObject方法代码:
29174506_gjie.gif 29174506_M0Qs.gif ConvertListToObject方法
 1 private IList ConvertListToObject(IList list, Type type)
 2 {
 3     // 如果type是null,或者是object,
 4     // 或者是Array,或者是ArrayList类型,
 5     // 那么就使用ArrayList作为容器返回。
 6     if (((type == null|| (type == typeof(object))) || (type.IsArray || (type == typeof(ArrayList))))
 7     {
 8         // 首先将元素对象假设为object
 9         Type eleType = typeof(object);
10         // 如果type不是null,并且type不是object的类型。
11         if ((type != null&& (type != typeof(object)))
12         {
13             // 那么获得IList的元素类型
14             eleType = type.GetElementType();
15         }
16 
17         // 创建目标容器:一个ArrayList
18         ArrayList destList1 = new ArrayList();
19         // 调用AddItemToList方法将list内的元素,
20         // 转换为eleType表示的类型,放入目标容器中。
21         this.AddItemToList(list, destList1, eleType);
22 
23         // 如果目标类型是ArrayList
24         if (type == typeof(ArrayList))
25         {
26             // 那么直接返回容器作为结果
27             return destList1;
28         }
29 
30         // 否则,则返回eleType表示类型的数组
31         return destList1.ToArray(eleType);
32     }
33 
34     // 如果这是一个“能够在客户端被实例化”的对象,即:
35     // 非抽象,非接口,非数组,没有JavaScriptConverter与之对应,
36     // 并且存在无参数的实例构造函数。
37     // 要进入该if体,目标type可以直接赋值给IList类型
38     if (ObjectConverter.IsClientInstantiatableType(type) && typeof(IList).IsAssignableFrom(type))
39     {
40         // 那么就使用Activator.CreateInstance方法构造目标type的对象,
41         // 作为容器,并将其转换为IList对象以便操作。
42         IList destList2 = (IList) Activator.CreateInstance(type);
43         // 调用AddItemToList方法将list内元素放入容器,
44         // 第三个参数为null表示不做类型转换。
45         this.AddItemToList(list, destList2, null);
46         return destList2;
47     }
48 
49     // 如果目标对象为范型类型,并且只有一个范型类型,例如List<T>
50     if (type.IsGenericType && (type.GetGenericArguments().Length == 1))
51     {
52         // 获得那个范型类型
53         Type genericType = type.GetGenericArguments()[0];
54         // 调用typeof(IEnumerable<>).MakeGenericType方法构造一个范型对象的Type
55         Type genericEnumType = ObjectConverter._enumerableGenericType.MakeGenericType(new Type[] { genericType });
56 
57         // 如果能够将这个对象直接赋值给type类型变量
58         if (genericEnumType.IsAssignableFrom(type))
59         {
60             // 调用typeof(List<>).MakeGenericType方法获得一个List<genericType>的Type
61             Type genericListType = ObjectConverter._listGenericType.MakeGenericType(new Type[] { genericType });
62             // 构造一个范型List
63             IList genericList = (IList) Activator.CreateInstance(genericListType);
64             // 调用AddItemToList方法将list内的元素,
65             // 转换为eleType表示的类型,放入目标容器中。
66             this.AddItemToList(list, genericList, genericType);
67             return genericList;
68         }
69     }
70 
71     // 无法转换,抛出异常。
72     throw new InvalidOperationException(
73         string.Format(
74             CultureInfo.CurrentCulture,
75             AtlasWeb.ArrayTypeNotSupported,
76             new object[] { type.FullName }
77         )
78     );
79 }

这个方法的作用是将IList对象list转换成type类型的对象。自然,它在实际使用中也是一个列表或者数组。在这个方法中,根据Type格式的不同,转换后的数据类型可能是ArrayList,Array或者Generic类型List<T>。可以简单地认为,在Atlas中所支持的“列表”类型或者“数组”类型就只有这些。因此,我们在Web Services方法中应该使用这些以及它们的兼容类型,例如IList<T>,ICollection<T>或者IList等。对于无法转换的数据类型,只能不得已地抛出InvalidOperationException了。我们在使用Atlas访问Web Services方法时应该完全避免使用这样的数据类型。当然,我们能够添加自己的扩展,以支持我们需要的数据类型。在下一片文章中我将给出相应的范例。

在上面的方法中,我们可以看到,它总是调用了AddItemToList方法来填充一个“列表”对象。其实我们也能够想象得到它的实现方式:它枚举了源列表的每个元素,调用ConvertObjectToType方法将其转换成合适的对象,然后将其填充至目标列表中。对象的转换总是在大量的间接递归中实现的。

与上述的ConvertListToObject方法相对应是ConvertDictionaryToObject方法。相比于前者返回的是一个“列表”,而后者则返回的则好比是一个“元素”的对象。代码如下:
29174506_gjie.gif 29174506_M0Qs.gif ConvertDictionaryToObject方法
 1 private object ConvertDictionaryToObject(IDictionary<stringobject> dictionary, Type type)
 2 {
 3     object typeObj;
 4     Type destType = type;
 5     string typeName = null;
 6     object resultObj = dictionary;
 7 
 8     // 查看Dictionary中有没有__serverType对应的值,
 9     // 如果有__serverType,则忽视type,而使用__serverType的标记。
10     if (dictionary.TryGetValue("__serverType"out typeObj))
11     {
12         // 如果有的话,则通过ConvertObjectToType方法将其转化为string,
13         // 一般来说,typeObj已经是string类型,因此这个方法会很快返回值。
14         typeName = this.ConvertObjectToType(typeObj, typeof(string)) as string;
15 
16         // 如果指定了typeName
17         if (typeName != null)
18         {
19             // 如果指定了JavaScript序列化操作的上下文
20             if (this._context != null)
21             {
22                 // 则必须在上下文中寻找这个类型
23                 destType = this._context.GetType(typeName);
24                 if (destType == null)
25                 {
26                     throw new InvalidOperationException();
27                 }
28             }
29             // 将__serverType移除,因为它不会被作为
30             // “成员变量”或“属性”赋值
31             dictionary.Remove("__serverType");
32         }
33     }
34 
35     // 如果是“客户端可实例化”的对象,即:
36     // 非抽象,非接口,非数组,没有JavaScriptConverter与之对应,
37     // 并且存在无参数的实例构造函数。
38     if ((typeName != null|| ((destType != null&& ObjectConverter.IsClientInstantiatableType(destType)))
39     {
40         // 则通过目标类型构造对象
41         resultObj = Activator.CreateInstance(destType);
42     }
43 
44     // 如果构造出来的对象无法被赋值到type
45     if ((type != null&& !type.IsAssignableFrom(resultObj.GetType()))
46     {
47         // 则抛出异常
48         throw new InvalidOperationException(
49             string.Format(
50                 CultureInfo.InvariantCulture, 
51                 AtlasWeb.DeserializerTypeMismatch, 
52                 new object[] { type.FullName }
53             )
54         );
55     }
56 
57     // 枚举输入Dictionary中的每个key。
58     List<string> keyList = new List<string>(dictionary.Keys);
59     foreach (string key in keyList)
60     {
61         // 为Property或者Field赋值
62         object value = dictionary[key];
63         this.AssignToPropertyOrField(value, resultObj, key);
64     }
65 
66     return resultObj;
67 }

在这里可以发现,如果客户端传来的对象指定了__serverType,那么将使用__serverType所表示的值从IJavaScriptSerializationContext对象中获得真正使用的类。请注意,它和目标type所表示的类,完全可以不是同一个!产生的对象是目标type的接口实现亦可,产生的对象是目标type的子类亦可,只要能够正确地调用Web Services方法的类型都可以。这就给了我们对于使用Atlas调用Web Services方法有了更高的灵活性,其能力甚至已经凌驾于Web Services本身!这就是Atlas的强大之处。以后将会出现与此相关的示例,敬请留意。:)

等拿到了 真正的目标对象类型后,就使用Activator.CreateInstance方法进行对象构造。最后枚举Dictionary中的每一个key,并将以之对应的value和对象传入AssignToPropertyOrField方法进行public属性或者成员变量的赋值。该方法首先再次使用ConvertObjectToType方法递归地将value转换为合适的对象,然后尝试寻找与key对应的属性的set方法,如果没有的话,则再寻找与key对应的Public Field,最后设值。

就这样,一个对象就成功地被构造出来了。



通过分析这篇文章的代码,我们至少可以发现一下几点:
  1. 在Atlas中,通过实现JavaScriptConverter的子类可以自定一个特定类型的序列化和反序列化能力。
  2. 在Atlas中,通过实现TypeConverter并使用TypeConverterAttribute标记可以为一个特定类型,自定义从基本类型(例如Int32,Double,DateTime,Boolean等)的反序列化的能力。
  3. Atlas访问Web Services方法对于范型类型(甚至对于普通数据类型)的支持,是不完整的,不过已经足够大多数应用的需求。而且对于不支持的数据类型,我们可以通过自定义的方式提供对它们的支持。
  4. Atlas访问Web Services方法有非常灵活的可控性,它的功能甚至超过普通的Web Services访问,能够做到普通Web Services无法做到的有用功能。
  在今后的文章中,我会一一提供对于这些功能的示例。



到现在为止,我们对于服务器端反序列化客户端数据的方式已经分析地差不多了,不过还缺少对于那个IJavaScriptSerializationContext,也就是那个WebServiceData对象中GetType方法的分析与理解。那么这个对象这个方法到底是如何工作的呢?另外,得到了调用Web Services方法的结果以后,它又是怎么被序列化并输出是客户端的呢?

我们将在下一篇文章中讨论这些问题。

转载于:https://my.oschina.net/abcijkxyz/blog/721099

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值