基于SpringBoot的httpclient实现(基础版)

一、背景

1.缺陷

(1)阻塞调用

  • 问题:RestTemplate 是基于阻塞 I/O 的,这意味着每个请求都会占用一个线程直到响应返回。在高并发场景下,这可能导致线程池耗尽,进而影响整个应用程序的性能。
  • 解决方案:
    (1)对于需要非阻塞通信的场景,推荐使用 WebClient,它是 Spring 5 引入的响应式 HTTP 客户端,支持异步和非阻塞操作;
    (2)替换RestTemplate内部实现的httpClient

(2)缺乏对HTTP特性的支持

  • 问题:RestTemplate 对于一些现代 HTTP 协议特性(如 HTTP/2 和 WebSocket)的支持有限或不存在。此外,它不支持响应式编程模型。
  • 解决方案:
    (1)WebClient 支持 HTTP/2、WebSocket 和其他现代协议特性,并且完全兼容响应式流标准;
    (2)httpclient4.x 支持HTTP/1、httpclient5.x的才支持HTTP/2,但是不支持WebSocket

(3)配置不够灵活

  • 问题:Spring 官方文档已经明确表示不再推荐新的应用程序使用 RestTemplate,并建议迁移到 WebClient。
  • 解决方案:为了确保代码库的长期维护性和与未来版本的兼容性,应考虑将现有的 RestTemplate 代码迁移到 WebClient。

(4)缺少内置重试和断路器机制

  • 问题:RestTemplate 没有内置的重试逻辑或断路器模式来处理失败的请求,这对于构建健壮的服务间通信非常重要。
  • 解决方案:
    (1)可以通过集成第三方库(如 Resilience4j 或 Hystrix)来添加这些功能,或者直接使用 WebClient,它原生支持这些特性
    (2)使用HttpClient的内置重试机制和获取链接超时处理

2.优势

(1)高级连接管理

  • 连接池:Apache HttpClient 提供了高效的连接池机制,这使得它能够更好地管理多个并发请求,减少了创建新连接的开销。
  • 持久连接:支持持久连接(Keep-Alive),可以在多次请求之间重用同一个 TCP 连接,从而提高了效率。

(2)改进的错误处理

  • 详细的错误信息:Apache HttpClient 提供了更为详尽的错误报告和异常处理机制,有助于更精确地诊断问题。
  • 重试逻辑:内置了对自动重试的支持,可以根据配置在遇到临时性错误时尝试重新发送请求。

(3)增强的安全特性

  • SSL/TLS 支持:提供了更加灵活和安全的 SSL/TLS 配置选项,包括自定义信任库、主机名验证等。
  • 认证机制:内置了多种身份验证方式的支持,如 BASIC、DIGEST、NTLM 等,简化了与受保护资源的交互。

(4)更好的性能

  • 异步操作:尽管 RestTemplate 是同步的,但 Apache HttpClient 支持异步请求,这对于构建高性能、非阻塞的应用程序非常有用。
  • 优化过的 I/O 操作:相比于默认的 HttpURLConnection,Apache HttpClient 在某些场景下可能提供更快的响应时间和更低的延迟

(5)丰富的配置选项

  • 定制化能力:允许开发者更细粒度地控制请求的行为,例如设置超时、代理服务器、cookie 管理等。
  • 插件式架构:可以通过扩展点添加额外的功能或修改现有行为,增加了灵活性。

二、实现

1.添加依赖

<?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 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>com.zzc</groupId>
        <artifactId>Demo</artifactId>
        <version>1.0-SNAPSHOT</version>
    </parent>

    <groupId>com.zzc.component</groupId>
    <artifactId>component</artifactId>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
		 <!-- 公共类、日志依赖等 -->
        <!-- <dependency>
            <groupId>com.zzc.common</groupId>
            <artifactId>common</artifactId>
            <version>1.0-SNAPSHOT</version>
        </dependency> -->

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <!-- springboot中包含了httpclient -->
        <dependency>
            <groupId>org.apache.httpcomponents</groupId>
            <artifactId>httpclient</artifactId>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <scope>provided</scope>
        </dependency>

    </dependencies>

</project>

2.代码实现

  • 定义配置类Properties
package com.zzc.component.http;

import lombok.Data;
import lombok.ToString;
import org.springframework.boot.context.properties.ConfigurationProperties;

import java.util.Map;

