Netflix之Hystrix详细分析(十四)

一、Hystrix的执行流程

下图显示了当您通过Hystrix向服务依赖项请求时所发生的情况:

下面的部分将更详细地解释这一流程:

  1. 构造一个HystrixCommand或hystrixObservableCommand对象
  2. 执行command命令
  3. 是缓存的响应吗?
  4. 断路器开着吗?
  5. 线程池/队列/信号量是否满载?
  6. HystrixObservableCommand.construct() or HystrixCommand.run()
  7. 计算断路器健康状态
  8. 得到回退
  9. 返回成功响应

知识补充--RxJava

RxJava最核心的两个东西是Observables(被观察者,事件源)和Subscribers(观察者)。Observables发出一系列事件,Subscribers处理这些事件。这里的事件可以是任何你感兴趣的东西(安卓中的触摸事件,web接口调用返回的数据等等)

一个Observable可以发出零个或者多个事件,直到结束或者出错。每发出一个事件,就会调用对应Subscriber的onNext方法,最后调用Subscriber.onNext()返回执行结果,或者发生异常调用Subscriber.onError()结束。

Rxjava的看起来很像设计模式中的观察者模式,但是有一点明显不同,那就是如果一个Observerble没有任何的的Subscriber,那么这个Observable是不会发出任何事件的。

更加详细的RxJava知识可自行查阅,我这里只做一个简单引入,便于理解下面的内容。

1.构造一个HystrixCommand或hystrixObservableCommand对象

第一步是构造一个HystrixCommand或hystrix观察家对象来表示您对依赖性所做的请求。在发出请求时,传递构造器的任何参数。

如果依赖项被期望返回单个响应,则构造一个HystrixCommand对象。例如:

HystrixCommand command = new HystrixCommand(arg1, arg2);

如果依赖项被期望返回一个可以发出响应的可观察对象,那么构造一个hystrixObservableCommand对象。例如:

HystrixObservableCommand command = new HystrixObservableCommand(arg1, arg2);

2.执行command命令

有四种方法可以执行该命令,方法是使用Hystrix命令对象的下列四种方法之一(最后两个方法只适用于简单的HystrixCommand对象,并且不适用于hystrixObservableCommand对象):

  • toObservable 冷观察,并不会马上执行Hystrix的run()方法,作为一个RxJava类,它返回的是一个最原始的可观察对象。当我们订阅这个对象时,它才会把执行的信息传递给订阅者。异步执行。
  • observe 热观察,调用toObservable并内置ReplaySubject,根据它返回的可观察对象,简单处理并完成run方法的执行,可以被立即执行,如果订阅了那么会重新通知。异步执行。
  • queue 同上,需要执行的command会在线程池中进行排队。异步执行。
  • execute 调用queue的get()方法来完成实现的。同步执行。

下面我们创建一个hystrix-conf-command简单java的maven项目,来演示这几个Hystrix执行命令。引入Hystrix的核心包

<dependency>
	<groupId>com.netflix.hystrix</groupId>
	<artifactId>hystrix-core</artifactId>
	<version>1.5.9</version>
</dependency>

创建org.init.springCloud包,并创建CommandRunTest测试类,为了方便,我们把继承了HystrixCommand类的LaunchCommand类作为一个内部类写入(上一篇博客有介绍这个command类):

package org.init.springCloud;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;

public class CommandRunTest {
	
	public static void main(String[] args) throws Exception{
		
	}
	
	static class LaunchCommand extends HystrixCommand<String>{
		
		String invokerName;
		
		public LaunchCommand(String invokerName){
			super(HystrixCommandGroupKey.Factory.asKey("myGroup"));
			this.invokerName = invokerName;
		}
		
		@Override
		protected String run() throws Exception {
			System.out.println("调用方法:"+invokerName);
			return "执行成功";
		}
		
		@Override
		protected String getFallback() {
			return "执行失败";
		}
	}
}

在main()方法中添加代码,运行测试用observe去执行处理LaunchCommand类:

LaunchCommand cr1 = new LaunchCommand("observe");
cr1.observe();
Thread.sleep(10000);

 

