RestTemplate使用HttpClient连接池

RestTemplate使用HttpClient连接池

ClientHttpRequestFactory

@FunctionalInterface
public interface ClientHttpRequestFactory {

	/**
	 * Create a new {@link ClientHttpRequest} for the specified URI and HTTP method.
	 * <p>The returned request can be written to, and then executed by calling
	 * {@link ClientHttpRequest#execute()}.
	 * @param uri the URI to create a request for
	 * @param httpMethod the HTTP method to execute
	 * @return the created request
	 * @throws IOException in case of I/O errors
	 */
	ClientHttpRequest createRequest(URI uri, HttpMethod httpMethod) throws IOException;

}

ClientHttpRequestFactory是个函数式接口,用于根据URI和HttpMethod创建出一个ClientHttpRequest来发送请求。

/**
 * Represents a client-side HTTP request.
 * Created via an implementation of the {@link ClientHttpRequestFactory}.
 *
 * <p>A {@code ClientHttpRequest} can be {@linkplain #execute() executed},
 * receiving a {@link ClientHttpResponse} which can be read from.
 *
 * @author Arjen Poutsma
 * @since 3.0
 * @see ClientHttpRequestFactory#createRequest(java.net.URI, HttpMethod)
 */
public interface ClientHttpRequest extends HttpRequest, HttpOutputMessage {

	/**
	 * Execute this request, resulting in a {@link ClientHttpResponse} that can be read.
	 * @return the response result of the execution
	 * @throws IOException in case of I/O errors
	 */
	ClientHttpResponse execute() throws IOException;

}

ClientHttpRequest则代表客户端的HTTP请求

在这里插入图片描述

ClientHttpRequest底下的实现有HttpClient,OkHttp3,以及Java jdk内置的HttpUrlConnection

SimpleClientHttpRequestFactory

RestTemplate默认使用SimpleClientHttpRequestFactory,是Spring内置默认的实现

在这里插入图片描述

SimpleClientHttpRequestFactory创建出的ClientHttpRequest是使用Java jdk内置的HttpUrlConnection实现的。

在这里插入图片描述

SimpleClientHttpRequestFactory 设置超时时间

特别需要注意的是当我们直接new RestTemplate的时候,底层默认使用的SimpleClientHttpRequestFactory是没有设置超时时间的,而Java jdk内置的HttpUrlConnection,若readTimeout和connectTimeout没有设置,那请求是没有超时时间的,会导致请求一直pending住。

在这里插入图片描述

在这里插入图片描述

所以我们使用RestTemplate的时候务必设置上超时时间

@Bean
  public RestTemplate restTemplate(){
    RestTemplate restTemplate = new RestTemplate();
    SimpleClientHttpRequestFactory simpleClientHttpRequestFactory = new SimpleClientHttpRequestFactory();
    simpleClientHttpRequestFactory.setConnectTimeout(10000);
    simpleClientHttpRequestFactory.setReadTimeout(30000);
    restTemplate.setRequestFactory(simpleClientHttpRequestFactory);
    return restTemplate;
  }

HttpURLConnection的缺点

HttpURLConnection是JDK内置的,所以它除了封装的比较简单之外还存在性能上的问题。

因为他在每一次创建请求的时候都会建立一个新的连接,所以没办法复用连接。而且如果通信异常会导致连接不被回收,进而导致创建的连接越来越多,最终导致服务卡死

在这里插入图片描述

在这里插入图片描述

HttpComponentsClientHttpRequestFactory

上面的HttpURLConnection的缺点就是我们为什么是需要使用HttpClient连接池。为了就是更好复用连接。

为什么要用连接池?
因为使用它可以有效降低延迟和系统开销。如果不采用连接池,每当我们发起http请求时,都需要重新发起Tcp三次握手建立链接,请求结束时还需要四次挥手释放链接。而Tcp链接的建立和释放是有时间和系统开销的。另外每次发起请求时,需要分配一个端口号,请求完毕后在进行回收。使用链接池则可以复用已经建立好的链接,一定程度的避免了建立和释放链接的时间开销。

