使用Rational Robot测试含有数据关联的Web应用

Rational Robot 可被用来对包含数据关联的复杂 Web 应用进行性能测试。这里所谓数据关联,是指 Web 页面之间存在的数据相关性,例如一个动态的页面 URL 或者个别输入参数需要从前一个页面中抽取出来,有时候还需要在抽取得到的结果的基础上做进一步处理。这就使得测试开发员通常必须对 Robot 自动生成的 VU 脚本进行修改从而保证其能正确运行。简单情形下, VU 语言库提供的一些库函数可以支持常见的抽取需求。但在很多更复杂的情形中,往往需要通过更多的编程来处理页面之间的数据关联,包括进行模式匹配、模拟 Java Script 或者 Java Applet 的行为等。本文将介绍处理最常见的几种数据关联的方法,并提供了一系列很有用的功能函数,帮助测试开发员编写更具灵活性的 VU 脚本。
随着越来越多的企业应用被移植到 Web 上, Web 应用正变得日益复杂。它们被用来实现复杂的业务流程例如交易甚至工作流。一个业务流程通常包含若干步骤。这些步骤间自然地需要共享某些数据以完成一次连续的 计算 。例如,某一个步骤的输出可能是下一个步骤所需的输入。在一个典型的 Web 应用实现中,业务流程的每个步骤对应为一个 HTML 页面,因而最终用户将与一系列连续的页面依次交互以完成一个完整的业务流程。由于 Web 的无状态特性,这些页面中通常需要存储一些信息来实现它们之间所需的数据共享,例如下一个页面的 URL 以及其他可能的输入参数等。这些信息常常是由服务器动态生成,因此每次的值都可能不同。但是,当 Robot 录制一个 HTTP 会话时,它只能记录这些数据在这个会话中的一个快照。尽管 Robot 采用了一种称为 动态数据关联 Dynamic data correlation )的技术使得它能够关联部分动态的值,但还是无法找出所有需要关联的值并据此产生具备完善功能和足够灵活性的 VU 脚本。即使 Robot 可以简单地认为所有的数据都是动态的,如何在可用的 HTML 页面中抽取甚至构造这些数据的值则是一个更加复杂和困难的问题,因为 Robot 对这些数据后隐含的逻辑一无所知。因此,在 Robot 不能产生令人满意的 VU 脚本时,就需要手工修改进行完善。
下面将首先对 Web 应用中的数据关联作更进一步的剖析,接着介绍如何使用 Robot 动态数据关联 技术,然后详细讨论当 Robot 不能产生满意的脚本时一些可能的解决方案,包括动态数据值的定制抽取和客户端数据构造的模拟等。
Web 应用中数据关联的分析
Web 应用中,当一个特定的 HTML 页面的 URL 或者个别输入参数的值是动态产生因而必须从先于它返回的页面包含的数据中抽取或者构造出来时,就发生了数据关联。动态输入参数的一个很好的例子就是当前很普遍的 “Session ID” ,它由服务器生成并返回给用户的浏览器,在访问下一个页面时这个 ID 需要被发送回去以获取存储在服务器端的会话上下文。输入参数通常以四种方式提交: HTTP 头参数、 Cookie URL 参数和 FORM 参数。由于 URL 参数可被认为是 URL 的一部分,因此可以认为有四种可能发生关联的动态数据: HTTP 头参数、 Cookie URL FORM 参数。在 Robot VU 语言中,一个 HTTP 请求是通过调用库函数 “http_request” 发出的,列表 1 是给出了一个典型的用例。请注意列表 1 中各粗体部分,它们分别代表了四种可能发生关联的动态数据的形式中的一种。


列表 1. 函数http_request的典型用例