接下来看看用toObservable执行处理LaunchCommand类,和上面类似,在休眠10s的代码前添加如下代码:

 

LaunchCommand cr2 = new LaunchCommand("toObservable");
Observable<String> returnOb = cr2.toObservable();
returnOb.subscribe(new Observer<Object>() {

	@Override
	public void onCompleted() {
		System.out.println("执行完成");
	}

	@Override
	public void onError(Throwable e) {
		System.out.println("执行发生错误");
		e.printStackTrace();
	}

	@Override
	public void onNext(Object t) {
		System.out.println("执行返回结果:" + t);
	}
});

由于使用toObservable()方法,是不能直接执行处理LaunchCommand类的,toObservable()方法会返回一个Observable的可观察对象,使用一个subcribe的观察者,通过实现匿名内部类Observer并重写内部方法,可以完成LaunchCommand的执行。

queue()和execute()方法是类似的,相当于从toObservable-->observe-->queue-->execute是一个不断升级的过程,execute具有队列和同步执行的功能。

3.是缓存的响应吗?

如果对该命令启用了请求缓存,并且如果对请求的响应在缓存中可用,则该缓存的响应将立即以可观察的形式返回。

4.断路器开着吗?

当您执行该命令时,Hystrix会检查断路器是否打开。
如果断路器是打开的,那么Hystrix将不会执行该命令,而是将流程路由到(8)获得回退。

如果断路器是关闭的,那么流将继续(5)检查是否有可用的容量来运行该命令。

5.线程池/队列/信号量是否满载?

如果与命令相关联的线程池和队列(或信号灯,如果不在线程中运行)满载了,那么Hystrix将不会执行该命令,

而是立即将流程路由到(8)获得回退。

6.HystrixObservableCommand.construct() or HystrixCommand.run()

在这里,Hystrix通过你为这个目的而编写的方法来调用依赖项的请求,可能是这两种方式中的一个:

  • HystrixCommand.run()  返回单个响应或抛出异常
  • HystrixObservableCommand.construct()  返回一个可以发出响应(有可能是多个响应)或发送onError通知的可观察对象Observable

如果run()方法或construct()方法超过了命令的超时值,那么线程将抛出TimeoutException(或者如果命令本身不在其自身线程中运行),则会抛出TimeoutException。在这种情况下,Hystrix将响应路由到(8)。获得回退,如果该方法没有取消/中断,它将丢弃最终返回值run()方法或construct()方法。

注意:没有办法强迫潜在的线程停止工作——Hystrix能在JVM上做的最优抉择就是将它抛出一个中断。如果Hystrix包装的工作不尊重中断,那么Hystrix线程池中的线程将继续工作,尽管客户端已经收到了TimeoutException。这种行为可以使Hystrix线程池饱和,尽管负载是“正确的”。大多数Java HTTP客户端库不解释中断。因此,请确保在HTTP客户机上正确地配置连接和读/写超时。

如果该命令没有抛出任何异常,而且返回了响应,Hystrix在执行一些日志记录和指标报告之后就会返回该响应。在run()的情况下,Hystrix返回一个可观察对象,这个对象发出单个响应,之后产生一个onCompleted()通知;在construct()的情况下,Hystrix返回由construct()返回的可观察对象。

7.计算断路器健康状态

Hystrix向断路器报告成功、失败、拒绝和超时,它维护了一组计算统计数据的计数器。

它使用这些统计数据来确定断路器何时应该“跳闸”,“跳闸”期间它会缩短任何后续的请求路线,直接走回退方法(如果编写了回退方法的话),直到恢复周期结束(默认5秒),之后尝试请求,如果能正常访问并调用服务,则恢复断路器为关闭状态,还是不能正常访问并调用服务,它会再次开启断路器。

8.得到回退

当命令执行失败时,Hystrix就会尝试去执行回退方法:在第(6)步执行run()方法或construct()方法的时候抛出异常;在第(4)步的时候,断路器开启了也会执行回退方法;在第(5)步当命令的线程池和队列或信号量处于满载时;或者命令请求超时的时候。

9.返回成功响应