/**
 *
 * httpclient:
 *   charset: UTF-8
 *   conn-max-total: 300
 *   max-per-route: 100
 *   retry-num: 1
 *   connect-timeout: 30000 # 单位:毫秒
 *   read-timeout: 15000   # 单位:毫秒
 *   request-timeout: 200  # 单位:毫秒
 *   keep-alive-time: 60   # 单位:秒
 *
 *   # 如果请求目标地址单独配置可长链接保持时间,使用该配置
 *   keep-alive-target-host:
 *     example.com: 120      # example.com 的保持活动时间为 120 秒
 *     api.example.org: 300  # api.example.org 的保持活动时间为 300 秒
 *
 *   # http 请求 header
 *   headers:
 *     User-Agent: MyHttpClient/1.0
 *     Accept-Language: en-US
 *     Authorization: Bearer your-token-here
 *     Custom-Header: custom-value
 *
 */
@Data
@ToString
@ConfigurationProperties(prefix = "httpclient")
public class HttpClientProperties {

    private String charset = "UTF-8";

    /**
     * 总链接数
     */
    private Integer connMaxTotal = 3000;

    /**
     * 并发数量
     */
    private Integer maxPerRoute = 1200;

    /**
     * 重试次数
     */
    private Integer retryNum = 1;

    /**
     * 链接超时
     */
    private Integer connectTimeout = 30000;

    /**
     * 读写超时
     */
    private Integer readTimeout = 15000;

    /**
     * 链接不够用的等待时间,不宜过长,必须设置
     */
    private Integer requestTimeout = 200;

    /**
     * 默认链接保持时间,单位 秒
     */
    private Integer keepAliveTime = 60;

    /**
     * 如果请求目标地址单独配置可长链接保持时间,使用该配置
     */
    private Map<String, Integer> keepAliveTargetHost;

    /**
     * http请求header
     */
    private Map<String, String> headers;

}


  • 初始化httpclient
package com.zzc.component.http;

import lombok.extern.slf4j.Slf4j;
import org.apache.http.Header;
import org.apache.http.HeaderElement;
import org.apache.http.HeaderElementIterator;
import org.apache.http.HttpHost;
import org.apache.http.client.HttpClient;
import org.apache.http.client.protocol.HttpClientContext;
import org.apache.http.config.Registry;
import org.apache.http.config.RegistryBuilder;
import org.apache.http.conn.ConnectionKeepAliveStrategy;
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.socket.PlainConnectionSocketFactory;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.DefaultHttpRequestRetryHandler;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicHeaderElementIterator;
import org.apache.http.protocol.HTTP;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.client.ClientHttpRequestFactory;
import org.springframework.http.client.HttpComponentsClientHttpRequestFactory;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.StringHttpMessageConverter;
import org.springframework.web.client.DefaultResponseErrorHandler;
import org.springframework.web.client.RestTemplate;

import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;

@Slf4j
@Configuration
@ConditionalOnClass(value = {RestTemplate.class, CloseableHttpClient.class})
@EnableConfigurationProperties(HttpClientProperties.class)
public class HttpClientFactory {

    private final HttpClientProperties httpClientProperties;

    public HttpClientFactory(HttpClientProperties httpClientProperties) {
        this.httpClientProperties = httpClientProperties;
    }

    protected HttpComponentsClientHttpRequestFactory httpComponentsClientHttpRequestFactory() {
        HttpComponentsClientHttpRequestFactory factory = new HttpComponentsClientHttpRequestFactory();

        factory.setConnectTimeout(httpClientProperties.getConnectTimeout());
        factory.setReadTimeout(httpClientProperties.getReadTimeout());
        factory.setConnectionRequestTimeout(httpClientProperties.getRequestTimeout());
        factory.setHttpClient(httpClient());
        return factory;
    }

