单元测试Mock工具TestableMock使用

单元测试原则

单元测试必须遵循AIR(Automatic, Independent, Repeatable)原则:单元测试在线上运行时,感觉像空气(AIR)一样感觉不到,但在测试质量的保障上,却是非常关键的。好的单元测试宏观上来说,具有自动化、独立性、可重复执行的特点。

  • Automatic(自动化):单元测试应该是全自动执行的,并且非交互式的。测试用例通常是被定期执行的,执行过程必须完全自动化才有意义。输出结果需要人工检查的测试不是一个好的单元测试。单元测试中禁止使用System.out来进行人肉验证,必须使用assert来验证。
  • Independent(独立性):保持单元测试的独立性。为了保证单元测试稳定可靠且便于维护,单元测试用例之间决不能互相调用,也不能依赖执行的先后次序。
  • Repeatable(可重复):单元测试是可以重复执行的,不能受到外界环境的影响。单元测试通常会被放到持续集成中,每次有代码check in时单元测试都会被执行。如果单测对外部环境(网络、服务、中间件等)有依赖,容易导致持续集成机制的不可用。

Mock工具对比

目前主要的Mock工具主要有Mockito、Spock、PowerMock和JMockit等,基本差异如下:

工具原理最小Mock单元对被Mock方法的限制上手难度IDE支持
Mockito动态代理不能Mock私有/静态和构造方法较容易很好
Spock动态代理不能Mock私有/静态和构造方法较复杂一般
PowerMock自定义类加载器任何方法皆可较复杂较好
JMockit运行时字节码修改不能Mock构造方法(new操作符)较复杂一般
TestableMock运行时字节码修改方法任何方法皆可很容易一般

本文以TestableMock为例,针对单元测试中常出现的几种场景进行测试。

TestableMock 官方文档:https://alibaba.github.io/testable-mock/#/

TestableMock GitHub地址:https://github.com/alibaba/testable-mock

备注:单元测试基础学习:https://www.yuque.com/atguigu/springboot/ksndgx

本文使用代码样例环境:Spring Boot 2.5.7、TestableMock 0.70、MySQL 5.7.32、MyBatis 2.2.0、H2 1.4.200等,详见pom.xml文件

数据初始化准备

创建数据库goodsdb,创建表并初始化数据:

DROP TABLE IF EXISTS book_t;
CREATE TABLE book_t (
  book_id int(11) AUTO_INCREMENT PRIMARY KEY,
  book_name varchar(32) NOT NULL,
  book_price decimal(5,2) NOT NULL
);
INSERT INTO book_t VALUES (1, '安徒生童话', 99.99);
INSERT INTO book_t VALUES (2, 'MySQL实战教程', 88.88);

创建SpringBoot项目

官方地址:https://start.spring.io/

pom.xml文件如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.5.7</version>
        <relativePath/>
    </parent>
    <groupId>com.lwy.it</groupId>
    <artifactId>testablemock-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>testablemock-demo</name>
    <description>Demo project for Spring Boot With TestableMock</description>
    <properties>
        <java.version>1.8</java.version>
        <testable.version>0.7.0</testable.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.2.0</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-configuration-processor</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- 单元测试相关依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.alibaba.testable</groupId>
            <artifactId>testable-all</artifactId>
            <version>${testable.version}</version>
            <scope>test</scope>
        </dependency>
        <!-- 内存数据库,用于Mock MySQL-->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <version>1.4.200</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-plugin</artifactId>
                <configuration>
                    <argLine>
                        -javaagent:${settings.localRepository}/com/alibaba/testable/testable-agent/${testable.version}/testable-agent-${testable.version}.jar
                    </argLine>
                </configuration>
            </plugin>
            <!-- 插件介绍:http://maven.apache.org/surefire/maven-surefire-report-plugin/ -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-surefire-report-plugin</artifactId>
                <version>3.0.0-M5</version>
                <configuration>
                    <outputDirectory>${basedir}/target/report</outputDirectory>
                </configuration>
            </plugin>
        </plugins>
    </build>

</project>

代码结构

本文采用MVC结构:

