测试restful接口 java_零成本实现接口自动化测试 – Java+TestNG 测试Restful service

接口自动化测试 – Java+TestNG 测试 Restful Web Service

关键词:基于Rest的Web服务,接口自动化测试,数据驱动测试,测试Restful Web Service, 数据分离,Java+Maven+TestNG

本文主要介绍如何用Java针对Restful web service 做接口自动化测试(数据驱动),相比UI自动化,接口自动化稳定性可靠性高,实施难易程度低,做自动化性价比高。所用到的工具或类库有 TestNG, Apache POI, Jayway rest-assured,Skyscreamer - JSONassert

简介:

思想是数据驱动测试,用Excel来管理数据,‘Input’ Sheet中存放输入数据,读取数据后拼成request 调用service, 拿到response后写入 ‘Output’ Sheet 即实际结果, ‘Baseline’为基线(期望结果)用来和实际结果对比的,‘Comparison’ Sheet里存放的是对比结果不一致的记录,‘Result’ Sheet 是一个简单的结果报告。

Maven工程目录结构:

fb6b2070b645d22d926505576870678a.png

详细介绍

核心就一个测试类HTTPReqGenTest.java 由四部分组成

@BeforeTest  读取Excel (WorkBook) 的 ‘Input’ 和 ‘Baseline’ sheet

11cf65be2987880da855924db5b978dd.png

a99307050f0d65ebcb04b00b560da65b.png

并且新建‘Output’, ‘Comparison’, ‘Result’ 三个空sheet

读取http_request_template.txt 内容转成string

6739253eb55bae69bc44ca778827be22.png

@DataProvider (name = "WorkBookData")

TestNG的DataProvider, 首先用DataReader构造函数,读取Excel中Input的数据,放入HashMap, Map的key值就是test case的ID,value是RecordHandler对象,此对象中一个重要的成员属性就是input sheet里面 column和value 的键值对,遍历Map将test case ID 与 test case的value 即input sheet前两列的值放入List ,最后返回List的迭代器iterator (为了循环调用@Test方法)

@Test (dataProvider = "WorkBookData", description = "ReqGenTest")

测试方法,由DataProvider提供数据,首先根据ID去取myInputData里的RecordHandler, 由它和template 去生成request, 然后执行request 返回response,这些工作都是由HTTPReqGen.java这个类完成的,借助com.jayway.restassured, 返回的response是一个JSON体,通过org.skyscreamer.jsonassert.JSONCompare 与Baseline中事先填好的期望结果(同样也是JSON格式)进行比较, 根据结果是Pass还是Fail, 都会相应的往Excel里的相应Sheet写结果。

@AfterTest

写入统计的一些数据

关闭文件流

实现代码:

HTTPReqGenTest.java

8f900a89c6347c561fdf2122f13be562.png

961ddebeb323a10fe0623af514929fc1.png