如果Hystrix命令成功,它将以可观察的形式返回给调用者一个响应或多个响应。根据你在步骤(2)中如何调用命令,在返回之前,这个可观察性可能会被转换。

二、断路器

下图展示了HystrixCommand或HystrixObservableCommand如何与HystrixCircuitBreaker(断路器)以及与它的逻辑和决策流程进行交互,包括计数器在断路器上的行为方式。

断路器开启和关闭的详细过程如下:

  • 假设整个电路的体积达到一定的阈值(circuitBreakerRequestVolumeThreshold()),默认10秒之内请求数达到20次,如果有19次也不满足条件(和下面的误差百分比组合起来控制断路器)。
  • 然后假设误差百分比超过阈值误差百分比(circuitBreakerErrorThresholdPercentage()),默认是50%,意思是一段时间内的所有请求有一半是触发了回退逻辑的。
  • 然后断路器从关闭到打开。
  • 当它是打开的时候,它会短路所有对断路器的请求。
  • 过了一段时间后(circuitBreakerSleepWindowInMilliseconds()),下一个请求是通过(这是半开放状态)。如果请求失败,断路器将在睡眠窗口期间返回到打开状态。如果请求成功,断路器将转换为关闭。

在hystrix-conf-command项目org.init.springCloud的包下新建circuitBreaker包,之后创建一个演示断路器强制打开的CircuitBreakerCommandTest测试类,测试类的内部类CircuitBreakerCommand和之前的代码类似,主要是我们需要在构造器里添加设置,强制开启断路器:

package org.init.springCloud.circuitBreaker;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import com.netflix.hystrix.HystrixCommandProperties;

public class CircuitBreakerCommandTest {

	public static void main(String[] args) throws Exception{
		CircuitBreakerCommand cbc = new CircuitBreakerCommand();
		System.out.println(cbc.execute());
		Thread.sleep(10000);
	}

	static class CircuitBreakerCommand extends HystrixCommand<String>{
		
		public CircuitBreakerCommand(){
			super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("myGroup"))
					.andCommandPropertiesDefaults(HystrixCommandProperties.Setter().
							withCircuitBreakerForceOpen(true)));
		}
		
		@Override
		protected String run() throws Exception {
			return "success";
		}
		
		@Override
		protected String getFallback() {
			return "fail";
		}
		
	}
	
}

可以看到我们在run()方法和回退方法里都没有做其他处理,只是返回了一个字符串,运行CircuitBreakerCommandTest类的main()方法,从控制台可以看见直接走了回退逻辑:

三、隔离机制

Hystrix采用隔离模式来隔离彼此的依赖关系,并限制对其中任何一个的并发访问。

soa-5-isolation-focused-640.pnguploading.4e448015.gif转存失败重新上传取消

1.线程池和信号量

Hystrix中提供了两种隔离策略,一种是线程和线程池(Threads & Thread Pools),另一种是信号量(Semaphores)。Hystrix推荐使用的隔离策略是Semaphores。

线程池(Thread Pools)之间是相互独立的,每个线程池默认包含10个线程(Threads)。对于每一个依赖项,都使用一个线程池来处理,如果这个依赖项拒绝服务或者超时(快速失败、失效不返回结果、回退),那由它导致的这个线程池满载,不会影响到其他的线程池。

简而言之,线程池提供的隔离允许在不导致停机的情况下优雅地处理客户机库和子系统性能特征的持续变化和动态组合。

我们也可以使用信号量(或计数器)来限制对任意给定依赖项的并发调用的数量,而不是使用线程池/队列大小,信号量在一个单独的线程上执行,当并发访问数量超过了设定的阈值(默认10个),方法调用将不再执行。这使得Hystrix可以在不使用线程池的情况下减少负载,但是它是不支持超时的。

2.隔离策略中的三种“key”

  • CommandKey,针对相同的接口一般CommandKey值相同,目的是把HystrixCommand,HystrixCircuitBreaker,HytrixCommandMerics以及其他相关对象关联在一起,形成一个原子组。
  • CommandGroupKey,对CommandKey分组,用于真正的隔离。相同CommandGroupKey会使用同一个线程池或者信号量。一般情况相同业务功能会使用相同的CommandGroupKey。
  • ThreadPoolKey,如果说CommandGroupKey只是逻辑隔离,那么ThreadPoolKey就是物理隔离,当没有设置ThreadPoolKey的时候,线程池或者信号量的划分按照CommandGroupKey,当设置了ThreadPoolKey,那么线程池和信号量的划分就按照ThreadPoolKey来处理,相同ThreadPoolKey采用同一个线程池或者信号量。

