Reactor响应式编程系列(五)- 解读publishOn()方法

Reactor响应式编程系列导航

前言

FluxMono不会创建线程,只有当触发subscribe()操作时才会执行对应的方法。而有些操作符,例如publishOn()subscribeOn()方法,能够创建线程,本篇文章就针对publishOn()来做个讲解,subscribeOn()则放在下一篇博客中。

publishOn()特性:

  • publishOn()操作会强制下一个操作符(或许是下一个的下一个…)运行于不同的线程上。
  • publishOn()和其他操作符一样也处于整个订阅操作链中,从上游源获取元素,从关联的Scheduler获取一个worker,并调用对应的schedule()方法向下游发放元素。

以下内容指定reactor-core版本3.4.4

一. Mono.publishOn()源码解读

public final Mono<T> publishOn(Scheduler scheduler) {
	// ...
	return onAssembly(new MonoPublishOn<>(this, scheduler));
}

关注代码中的最后一行,可见new出了一个MonoPublishOn对象,MonoPublishOn继承了MonoOperator类,来看下其继承关系:
在这里插入图片描述
可以见到最上层的Publisher,说明了什么?说明MonoPublishOn就是一个发布者,故看其subscribe()方法:

final class MonoPublishOn<T> extends MonoOperator<T, T> {
	public void subscribe(CoreSubscriber<? super T> actual) {
		// source:该发布者的上一级
		// PublishOnSubscriber类:用于维护订阅者和发布者的类
        this.source.subscribe(new MonoPublishOn.PublishOnSubscriber(actual, this.scheduler));
    }
}

以此来看下MonoPublishOn的静态内部类PublishOnSubscriber的继承关系:
在这里插入图片描述
可以发现,它实现了Runnable接口,用于异步运行某个任务。PublishOnSubscriber拥有者订阅和发布两个角色,本文关注其订阅的层面,因此来看下其onNext()方法:

static final class PublishOnSubscriber<T> implements InnerOperator<T, T>, Runnable {
	// 1.传参,执行
	public void onNext(T t) {
		// 接受上游数据后,进行赋值,然后调用trySchedule方法
        this.value = t;
        this.trySchedule(this, (Throwable)null, t);
    }
    // 2.交给调度器调度任务
    void trySchedule(@Nullable Subscription subscription, @Nullable Throwable suppressed, @Nullable Object dataSignal) {
        if (this.future == null) {
            try {
            	// 该方法主要就是运行调度器,将自身作为任务,因为本身实现了Runnable接口。
                this.future = this.scheduler.schedule(this);
            } catch (RejectedExecutionException var5) {
                this.actual.onError(Operators.onRejectedExecution(var5, subscription, suppressed, dataSignal, this.actual.currentContext()));
            }
        }
    }
    // 3.既然将自身当做任务直接交给调度器来调用,那么我们需要关注一下它的run()方法,(重写Runnable接口的run)
    // 也就是启动任务时,需要执行的逻辑
    public void run() {
        if (!OperatorDisposables.isDisposed(this.future)) {
            T v = VALUE.getAndSet(this, (Object)null);
            // 如果当前的值不是空,那么就通过链式的方式来调用下级的onNext()方法。
            // 也可以这么理解,即A-B-C-D-E-F-G,主线程Main在B处调用了publishOn方法,然后接下来的CDEFG则在另外一个线程thread上执行
            if (v != null) {
                this.actual.onNext(v);
                this.actual.onComplete();
            } else {
                Throwable e = this.error;
                if (e != null) {
                    this.actual.onError(e);
                } else {
                    this.actual.onComplete();
                }
            }
        }
    }
}

1.1 案例

@Test
public void publishOnTest() {
    Flux.range(1, 2)
            .map(i -> {
                System.out.println(Thread.currentThread().getName());
                return i;
            })
            .publishOn(Schedulers.single())
            .map(i -> {
                System.out.println(Thread.currentThread().getName());
                return i;
            })
            .publishOn(Schedulers.newParallel("parallel", 4))
            .map(i -> {
                System.out.println(Thread.currentThread().getName());
                return i;
            })
            .subscribe();
}