packagecom.demo.qa.rest_api_test;importjava.io.FileInputStream;importjava.io.FileNotFoundException;importjava.io.FileOutputStream;importjava.io.IOException;importjava.io.InputStream;importjava.nio.charset.Charset;importjava.text.SimpleDateFormat;importjava.util.ArrayList;importjava.util.Date;importjava.util.Iterator;importjava.util.List;importjava.util.Map;importorg.apache.commons.io.IOUtils;importorg.apache.poi.xssf.usermodel.XSSFSheet;importorg.apache.poi.xssf.usermodel.XSSFWorkbook;importorg.json.JSONException;importorg.skyscreamer.jsonassert.JSONCompare;importorg.skyscreamer.jsonassert.JSONCompareMode;importorg.skyscreamer.jsonassert.JSONCompareResult;importorg.testng.Assert;importorg.testng.ITest;importorg.testng.ITestContext;importorg.testng.annotations.AfterTest;importorg.testng.annotations.BeforeTest;importorg.testng.annotations.DataProvider;importorg.testng.annotations.Parameters;importorg.testng.annotations.Test;importcom.demo.qa.utils.DataReader;importcom.demo.qa.utils.DataWriter;importcom.demo.qa.utils.HTTPReqGen;importcom.demo.qa.utils.RecordHandler;importcom.demo.qa.utils.SheetUtils;importcom.demo.qa.utils.Utils;importcom.jayway.restassured.response.Response;public class HTTPReqGenTest implementsITest {privateResponse response;privateDataReader myInputData;privateDataReader myBaselineData;privateString template;publicString getTestName() {return "API Test";

}

String filePath= "";

XSSFWorkbook wb= null;

XSSFSheet inputSheet= null;

XSSFSheet baselineSheet= null;

XSSFSheet outputSheet= null;

XSSFSheet comparsionSheet= null;

XSSFSheet resultSheet= null;private double totalcase = 0;private double failedcase = 0;private String startTime = "";private String endTime = "";

@BeforeTest

@Parameters("workBook")public voidsetup(String path) {

filePath=path;try{

wb= new XSSFWorkbook(newFileInputStream(filePath));

}catch(FileNotFoundException e) {

e.printStackTrace();

}catch(IOException e) {

e.printStackTrace();

}

inputSheet= wb.getSheet("Input");

baselineSheet= wb.getSheet("Baseline");

SheetUtils.removeSheetByName(wb,"Output");

SheetUtils.removeSheetByName(wb,"Comparison");

SheetUtils.removeSheetByName(wb,"Result");

outputSheet= wb.createSheet("Output");

comparsionSheet= wb.createSheet("Comparison");

resultSheet= wb.createSheet("Result");try{

InputStream is= HTTPReqGenTest.class.getClassLoader().getResourceAsStream("http_request_template.txt");

template=IOUtils.toString(is, Charset.defaultCharset());

}catch(Exception e) {

Assert.fail("Problem fetching data from input file:" +e.getMessage());

}

SimpleDateFormat sf=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

startTime= sf.format(newDate());

}

@DataProvider(name= "WorkBookData")protected IteratortestProvider(ITestContext context) {

List test_IDs = new ArrayList();

myInputData= new DataReader(inputSheet, true, true, 0);

Map myInput =myInputData.get_map();//sort map in order so that test cases ran in a fixed order

Map sortmap =Utils.sortmap(myInput);for (Map.Entryentry : sortmap.entrySet()) {

String test_ID=entry.getKey();

String test_case= entry.getValue().get("TestCase");if (!test_ID.equals("") && !test_case.equals("")) {

test_IDs.add(newObject[] { test_ID, test_case });

}

totalcase++;

}

myBaselineData= new DataReader(baselineSheet, true, true, 0);returntest_IDs.iterator();

}

@Test(dataProvider= "WorkBookData", description = "ReqGenTest")public voidapi_test(String ID, String test_case) {

HTTPReqGen myReqGen= newHTTPReqGen();try{

myReqGen.generate_request(template, myInputData.get_record(ID));

response=myReqGen.perform_request();

}catch(Exception e) {

Assert.fail("Problem using HTTPRequestGenerator to generate response: " +e.getMessage());

}

String baseline_message= myBaselineData.get_record(ID).get("Response");if (response.statusCode() == 200)try{

DataWriter.writeData(outputSheet, response.asString(), ID, test_case);

JSONCompareResult result=JSONCompare.compareJSON(baseline_message, response.asString(), JSONCompareMode.NON_EXTENSIBLE);if (!result.passed()) {

DataWriter.writeData(comparsionSheet, result, ID, test_case);

DataWriter.writeData(resultSheet,"false", ID, test_case, 0);

DataWriter.writeData(outputSheet);

failedcase++;

}else{

DataWriter.writeData(resultSheet,"true", ID, test_case, 0);

}

}catch(JSONException e) {

DataWriter.writeData(comparsionSheet,"", "Problem to assert Response and baseline messages: "+e.getMessage(), ID, test_case);

DataWriter.writeData(resultSheet,"error", ID, test_case, 0);

failedcase++;

Assert.fail("Problem to assert Response and baseline messages: " +e.getMessage());

}else{

DataWriter.writeData(outputSheet, response.statusLine(), ID, test_case);if(baseline_message.equals(response.statusLine())) {

DataWriter.writeData(resultSheet,"true", ID, test_case, 0);

}else{

DataWriter.writeData(comparsionSheet, baseline_message, response.statusLine(), ID, test_case);

DataWriter.writeData(resultSheet,"false", ID, test_case, 0);

DataWriter.writeData(outputSheet);

failedcase++;

}

}

}

@AfterTestpublic voidteardown() {

SimpleDateFormat sf=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

endTime= sf.format(newDate());

DataWriter.writeData(resultSheet, totalcase, failedcase, startTime, endTime);try{

FileOutputStream fileOutputStream= newFileOutputStream(filePath);

wb.write(fileOutputStream);

fileOutputStream.close();

}catch(FileNotFoundException e) {

e.printStackTrace();

}catch(IOException e) {

e.printStackTrace();

}

}

}