    public HttpClient httpClient() {
        HttpClientBuilder httpClientBuilder = HttpClientBuilder.create();
        try {
            Registry<ConnectionSocketFactory> registry = RegistryBuilder.<ConnectionSocketFactory>create()
                    .register("http", PlainConnectionSocketFactory.getSocketFactory())
                    .register("https", SSLConnectionSocketFactory.getSocketFactory())
                    .build();

            PoolingHttpClientConnectionManager httpClientConnectionManager = new PoolingHttpClientConnectionManager(registry);
            httpClientConnectionManager.setMaxTotal(httpClientProperties.getConnMaxTotal());
            httpClientConnectionManager.setDefaultMaxPerRoute(httpClientProperties.getMaxPerRoute());
            httpClientBuilder.setConnectionManager(httpClientConnectionManager);
            httpClientBuilder.setRetryHandler(new DefaultHttpRequestRetryHandler(httpClientProperties.getRetryNum(), httpClientProperties.getRetryNum() != 0));
            List<Header> headers = genHeaders();
            if (headers != null && !headers.isEmpty()) {
                httpClientBuilder.setDefaultHeaders(headers);
            }

            httpClientBuilder.setKeepAliveStrategy(connectionKeepAliveStrategy());

            //设置定时关闭无效链接
            httpClientBuilder.evictIdleConnections(30L, TimeUnit.SECONDS);
            return httpClientBuilder.build();
        } catch (Exception e) {
            log.error("init http factory error", e);
        }
        return null;
    }

    private ConnectionKeepAliveStrategy connectionKeepAliveStrategy() {
        return ((httpResponse, httpContext) -> {
            HeaderElementIterator it = new BasicHeaderElementIterator(httpResponse.headerIterator(HTTP.CONN_KEEP_ALIVE));
            while (it.hasNext()) {
                HeaderElement he = it.nextElement();
                String name = he.getName();
                String value = he.getValue();
                if (value != null && "timeout".equalsIgnoreCase(name)) {
                    try {
                        return Long.parseLong(value) * 1000L;
                    } catch (NumberFormatException ignore) {
                        log.error("resolve Keep-Alive timeout", ignore);
                    }
                }
            }
            HttpHost target = (HttpHost) httpContext.getAttribute(HttpClientContext.HTTP_TARGET_HOST);
            //如果请求的目标地址单独做了配置,使用以下的设置
            Optional<Map.Entry<String, Integer>> any = Optional.ofNullable(httpClientProperties.getKeepAliveTargetHost())
                    .orElseGet(HashMap::new)
                    .entrySet()
                    .stream()
                    .filter(e -> e.getKey().equalsIgnoreCase(target.getHostName()))
                    .findAny();
            int keepAliveTime = httpClientProperties.getKeepAliveTime() == null ? 60 : httpClientProperties.getKeepAliveTime();
            return any.map(e -> e.getValue() * 1000L).orElse(keepAliveTime * 1000L);
        });
    }

    private List<Header> genHeaders() {
        List<Header> headers = new ArrayList<>();
        if (httpClientProperties.getHeaders() == null) {
            log.warn("init header is null");
            return headers;
        }
        for (Map.Entry<String, String> entry : httpClientProperties.getHeaders().entrySet()) {
            headers.add(new BasicHeader(entry.getKey(), entry.getValue()));
        }
        return headers;
    }

    private void modifyDefaultCharset(RestTemplate restTemplate) {
        List<HttpMessageConverter<?>> converterList = restTemplate.getMessageConverters();
        HttpMessageConverter<?> converterTarget = null;
        for (HttpMessageConverter<?> item : converterList) {
            if (StringHttpMessageConverter.class == item.getClass()) {
                log.info("HttpMessageConvert exist null");
                converterTarget = item;
                break;
            }
        }
        if (null != converterTarget) {
            converterList.remove(converterTarget);
        }
        Charset defaultCharset = Charset.forName(httpClientProperties.getCharset());
        converterList.add(1, new StringHttpMessageConverter(defaultCharset));
    }

    @Bean
    @ConditionalOnMissingBean(value = {RestTemplate.class})
    public RestTemplate httpClientRestTemplate() {
        log.info("init httpClientRestTemplate.");
        RestTemplate restTemplate = new RestTemplate(httpComponentsClientHttpRequestFactory());
        modifyDefaultCharset(restTemplate);
        restTemplate.setErrorHandler(new DefaultResponseErrorHandler());
        return restTemplate;
    }
}


  • 定义接口
package com.zzc.component.http;

import org.springframework.web.client.ResponseExtractor;

import java.util.Map;

public interface RestTemplateComponent {

    /**
     * post请求
     * @param url 请求url,可以是域名,也可以是ip
     * @param headers 请求头,key-value格式,若传入的header为空,则使用默认的header 请求header默认"Content-Type", "application/json;charset=utf-8"
     * @param body 请求消息体
     * @param clazz 返回类型
     * @return
     * @param <T>
     */
    <T> T post(String url, Map<String, String> headers, Object body, Class<T> clazz);