创建ResultVO对象,用于封装返回结果给前端:

package com.lwy.it;

import lombok.Data;

import java.io.Serializable;
import java.util.List;

@Data
public class ResultVO<T> implements Serializable {
    private List<T> data;
    private String message;

    public ResultVO() {
    }

    public ResultVO(List<T> data) {
        this.data = data;
    }

    public ResultVO(List<T> data, String message) {
        this.data = data;
        this.message = message;
    }
}

vo对象:BookVO

package com.lwy.it.book.vo;

import lombok.Data;

import java.io.Serializable;

@Data
public class BookVO implements Serializable {
    private Integer bookId;
    private String bookName;
    private Double bookPrice;
}

控制器层controller对象:BookController

package com.lwy.it.book.controller;

import com.lwy.it.ResultVO;
import com.lwy.it.book.service.BookService;
import com.lwy.it.book.vo.BookVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Arrays;

@RestController
public class BookController {

    @Autowired
    private BookService bookService;

    @GetMapping("/books")
    public ResultVO<BookVO> findAllBook() {
        return bookService.getAllBook();
    }

    /**
     * 用于模拟远程服务,通过RestTemplate调用
     * 接口服务地址:http://localhost:8080/mock/book
     *
     * @return ResultVO<BookVO>
     */
    @GetMapping("/book")
    public ResultVO<BookVO> exclusiveBook() {
        BookVO bookVO = new BookVO();
        bookVO.setBookId(100000);
        bookVO.setBookName("专属丛书:十万个为什么");
        bookVO.setBookPrice(998d);
        ResultVO<BookVO> resultVO = new ResultVO<>(Arrays.asList(bookVO));
        resultVO.setMessage("获取专属丛书成功");
        return resultVO;
    }

}

服务层service对象:BookService

package com.lwy.it.book.service;

import com.lwy.it.ResultVO;
import com.lwy.it.book.configuration.BookConfiguration;
import com.lwy.it.book.dao.BookDao;
import com.lwy.it.book.vo.BookVO;
import com.lwy.it.utils.DataUtils;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.List;
import java.util.Objects;

@Slf4j
@Service
public class BookService {

    @Autowired
    private BookDao bookDao;

    @Autowired
    private BookConfiguration bookConfiguration;

    @Autowired
    private RestTemplate restTemplate;

    public ResultVO<BookVO> getAllBook() {
        List<BookVO> list = bookDao.getAllBook();
        ResultVO<BookVO> resultVO = DataUtils.convert(list);
        // 调用私有方法
        ResultVO<BookVO> vo = this.getExclusiveBook();
        log.info("获取到远程服务结果为:{}", vo);
        return resultVO;
    }

    // 用于模拟远程服务接口获取数据过程
    private ResultVO<BookVO> getExclusiveBook() {
        ResultVO<BookVO> resultVO = null;
        String url = bookConfiguration.getServerUrl();
        log.info("获取到的配置服务URL地址为:{}", url);
        // 返回结果是泛型类型,使用ParameterizedTypeReference进行包装
        ParameterizedTypeReference<ResultVO<BookVO>> reference = new ParameterizedTypeReference<ResultVO<BookVO>>() {
        };
        ResponseEntity<ResultVO<BookVO>> responseEntity = restTemplate.exchange(url, HttpMethod.GET, null, reference);
        if (Objects.equals(responseEntity.getStatusCode(), HttpStatus.OK)) {
            resultVO = responseEntity.getBody();
            log.info("获取到的结果为:{}", resultVO);
        } else {
            log.error("获取数据失败");
        }
        return resultVO;
    }
}

备注:RestTemplate基础学习:https://mp.weixin.qq.com/s/jIZCFOW4iy0j-epKVKQpOQ

数据库持久层dao对象:BookDao

package com.lwy.it.book.dao;

import com.lwy.it.book.vo.BookVO;
import org.apache.ibatis.annotations.Mapper;

import java.util.List;

@Mapper
public interface BookDao {
    List<BookVO> getAllBook();
}