在HttpClient 4.3以后增加了PoolingHttpClientConnectionManager连接池来管理持有连接,同一条TCP链路上,连接是可以复用的。HttpClient通过连接池的方式进行连接持久化(所以它这个连接池其实是tcp的连接池。它里面有一个很重要的概念:Route的概念,代表一条线路。比如baidu.com是一个route,163.com是一个route…)。
连接池:可能是http请求,也可能是https请求
加入池话技术,就不用每次发起请求都新建一个连接(每次连接握手三次,效率太低)
参考:https://blog.51cto.com/u_3631118/3121677

使用HttpClient我们首先需要引入依赖

<dependency>
      <groupId>org.apache.httpcomponents</groupId>
      <artifactId>httpclient</artifactId>
      <version>4.5.13</version>
</dependency>

然后我们只需要把之前的SimpleClientHttpRequestFactory改成HttpComponentsClientHttpRequestFactory

@Bean
  public RestTemplate restTemplate(){
    RestTemplate restTemplate = new RestTemplate();
    HttpComponentsClientHttpRequestFactory clientHttpRequestFactory = new HttpComponentsClientHttpRequestFactory();
    restTemplate.setRequestFactory(clientHttpRequestFactory);
    return restTemplate;
  }

PoolingHttpClientConnectionManager配置连接池

配置连接池,我们就需要用到PoolingHttpClientConnectionManager

PoolingHttpClientConnectionManager的作用如下:

ClientConnectionPoolManager maintains a pool of HttpClientConnections and is able to service connection requests from multiple execution threads. Connections are pooled on a per route basis. A request for a route which already the manager has persistent connections for available in the pool will be services by leasing a connection from the pool rather than creating a brand new connection.
参考: https://hc.apache.org/httpcomponents-client-4.5.x/current/httpclient/apidocs/

大概意思就是PoolingHttpClientConnectionManager使用来维护一个连接池,能够为来自多个执行线程的连接请求提供服务。连接池是基于每个路由的(比如baidu.com是一个路由,163.com是一个路由)。对路由的请求,如果连接池中有可用的持久连接,则将通过将复用连接池中的连接,而不是创建全新的连接。

那我们在如何给RestTemplate设置HttpClient的连接池呢?

  @Bean
  public RestTemplate restTemplate(){
    RestTemplate restTemplate = new RestTemplate();
    restTemplate.setRequestFactory(httpComponentsClientHttpRequestFactory());
    return restTemplate;
  }
  
    private HttpComponentsClientHttpRequestFactory httpComponentsClientHttpRequestFactory() {
    HttpComponentsClientHttpRequestFactory requestFactory =
        new HttpComponentsClientHttpRequestFactory(httpClientBuilder().build());
    return requestFactory;
  }
  
  private HttpClientBuilder httpClientBuilder() {
    return HttpClients.custom()
        .setConnectionManager(poolingConnectionManager());
  }

  private PoolingHttpClientConnectionManager poolingConnectionManager() {
    PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager();
    return connectionManager;
  }

我们首先需要创建一个HttpClientBuilder,然后我们需要在创建我们的PoolingHttpClientConnectionManager,然后为HttpClientBuilder调用setConnectionManager设置连接管理器。最后我们再用这个HttpClientBuilder来初始化我们的HttpComponentsClientHttpRequestFactory

接下来我们的核心重点还是在PoolingHttpClientConnectionManager

我们可以看看PoolingHttpClientConnectionManager的默认构造方法

在这里插入图片描述

有两个很显眼的默认参数值分别是2和20,且这两个值分别对应HttpClientBuilder的两个配置参数。官方给出了解释

ClientConnectionPoolManager maintains a maximum limit of connection on a per route basis and in total. Per default this implementation will create no more than than 2 concurrent connections per given route and no more 20 connections in total.

大概意思就是PoolingHttpClientConnectionManager在默认情况下,每个路由的并发连接最大是2个,全部路由总共最大是20个。

默认配置限制的太小了,所以我们一般需要根据自己需求进行配置,如下:

connectionManager.setMaxTotal(1000); //最大连接数
connectionManager.setDefaultMaxPerRoute(500); //每个路由(域名)最大连接数

顺便一提的是,在HttpClientBuilder中也可以设置这两个参数,分别是setMaxConnPerRoutesetMaxConnTotal。但是他们都会被PoolingHttpClientConnectionManager中设置的覆盖

在这里插入图片描述

接下来我们来看看超时的配置