简而言之,ThreadPoolKey是用作于物理隔离(距离很远的机房)的,CommandGroupKey是用于做逻辑隔离的。Hystrix在底层用一个Map集合的方式,来管理这些线程池的集合,Map的key对应ThreadPoolKey(未设置ThreadPoolKey的时候,CommandGroupKey就是ThreadPoolKey)或者CommandGroupKey,value对应具体线程池,类似“Map<String,pool>”的形式,线程(pool)里面对应的就是一个一个的线程(Thread)。

在org.init.springCloud包下新建strategy包,创建StrategyCommandTest测试类,创建StrategyCommand内部类,构造器传入一个索引,在run()方法和getFallback()中打印这个索引,之后创建main()方法测试,在线程池和信号量限制的情况下,Hystrix走的回退逻辑:

package org.init.springCloud.strategy;

import com.netflix.config.ConfigurationManager;
import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import com.netflix.hystrix.HystrixCommandProperties.ExecutionIsolationStrategy;

public class StrategyCommandTest {
	
	public static void main(String[] args) throws Exception{
		//1.使用线程池的方式
//		ConfigurationManager
//		.getConfigInstance()
//		.setProperty(
//			"hystrix.threadpool.default.coreSize"
//		  , "4");
//		for(int i=0; i < 6; i++){
//			StrategyCommand sc = new StrategyCommand(i);
//			sc.queue();//异步执行,即并发请求
//		}
		
		//2.使用信号量的方式
		ConfigurationManager.getConfigInstance()
			.setProperty("hystrix.command.default.execution.isolation.strategy"
				, ExecutionIsolationStrategy.SEMAPHORE);
		ConfigurationManager.getConfigInstance()
			.setProperty(
				"hystrix.command.default.execution.isolation.semaphore.maxConcurrentRequests", 2);
		for(int i=0; i < 6; i++){//由于信号量外部是没有线程包裹的,直接执行会同步执行
			final int index = i;
			new Thread(() -> {
				StrategyCommand sc = new StrategyCommand(index);
				//这个时候不论怎么执行,都是异步执行的,每个执行命令都被包裹到一个线程里了
				sc.queue();
			}).start();
		}
		Thread.sleep(10000);
	}
	
	static class StrategyCommand extends HystrixCommand<String>{
		
		Integer index;
		
		public StrategyCommand(Integer index){
			super(HystrixCommandGroupKey.Factory.asKey("myGroup"));
			this.index = index;
		}
		
		@Override
		protected String run() throws Exception {
			System.out.println("执行成功:"+index);
			return "success";
		}
		
		@Override
		protected String getFallback() {
			System.out.println("执行失败:"+index+" 回退");
			return "fail";
		}
		
	}
}

两种情况下执行的结果分别是:

四、缓存

对于单次请求里面执行的多次相同命令,可以使用缓存来获取结果。譬如在一次商品购买逻辑中,需要反复查询同一个商品3次,每次查询的结果都是一致的,对于这种读取数据的形式,就可以使用考虑使用缓存。缓存可以减少重复请求,降低数据库压力。

在org.init.springCloud包下新建cache包,创建CacheCommandTest测试类,内部类CacheCommand的构造器中传入一个自定义的缓存key值,我们在同一次请求上下文中,执行三次同样的命令,来查看是否使用了缓存:

package org.init.springCloud.cache;

import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import com.netflix.hystrix.strategy.concurrency.HystrixRequestContext;

public class CacheCommandTest {
	