http_request ["t3079"]
   "POST /pkmslogin.form HTTP/1.1/r/n"
   "Accept: image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, applicat"
   "ion/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, ap"
   "plication/x-shockwave-flash, */*/r/n"
   "Referer: " + SgenURI_009 + "/r/n"
   /* "Referer: http://gclgtod.cn.ibm.com/wps/myportal?lang=en_US" */
   "Accept-Language: en-us,zh-cn;q=0.5/r/n"
   "Content-Type: application/x-www-form-urlencoded/r/n"
   "Accept-Encoding: gzip, deflate/r/n"
   "User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)/r/n"
   "Host: gclgtod.cn.ibm.com/r/n"
   "Content-Length: 55/r/n"
   "Connection: Keep-Alive/r/n"
   "Cache-Control: no-cache/r/n"
   "Cookie: w3sauid=d002000000001363710753854620000923482.0009B551AB; PBC_N LSP"
   "=en_US; msp=alreadyOffered; JSESSIONID=0000fRBw1aq9nolhnP9ZMKhaw2B:- 1; "
   "PD-H-SESSION-ID=4_oxjUZgfvY4ToFOhh9cFnnAg54o4sndHOA6rRkqpxbT2NAAAA/r"
   "/n"
   "/r/n"
   "username=admin&password=admin&login-form-type="
     + http_url_encode(SgenRes_005[0]) + "";

 
正确处理数据关联的第一步是使用变量替换 Robot 录制的脚本中包含的动态数据的静态值,这些变量将在脚本运行时被动态地赋值。以列表 1 中的 HTTP 头参数 “Referer” FORM 参数 “login-form-type” 为例,它们都由一个变量来赋值。但接下来的难题是:如何得到这些变量的值?一般有两种可能:一种是它们的值被直接包含在返回的 HTTP 响应中(包括响应的头和 HTML 内容)因而可以通过字符串抽取获得;另一种则需要进一步在抽取得到的若干值的基础上进行构造来获得。 Robot 能够自动地识别并抽取某些类型的动态值,这将在下一节中进行介绍。然而,目前它还不能发现所有这些动态值,更不用说根据一个未知的逻辑去构造一个值。因此,通常需要测试开发人员通过对 VU 脚本进行编程来定制变量值的抽取过程或者模拟某个数据构造过程。
这里需要澄清的是,动态数据和需要使用数据池( Datapool )的数据是不一样的。后者基本上是为了模仿最终用户的输入,它的值可以在脚本运行前确定并加载到数据池中(例如用户名和密码)。而本文中所指的动态数据大多是由服务器在运行时间生成和返回并需要在后续请求中以某种形式发送回去。不过,在某些非常特殊的情形下,例如当服务器对某个动态数据只生成一个有限集合的值(例如 true false )并且对用户会话不敏感,那么可以使用一个加载了所有这些可能值的数据池用于该动态数据的赋值,前提是能与服务器生成这些值类似的 逻辑 来从数据池中获得这些值(例如随机方式)。

 

 
使用 Robot动态数据关联功能
如前所述,关联的动态数据可以有四种形式提交给服务器,下面介绍 Robot 如何分别处理它们。
HTTP 头参数
HTTP 头参数产生关联的情形较其他几种形式少,但是有一个例外,即 “Referer” 头参数。根据 HTTP 协议,该参数的值应该指向上一个被访问的 URL 地址。由于 URL 的动态性, “Referer” 成为一个非常普遍的需要进行关联的动态数据,因此 Robot 特别定义了一个名为 “_reference_URI” 的只读系统变量,用来存储上一个 GET 或者 POST 命令请求的完整 URL 地址。在其生成的脚本中, Robot 会自动地用变量 “_reference_URI” 替换所有 “Referer” 头参数的值。
Cookie 被广泛用来传递关联的动态数据,例如会话 ID 。使用 Cookie 的形式提交动态数据的好处是其值往往来自 Cookie 自身。具体地讲,如果服务器选择使用 Cookie 来存储一些动态数据,它会使用 “Set-Cookie” 语句在 HTTP 响应头中指定这些数据和它们的值。在浏览器发出下一个请求时,只要简单地将这些 Cookie 包含在 HTTP 请求头中发送回去。 Cookie 的这种特性使得 Robot 能方便地处理它们携带的动态数据。
Robot 能处理两种不同的 Cookie :浏览器存储的 Cookie 和动态 Cookie 。对于浏览器存储的 Cookie Robot 会在录制一个 HTTP 会话前查询浏览器存储的所有 Cookie 并将它们放在最后生成的脚本的 COOKIE_CACHE 部分中。 Robot 会把这些 Cookie 的过期日期设置为足够远的将来以保证在脚本运行的时候它们不会过期。当脚本被回放时, COOKIE_CACHE 中的 Cookie 都会被加载到内存中使得回放过程尽量符合实际情况。整个过程都由 Robot 自动完成,不需测试开发人员干预。对于动态 Cookie ,即那些在录制脚本时由服务器返回的 Cookie Robot 会将它们作为 HTTP 请求头的一部分保存在生成的脚本中(见列表 1 ),但是在脚本被回放时它们的值会被替换为服务器实际返回的值 —— 如果服务器确实有返回的话,否则就使用脚本中记录的值。
URL
从动态性的角度考虑,一个 URL 可以被分割为两个部分:出现在 “?” 前的 location 部分,和出现在 “?” 后的可选的 search 部分。后者用于携带若干个 URL 参数因此比前者更具动态性。由于 URL 参数和 FORM 参数的处理非常类似,将下一小节中一起讨论。对于相对比较静态的 location 部分, Robot 一般只简单地保存录制到的值而不会做任何处理。但不幸的是在实际情况中 URL location 部分也可能是动态地生成的,一个很好的例子就是 WebSphere Portal Server 生成的 URL 链接,在这种情况下就需要通过编程定制的字符串抽取来获得。
FORM 参数
很多 Web 应用并不区分 FORM 参数和 URL 参数。实际上,当一个 FORM GET 方法提交的时候,它的参数就变为 URL 参数了。从对数据关联的意义上讲,也可以认为两者没有区别,除了在提交的时候它们位于 HTTP 请求中的不同位置: URL 参数作为 URL 的一部分出现在 HTTP 请求头中,而 FORM 参数则出现在内容中。 Robot 采用一种名为 动态数据关联 的技术来完成部分参数的自动关联。通过以下步骤可以激活这一功能:
1. 点击菜单 “Tools” >“Session Record Options”
2. 点击 “Generator per Protocol” 标签,见图 1
3. “Correlate variables in response” 设置区中,选择以下选项之一:
a. All 关联所有可识别的变量。
b. Specific 只关联指定的变量。通过设置区中的 “Add” “Remove” 按钮来指定需要关联的参数的名称。
c. None 不关联任何变量。