View Code

DataReader

8f900a89c6347c561fdf2122f13be562.png

961ddebeb323a10fe0623af514929fc1.png

packagecom.demo.qa.utils;importjava.util.ArrayList;importjava.util.HashMap;importjava.util.List;importorg.apache.poi.ss.usermodel.Cell;importorg.apache.poi.xssf.usermodel.XSSFCell;importorg.apache.poi.xssf.usermodel.XSSFRow;importorg.apache.poi.xssf.usermodel.XSSFSheet;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;/*** Class that read data from XSSF sheet

**/

public classDataReader {protected static final Logger logger = LoggerFactory.getLogger(DataReader.class);private HashMap map = new HashMap();private Boolean byColumnName = false;private Boolean byRowKey = false;private List headers = new ArrayList();private Integer size = 0;publicDataReader() {

}/*** Primary constructor. Uses Apache POI XSSF to pull data from given excel workbook sheet. Data is stored in a

* structure depending on the options from other parameters.

*

*@paramsheet Given excel sheet.

*@paramhas_headers Boolean used to specify if the data has a header or not. The headers will be used as field keys.

*@paramhas_key_column Boolean used to specify if the data has a column that should be used for record keys.

*@paramkey_column Integer used to specify the key column for record keys.*/

publicDataReader(XSSFSheet sheet, Boolean has_headers, Boolean has_key_column, Integer key_column) {

XSSFRow myRow= null;

HashMapmyList;

size= 0;this.byColumnName =has_headers;this.byRowKey =has_key_column;try{if(byColumnName) {

myRow= sheet.getRow(0);for(Cell cell: myRow) {

headers.add(cell.getStringCellValue());

}

size= 1;

}for(; (myRow = sheet.getRow(size)) != null; size++) {

myList= new HashMap();if(byColumnName) {for(int col = 0; col < headers.size(); col++) {if(col

myList.put(headers.get(col), getSheetCellValue(myRow.getCell(col)));//myRow.getCell(col).getStringCellValue());

} else{

myList.put(headers.get(col),"");

}

}

}else{for(int col = 0; col < myRow.getLastCellNum(); col++) {

myList.put(Integer.toString(col), getSheetCellValue(myRow.getCell(col)));

}

}if(byRowKey) {if(myList.size() == 2 && key_column == 0) {

map.put(getSheetCellValue(myRow.getCell(key_column)),new RecordHandler(myList.get(1)));

}else if(myList.size() == 2 && key_column == 1) {

map.put(getSheetCellValue(myRow.getCell(key_column)),new RecordHandler(myList.get(0)));

}else{

map.put(getSheetCellValue(myRow.getCell(key_column)),newRecordHandler(myList));

}

}else{

map.put(Integer.toString(size),newRecordHandler(myList));

}

}

}catch(Exception e) {

logger.error("Exception while loading data from Excel sheet:"+e.getMessage());

}

}/*** Utility method used for getting an excel cell value. Cell's type is switched to String before accessing.

*

*@paramcell Given excel cell.*/

privateString getSheetCellValue(XSSFCell cell) {

String value= "";try{

cell.setCellType(Cell.CELL_TYPE_STRING);

value=cell.getStringCellValue();

}catch(NullPointerException npe) {return "";

}returnvalue;

}/*** Returns entire HashMap containing this class's data.

*

*@returnHashMap, map of ID-Record data.*/

public HashMapget_map() {returnmap;

}/*** Gets an entire record.

*

*@paramrecord String key value for record to be returned.

*@returnHashMap of key-value pairs representing the specified record.*/

publicRecordHandler get_record(String record) {

RecordHandler result= newRecordHandler();if(map.containsKey(record)) {

result=map.get(record);

}returnresult;

}

}

View Code

HTTPReqGen

8f900a89c6347c561fdf2122f13be562.png