结果如下:可以发现线程确确实实都发生了改变。
在这里插入图片描述

1.2 总结

调用publishOn()时的过程:

  1. 最终方法return的时候,传入了一个MonoPublishOn类型的对象,其顶层父类包含Publisher,因此可以看做返回了一个新的发布者对象。
  2. MonoPublishOn中的subscribe()方法,从当前发布者的上一级处起,开始产生新的订阅关系,传入PublishOnSubscriber类型的对象。
  3. PublishOnSubscriber用于维护订阅者和发布者,最终实现了Runnable接口,因此当前实例可以看做为一个task,用于异步的运行当前任务。
  4. 会调用onNext()方法,接受上游数据,并将当前task交给调度器去调度,调度时通过链式的方式来调用下级的onNext()方法。

我们知道,序列在subscribe之前不会发生任何事情。那么publishOn()这方法的本质,也就是新建了一个线程任务PublishOnSubscriber同时拥有订阅方法并执行调用(订阅动作能让动作链执行),也就相当于publishOn()后面的逻辑都执行在另外一个线程上了。


二. Flux.publishOn()源码解读

以同样的方式进入publishOn()方法:

// 一般我们在开发过程中,都只传入一个Scheduler类型的参数,但是从源码角度来看可以发现
// 我们无须指定队列的大小,用系统默认即可(256)
public final Flux<T> publishOn(Scheduler scheduler) {
    return this.publishOn(scheduler, Queues.SMALL_BUFFER_SIZE);
}
↓↓↓↓↓
public final Flux<T> publishOn(Scheduler scheduler, int prefetch) {
    return this.publishOn(scheduler, true, prefetch);
}
↓↓↓↓↓
public final Flux<T> publishOn(Scheduler scheduler, boolean delayError, int prefetch) {
	return publishOn(scheduler, delayError, prefetch, prefetch);
}
↓↓↓↓↓
final Flux<T> publishOn(Scheduler scheduler, boolean delayError, int prefetch, int lowTide) {
	// ...
	// Queues.get(prefetch)也就是返回一个有界队列SpscArrayQueue
	return onAssembly(new FluxPublishOn<>(this, scheduler, delayError, prefetch, lowTide, Queues.get(prefetch)));
}

2.查看对应的FluxPublishOn,看下它的继承关系:
在这里插入图片描述
同样,由于其最终父类包含Publisher接口,因此查看该类中有关订阅的方法subscribeOrReturn()

final class FluxPublishOn<T> extends InternalFluxOperator<T, T> implements Fuseable {

	final Scheduler scheduler;

	final boolean delayError;

	final Supplier<? extends Queue<T>> queueSupplier;

	final int prefetch;

	final int lowTide;
	
	@Override
	@SuppressWarnings("unchecked")
	public CoreSubscriber<? super T> subscribeOrReturn(CoreSubscriber<? super T> actual) {
	// 1.获取一个worker实例
	Worker worker = Objects.requireNonNull(scheduler.createWorker(),
			"The scheduler returned a null worker");
	// ..
	// 2.将各种参数包装成一个PublisbOnSubscriber
	return new PublishOnSubscriber<>(actual,
			scheduler,
			worker,
			delayError,
			prefetch,
			lowTide,
			queueSupplier);
	}
}

其实到这里,跟Mono.publishOn()方法的流程都是非常相似的,都是:

  1. 调用xxx.publishOn()方法都需要生成xxxPublishOn对象。
  2. xxxPublishOn对象都实现了Publisher接口,都有相关的subscribe()方法。
  3. 相关subscribe()方法最后都会生成一个xxxPublishOn.PublishOnSubscriber对象。

对于前缀为Mono的分支,我对于其内部的PublishOnSubscriber类只是概括成一个用于维护订阅和发布的一个类,并没有详细解释,那么在Flux这一分支就好好讲一讲。

2.1 PublisbOnSubscriber的解读

备注:下面的类位于FluxPublishOn类中,记得区分MonoPublishOn