对应mapper文件:book.mysql.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.lwy.it.book.dao.BookDao">
    <select id="getAllBook" resultType="com.lwy.it.book.vo.BookVO">
        SELECT
            book_id AS bookId,
            book_name AS bookName,
            book_price AS bookPrice
        FROM
            book_t
    </select>
</mapper>

相关配置信息

application.properties

server.port=8080

server.servlet.context-path=/mock

#MyBatis配置
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.mapper-locations=classpath:mapper/*.xml

#MySQL配置
spring.datasource.url=jdbc:mysql://localhost:3306/goodsdb?allowMultiQueries=true&serverTimezone=GMT&characterEncoding=utf8
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=123456

#配置远程服务地址
book.server.url=${bookUrl}

创建配置类,用于接收book.server.url变量参数,BookConfiguration

package com.lwy.it.book.configuration;

import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestTemplate;

@Data
@Configuration
public class BookConfiguration {

    @Value("${book.server.url}")
    private String serverUrl;

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder.build();
    }

}

工具类:DataUtils

package com.lwy.it.utils;

import com.lwy.it.ResultVO;
import org.springframework.util.CollectionUtils;

import java.util.Collections;
import java.util.List;

public class DataUtils {

    /**
     * 用于数据包装转换
     *
     * @param list 泛型结合
     * @param <T>  泛型类型
     * @return ResultVO<T>
     */
    public static <T> ResultVO<T> convert(List<T> list) {
        ResultVO<T> resultVO = new ResultVO<>();
        if (CollectionUtils.isEmpty(list)) {
            resultVO.setData(Collections.emptyList());
            resultVO.setMessage("数据为空");
        } else {
            resultVO.setData(list);
            resultVO.setMessage("数据不为空");
        }
        return resultVO;
    }

}

以上为服务的相关代码,均在src/main目录下面的目录中。