	public static void main(String[] args) {
		HystrixRequestContext context = HystrixRequestContext.initializeContext();//开启一个上下文
		String caCheKey = "myCaCheKey";
		CacheCommand cc1 = new CacheCommand(caCheKey);
		CacheCommand cc2 = new CacheCommand(caCheKey);
		CacheCommand cc3 = new CacheCommand(caCheKey);
		
		cc1.execute();
		cc2.execute();
		cc3.execute();
		
		System.out.println("是否是从缓存中读取的数据:"+cc1.isResponseFromCache());
		System.out.println("是否是从缓存中读取的数据:"+cc2.isResponseFromCache());
		System.out.println("是否是从缓存中读取的数据:"+cc3.isResponseFromCache());
		
		context.shutdown();//关闭上下文
	}

	static class CacheCommand extends HystrixCommand<String>{
		String cacheKey;
		public CacheCommand(String cacheKey){
			super(HystrixCommandGroupKey.Factory.asKey("myGroup"));
			this.cacheKey = cacheKey;
		}
		
		@Override
		protected String run() throws Exception {
			System.out.println("执行成功");
			return "success";
		}
		
		@Override
		protected String getFallback() {
			System.out.println("执行失败");
			return "fail";
		}
		
		@Override
		protected String getCacheKey() {
			return this.cacheKey;
		}
	}
	
}

执行main()方法,控制台输出了请求结果:

五、请求合并

您可以在HystrixCommand前面使用一个请求合并器(HystrixCollapser是抽象的父节点),将多个请求合并成一个后端依赖项调用。

下图显示了两种场景中的线程数和网络连接数:首先没有,然后使用请求合并(假设所有连接都是在短时间内并发的,这里的案例是10 ms,超过10ms无法合并)。

Hystrix支持两种请求合并的方式:全局上下文(跨所有Tomcat线程)和用户请求上下文(单一Tomcat线程)。全局上下文的方式对应com.netflix.hystrix.HystrixCollapser.Scope.GLOBAL属性,用户请求上下文对应的是com.netflix.hystrix.HystrixCollapser.Scope.REQUEST。
使用“全局上下文”,对于任何Tomcat线程上的任何用户请求都可以一起合并。如果只处理单个用户的批处理请求,那么Hystrix可以从单个Tomcat线程(请求)中合并请求。
我们演示在一次请求中的批处理请求:

在org.init.springCloud包下新建collapser包,创建HystrixCollapserTest测试类,创建继承了HystrixCollapser类的内部类UserCommandCollapser,用于合并我们的请求并返回响应数据,再在UserCommandCollapser里面创建内部类BatchCommand,这个command和之前的做饭没太大区别,把UserCommandCollapser传过来的请求简单处理一下返回数据:

package org.init.springCloud.collapser;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.Future;

import com.netflix.hystrix.HystrixCollapser;
import com.netflix.hystrix.HystrixCollapserKey;
import com.netflix.hystrix.HystrixCollapserProperties;
import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import com.netflix.hystrix.HystrixCommandKey;
import com.netflix.hystrix.HystrixEventType;
import com.netflix.hystrix.HystrixRequestLog;
import com.netflix.hystrix.strategy.concurrency.HystrixRequestContext;

public class HystrixCollapserTest {
	
	public static void main(String[] args) throws Exception{
        HystrixRequestContext context = HystrixRequestContext.initializeContext();
        try {
            Future<String> f1 = new UserCommandCollapser(1).queue();
            //Thread.sleep(100);
            Future<String> f2 = new UserCommandCollapser(2).queue();
            Future<String> f3 = new UserCommandCollapser(3).queue();
            Future<String> f4 = new UserCommandCollapser(4).queue();
 
            System.out.println(f1.get());
            System.out.println(f2.get());
            System.out.println(f3.get());
            System.out.println(f4.get());
            
            //查看当前请求所有已执行命令数的大小
            System.out.println(HystrixRequestLog.getCurrentRequest().getAllExecutedCommands().size());
            HystrixCommand<?> command = HystrixRequestLog.getCurrentRequest().getAllExecutedCommands().toArray(new HystrixCommand<?>[1])[0];

            System.out.println("已执行command的commandKey:"+command.getCommandKey().name());
            System.out.println("请求是否被合并了:"+command.getExecutionEvents().contains(HystrixEventType.COLLAPSED));
            System.out.println("请求是否成功了:"+command.getExecutionEvents().contains(HystrixEventType.SUCCESS));
        } finally {
            context.shutdown();
        }
	}
	