    /**
     * post请求
     * 请求header默认"Content-Type", "application/json;charset=utf-8"
     * @param url 请求url,可以是域名,也可以是ip
     * @param body 请求消息体
     * @param clazz 返回类型
     * @return
     * @param <T>
     */
    <T> T post(String url, Object body, Class<T> clazz);

    /**
     * get请求
     * @param url 请求url,可以是域名,也可以是ip
     * @param headers 请求头,key-value格式
     * @param params 不在url中的参数,将在该接口框架中进行处理
     * @param clazz 返回类型
     * @return
     * @param <T>
     */
    <T> T get(String url, Map<String, String> headers, Object params, Class<T> clazz);

    /**
     * get请求
     * @param url 请求url,可以是域名,也可以是ip
     * @param headers 请求头,key-value格式
     * @param clazz 返回类型
     * @return
     * @param <T>
     */
    <T> T get(String url, Map<String, String> headers, Class<T> clazz);

    /**
     * get请求
     * @param url 请求url,可以是域名,也可以是ip
     * @param params 不在url中的参数,将在该接口框架中进行处理
     * @param clazz 返回类型
     * @return
     * @param <T>
     */
    <T> T get(String url, Object params, Class<T> clazz);

    /**
     * get请求
     * @param url 请求url,可以是域名,也可以是ip
     * @param clazz 返回类型
     * @return
     * @param <T>
     */
    <T> T get(String url, Class<T> clazz);

    /**
     * get请求
     * 当对响应的内容需要进行解析时,使用该方法;例:文件下载,对response进行解析等
     * @param url 请求url,可以是域名,也可以是ip
     * @param headers
     * @param extractor 响应回调对象,实现 extractData 接口并从中处理响应的 response
     * @return
     * @param <T>
     */
    <T> T get(String url, Map<String, String> headers, ResponseExtractor<T> extractor);

    /**
     * get请求
     * 当对响应的内容需要进行解析时,使用该方法;例:文件下载,对response进行解析等
     * @param url 请求url,可以是域名,也可以是ip
     * @param extractor 响应回调对象,实现 extractData 接口并从中处理响应的 response
     * @return
     * @param <T>
     */
    <T> T get(String url, ResponseExtractor<T> extractor);
}


  • 实现接口
package com.zzc.component.http;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.impl.client.CloseableHttpClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Lazy;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RequestCallback;
import org.springframework.web.client.ResponseExtractor;
import org.springframework.web.client.RestTemplate;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;

@Slf4j
@Lazy
@Component
@ConditionalOnClass(value = {RestTemplate.class, CloseableHttpClient.class})
@RequiredArgsConstructor
public class RestTemplateComponentImpl implements RestTemplateComponent {


    private final RestTemplate restTemplate;

    @Override
    public <T> T post(String url, Map<String, String> headers, Object body, Class<T> clazz) {
        log.debug("rest post url:{}, headers:{}, body:{}", url, headers, body);
        HttpEntity<Map<String, Object>> formEntity = new HttpEntity(body, generateHeader(headers));
        try {
            ResponseEntity<T> response = restTemplate.postForEntity(url, formEntity, clazz);
            if (response == null) {
                log.error("request error, url:{}", url);
                return null;
            }
            return response.getBody();
        } catch (Exception e) {
            log.error("post error. url:{}, headers:{}, body:{}", url, headers, body, e);
            throw e;
        }
    }

    @Override
    public <T> T post(String url, Object body, Class<T> clazz) {
        return post(url, null, body, clazz);
    }