启动服务(要添加环境变量参数:bookUrl=http://localhost:8080/mock/book):访问http://localhost:8080/mock/books,得到数据库中数据:

{"data":[{"bookId":1,"bookName":"安徒生童话","bookPrice":99.99},{"bookId":2,"bookName":"MySQL实战教程","bookPrice":88.88}],"message":"数据不为空"}

编写单元测试代码

单元测试文件均位于在src/test目录下:

数据持久层单元测试

对于Dao层(数据库相关的查询,更新,删除等)操作,使用嵌入式内存数据库H2 Database验证逻辑。和数据库相关的单元测试,不给数据库造成脏数据。不能假设数据库里的数据是存在的,或者直接操作数据库把数据插入进去,请使用程序插入或者导入数据的方式来准备数据。 反例:删除某一行数据的单元测试,在数据库中,先直接手动增加一行作为删除目标,但是这一行新增数据并不符合业务插入规则,导致测试结果异常。

针对BookDao访问数据库进行单元测试,需要在src/test/resources目录下新建application.properties文件,内容如下

server.port=8080
server.servlet.context-path=/mock
#MyBatis配置
mybatis.configuration.map-underscore-to-camel-case=true
mybatis.mapper-locations=classpath:mapper/*.xml

########################数据更改###########################
#内存数据库H2以MYSQL模式运行,初始化时执行classpath:initdb.sql脚本,详情参考:http://www.h2database.com/html/features.html
spring.datasource.url=jdbc:h2:mem:goodsdb;MODE=MYSQL;INIT=RUNSCRIPT FROM 'classpath:initdb.sql'
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=
spring.datasource.password=
#########################数据更改##########################

#自定义变量配置
book.server.url=${bookUrl}

H2数据库初始化脚本initdb.sql文件如下:

DROP TABLE IF EXISTS book_t;
CREATE TABLE book_t (
                        book_id int(11) AUTO_INCREMENT PRIMARY KEY,
                        book_name varchar(32) NOT NULL,
                        book_price decimal(5,2) NOT NULL,
                        PRIMARY KEY (book_id)
);

INSERT INTO book_t VALUES (1, '测试数据1', 99.99);
INSERT INTO book_t VALUES (2, '测试数据2', 88.88);
INSERT INTO book_t VALUES (3, '测试数据2', 77.77);
INSERT INTO book_t VALUES (4, '测试数据2', 66.66);
INSERT INTO book_t VALUES (5, '测试数据2', 55.55);
INSERT INTO book_t VALUES (6, '测试数据2', 44.44);

BookDao对应单元测试BookDaoTest代码如下(包路径一致):

package com.lwy.it.book.dao;

import com.lwy.it.book.vo.BookVO;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;

@Slf4j
@SpringBootTest
public class BookDaoTest {

    @Autowired
    private BookDao bookDao;

    @Test
    @DisplayName("getAllBook")
    public void getAllBook_test() {
        List<BookVO> list = bookDao.getAllBook();
        log.info("结果为:{}", list);
        assertEquals(6, list.size());
    }
}

启动测试方法(要添加环境变量参数:bookUrl=http://localhost:8080/mock/book),日志如下,执行成功。我们发现数据被H2数据库中我们初始化的数据进行了替代。

2021-12-01 16:25:31.458  INFO 21644 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2021-12-01 16:25:31.919  INFO 21644 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2021-12-01 16:25:31.983  INFO 21644 --- [           main] com.lwy.it.book.dao.BookDaoTest          : 结果为:[BookVO(bookId=1, bookName=测试数据1, bookPrice=99.99), BookVO(bookId=2, bookName=测试数据2, bookPrice=88.88), BookVO(bookId=3, bookName=测试数据2, bookPrice=77.77), BookVO(bookId=4, bookName=测试数据2, bookPrice=66.66), BookVO(bookId=5, bookName=测试数据2, bookPrice=55.55), BookVO(bookId=6, bookName=测试数据2, bookPrice=44.44)]
服务层单元测试

因为前面场景服务层依赖外部服务等,测试案例应不依赖环境,所以我们需要把依赖Mock掉

我们使用TestableMock工具,按照官方文档进行操作:https://alibaba.github.io/testable-mock/#/

备注:这里的版本我们使用0.7.x版本,<testable.version>0.7.0</testable.version>,对应泛型Mock更加便捷。

单元测试代码如下:

package com.lwy.it.book.service;

import com.alibaba.testable.core.annotation.MockInvoke;
import com.alibaba.testable.core.tool.PrivateAccessor;
import com.lwy.it.ResultVO;
import com.lwy.it.book.dao.BookDao;
import com.lwy.it.book.vo.BookVO;
import com.lwy.it.utils.DataUtils;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.lang.Nullable;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;

@Slf4j
@SpringBootTest
public class BookServiceTest {

    @Autowired
    private BookService bookService;

    // 前置步骤,准备Mock容器
    public static class Mock {
        // 放置Mock方法的地方,按照官方示例 把Mock方法访问控制都改成private

        /**
         * 静态方法&泛型类型方法进行Mock
         * DataUtils类中public static <T> ResultVO<T> convert(List<T> list)
         *
         * @param list 泛型集合
         * @param <T>  泛型类型
         * @return ResultVO<T>
         */
        @MockInvoke(targetClass = DataUtils.class)
        private static <T> ResultVO<T> convert(List<T> list) {
            log.info("~~~~~~泛型&静态方法被执行~~~~~~");
            return new ResultVO<>(Collections.emptyList());
        }

        /**
         * Mock掉数据访问层BookDao的List<BookVO> getAllBook()方法
         *
         * @return List<BookVO>
         */
        @MockInvoke(targetClass = BookDao.class)
        private List<BookVO> getAllBook() {
            BookVO bookVO = new BookVO();
            bookVO.setBookId(10000);
            bookVO.setBookName("测试数据");
            bookVO.setBookPrice(99.99d);
            log.info("~~~~~~Dao层访问被Mock替代~~~~~~");
            return new ArrayList<BookVO>() {{
                add(bookVO);
            }};
        }

        /**
         * BookService getExclusiveBook()方法中调用RestTemplate的方法,外部依赖需要Mock掉
         * public <T> ResponseEntity<T> exchange(String url, HttpMethod method, @Nullable HttpEntity<?> requestEntity, ParameterizedTypeReference<T> responseType, Object... uriVariables) throws RestClientException
         *
         * @param url           访问url
         * @param method        get/post访问方式等
         * @param requestEntity 请求参数
         * @param responseType  返回类型(因为涉及泛型)
         * @param uriVariables
         * @param <T>           泛型类型
         * @return
         * @throws RestClientException
         */
        @MockInvoke(targetClass = RestTemplate.class)
        private <T> ResponseEntity<T> exchange(String url, HttpMethod method, @Nullable HttpEntity<?> requestEntity,
                                               ParameterizedTypeReference<T> responseType, Object... uriVariables) throws RestClientException {
            log.info("~~~~~~调用RestTemplate方法被Mock替代~~~~~~");
            return new ResponseEntity<T>(HttpStatus.OK);
        }
    }

    @Test
    @DisplayName("public方法:getAllBook单元测试")
    public void getAllBook_test() {
        ResultVO<BookVO> resultVO = bookService.getAllBook();
        log.info("ResultVO结果为:{}", resultVO);
        assertEquals(0, resultVO.getData().size());

    }

    /**
     * 对私有方法进行单元测试
     */
    @Test
    @DisplayName("private方法:getExclusiveBook单元测试")
    public void getExclusiveBook_test() {
        ResultVO<BookVO> resultVO = PrivateAccessor.invoke(bookService, "getExclusiveBook");
        log.info("ResultVO结果为:{}", resultVO);
        assertNull(resultVO);
    }

}