	//	- BatchReturnType:createCommand()方法创建批量命令的返回值的类型。 
	//	- ResponseType:单个请求返回的类型。 
	//	- RequestArgumentType:getRequestArgument()方法请求参数的类型。
	static class UserCommandCollapser extends HystrixCollapser<List<String>, String, Integer>{

		final Integer index;
		
		public UserCommandCollapser(Integer index){
			//设置批处理请求合并的最小时间范围
			super(Setter.withCollapserKey(HystrixCollapserKey.Factory.asKey("myCollapserKey"))
					.andCollapserPropertiesDefaults(
							HystrixCollapserProperties.Setter().withTimerDelayInMilliseconds(100)));
			this.index = index;
		}
		
		@Override
		public Integer getRequestArgument() {//获取请求参数
			return this.index;
		}

		//合并请求产生批量命令
		@Override
		protected HystrixCommand<List<String>> createCommand(
				Collection<com.netflix.hystrix.HystrixCollapser.CollapsedRequest<String, Integer>> requests) {
			return new BatchCommand(requests);
		}

		//批量命令执行后的返回结果
		@Override
		protected void mapResponseToRequests(
				List<String> batchResponse,
				Collection<com.netflix.hystrix.HystrixCollapser.CollapsedRequest<String, Integer>> requests) {
			int count = 0;
	        for (CollapsedRequest<String, Integer> request : requests) {
	            request.setResponse(batchResponse.get(count++));
	        }
		}
		
		private static final class BatchCommand extends HystrixCommand<List<String>> {
	        private final Collection<CollapsedRequest<String, Integer>> requests;
	 
	        private BatchCommand(Collection<CollapsedRequest<String, Integer>> requests) {
	                super(Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey("myGroupKey"))
	                    .andCommandKey(HystrixCommandKey.Factory.asKey("myCommandKey")));
	            this.requests = requests;
	        }
	 
	        @Override
	        protected List<String> run() {
	            ArrayList<String> response = new ArrayList<String>();
	            for (CollapsedRequest<String, Integer> request : requests) {
	                // 批量收到的每个参数的响应
	                response.add("collapser: " + request.getArgument());
	            }
	            return response;
	        }
	    }
		
	}
}

运行HystrixCollapserTest的main()方法,控制台输出信息,我们可以看到,一次请求内的四个执行命令都被合并了:

解开main()方法中线程休眠的限制,再次运行main()方法。由于之前配置了100ms内的请求会被合并,所以2-4个命令被合并了:

至此,Hystrix的大部分内容都被我们囊括进来了,更详细的资料还是需要看Netflix在GitHub上托管的代码和文档:https://github.com/Netflix/Hystrix/

源码点击这里

最后,大家有什么不懂的或者其他需要交流的内容,也可以进入我的QQ讨论群一起讨论:654331206

Spring Cloud系列:

Spring Cloud介绍与环境搭建(一)

Spring Boot的简单使用(二)

Spring Cloud服务管理框架Eureka简单示例(三)

Spring Cloud服务管理框架Eureka项目集群(四)

Spring Cloud之Eureka客户端健康检测(五)

Netflix之第一个Ribbon程序(六)

Ribbon负载均衡器详细介绍(七)

Spring Cloud中使用Ribbon(八)

具有负载均衡功能的RestTemplate底层原理(九)

OpenFeign之第一个Feign程序(十)

OpenFeign之feign使用简介(十一)

Spring Cloud中使用Feign(十二)

Netflix之第一个Hystrix程序(十三)

Netflix之Hystrix详细分析(十四)

Spring Cloud中使用Hystrix(十五)

Netflix之第一个Zuul程序(十六)

Spring Cloud集群中使用Zuul(十七)

Netflix之Zuul的进阶应用(十八)

消息驱动之背景概述(十九)

消息中间件之RabbitMQ入门讲解(二十)

消息中间件之Kafka入门讲解(二十一)

Spring Cloud整合RabbitMQ或Kafka消息驱动(二十二)

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值