961ddebeb323a10fe0623af514929fc1.png

packagecom.demo.qa.utils;import staticcom.jayway.restassured.RestAssured.given;importjava.io.BufferedReader;importjava.io.InputStream;importjava.io.InputStreamReader;importjava.util.HashMap;importjava.util.Map;importorg.apache.commons.io.IOUtils;importorg.slf4j.Logger;importorg.slf4j.LoggerFactory;importcom.jayway.restassured.response.Response;importcom.jayway.restassured.specification.RequestSpecification;/*** Wrapper for RestAssured. Uses an HTTP request template and a single record housed in a RecordHandler object to

* generate and perform an HTTP requests.

**/

public classHTTPReqGen {protected static final Logger logger = LoggerFactory.getLogger(HTTPReqGen.class);privateRequestSpecification reqSpec;private String call_host = "";private String call_suffix = "";private String call_string = "";private String call_type = "";private String body = "";private Map headers = new HashMap();private HashMap cookie_list = new HashMap();public MapgetHeaders() {returnheaders;

}publicString getCallString() {returncall_string;

}/*** Constructor. Initializes the RequestSpecification (relaxedHTTPSValidation avoids certificate errors).

**/

publicHTTPReqGen() {

reqSpec=given().relaxedHTTPSValidation();

}publicHTTPReqGen(String proxy) {

reqSpec=given().relaxedHTTPSValidation().proxy(proxy);

}/*** Pulls HashMap from given RecordHandler and calls primary generate_request method with it.

*

*@paramtemplate String, should contain the full template.

*@paramrecord RecordHandler, the input data used to fill in replacement tags that exist in the template.

*@returnthis Reference to this class, primarily to allow request generation and performance in one line.

*@throwsException*/

public HTTPReqGen generate_request(String template, RecordHandler record) throwsException {return generate_request(template, (HashMap) record.get_map());

}/*** Generates request data, using input record to fill in the template and then parse out relevant data. To fill in the

* template, identifies tags surrounded by << and >> and uses the text from the corresponding fields in the

* RecordHandler to replace them. The replacement is recursive, so tags may also exist in the fields of the

* RecordHandler so long as they are also represented by the RecordHandler and do not form an endless loop.

* After filling in the template, parses the resulting string in preparation for performing the HTTP request. Expects the

* the string to be in the following format:

*

* <> <>

* Host: <>

* <>:<>

* ...

* <>: <>

*

* <>

*

* <> must be GET, PUT, POST, or DELETE. <> must be a string with no spaces. It is appended to

* <> to form the complete call string. After a single blank line is encountered, the rest of the file

* is used as the body of text for PUT and POST calls. This function also expects the Record Handler to include a field

* named "VPID" containing a unique record identifier for debugging purposes.

*

*@paramtemplate String, should contain the full template.

*@paramrecord RecordHandler, the input data used to fill in replacement tags that exist in the template.

*@returnthis Reference to this class, primarily to allow request generation and performance in one line.

*@throwsException*/

public HTTPReqGen generate_request(String template, HashMap record) throwsException {

String filled_template= "";

Boolean found_replacement= true;

headers.clear();try{//Splits template into tokens, separating out the replacement strings//like <>

String[] tokens =tokenize_template(template);//Repeatedly perform replacements with data from record until no//replacements are found//If a replacement's result is an empty string, it will not throw an//error (but will throw one if there is no column for that result)

while(found_replacement) {

found_replacement= false;

filled_template= "";for(String item: tokens) {if(item.startsWith("<>")) {

found_replacement= true;

item= item.substring(2, item.length() - 2);if( !record.containsKey(item)) {

logger.error("Template contained replacement string whose value did not exist in input record:[" + item + "]");

}

item=record.get(item);

}

filled_template+=item;

}

tokens=tokenize_template(filled_template);

}

}catch(Exception e) {

logger.error("Problem performing replacements from template: ", e);

}try{//Feed filled template into BufferedReader so that we can read it line//by line.

InputStream stream = IOUtils.toInputStream(filled_template, "UTF-8");

BufferedReader in= new BufferedReader(newInputStreamReader(stream));

String line= "";

String[] line_tokens;//First line should always be call type followed by call suffix

line =in.readLine();

line_tokens= line.split(" ");

call_type= line_tokens[0];

call_suffix= line_tokens[1];//Second line should contain the host as it's second token

line =in.readLine();

line_tokens= line.split(" ");

call_host= line_tokens[1];//Full call string for RestAssured will be concatenation of call//host and call suffix

call_string = call_host +call_suffix;//Remaining lines will contain headers, until the read line is//empty

line =in.readLine();while(line != null && !line.equals("")) {

String lineP1= line.substring(0, line.indexOf(":")).trim();

String lineP2= line.substring(line.indexOf(" "), line.length()).trim();

headers.put(lineP1, lineP2);

line=in.readLine();

}//If read line is empty, but next line(s) have data, create body//from them

if(line != null && line.equals("")) {

body= "";while( (line = in.readLine()) != null && !line.equals("")) {

body+=line;

}

}

}catch(Exception e) {

logger.error("Problem setting request values from template: ", e);

}return this;

}/*** Performs the request using the stored request data and then returns the response.

*

*@returnresponse Response, will contain entire response (response string and status code).*/

public Response perform_request() throwsException {

Response response= null;try{for(Map.Entryentry: headers.entrySet()) {

reqSpec.header(entry.getKey(), entry.getValue());

}for(Map.Entryentry: cookie_list.entrySet()) {

reqSpec.cookie(entry.getKey(), entry.getValue());

}switch(call_type) {case "GET": {

response=reqSpec.get(call_string);break;

}case "POST": {

response=reqSpec.body(body).post(call_string);break;

}case "PUT": {

response=reqSpec.body(body).put(call_string);break;

}case "DELETE": {

response=reqSpec.delete(call_string);break;

}default: {

logger.error("Unknown call type: [" + call_type + "]");

}

}

}catch(Exception e) {

logger.error("Problem performing request: ", e);

}returnresponse;

}/*** Splits a template string into tokens, separating out tokens that look like "<>"

*

*@paramtemplate String, the template to be tokenized.

*@returnlist String[], contains the tokens from the template.*/

privateString[] tokenize_template(String template) {return template.split("(?=[]{2})");

}

}