1. 设置Robot的自动关联功能
如果选择了 “All” 或者 “Specific” 选项,那么生成的 VU 脚本中会包含若干对库函数 “http_find_values” 的调用。该库函数会找出由服务器返回并且最终用户不作修改的参数,然后抽取出它们的值并保存在一系列以 “SgenRes_nnn” 形式命名的变量中。举例来讲,列表 2 中包含了一个隐藏的 FORM 参数 “mode” Robot 会确定该参数需要进行关联并生成相应的脚本代码(见列表 3 )来动态抽取它的值。


列表 2. 一个FORM样例

<form action="/search" name="frmSearch" method="GET">
<input type="hidden" name="mode" value="simple">
<input maxLength="256" size="55" name="keyword" value=""><br>
<input type="submit" value="Search" name="btnSearch">
</form>




列表 3. Robot生成的VU脚本片断样例

{
string SgenRes_001[];
SgenRes_001 = http_find_values("mode", HTTP_FORM_DATA, 1);
CHECK_FIND_RESULT(SgenRes_001,"mode","simpe")
}

 
库函数 http_find_values 会在当前 HTTP 连接的响应中搜索所需的参数值。它的语法如下:

string[] http_find_values(name, type, tag[, name, type, tag ... ])

 
其中 name 指定参数的名称, type 指定参数所在的数据形式, tag 指定使用符合条件的第几个参数值。 type 的值应为以下值之一: HTTP_FORM_DATA HTTP_HREF_DATA HTTP_COOKIE_DATA ,分别代表 FORM 数据、 URL 数据或 Cookie 数据。每一个 name type tag 的组合都唯一地确定了一个单一的值,调用 http_find_values 时最多可以指定 21 个这样的组合。宏 CHECK_FIND_RESULT 验证它返回的值不为空,若为空则提供一个缺省值,该缺省值是在脚本录制时记录的值。
可以发现,虽然使用了动态数据关联技术, Robot 还是只能从 FORM 数据、 URL 数据或者 Cookie 数据中抽取参数值。如果动态数据的值被包含在其他地方,例如 FORM 中的 “action” 属性中, Robot 就无能为力了。
 