我们可以通过HttpComponentsClientHttpRequestFactory中的三个参数来设置超时

requestFactory.setConnectTimeout(CONNECT_TIMEOUT);
requestFactory.setConnectionRequestTimeout(CONNECT_TIMEOUT);
requestFactory.setReadTimeout(TIMEOUT);

这三个参数跟我们设置RequestConfig中是等价的。

我们可以在HttpClientBuilder中利用setDefaultRequestConfig方法设置RequestConfig。在RequestConfig一样有三个参数来配置超时。

public RequestConfig requestConfig() {
    return RequestConfig
        .custom().setConnectionRequestTimeout(CONNECT_TIMEOUT)
        .setConnectTimeout(CONNECT_TIMEOUT)
        .setSocketTimeout(TIMEOUT)
        .build();
  }

我们先来介绍RequestConfig中的这三个配置

  • setConnectionRequestTimeout: 从连接管理器请求连接时使用的超时时间(以毫秒为单位)
  • setConnectTimeout:确定连接建立之前的超时时间(以毫秒为单位)。也就是客户端发起TCP连接请求的超时时间,一般也就是TCP三次握手的时间
  • setSocketTimeout:客户端等待服务端返回数据的超时时间

这三个值默认都是-1,也就是没有超时的限制。

HttpComponentsClientHttpRequestFactory中的三个超时配置其实内部也是在配置RequestConfig的超时配置。

/**
	 * Set the connection timeout for the underlying {@link RequestConfig}.
	 * A timeout value of 0 specifies an infinite timeout.
	 * <p>Additional properties can be configured by specifying a
	 * {@link RequestConfig} instance on a custom {@link HttpClient}.
	 * <p>This options does not affect connection timeouts for SSL
	 * handshakes or CONNECT requests; for that, it is required to
	 * use the {@link org.apache.http.config.SocketConfig} on the
	 * {@link HttpClient} itself.
	 * @param timeout the timeout value in milliseconds
	 * @see RequestConfig#getConnectTimeout()
	 * @see org.apache.http.config.SocketConfig#getSoTimeout
	 */
	public void setConnectTimeout(int timeout) {
		Assert.isTrue(timeout >= 0, "Timeout must be a non-negative value");
		this.requestConfig = requestConfigBuilder().setConnectTimeout(timeout).build();
	}

	/**
	 * Set the timeout in milliseconds used when requesting a connection
	 * from the connection manager using the underlying {@link RequestConfig}.
	 * A timeout value of 0 specifies an infinite timeout.
	 * <p>Additional properties can be configured by specifying a
	 * {@link RequestConfig} instance on a custom {@link HttpClient}.
	 * @param connectionRequestTimeout the timeout value to request a connection in milliseconds
	 * @see RequestConfig#getConnectionRequestTimeout()
	 */
	public void setConnectionRequestTimeout(int connectionRequestTimeout) {
		this.requestConfig = requestConfigBuilder()
				.setConnectionRequestTimeout(connectionRequestTimeout).build();
	}

	/**
	 * Set the socket read timeout for the underlying {@link RequestConfig}.
	 * A timeout value of 0 specifies an infinite timeout.
	 * <p>Additional properties can be configured by specifying a
	 * {@link RequestConfig} instance on a custom {@link HttpClient}.
	 * @param timeout the timeout value in milliseconds
	 * @see RequestConfig#getSocketTimeout()
	 */
	public void setReadTimeout(int timeout) {
		Assert.isTrue(timeout >= 0, "Timeout must be a non-negative value");
		this.requestConfig = requestConfigBuilder().setSocketTimeout(timeout).build();
	}

从查看源码可以看出来HttpComponentsClientHttpRequestFactorysetConnectTimeout等价于RequestConfig中的setConnectTimeout,HttpComponentsClientHttpRequestFactorysetConnectionRequestTimeout等价于RequestConfig中的setConnectionRequestTimeout,HttpComponentsClientHttpRequestFactorysetReadTimeout等价于RequestConfig中的setSocketTimeout

但从源码上看,他们本质上设置的不是同一个RequestConfig,而是在createRequest操作的时候会进行一个merge的操作。