    @Override
    public <T> T get(String url, Map<String, String> headers, Object params, Class<T> clazz) {
        try {
            ResponseEntity<T> response = restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(null, generateHeader(headers)), clazz, params);
            if (response == null) {
                log.error("request error, url:{}", url);
                return null;
            }
            return response.getBody();
        } catch (Exception e) {
            log.error("get error. url:{}, headers:{}, params:{}", url, headers, params, e);
            throw e;
        }
    }

    @Override
    public <T> T get(String url, Map<String, String> headers, Class<T> clazz) {
        return get(url, headers, null, clazz);
    }

    @Override
    public <T> T get(String url, Object params, Class<T> clazz) {
        return get(url, null, params, clazz);
    }

    @Override
    public <T> T get(String url, Class<T> clazz) {
        return get(url, null, null, clazz);
    }

    @Override
    public <T> T get(String url, Map<String, String> headers, ResponseExtractor<T> extractor) {
        RequestCallback requestCallback = null;
        if (headers == null || headers.isEmpty()) {
            requestCallback = restTemplate.httpEntityCallback(HttpEntity.EMPTY);
        } else {
            HttpEntity<Map<String, Object>> formEntity = new HttpEntity<>(null, generateHeader(headers));
            requestCallback = restTemplate.httpEntityCallback(formEntity);
        }
        return restTemplate.execute(url, HttpMethod.GET, requestCallback, extractor);
    }

    @Override
    public <T> T get(String url, ResponseExtractor<T> extractor) {
        return get(url, null, extractor);
    }


    /**
     * 则需参数接收的载体需要使用 MultiValueMap
     * @return
     */
    private static MultiValueMap<String, String> defaultHeaders() {
        return new HttpHeaders();
    }

    private static MultiValueMap<String, String> generateHeader(Map<String, String> headerMap) {
        if (headerMap == null || headerMap.isEmpty()) {
            return defaultHeaders();
        }
        MultiValueMap<String, String> headers = new HttpHeaders();
        for (Map.Entry<String, String> entry : headerMap.entrySet()) {
            List<String> objList = new ArrayList<>();
            objList.add(entry.getValue());
            headers.put(entry.getKey(), objList);
        }
        return headers;
    }
}

附录

org.apache.httpcomponents 是一组用于构建 HTTP 客户端和服务器的库,它提供了广泛的支持来处理 HTTP 协议的各种特性。随着版本的演进,特别是从 HttpClient 4.x 到 5.x 的升级,支持的功能也得到了增强。以下是 org.apache.httpcomponents 支持的主要 HTTP 特性:

  1. HTTP/1.1
    持久连接(Keep-Alive):支持通过单个 TCP 连接发送多个请求。
    分块传输编码(Chunked Transfer Encoding):允许服务器动态地发送响应数据,而不需要预先知道内容长度。
    压缩:支持使用 gzip 和 deflate 等算法对响应体进行压缩,以减少传输的数据量。
    缓存控制:支持标准的 HTTP 缓存机制,包括 ETag、Cache-Control 头部等。
    认证:内置了多种身份验证方式的支持,如 BASIC、DIGEST、NTLM 等。

  2. HTTP/2
    多路复用(Multiplexing):允许多个请求在同一个连接上并发执行,从而减少了延迟并提高了性能。
    头部压缩(Header Compression):采用 HPACK 压缩算法来减少头部信息的大小。
    服务端推送(Server Push):使服务器能够主动向客户端推送资源,无需等待客户端发起请求。
    优先级(Priority):为不同的流设置优先级,确保重要的请求得到优先处理。

  3. WebSocket
    虽然 Apache HttpClient 没有直接提供 WebSocket 支持,但你可以结合使用 HttpCore NIO 和专门的 WebSocket 库(例如 Java-WebSocket 或 Apache Tomcat WebSocket API)来实现 WebSocket 功能。

  4. 异步非阻塞 I/O
    异步请求:HttpClient 5.x 提供了对异步非阻塞 I/O 的支持,这使得它非常适合构建高性能、低延迟的应用程序。
    事件驱动模型:支持基于事件的编程模型,可以更高效地管理大量并发连接。

  5. 高级连接管理
    连接池:提供了高效的连接池机制,可以更好地管理多个并发请求,减少了创建新连接的开销。
    超时配置:允许为连接建立、请求发送和响应接收分别设置超时时间。
    代理支持:支持通过 HTTP 或 SOCKS 代理服务器转发请求。

  6. SSL/TLS
    安全通信:全面支持 SSL/TLS 协议,确保数据在网络上传输时的安全性。
    自定义 SSL 上下文:允许开发者根据需要配置 SSLContext,例如指定信任库、密钥库等。
    主机名验证:可以在 HTTPS 请求中启用或禁用主机名验证。

  7. 插件式架构
    扩展点:提供了丰富的扩展点,允许开发者添加自定义功能或修改现有行为。
    拦截器:支持请求和响应拦截器,以便在请求发出前或响应接收后执行额外逻辑。

  8. 详细的错误处理
    异常处理:提供了详尽的异常类,有助于更精确地诊断问题。
    重试逻辑:内置了自动重试机制,可以根据配置在遇到临时性错误时尝试重新发送请求。

  9. 国际化和本地化
    多语言支持:支持 Accept-Language 头部,允许客户端声明其偏好的语言。
    字符集处理:正确处理不同字符集编码的内容,确保文本数据的准确性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值