View Code

RecordHandler

8f900a89c6347c561fdf2122f13be562.png

961ddebeb323a10fe0623af514929fc1.png

packagecom.demo.qa.utils;importjava.util.ArrayList;importjava.util.HashMap;importjava.util.List;public classRecordHandler {private enumRecordType {

VALUE, NAMED_MAP, INDEXED_LIST

}private String single_value = "";private HashMap named_value_map = new HashMap();private List indexed_value_list = new ArrayList();privateRecordType myType;publicRecordHandler() {this("");

}publicRecordHandler(String value) {this.myType =RecordType.VALUE;this.single_value =value;

}public RecordHandler(HashMapmap) {this.myType =RecordType.NAMED_MAP;this.named_value_map =map;

}public RecordHandler(Listlist) {this.myType =RecordType.INDEXED_LIST;this.indexed_value_list =list;

}public HashMapget_map() {returnnamed_value_map;

}public intsize() {int result = 0;if(myType.equals(RecordType.VALUE)) {

result= 1;

}else if(myType.equals(RecordType.NAMED_MAP)) {

result=named_value_map.size();

}else if(myType.equals(RecordType.INDEXED_LIST)) {

result=indexed_value_list.size();

}returnresult;

}publicString get() {

String result= "";if(myType.equals(RecordType.VALUE)) result =single_value;else{

System.out.println("Called get() on wrong type:" +myType.toString());

}returnresult;

}publicString get(String key) {

String result= "";if(myType.equals(RecordType.NAMED_MAP)) result =named_value_map.get(key);returnresult;

}publicString get(Integer index) {

String result= "";if(myType.equals(RecordType.INDEXED_LIST)) result =indexed_value_list.get(index);returnresult;

}publicBoolean set(String value) {

Boolean result= false;if(myType.equals(RecordType.VALUE)) {this.single_value =value;

result= true;

}else if(myType.equals(RecordType.INDEXED_LIST)) {this.indexed_value_list.add(value);

result= true;

}returnresult;

}publicBoolean set(String key, String value) {

Boolean result= false;if(myType.equals(RecordType.NAMED_MAP)) {this.named_value_map.put(key, value);

result= true;

}returnresult;

}publicBoolean set(Integer index, String value) {

Boolean result= false;if(myType.equals(RecordType.INDEXED_LIST)) {if(this.indexed_value_list.size() > index) this.indexed_value_list.set(index, value);

result= true;

}returnresult;

}publicBoolean has(String value) {

Boolean result= false;if(myType.equals(RecordType.VALUE) && this.single_value.equals(value)) {

result= true;

}else if(myType.equals(RecordType.NAMED_MAP) && this.named_value_map.containsKey(value)) {

result= true;

}else if(myType.equals(RecordType.INDEXED_LIST) && this.indexed_value_list.contains(value)) {

result= true;

}returnresult;

}publicBoolean remove(String value) {

Boolean result= false;if(myType.equals(RecordType.VALUE) && this.single_value.equals(value)) {this.single_value = "";

result= true;

}if(myType.equals(RecordType.NAMED_MAP) && this.named_value_map.containsKey(value)) {this.named_value_map.remove(value);

result= true;

}else if(myType.equals(RecordType.INDEXED_LIST) && this.indexed_value_list.contains(value)) {this.indexed_value_list.remove(value);

result= true;

}returnresult;

}publicBoolean remove(Integer index) {

Boolean result= false;if(myType.equals(RecordType.INDEXED_LIST) && this.indexed_value_list.contains(index)) {this.indexed_value_list.remove(index);

result= true;

}returnresult;

}

}