/**
	 * Merge the given {@link HttpClient}-level {@link RequestConfig} with
	 * the factory-level {@link RequestConfig}, if necessary.
	 * @param clientConfig the config held by the current
	 * @return the merged request config
	 * @since 4.2
	 */
	protected RequestConfig mergeRequestConfig(RequestConfig clientConfig) {
		if (this.requestConfig == null) {  // nothing to merge
			return clientConfig;
		}

		RequestConfig.Builder builder = RequestConfig.copy(clientConfig);
		int connectTimeout = this.requestConfig.getConnectTimeout();
		if (connectTimeout >= 0) {
			builder.setConnectTimeout(connectTimeout);
		}
		int connectionRequestTimeout = this.requestConfig.getConnectionRequestTimeout();
		if (connectionRequestTimeout >= 0) {
			builder.setConnectionRequestTimeout(connectionRequestTimeout);
		}
		int socketTimeout = this.requestConfig.getSocketTimeout();
		if (socketTimeout >= 0) {
			builder.setSocketTimeout(socketTimeout);
		}
		return builder.build();
	}

从源码的注释也可以看出来,他会把HttpComponentsClientHttpRequestFactory中的RequestConfig和我们在HttpClientBuilder中设置的RequestConfig进行一个合并。

那我们继续来关注PoolingHttpClientConnectionManager, 我们可以发现PoolingHttpClientConnectionManager有如下的构造方法

public PoolingHttpClientConnectionManager(long timeToLive, TimeUnit timeUnit)

那这个timeToLive,也就是我们常说的TTL是什么意思呢?
我们可以看官网的解释如下:

Total time to live (TTL) set at construction time defines maximum life span of persistent connections regardless of their expiration setting. No persistent connection will be re-used past its TTL value.

大概意思就是在构造时设置的持久链接的存活时间(TTL),它定义了持久连接的最大使用时间。超过其TTL值的连接不会再被复用。

   /**
     * Creates new {@code PoolEntry} instance.
     *
     * @param id unique identifier of the pool entry. May be {@code null}.
     * @param route route to the opposite endpoint.
     * @param conn the connection.
     * @param timeToLive maximum time to live. May be zero if the connection
     *   does not have an expiry deadline.
     * @param timeUnit time unit.
     */
    public PoolEntry(final String id, final T route, final C conn,
            final long timeToLive, final TimeUnit timeUnit) {
        super();
        Args.notNull(route, "Route");
        Args.notNull(conn, "Connection");
        Args.notNull(timeUnit, "Time unit");
        this.id = id;
        this.route = route;
        this.conn = conn;
        this.created = System.currentTimeMillis();
        this.updated = this.created;
        if (timeToLive > 0) {
            final long deadline = this.created + timeUnit.toMillis(timeToLive);
            // If the above overflows then default to Long.MAX_VALUE
            this.validityDeadline = deadline > 0 ? deadline : Long.MAX_VALUE;
        } else {
            this.validityDeadline = Long.MAX_VALUE;
        }
        this.expiry = this.validityDeadline;
    }

从上面代码我们可以看出来当我们设置了TTL,创建PoolEntry的时候就会设置一个expiry过期时间。超过过期时间的连接就会标志为过期的。

在这里插入图片描述

所以我们设置了TTL,就相当于设置了连接最大的可用时间,超过了这个可用时间的连接,就会从池中剔除,变为不可重用。

除此之外HttpClientBuilder中也能设置TTL

   /**
     * Sets maximum time to live for persistent connections
     * <p>
     * Please note this value can be overridden by the {@link #setConnectionManager(
     *   org.apache.http.conn.HttpClientConnectionManager)} method.
     * </p>
     *
     * @since 4.4
     */
    public final HttpClientBuilder setConnectionTimeToLive(final long connTimeToLive, final TimeUnit connTimeToLiveTimeUnit) {
        this.connTimeToLive = connTimeToLive;
        this.connTimeToLiveTimeUnit = connTimeToLiveTimeUnit;
        return this;
    }

方法的注释上也说明了,这个设置会被PoolingHttpClientConnectionManager中设置的TTL覆盖。

同时官网还提到了

The handling of stale connections was changed in version 4.4. Previously, the code would check every connection by default before re-using it. The code now only checks the connection if the elapsed time since the last use of the connection exceeds the timeout that has been set. The default timeout is set to 2000ms