执行验证逻辑:

执行getAllBook_test测试用例(要添加环境变量参数:bookUrl=http://localhost:8080/mock/book),结果如下,实际调用过程中被替代

2021-12-01 16:56:32.623  INFO 23380 --- [           main] com.lwy.it.book.service.BookServiceTest  : ~~~~~~Dao层访问被Mock替代~~~~~~
2021-12-01 16:56:32.625  INFO 23380 --- [           main] com.lwy.it.book.service.BookServiceTest  : ~~~~~~泛型&静态方法被执行~~~~~~
2021-12-01 16:56:32.625  INFO 23380 --- [           main] com.lwy.it.book.service.BookService      : 获取到的配置服务URL地址为:http://localhost:8080/mock/book
2021-12-01 16:56:32.628  INFO 23380 --- [           main] com.lwy.it.book.service.BookServiceTest  : ~~~~~~调用RestTemplate方法被Mock替代~~~~~~
2021-12-01 16:56:32.638  INFO 23380 --- [           main] com.lwy.it.book.service.BookService      : 获取到的结果为:null
2021-12-01 16:56:32.638  INFO 23380 --- [           main] com.lwy.it.book.service.BookService      : 获取到远程服务结果为:null
2021-12-01 16:56:32.638  INFO 23380 --- [           main] com.lwy.it.book.service.BookServiceTest  : ResultVO结果为:ResultVO(data=[], message=null)

执行getExclusiveBook_test测试用例(要添加环境变量参数:bookUrl=http://localhost:8080/mock/book),结果如下,实际调用过程中被替代

2021-12-01 17:08:10.378  INFO 7740 --- [           main] com.lwy.it.book.service.BookService      : 获取到的配置服务URL地址为:http://localhost:8080/mock/book
2021-12-01 17:08:10.383  INFO 7740 --- [           main] com.lwy.it.book.service.BookServiceTest  : ~~~~~~调用RestTemplate方法被Mock替代~~~~~~
2021-12-01 17:08:10.394  INFO 7740 --- [           main] com.lwy.it.book.service.BookService      : 获取到的结果为:null
2021-12-01 17:08:10.394  INFO 7740 --- [           main] com.lwy.it.book.service.BookServiceTest  : ResultVO结果为:null

执行所有测试用例:

mvn clean test -DbookUrl=http://localhost:8080/mock/book
若涉及多profile文件,则添加-P参数

执行所有测试用例并生成报告:

mvn clean surefire-report:report -DbookUrl=http://localhost:8080/mock/book

在target/report/(pom.xml文件配置的${basedir}/target/report)目录下生成了surefire-report.html文件,用浏览器打开即可。

如果需要覆盖率信息,则需要使用Jacoco。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值