View Code

其它不重要的类不一一列出来了。

pom.xml

8f900a89c6347c561fdf2122f13be562.png

961ddebeb323a10fe0623af514929fc1.png

4.0.0

com.demo

qa

0.0.1-SNAPSHOT

Automation

Test project for Demo

UTF-8

maven-compiler-plugin

3.1

1.7

1.7

org.apache.maven.plugins

maven-jar-plugin

org.apache.maven.plugins

maven-surefire-plugin

maven-dependency-plugin

org.apache.maven.plugins

maven-jar-plugin

2.5

default-jar

test-jar

com.demo.qa.utils.TestStartup

true

lib/

false

org.apache.maven.plugins

maven-surefire-plugin

2.17

true

src\test\resources\HTTPReqGenTest.xml

maven-dependency-plugin

2.8

default-cli

package

copy-dependencies

${project.build.directory}/lib

org.eclipse.m2e

lifecycle-mapping

1.0.0

org.apache.maven.plugins

maven-dependency-plugin

[1.0.0,)

copy-dependencies

org.apache.commons

commons-lang3

3.3.2

commons-io

commons-io

2.4

com.jayway.restassured

rest-assured

2.3.3

com.jayway.restassured

json-path

2.3.3

org.apache.poi

poi

3.10.1

commons-codec

commons-codec

org.testng

testng

6.8

commons-cli

commons-cli

1.2

org.apache.poi

poi-ooxml

3.10.1

xml-apis

xml-apis

org.skyscreamer

jsonassert

1.2.3

org.slf4j

slf4j-api

1.7.7

org.slf4j

slf4j-simple

1.7.6

View pom.xml

运行是通过TestNG的xml文件来执行的, 里面配置了Parameter “workBook” 的路径

4fddc4e5d0cc922919f3af0a62ca9160.png

6d181ba21a9dd4e538c3ceac4b708371.png

TestNG的运行结果都是Pass, 但事实上里面有case是Fail的,我只是借助TestNG来运行,我并没有在@Test方法里加断言Assert, 所以这里不会Fail, 我的目的是完全用Excel来管理维护测试数据以及测试结果,做到数据脚本完全分离。

a59a98c8dae9551325193969f796fa90.png

Output sheet

c07df3dd4404f9f256f18e77541e4a00.png

Comparison sheet

3314188f4188d46051e554294dacc6de.png

Result sheet

01889f4f96226cfd1c9350b80c9bcc49.png

当然 你也可以把maven工程打成一个可执行jar来运行,不过需要增加一个main函数作为入口,xml测试文件通过参数传递进去,另外还需要在pom里配置一些插件,像maven-jar-plugin。

如果你还需要做back-end DB check,你可以在Input里再增加几列,你要查询的表,字段,Baseline里也相应的加上期望结果,这里就不再赘述了。

注:转载需注明出处及作者名。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值