URL location 部分是由服务器动态生成或者部分参数不在 Robot 能自动关联的范围之内时,就需要通过编程来定制参数值的抽取,简单地讲就是进行字符串匹配。 VU 语言的库提供了几个用于此目的的函数。除了前面已经介绍过的库函数 http_find_values ,库函数 http_header_info 可被用来从最近的 HTTP 响应头中抽取一个头参数。此外还有很多基本的字符串处理函数,可在它们的基础上编写更复杂的自定义函数。下面介绍几个作者编写的可用于一般目的抽取函数。
列表 4 中定义的 “getURLByText” 函数可以通过指定一个字符串获得围绕该字符串的 HTML Anchor 标签的 HREF 属性。例如, getURLByText(“<p>Hello world! <a href href=hello1.jsp><img src=surprise.jpg></a><a href=hello2.jsp>Click here for a surprise!</a></p>”, “a surprise”) 将返回 “hello2.jsp” 。如果第二个参数变为 “surprise” ,则返回 “hello1.jsp” ,因为该函数总是返回第一个被匹配到的结果。如果没有找到任何匹配, getURLByText 返回一个空字符串。


列表 4. 函数getURLByText

string func getURLByText(source, text)
string source, text;
{
    int startText, startA, startHref;
    string remainingText, beforeText, aOpenText, hrefText, url;
    string pattern;
    pattern = "([ //t//n//r]*/"(([^/"]*)$0)/")|";
    pattern += "([ //t//n//r]*//'(([^//']*)$0)//')|";
    pattern += "([ //t//n//r]*(([^ //t//n//r]*)$0)[ //t//n//r>])";
    remainingText = source;
    while (1) {
        startText = strstr(remainingText, text);
        if (0 == startText) {
            break;
        } else {
            beforeText = substr(remainingText, 1, startText - 1);
            startA = 0;
            // Find the position of the last occurrence of "<a" or "<A".
            while (match('(<[aA][ /t/n/r]+)$0', beforeText, &aOpenText)) {
                startA = strstr(beforeText, aOpenText);
                beforeText = substr(beforeText, startA + 2, strlen(beforeText));
            }
            if (0 < startA) {
                if (!match('</[aA][ /t/n/r]*>', beforeText)) {
                    // The anchor does enclose specified text.
                    if (match(
                        '([Hh][Rr][Ee][Ff][ /t/n/r]*=)$0',
                        beforeText,
                        &hrefText)) {
                        // Check the location of the found "href".
                        startHref = strstr(beforeText, hrefText);
                        if (startHref < strstr(beforeText, ">")) {
                            // Now try to extract the URL
                            if (match(
                                pattern,
                                substr(
                                    beforeText,
                                    startHref + strlen(hrefText),
                                    strlen(beforeText)),
                                &url)) {
                                return url;
                            }
                        }
                    }
                }
            }
        }
        remainingText =
            substr(
                remainingText,
                startText + strlen(text),
                strlen(remainingText));
    }
    return "";
}

 
列表 5 中定义的函数 “getURLByTextEx” 提供了类似但更强大的功能。它允许使用 VU 语言所支持的正则表达式来指定目标 Anchor 标签所围绕的字符串的模式。例如, getURLByTextEx(“<p>Hello world! <a href href=hello1.jsp><img src=surprise.jpg></a><a href=hello2.jsp>Click here for a surprise!</a></p>, “[Ss]urprise”) 将返回 “hello1.jsp”


列表 5. 函数getURLByTextEx

string func getURLByTextEx(source2, expression2)
string source2, expression2;
{
    int startText2, startA2, startHref2;
    string text2, remainingText2, beforeText2, aOpenText2, hrefText2, url2;
    string newExpression2, pattern2;
    pattern2 = "([ //t//n//r]*/"(([^/"]*)$0)/")|";
    pattern2 += "([ //t//n//r]*//'(([^//']*)$0)//')|";
    pattern2 += "([ //t//n//r]*(([^ //t//n//r]*)$0)[ //t//n//r>])";
   
    newExpression2 = "(" + expression2 + ")$0";
    remainingText2 = source2;
    while (1) {
        if (!match(newExpression2, remainingText2, &text2)) {
            break;
        } else {
            startText2 = strstr(remainingText2, text2);
            beforeText2 = substr(remainingText2, 1, startText2 - 1);
            startA2 = 0;
            // Find the position of the last occurrence of "<a" or "<A".
            while (match('(<[aA][ /t/n/r]+)$0', beforeText2, &aOpenText2)) {
                startA2 = strstr(beforeText2, aOpenText2);
                beforeText2 = substr(beforeText2, startA2 + 2, strlen(beforeText2));
            }
            if (0 < startA2) {
                if (!match('</[aA][ /t/n/r]*>', beforeText2)) {
                    // The anchor does enclose specified text.
                    if (match(
                        '([Hh][Rr][Ee][Ff][ /t/n/r]*=)$0',
                        beforeText2,
                        &hrefText2)) {
                        // Check the location of the found "href".
                        startHref2 = strstr(beforeText2, hrefText2);
                        if (startHref2 < strstr(beforeText2, ">")) {
                            // Now try to extract the URL
                            if (match(
                                pattern2,
                                substr(
                                    beforeText2,
                                    startHref2 + strlen(hrefText2),
                                    strlen(beforeText2)),
                                &url2)) {
                                return url2;
                            }
                        }
                    }
                }
            }
        }
        remainingText2 =
            substr(
                remainingText2,
                startText2 + strlen(text2),
                strlen(remainingText2));
    }
    return "";
}

 
列表 6 中定义的一系列函数可根据一个字符串的包围字符串、前缀或者后缀进行抽取。它们的名字暗示了其各自的功能。例如,假设列表 2 中的 HTML 内容被保存在系统变量 “_response” 中,那么通过调用 getStringByBoundaries(_response, “action=/””, “/””) 可以得到字符串 “/search” ;或者,也可以通过调用 getStringByPrefixAndBoundary(_response, “/sea”, “/” name”) getStringByBoundaryAndPostfix(_response, “action=/””, “ch”) getStringByPrefixAndPostfix(_response, “/sea”, “ch”) 来得到。这些函数也有相应的支持正则表达式的版本。


列表 6. 一系列字符串抽取函数

string func getStringByBoundaries(source3, b1, b2)
string source3, b1, b2;
{
    int startPos, endPos;
    startPos = strstr(source3, b1);
    if (0 == startPos) {
        return "";
    }
    startPos += strlen(b1);
    endPos = strstr(substr(source3, startPos, strlen(source3)), b2);
    if (0 == endPos) {
        return "";
    }
    return substr(source3, startPos, endPos - 1);
}
string func getStringByPrefixAndBoundary(source4, prefix, b3)
string source4, prefix, b3;
{
    int startPos2, endPos2;
    startPos2 = strstr(source4, prefix);
    if (0 == startPos2) {
        return "";
    }
    endPos2 = strstr(
        substr(source4, startPos2 + strlen(prefix), strlen(source4)), b3);
    if (0 == endPos2) {
        return "";
    }
    return
        substr(source4, startPos2, strlen(prefix) + endPos2 - 1);
}
string func getStringByBoundaryAndPostfix(source5, b4, postfix)
string source5, b4, postfix;
{
    int startPos3, endPos3;
    startPos3 = strstr(source5, b4);
    if (0 == startPos3) {
        return "";
    }
    startPos3 += strlen(b4);
    endPos3 = strstr(
        substr(source5, startPos3, strlen(source5)), postfix);
    if (0 == endPos3) {
        return "";
    }
    return
        substr(source5, startPos3, strlen(postfix) + endPos3 - 1);
}
string func getStringByPrefixAndPostfix(source6, prefix2, postfix2)
string source6, prefix2, postfix2;
{
    int startPos4, endPos4;
    startPos4 = strstr(source6, prefix2);
    if (0 == startPos4) {
        return "";
    }
    endPos4 = strstr(
        substr(source6, startPos4 + strlen(prefix2), strlen(source6)),
        postfix2);
    if (0 == endPos4) {
        return "";
    }
    return substr(source6, startPos4,
        strlen(prefix2) + strlen(postfix2) + endPos4 - 1);
}

 
上面介绍的所有这些函数都定义在文件 “routines.s” 中(见资源)。若要使用它们,请在 Robot 中创建一个新的空脚本然后将 routines.s 的内容粘贴进去。在其他脚本中,只要在文件头中添加下面这一行就可以使用上面介绍的函数了:

#include “newscript.s”

 
请将 “newscript” 替换为实际的文件名。
 
一个具备完整功能的 VU 脚本应该具备模仿浏览器所有相关行为的能力。举个简单的例子,仔细阅读列表 1 中的脚本片断会发现,把 “Content-Length” 这个头参数的值静态地设置为 55 是不恰当的,原因是实际的内容长度取决于可能出现的使用关联的动态值或者数据池的 FORM 参数值。因此,更好的做法是模仿浏览器在运行时计算实际的内容长度,而不是使用录制脚本时记录的静态值。列表 7 给出了改进后的脚本。


列表 7. 改进后的脚本

{
string formData;
formData = "username=admin&password=admin&login-form-type="
          + http_url_encode(SgenRes_005[0]) + "";
}
http_request ["t3079"]
   "POST /pkmslogin.form HTTP/1.1/r/n"
   "Accept: image/gif, image/x-xbitmap, image/jpeg, image/pjpeg, applicat"
   "ion/vnd.ms-excel, application/vnd.ms-powerpoint, application/msword, ap"
   "plication/x-shockwave-flash, */*/r/n"
   "Referer: " + SgenURI_009 + "/r/n"
   /* "Referer: http://gclgtod.cn.ibm.com/wps/myportal?lang=en_US" */
   "Accept-Language: en-us,zh-cn;q=0.5/r/n"
   "Content-Type: application/x-www-form-urlencoded/r/n"
   "Accept-Encoding: gzip, deflate/r/n"
   "User-Agent: Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)/r/n"
   "Host: gclgtod.cn.ibm.com/r/n"
   "Content-Length: " + itoa(strlen(formData)) + "/r/n"
   "Connection: Keep-Alive/r/n"
   "Cache-Control: no-cache/r/n"
   "Cookie: w3sauid=d002000000001363710753854620000923482.0009B551AB; PBC_N LSP"
   "=en_US; msp=alreadyOffered; JSESSIONID=0000fRBw1aq9nolhnP9ZMKhaw2B:- 1; "
   "PD-H-SESSION-ID=4_oxjUZgfvY4ToFOhh9cFnnAg54o4sndHOA6rRkqpxbT2NAAAA/r"
   "/n"
   "/r/n"
   "" + formData + "";

 
HTML 页面中包含 JavaScript 代码或者嵌入式组件例如 Java Applet ActiveX 控件时,客户端的模拟就变得更为重要,因为它们常会被用来根据某一逻辑响应用户的操作在客户端动态地生成一些数据的值。客户端模拟的最直接有效的方法就是用 VU 语言实现由网页中的 JavaScript 或者嵌入式组件所实现的数据构造过程。但在这之前,通常需要先抽取构造所需的输入,上一节中介绍的函数会有助于此。举个例子,列表 8 中的 HTML 页面片断使用 JavaScript 来根据一个员工的名字动态地生成一个编码后的 URL ,列表 9 中的 VU 脚本片断模拟了这一 URL 的构造过程。


列表 8. 包含JavaScript代码的HTML片断样例

<script language="JavaScript">
<!--
function URLEncode(aURL) {
         // Encode a URL.
         …
}
function gotoPage(employeeName) {
         employeeName += ".htm";
         employeeName = URLEncode(employeeName);
         document.location.href = employeeName;
}
//-->
</script>
Jack Lee
<input type="button" onClick="javascript:gotoPage('Jack Lee')"
         value="View profile" /><br>
Rose Smith
<input type="button" onClick="javascript:gotoPage('Rose Smith')"
         value="View profile" /><br>




列表 9. 模拟JavaScriptVU脚本片断样例

#include "routines.s"
{
         string employeeName;
         employeeName = getStringByBoundaries(_response, "javascript:gotoPage('", "'");
         employeeName += ".htm";
         employeeName = http_url_encode(employeeName);
}

Web 应用中的连续页面存在数据关联是很普遍的现象。使用 Rational Robot 通过或多或少的人工干预可以正确地处理这些关联从而产生更完善的 VU 脚本。本文在分析了常见形式的数据关联的基础上介绍了其相应的处理方法。
 
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值