final class FluxPublishOn<T> extends InternalFluxOperator<T, T> implements Fuseable {
	static final class PublishOnSubscriber<T>
				implements QueueSubscription<T>, Runnable, InnerOperator<T, T> {

可见,FluxPublishOn.PublishOnSubscriberMonoPublishOn.PublishOnSubscriber多实现了一个接口:QueueSubscription。为什么会有这样的情况,接下来开始讲解。

当我们生产源无须存储元素,并且根据状态来进行元素下发的时候,也就是状态感知型下发操作

Flux.generate为例,我们看下FluxGenerate.subscribe()方法调用的时候会发生什么:

@Override
public void subscribe(CoreSubscriber<? super T> actual) {
	S state;

	try {
		state = stateSupplier.call();
	} catch (Throwable e) {
		Operators.error(actual, Operators.onOperatorError(e, actual.currentContext()));
		return;
	}
	// 该方法接收了一个FluxGenerate.GenerateSubscription对象
	// 当下游操作链中需要执行异步化切换钱程的操作时,就将 publisbOn 放在 Flux.generale 之后
	// 此时会调用FluxPublishOn.PublishOnSubscriber中的onSubscribe方法
	actual.onSubscribe(new GenerateSubscription<>(actual, state, generator, stateConsumer));
}

先来看下FluxPublishOn实现的接口Fuseable:可以发现这里定义了几个字段。

public interface Fuseable {

	/** Indicates the QueueSubscription can't support the requested mode. */
	int NONE = 0;
	/** Indicates the QueueSubscription can perform sync-fusion. */
	int SYNC = 1;
	/** Indicates the QueueSubscription can perform only async-fusion. */
	int ASYNC = 2;
	/** Indicates the QueueSubscription should decide what fusion it performs (input only). */
	int ANY = 3;
	/**
	 * Indicates that the queue will be drained from another thread
	 * thus any queue-exit computation may be invalid at that point.
	 */
	int THREAD_BARRIER = 4;

紧接着再回到PublishOnSubscriber类,看下其onSubscribe方法:

@Override
public void onSubscribe(Subscription s) {
	if (Operators.validate(this.s, s)) {
		this.s = s;

		if (s instanceof QueueSubscription) {
			@SuppressWarnings("unchecked") QueueSubscription<T> f =
					(QueueSubscription<T>) s;
			// 1.到这里对应着两种不同的方法,看下面 注释A 和 注释B 两种代码
			// Fuseable.ANY=3,Fuseable.THREAD_BARRIER=4,  3 | 4 = 7
			// 这里调用的方法是下面代码B的,而最终返回的值是None,也就是0
			int m = f.requestFusion(Fuseable.ANY | Fuseable.THREAD_BARRIER);
			// 因此这里的代码分支都不会走到,都跳过
			if (m == Fuseable.SYNC) {
				// ..
			}
			if (m == Fuseable.ASYNC) {
				sourceMode = Fuseable.ASYNC;
				queue = f;
				actual.onSubscribe(this);
				s.request(Operators.unboundedOrPrefetch(prefetch));
				return;
			}
		}
		// 2.得到有界队列SpscArrayQueue,publisbOn中的异步操作主要是该队列来实现的
		// 通过这个 queue将上游源与下游订阅者分隔开来.
		queue = queueSupplier.get();
		// 3.由下游的传入的订阅者执行下面的方法。
		actual.onSubscribe(this);

		s.request(Operators.unboundedOrPrefetch(prefetch));
	}
}

// A:reactor.core.publisher.FluxPublishOn.PublishOnSubscriber.requestFusion
@Override
public int requestFusion(int requestedMode) {
	if ((requestedMode & ASYNC) != 0) {
		outputFused = true;
		return ASYNC;
	}
	return NONE;
}
// B:reactor.core.publisher.FluxGenerate.GenerateSubscription.requestFusion
@Override
public int requestFusion(int requestedMode) {
	if ((requestedMode & Fuseable.SYNC) != 0 && (requestedMode & Fuseable.THREAD_BARRIER) == 0) {
		outputFused = true;
		return Fuseable.SYNC;
	}
	return Fuseable.NONE;
}

言归正传,在Flux.generate生产源的场景下,public void onSubscribe(Subscription s)中传入的参数类型是GenerateSubscription,它实现了QueueSubscription接口,即f.requestFusion方法走的是上面注释B中的逻辑,
因此传入的参数为7,在代码B中的If块中的值是False。因此最终返回Fuseable.NONE,对应的值是0。因此下面两个If块直接跳过,可以获得一个有界队列,而我们的异步就是靠其来实现的。

问题:

因为下发和请求操作依然是在异步线程里执行的,所以它们可能会分布在多个线程中执行任务。如何保证请求的单一性?

解决:

  1. 将上游元素下发到这个queue中,当下游多个线程发送请求的时候,直接增加PublishOnSubscriber中定义的 REQUESTED数量
  2. 接着等待PublishOnSubscriber从这个queue中获取元素并下发给下游订阅者。

同时,Flux.generate产生源并调用publishOn()方法,会调用到下面的request方法:

// FluxGenerate.GenerateSubscription
@Override
public void request(long n) {
	if (Operators.validate(n)) {
		if (Operators.addCap(REQUESTED, this, n) == 0) {
			if (n == Long.MAX_VALUE) {
				fastPath();
			} else {
				slowPath(n);
			}
		}
	}
}

我们知道request()也就是一个线程一次的请求数据的数量,从代码来看,无非就是执行两种情况:

  • fastPath
  • slowPath

但是在多线程的情况下,毫无疑问的会多次调用,并且在n没有达到最大值的情况下,代码一般会调用slowPath(n)

// 也就是会多次调用apply方法
void slowPath(long n) {
	S s = state;
	// ...
	try {
		s = g.apply(s, this);
		if (!hasValue) {
			cleanup(s);

			actual.onError(new IllegalStateException("The generator didn't call any of the " +
			  "SynchronousSink method"));
			return;
		}
		// 若正常执行,这个hasValue就会赋值为false
		hasValue = false;
		// ..
	}

什么意思呢?就是一个线程执行完后,可能将hasValue改为False,那么另外的线程在执行相同的代码的时候,遇到if (!hasValue)->true 代码,就直接抛异常了。那么该如何避免呢?

2.1.1 多次调用publishOn的情况下避免异常

前面分析到,以Flux.generate为例,第一次调用publishOn()的情况下,会走注释B代码,得到结果None,但是在第二次调用publishOn()的情况下,会执行到PublishOnSubscriber.requestFusion()方法(注释A),会得到ASYNC(赋值给sourceMode属性),并将上一个PublishOnSubscriberoutputFused属性改为true。并且,在调用onNext()方法的时候:(代码流程讲的是第二次调用publishOn的情况

// PublishOnSubscriber.PublishOnSubscriber
@Override
public void onNext(T t) {
	// 此时sourceMode = ASYNC ,代码走该分支
	// 此时不会下发任何元素,这是因为元素都由它前面的那个publishOn操作放置在它自己的queue中。
	// 后面这个publishOn操作只需要在trySchedule中从队列中拉取元素井下发即可。
	if (sourceMode == ASYNC) {
		trySchedule(this, null, null /* t always null */);
		return;
	}
	if (done) {
		Operators.onNextDropped(t, actual.currentContext());
		return;
	}

	if (cancelled) {
		Operators.onDiscard(t, actual.currentContext());
		return;
	}

	if (!queue.offer(t)) {
		Operators.onDiscard(t, actual.currentContext());
		error = Operators.onOperatorError(s,
				Exceptions.failWithOverflow(Exceptions.BACKPRESSURE_ERROR_QUEUE_FULL),
				t, actual.currentContext());
		done = true;
	}
	// 第一次调用publishOn走的流程
	trySchedule(this, null, t);
}
// 
void trySchedule(
				@Nullable Subscription subscription,
				@Nullable Throwable suppressed,
				@Nullable Object dataSignal) {
	if (WIP.getAndIncrement(this) != 0) {
		// ..
	try {
		// 因为PublishOnSubscriber实现了Runnable接口,因此本身就是个线程任务,则直接传入this,执行调度
		worker.schedule(this);
	}
	// ..
}
// 再看下其run方法的实现:
@Override
public void run() {
	// 第二次执行publishOn时(一条链),此时outputFused为true,执行该分支代码,什么意思呢?
	// 当一个调用链中出现多次publisbO 操作的时候,后面的那一个操作会通过QueueSubscriprion废弃前一个publishOo操作。
	// 也就是说上一个publishOn只会切换线程,而并不会做与真实下发元素相关的其他任何事情
	if (outputFused) {
		runBackfused();
	}
	else if (sourceMode == Fuseable.SYNC) {
		runSync();
	}
	else {
		// 第一次publishOn会执行该代码
		runAsync();
	}
}

可以发现如果调用两次publishOn(),会将上一个publishOn()操作给废弃掉,即保证了不会报错,那么第一次publishOn()的时候发生了什么?最后会调用到runAsync()方法:

void runAsync() {
	int missed = 1;
	
	final Subscriber<? super T> a = actual;
	// 1.这里的queue就是上文提到的SpscArrayQueue类,其内部是由原子类来保证操作的安全性
	// 既可以保证多线程下的并发获取操作
	final Queue<T> q = queue;
	long e = produced;
	
	for (; ; ) {
		long r = requested;	
		while (e != r) {
			boolean d = done;
			T v;	
			try {
				v = q.poll();
			}
			// ..
			// 当元素生产速度小于消费速度时,上述q.poll得到的值为null
			// 若empty为true则跳出循环
			// 当元素生产速度大于消费速度时,工作的钱程会不断地从FluxPublishOn的暂存队列中获取元素进行消费
			// 此时不会出现消费线程反复切换的情况
			boolean empty = v == null;
			e++;
			// 即消费元素个数 = publishOn操作请求上游元素个数
			if (e == limit) {
				if (r != Long.MAX_VALUE) {
					// 此时会通过原子类操作REQUESTED来保证元素请求数量的原子性
					// 此时队列中已经没有元素了,因此需要publishOn操作去上游拉取元素,
					// 首先,将下游订阅者所需总元素数量减去这次要请求元素的数量:r-e
					r = REQUESTED.addAndGet(this, -e);
				}
				// 在执行该操作,e代表一次能请求的元素个数最大值
				s.request(e);
				// 请求完毕,将e设定为0
				e = 0L;
			}
		}
	
		if (e == r && checkTerminated(done, q.isEmpty(), a, null)) {
			return;
		}
	
		int w = wip;
		if (missed == w) {
			produced = e;
			missed = WIP.addAndGet(this, -missed);
			if (missed == 0) {
				break;
			}
		}
		else {
			missed = w;
		}
	}
	}

2.1.2 案例

源中的元素生成速度<消费速度(代码中sleep了100ms)

@org.junit.Test
public void test5() throws Exception {
    final Random random = new Random();
    Flux.generate(ArrayList::new, (list, sink) -> {
        int value = random.nextInt(100);
        list.add(value);
        System.out.println("线程" + Thread.currentThread().getName() + "发射元素");
        sink.next(value);
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        if (list.size() == 0) {
            sink.complete();
        }
        return list;
    }).publishOn(Schedulers.parallel(), 2)
            .map(x -> String.format("[%s} %s", Thread.currentThread().getName(), x))
            .subscribe(System.out::println);
    Thread.sleep(30000);
}

结果如下:
在这里插入图片描述
若去掉睡眠的时间,结果如下(我试了好多遍才有这样的结果,建议大家点击Run后狂点停止按钮,否则输出结果太多,最前面的内容会被屏蔽):
在这里插入图片描述
两者有什么区别?

  • 结果1:都是主线程Main在输出。parallel-1负责消费。即产生了线程不断切换的情况。
  • 结果2:主线程Main输出2条数据后,由parallel-1线程来输出。parallel-1负责消费数据,即没有结果1那样,线程在不断地切换。

这佐证了上面注释中提到的:

  1. 当元素生产速度小于消费速度时(结果1),如果我们所用调度器底层执行者为多个线程的线程池,那么就会看到线程切换的情况 。(主线程输出,parallel-1)
  2. 当元素生产速度大于消费速度时(结果2),工作的线程会不断地从FluxPublishOn的暂存队列中获取元素进行消费,这样就不会出现消费线程反复切换的情况。

2.2 总结

Flux.publishOn()的源码讲解我主要参考的是《响应式Spring Reactor 3设计与实现》这本书,我自己来回看了好几遍,最后才懂,真的难搞哦。我自己写的也可能有点乱(真的抱歉),跟着书的节奏来写的,所以我在这里先进行个总结,方便大家理解。
在这里插入图片描述
总结:

  1. Flux.publishOn()方法最后需要传入一个FluxPublishOn类型的对象,其顶层父类包含Publisher,因此可以看做返回了一个新的发布者对象。
  2. 查看其subscribeOrReturn()订阅方法,最后将各种参数包装成新对象PublishOnSubscriber返回(类同Mono)。
  3. FluxPublishOn.PublishOnSubscriberMonoPublishOn.PublishOnSubscriber多实现了一个接口:QueueSubscription目的是为了多线程异步的任务处理,保证高并发下程序运行正常。
  4. 后面给的分析是基于Flux.generate为例,并且调用链调用了两次publishOn()的情况 ,当调用对应的publishOn时,都会调用到PublishOnSubscriber中的onSubscribe()方法,该方法做了这么几个事情。

第一次调用时:产生的PublishOnSubscriber记为P1
-----1.调用FluxGenerate.GenerateSubscription.requestFusion方法,返回NONE
-----2.得到有界队列SpscArrayQueue 中,记为A,将上游的源和下游的订阅分隔开来,即将上游的元素都放入到队列A中,由下游的传入的订阅者执行后续的方法。
-----3.在调用publishOn操作相关的onNext方法时,由于sourceMode=NONE,调用方法trySchedule(this, null, t)
-----4.由于P1实现了Runnable接口,最后实现其run()方法,执行runAsync()逻辑。

第二次调用时:产生的PublishOnSubscriber记为P2
-----1.调用FluxGenerate.PublishOnSubscriber.requestFusion方法,返回ASYNC
-----2.并将上一个PublishOnSubscriberoutputFused属性设置为True
-----3.在调用publishOn操作相关的onNext方法时,由于sourceMode=ASYNC,调用方法trySchedule(this, null, null),该方法不会下发任何元素,而只需要从队列A中拉取元素即可。
-----4.由于P2实现了Runnable接口,最后实现其run()方法,但是由于outputFused=true,执行runBackfused()逻辑,通过QueueSubscriprion废弃前一个publishOn操作。

通俗点来说就是(个人理解,若有出入或者错,还望指正!):

  1. 第一次publishOn操作记为A,第二次publishOn操作记为B。第三次publishOn操作记为C。数据总数量为M。
  2. A负责另起一个线程,创建一个队列,并把元素扔进去。而操作则交给B来处理。
  3. B负责另起一个线程,去队列中拉取元素去消费,同时废弃操作A,然后自身处理一次请求,处理掉对应数量的数据request(n),总数据量-n。
  4. C负责另起一个线程,去队列中拉取元素去消费,同时废弃操作B,然后自身处理一次请求,处理掉对应数量的数据request(n),总数据量-n,后续以此类推

并且根据案例我们需要注意两点:

  • 元素生产速度小于消费速度时,在FluxPublishOn中出现的情况是暂存元素队列调用poll方法得到的是null,即空队列,会看到线程切换的情况。
  • 元素生产速度大于消费速度时,工作的钱程会不断地从FluxPublishOn的暂存队列中获取元素进行消费 , 这样就不会出现消费线程反复切换的情况。

本篇文章的精髓就是:

就是后者通过一个接口QueueSubscriprion,一个本地状态sourceMode,一个链式的调用方法PublishOnSubscriber.onSubscribe()来根据前者的结果来进行自己的行为设定。

最后感谢大家能够耐心看到这里,下一篇文章会努力写好,下一篇文章就是分学习Reactor中的subscribeOn()方法,并且在后续准备好好学习下QueueSubscriprion类,看看他到底在Reactor框架中气到了什么作用。如果你喜欢这篇文章,还望给个三连~😝😝
在这里插入图片描述

  • 11
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zong_0915

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值