大概意思就是在4.4版中更改了对不可重用连接的处理。4.4之前在重用每个连接之前默认检查每个连接是否已经可重用。4.4之后是自上次使用连接以来所经过的时间超过已设置的连接不活动时间(默认连接不活动时间设置为2000ms),才检查连接。如果发现连接不可用,则从连接池剔除,在重新获取新的链接。

上面getPoolEntryBlocking的代码中,我们不是已经判断了连接是否过期,还有连接是否关闭了吗?为什么还需要判断连接是否可用?

在这里插入图片描述

我的想法是,连接过期或者是连接是否关闭并不代表连接还是能重用的,有可能连接是打开状态的,但是连接的时候存在一些问题(这种概率可能很小),所以需要作进一步的可重用检测

在这里插入图片描述

在这里插入图片描述

setValidateAfterInactivity使用来定义以毫秒为单位的连接不活动时间,在此之后,在将持久连接租给使用者之前必须重新验证。如果ValidateAfterInactivity的值小于0则表示禁用连接验证。

从上面的源码我们可以看出来,默认配置的检查时间为2s。然后我们从代码上可以看出来,再每次去创建连接的时候,会从连接池中进行连接的租赁,在去连接池获取连接的时候,会判validateAfterInactivity + 当前获取的连接上次最后的使用时间是否小于当前时间,如果小于则需要检查连接是否可用。如果检查到连接不可用,则会把当前连接从连接池中剔除,然后重新获取新的连接。

我们可以看到校验方法最后调用isStale方法

在这里插入图片描述

 /**
     * Checks whether this connection has gone down.
     * Network connections may get closed during some time of inactivity
     * for several reasons. The next time a read is attempted on such a
     * connection it will throw an IOException.
     * This method tries to alleviate this inconvenience by trying to
     * find out if a connection is still usable. Implementations may do
     * that by attempting a read with a very small timeout. Thus this
     * method may block for a small amount of time before returning a result.
     * It is therefore an <i>expensive</i> operation.
     *
     * @return  {@code true} if attempts to use this connection are
     *          likely to succeed, or {@code false} if they are likely
     *          to fail and this connection should be closed
     */
    boolean isStale();

isStale()方法是会阻塞一小段时间的,所以为什么在4.4版本之后不会每次都检查,而是超过连接不活动时间之后才会进行检查。

最后我们再来看两个配置,这两个配置能够帮助我们定期清理连接。

这两个配置都是在HttpClientBuilder中进行配置。

  • evictExpiredConnections:定时清理过期连接的开关,默认关闭,建议打开

  • evictIdleConnections:定时清理闲置连接的开关,默认关闭, 需要指定时间,建议打开

这两个分别是什么意思呢?

定时清理过期连接

其实就是清理超过TTL时间的链接,跟上面getPoolEntryBlocking代码中获取连接中会检查过期连接是一样的。我个人想法就是一个是主动清理,一个是获取连接的时候才会清理。而且一个是主动清理池中全部过期的,而另一个只是获取到池中的连接才进行清理,并不是清理全部。

定时清理闲置连接

先说 keep-alive 机制。每个 TCP 连接都要经过三次握手建立连接后才能发送数据,要经过四次挥手才能断开连接,如果每个 TCP 连接在服务端返回后都立马断开,则发起多个 HTTP 请求就要多次创建和断开 TCP,这在请求很多的情况下无疑是很耗性能的。如果在服务端返回后不立即断开 TCP 链接,而是复用这条连接进行下一次的 Http 请求,则可以省略了很多创建 断开 TCP 的开销,性能上无疑会有很大提升。虽然 keep-alive 省去了很多不必要的握手/挥手操作,但由于连接长期存活,如果没有请求的话也会浪费系统资源。所以定时清理闲置连接就是主动去清理超过指定时间都没有被使用过的连接。

我们直接上源代码来看看

在这里插入图片描述

当我们设置了evictExpiredConnections或者设置了evictIdleConnections, 就会构造一个IdleConnectionEvictor空闲连接清除器。如果没有指定maxIdleTime的话,但是有设置evictExpiredConnections的话,默认是10秒。

在这里插入图片描述

IdleConnectionEvictor中会启动一个线程,然后在指定的maxIdleTime时间之后调用connectionManager.closeExpiredConnections();connectionManager.closeIdleConnections(maxIdleTimeMs, TimeUnit.MILLISECONDS);进行连接清理。

我们先来看connectionManager.closeExpiredConnections();方法

 /**
     * Closes all expired connections in the pool.
     * <p>
     * Open connections in the pool that have not been used for
     * the timespan defined when the connection was released will be closed.
     * Currently allocated connections are not subject to this method.
     * Times will be checked with milliseconds precision.
     * </p>
     */
    void closeExpiredConnections();
@Override
    public void closeExpiredConnections() {
        this.log.debug("Closing expired connections");
        this.pool.closeExpired();
    }
 /**
     * Closes expired connections and evicts them from the pool.
     */
    public void closeExpired() {
        final long now = System.currentTimeMillis();
        enumAvailable(new PoolEntryCallback<T, C>() {

            @Override
            public void process(final PoolEntry<T, C> entry) {
                if (entry.isExpired(now)) { //超过TTL时间的会标记为过期,对于过期的连接则会进行清理
                    entry.close();
                }
            }

        });
    }

从上面的代码可以看出来,closeExpiredConnections方法会清理池中全部的过期连接,判断过期则会依据我们设置的TTL

然后我们来看connectionManager.closeIdleConnections(maxIdleTimeMs, TimeUnit.MILLISECONDS);方法

/**
     * Closes idle connections in the pool.
     * <p>
     * Open connections in the pool that have not been used for the
     * timespan given by the argument will be closed.
     * Currently allocated connections are not subject to this method.
     * Times will be checked with milliseconds precision
     * </p>
     * <p>
     * All expired connections will also be closed.
     * </p>
     *
     * @param idletime  the idle time of connections to be closed
     * @param timeUnit     the unit for the {@code idletime}
     *
     * @see #closeExpiredConnections()
     */
    void closeIdleConnections(long idletime, TimeUnit timeUnit);
@Override
   public void closeIdleConnections(final long idleTimeout, final TimeUnit timeUnit) {
       if (this.log.isDebugEnabled()) {
           this.log.debug("Closing connections idle longer than " + idleTimeout + " " + timeUnit);
       }
       this.pool.closeIdle(idleTimeout, timeUnit);
   }
/**
     * Closes connections that have been idle longer than the given period
     * of time and evicts them from the pool.
     *
     * @param idletime maximum idle time.
     * @param timeUnit time unit.
     */
    public void closeIdle(final long idletime, final TimeUnit timeUnit) {
        Args.notNull(timeUnit, "Time unit");
        long time = timeUnit.toMillis(idletime);
        if (time < 0) {
            time = 0;
        }
        final long deadline = System.currentTimeMillis() - time;
        enumAvailable(new PoolEntryCallback<T, C>() {

            @Override
            public void process(final PoolEntry<T, C> entry) {
                if (entry.getUpdated() <= deadline) {
                    entry.close();
                }
            }

        });
    }

从源码看出来,closeIdleConnections会清理池中所有的空闲连接。只要连接的上次使用时间超过了我们设置的maxIdleTime则属于空闲连接,需要清除掉。

HttpClient总结图

在这里插入图片描述

RestTemplate最佳实践

  • 一定要设置超时时间
  • 底层http库使用像这种带连接池的HttpClient来替代JDK原生的HTTP客户端,或者其他高性能的HTTP客户端,比如OkHttp
  • 根据项目需求设置连接池参数

参考

RestTemplate组件:ClientHttpRequestFactory、ClientHttpRequestInterceptor、ResponseExtractor

HttpClient使用连接池

使用HttpClient的正确姿势

RestTemplate未使用线程池问题

傻傻分不清的TCP keepalive和HTTP keepalive

【348期】高并发场景下的 httpClient 使用优化

HttpClient 在vivo内销浏览器的高并发实践优化

httpClient连接池管理,你用对了?

简单粗暴的RestTemplate

Spring RestTemplate 设置每次请求的 Timeout

可视化的分析Keep-Alive长连接

restTemplate超时时间引发的生产事故

HttpClient官网

Http 持久连接与 HttpClient 连接池

httpclient参数配置

HttpClient 专题

Http 持久连接与 HttpClient 连接池,有哪些不为人知的关系?

  • 11
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 3
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值