sentinel自定义slot实现对于指定参数的直接熔断

sentinel自定义slot实现对于指定参数的直接熔断

​ 随着微服务的流行,服务和服务之间的稳定性变得越来越重要。Sentinel 是面向分布式、多语言异构化服务架构的流量治理组件,主要以流量为切入点,从流量路由、流量控制、流量整形、熔断降级、系统自适应过载保护、热点流量防护等多个维度来帮助开发者保障微服务的稳定性,由阿里巴巴开源。

sentinel学习地址:介绍 · alibaba/Sentinel Wiki (github.com)

sentinel中文官网:quick-start | Sentinel (sentinelguard.io)

本例代码地址:yealike/SentinelParamDegrade (github.com)

1.背景

​ 现在有这么一个场景,在微服务架构中,系统间的相互调用是不可避免的,然而在业务高峰期,我的系统自身已经承担了很多压力,这时候我不希望再有那么多的第三方来调用我。这时候该怎么办呢?

注:在第三方调用我的时候,业务方会传入一个唯一的用户id。我根据这个业务id来判断这个第三方是否是经过我授权的,如果业务id识别正确,那么我就放行,允许他来访问我的接口,如果不正确那么不好意思直接熔断拒绝服务。

​ 说到这,大家心里可能大概了解了,对于一下不重要的第三方(比如说给钱少的)请求到达的时候,我只允许他在规定的时候内调用指定次数。例如给他限定一个QPS为5的调用量,当他的调用量用完之后,将会进入熔断期,比如10秒,这10秒内的请求都会被拒绝,10后重新开放5个QPS给他调用。这样就能达到了我对自身系统的保护。

要说熔断限流的工具,笔者知识范围有限,只知道一个sentinel

然而查阅官方文档,sentinel并没有这种限流方式,只有一个热点参数限流与我想要的功能接近,但是我要的是直接熔断而不是限流。

限流与熔断,所以只能扩展源码。

2.限流与熔断的区别

举例,一个接口有两个参数id和name,我对id=5这个参数设置熔断和限流的阈值QPS为5

@Operation(method = GET_METHOD, summary = "index")
@GetMapping(path = {"/index"})
public String index(int id, String name) {
    return "welcome id=" + id + "name" + name;
}

热点参数限流:当请求QPS到达阈值5的时候,触发限流条件,抛出一次异常,下一个时间窗口到达时重新计算流控触发条件

指定参数熔断:当请求QPS达到阈值5的时候,触发熔断机制,熔断器开始工作,在定义熔断条件时,会设置进入熔断保护的时间(比如30秒),那么在未来的30秒内,如果再次传入id=5这个参数请求这个接口时,会持续抛出异常,直到熔断时间结束,解除保护。然而当熔断期内利用id=6这个参数请求该接口时,接口可以正常返回数据给该线程,当id=5时抛出异常,拒绝提供服务。

3.实现原理

请直接将我github上的代码clone到本地,参照代码理解更加方便。yealike/SentinelParamDegrade (github.com)

在数据统计部分,借鉴Sentinel原生的热点参数熔断的代码。

在sentinel源码中热点参数限流的代码位于sentinel-extension模块下的sentinel-parameter-flow-control子模块,有兴趣的读者可以研究一下。

1)新建maven项目引入依赖

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-core</artifactId>
    <version>1.8.6</version>
</dependency>
<!-- 其实不引入下面这个依赖也行,下面的包里的代码只是作为参考 -->
<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-parameter-flow-control</artifactId>
    <version>1.8.6</version>
</dependency>

2)新建slot

在 Sentinel 里面,所有的资源都对应一个资源名称以及一个 Entry。Entry 可以通过对主流框架的适配自动创建,也可以通过注解的方式或调用 API 显式创建;每一个 Entry 创建的时候,同时也会创建一系列功能插槽(slot chain)。这些插槽有不同的职责。

Sentinel 将 ProcessorSlot 作为 SPI 接口进行扩展(1.7.2 版本以前 SlotChainBuilder 作为 SPI),使得 Slot Chain 具备了扩展的能力。您可以自行加入自定义的 slot 并编排 slot 间的顺序,从而可以给 Sentinel 添加自定义的功能。

在这里插入图片描述

在maven的resource目录下新建META-INF/services两级目录

在该目录下新建文件,文件名为

com.alibaba.csp.sentinel.slotchain.ProcessorSlot

文件的内容是我们自定义的slot的全限定名。此处主要是利用java的spi机制动态加载插件,感兴趣的小伙伴可以研究一下java的SPI机制。Sentinel 主要是利用职责链的设计模式将一系列slot加入到他的职责链中,我们自定义的slot只要符合规范也可以加入其中,注意调整优先级即可,关于自定义的slotParamCircuitBreakerSlot在下面会有详细解释。

3)拷贝源码

既然sentinel已经提供了数据统计的能力,我就不再重复的造轮子了,直接拷贝源码

sentinel-parameter-flow-control模块下的如下几个类拷贝到我们自己的项目中

ParameterMetric
ParameterMetricStorage
ParamFlowArgument
ParamFlowChecker
ParamFlowClusterConfig
ParamFlowItem
ParamFlowException
ParamFlowRule
ParamFlowRuleManager
ParamFlowRuleUtil

我的项目中将这些类进行了重命名,并且将不同功能的类放入了不同的怕package下,目的是防止产生歧义和方便理解

4)改造源码

源码有如下几个地方需要改造

1.ParamDegradeRule类中新增一个参数为recoveryTimeoutMs含义为熔断器的恢复时间

2.我在我的unit包下定义了两个类,用来封装熔断参数的单位。分别为BreakTimeParamDegradeWrapper。其中ParamDegradeWrapper需要重写hashCodeequals方法,用来封装唯一的熔断参数。

  • ParamDegradeWrapper:用于封装资源名称,资源名称是唯一的。
  • BreakTime:用于封装熔断时间和恢复时间

3.在自定义的slot类ParamCircuitBreakerSlot是我们本次改造的重点,首先在类上添加@Spi(order = -1400)将我们自定义的熔断器加入到sentinel的职责链中。

该类继承AbstractLinkedProcessorSlot需要重写两个方法entry和exit分别对应入口流控和出口流控规则

出口流控规则比较简单,我们不对出口流量做限制,直接将请求放行到下一个职责链,代码如下

// 出口流控
    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
        fireExit(context,resourceWrapper,count,args);
    }

5)ParamCircuitBreakerSlot详解

入口流控规则比较丰富,自定义的slot ParamCircuitBreakerSlot代码如下

@Spi(order = -1400)
public class ParamCircuitBreakerSlot extends AbstractLinkedProcessorSlot<DefaultNode> {
    private static ConcurrentHashMap<ParamDegradeWrapper, BreakTime> circuitBreaker = new ConcurrentHashMap<>();

    // 入口流控
    @Override
    public void entry(Context context, ResourceWrapper resourceWrapper, DefaultNode node, int count, boolean prioritized, Object... args) throws Throwable {
        // 判断是否触发降级
        // 如果没有配置改熔断规则,直接放行进入下一个slot
        if (!ParamDegradeRuleManager.hasRules(resourceWrapper.getName())) {
            fireEntry(context, resourceWrapper, node, count, prioritized, args);
            return;
        }

        performChecking(resourceWrapper, count, args);
        // 进入下一个slot
        fireEntry(context, resourceWrapper, node, count, prioritized, args);
    }

    private void performChecking(ResourceWrapper resourceWrapper, int count, Object[] args) throws ParamDegradeException {
        if (args == null) {
            return;
        }
        if (!ParamDegradeRuleManager.hasRules(resourceWrapper.getName())) {
            return;
        }

        // 获取所有规则
        List<ParamDegradeRule> rules = ParamDegradeRuleManager.getRulesOfResource(resourceWrapper.getName());
        for (ParamDegradeRule rule : rules) {
            applyRealParamIdx(rule, args.length);
            // 判断当前rule下是否有熔断器
            if (!circuitBreaker.isEmpty() && args.length > 0) {
                checkCircuitBreaker(resourceWrapper, rule, rule.getParamDegradeItemList(), args);
            }
            ParameterMetricStorage.initParamMetricsFor(resourceWrapper, rule);
            // 如果不满足某个rule 则抛出异常,代表当前请求被限流
            if (!ParamDegradeChecker.passCheck(resourceWrapper, rule, count, args)) {
                String triggeredParam = "";
                if (args.length > rule.getParamIdx()) {
                    Object value = args[rule.getParamIdx()];
                    // Assign actual value with the result of paramFlowKey method
                    if (value instanceof ParamFlowArgument) {
                        value = ((ParamFlowArgument) value).paramFlowKey();
                    }
                    triggeredParam = String.valueOf(value);
                }
                // 打开熔断器,指定参数被熔断
                List<ParamDegradeItem> items = rule.getParamDegradeItemList();
                for (ParamDegradeItem item : items) {
                    ParamDegradeWrapper wrapper = new ParamDegradeWrapper(resourceWrapper.getName(), rule.getParamIdx(), triggeredParam, item.getClassType());
                    circuitBreaker.put(wrapper, new BreakTime(System.currentTimeMillis(), rule.getRecoveryTimeoutMs()));
                }
                throw new ParamDegradeException(resourceWrapper.getName(), triggeredParam, rule);
            }
        }

    }

    private void checkCircuitBreaker(ResourceWrapper resourceWrapper, ParamDegradeRule rule, List<ParamDegradeItem> items, Object[] args) throws ParamDegradeException {
        if (!items.isEmpty()) {
            for (ParamDegradeItem item : items) {
                Integer paramIdx = rule.getParamIdx();
                if (paramIdx >= args.length) {
                    throw new IllegalArgumentException("参数长度有误,需要熔断的参数idx超过传入的参数长度");
                }
                // 拿出当前请求的参数
                String currentArg = String.valueOf(args[paramIdx]);
                ParamDegradeWrapper wrapper = new ParamDegradeWrapper(resourceWrapper.getName(), paramIdx, currentArg, item.getClassType());
                BreakTime breakTime = circuitBreaker.get(wrapper);
                // 当前对象取值不为空,且当前参数在熔断范围内,执行熔断
                if (breakTime != null && wrapper.getValue().equals(currentArg)) {
                    if (breakTime.getStartTime() + breakTime.getDuration() > System.currentTimeMillis()) {
                        throw new ParamDegradeException(resourceWrapper.getName(), item.getObject(), rule);
                    } else {
                        circuitBreaker.remove(wrapper);
                    }
                }
            }
        }
    }

    // paramIdx允许为负数,倒数第几个参数,将其转换为正数
    void applyRealParamIdx(/*@NonNull*/ ParamDegradeRule rule, int length) {
        int paramIdx = rule.getParamIdx();
        if (paramIdx < 0) {
            if (-paramIdx <= length) {
                rule.setParamIdx(length + paramIdx);
            } else {
                // Illegal index, give it a illegal positive value, latter rule checking will pass.
                rule.setParamIdx(-paramIdx);
            }
        }
    }

    // 出口流控
    @Override
    public void exit(Context context, ResourceWrapper resourceWrapper, int count, Object... args) {
        fireExit(context,resourceWrapper,count,args);
    }

}

@Spi(order = -1400)该slot的加载顺序在DegradeSlot之前DefaultCircuitBreakerSlot之后。

注:笔者参考的源码是sentinel截至目前最新的master分支的代码,而实际引用的是sentinel1.8.6的代码,所以1.8.6的源码中可能找不到DefaultCircuitBreakerSlot,不过影响我们自定义slot的改造。

在入口流控规则中主要有三件事

  1. 判断是否配置了降级规则,如果没有配置降级规则,直接放行进入下一个slot
  2. 配置降级规则之后,判断是否触发降级条件
  3. 进入下一个slot

对其中的第二条进行详解

我的代码中将判断是否触发降级条件封装成一个方法performChecking(resourceWrapper, count, args);

在讲performChecking方法之前,先介绍一下自定义的熔断器,是一个Map集合。自定义的熔断器与sentinel官方提供的根据异常数/异常比例、慢请求的熔断器有很大区别。官方的熔断器有open、half-open、close这三种状态,并且在熔断器工作的时间内还会涉及到这三种状态相互转化的逻辑。

自定义的熔断器是一个Map集合,很简单,没有任何状态,如果集合内有元素就说明有资源需要熔断,否则没有。

 private static ConcurrentHashMap<ParamDegradeWrapper, BreakTime> circuitBreaker 
     = new ConcurrentHashMap<>();

定义一个ConcurrentHashMap用来存储每个资源对应的熔断规则,其中的泛型为自定义的两个类,ParamDegradeWrapper用来封装资源名(为了确保唯一性,需要重写hashCode和equals方法),BreakTime用来封装熔断开始时间和熔断时长。

下面看一下performChecking的代码。

 private void performChecking(ResourceWrapper resourceWrapper, int count, Object[] args) throws ParamDegradeException {
        if (args == null) {
            return;
        }
        if (!ParamDegradeRuleManager.hasRules(resourceWrapper.getName())) {
            return;
        }

        // 获取所有规则
        List<ParamDegradeRule> rules = ParamDegradeRuleManager.getRulesOfResource(resourceWrapper.getName());
        for (ParamDegradeRule rule : rules) {
            applyRealParamIdx(rule, args.length);
            // 判断当前rule下是否有熔断器
            if (!circuitBreaker.isEmpty() && args.length > 0) {
                checkCircuitBreaker(resourceWrapper, rule, rule.getParamDegradeItemList(), args);
            }
            ParameterMetricStorage.initParamMetricsFor(resourceWrapper, rule);
            // 如果不满足某个rule 则抛出异常,代表当前请求被限流
            if (!ParamDegradeChecker.passCheck(resourceWrapper, rule, count, args)) {
                String triggeredParam = "";
                if (args.length > rule.getParamIdx()) {
                    Object value = args[rule.getParamIdx()];
                    // Assign actual value with the result of paramFlowKey method
                    if (value instanceof ParamFlowArgument) {
                        value = ((ParamFlowArgument) value).paramFlowKey();
                    }
                    triggeredParam = String.valueOf(value);
                }
                // 打开熔断器,指定参数被熔断
                List<ParamDegradeItem> items = rule.getParamDegradeItemList();
                for (ParamDegradeItem item : items) {
                    ParamDegradeWrapper wrapper = new ParamDegradeWrapper(resourceWrapper.getName(), rule.getParamIdx(), triggeredParam, item.getClassType());
                    circuitBreaker.put(wrapper, new BreakTime(System.currentTimeMillis(), rule.getRecoveryTimeoutMs()));
                }
                throw new ParamDegradeException(resourceWrapper.getName(), triggeredParam, rule);
            }
        }

    }

这段代码中的主要内容在循环体内,ParamDegradeRuleManager.getRulesOfResource()拿出所有的熔断规则之后,开始遍历其中的rules依次判断,检查哪个resource达到了熔断的条件,如果触发了熔断条件,则执行熔断逻辑。

在检查是否触发熔断条件之前,有一个条件语句,用来判断当前的resource是否包含在熔断器内,

if (!circuitBreaker.isEmpty() && args.length > 0) {
	checkCircuitBreaker(resourceWrapper, rule, rule.getParamDegradeItemList(), args);
}

如下代码属于核心逻辑

private void checkCircuitBreaker(ResourceWrapper resourceWrapper, ParamDegradeRule rule, List<ParamDegradeItem> items, Object[] args) throws ParamDegradeException {
        if (!items.isEmpty()) {
            for (ParamDegradeItem item : items) {
                Integer paramIdx = rule.getParamIdx();
                if (paramIdx >= args.length) {
                    throw new IllegalArgumentException("参数长度有误,需要熔断的参数idx超过传入的参数长度");
                }
                // 拿出当前请求的参数
                String currentArg = String.valueOf(args[paramIdx]);
                ParamDegradeWrapper wrapper = new ParamDegradeWrapper(resourceWrapper.getName(), paramIdx, currentArg, item.getClassType());
                BreakTime breakTime = circuitBreaker.get(wrapper);
                // 当前对象取值不为空,且当前参数在熔断范围内,执行熔断
                if (breakTime != null && wrapper.getValue().equals(currentArg)) {
                    if (breakTime.getStartTime() + breakTime.getDuration() > System.currentTimeMillis()) {
                        throw new ParamDegradeException(resourceWrapper.getName(), item.getObject(), rule);
                    } else {
                        circuitBreaker.remove(wrapper);
                    }
                }
            }
        }
    }

代码非常精简

主要是将当前资源封装成自定的资源包装器ParamDegradeWrapper,然后从熔断器的Map里面尝试获取元素,如果能够获取到元素,并且当前资源在熔断时间内,那么进行熔断操作,否则说明当前资源不需要熔断。从map集合中移除该元素。

熔断器看完之后,再回头看performChecking()方法中的其他内容。接着往下看,有一个条件语句!ParamDegradeChecker.passCheck(resourceWrapper, rule, count, args)的作用是判断当前资源是否达到的熔断条件,如果判断为true那么说明判断成功,达到熔断条件,此时就需要将当前资源封装成自定义的资源包装器ParamDegradeItem,并将该资源添加进自定义的熔断器集合circuitBreaker。同时拒绝本次访问,具体措施为抛出异常throw new ParamDegradeException(resourceWrapper.getName(), triggeredParam, rule);

至此,我们自定义的熔断器来进行指定参数熔断的功能就完成了,下面是对我们的熔断器进行测试

4.测试

测试方法完整代码如下

public class ParamDegradeTest {
    private static final String RESOURCE_NAME = "paramResource";

    public static void main(String[] args) {

        paramDegrade();


        ExecutorService executor = Executors.newFixedThreadPool(2);

        // 创建第一个线程
        Runnable task1 = () -> {
            while (true) {
                handleRequest("paramA");
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };

        // 创建第二个线程
        Runnable task2 = () -> {
            while (true) {
                handleRequest("paramB");
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };

        // 提交任务到线程池
        executor.execute(task1);
        executor.execute(task2);

        // 关闭线程池,这将使得之前提交的任务执行完毕后,线程池关闭
        executor.shutdown();
    }

    private static void paramDegrade() {
        ParamDegradeRule rule = new ParamDegradeRule(RESOURCE_NAME)
                .setParamIdx(0)
                .setRecoveryTimeoutMs(3000)
                .setCount(5);
        // 针对 int 类型的参数 PARAM_B,单独设置限流 QPS 阈值为 10,而不是全局的阈值 5.
        ParamDegradeItem item = new ParamDegradeItem().setObject("paramA")
                .setClassType(String.class.getName())
                .setCount(2);
        rule.setParamDegradeItemList(Collections.singletonList(item));

        ParamDegradeRuleManager.loadRules(Collections.singletonList(rule));
    }


    private static void handleRequest(String param) {
        ContextUtil.enter(RESOURCE_NAME);
        Entry entry = null;
        try {
            SphU.entry(RESOURCE_NAME, EntryType.IN, 1, param);
            // 业务代码
            System.out.println("正常" + param + "==>" + "时间" + System.currentTimeMillis());
        } catch (BlockException ex) {
            System.out.println("熔断: " + param + "==>" + System.currentTimeMillis());
        } finally {
            if (entry != null) {
                entry.exit(1, param);
            }
        }
    }
}

先看main方法

主方法中主要有两件事

1.定义熔断规则

paramDegrade();

2.开辟线程池,定义两个线程,同时执行一段相同的业务代码,这两个线程执行的业务代码逻辑相同,唯一的不同点是所携带的参数内容不同,分别为paramA和paramB并且执行频率为300ms一次(即QPS为3左右)

先看下熔断规则是如何定义的,代码如下

private static void paramDegrade() {
        ParamDegradeRule rule = new ParamDegradeRule(RESOURCE_NAME)
                .setParamIdx(0)
                .setRecoveryTimeoutMs(3000)
                .setCount(5);
        // 针对 int 类型的参数 PARAM_B,单独设置限流 QPS 阈值为 10,而不是全局的阈值 5.
        ParamDegradeItem item = new ParamDegradeItem().setObject("paramA")
                .setClassType(String.class.getName())
                .setCount(2);
        rule.setParamDegradeItemList(Collections.singletonList(item));

        ParamDegradeRuleManager.loadRules(Collections.singletonList(rule));
    }

下面我将对参数进行详细讲解

ParamDegradeRule相当于全局配置,如果没有对指定的某个参数配置规则,那么当请求QPS到达5之后,无论请求的是什么参数都将会熔断。

ParamDegradeItem相当于配置全局参数的例外参数,该参数当请求QPS到达2的时候就执行了熔断操作,熔断条件更为苛刻。

ParamDegradeRule有一个构造函数,参数名是自定义的资源名,由于在业务代码中也用到了资源名。所以将资源名抽取成全局变量private static final String RESOURCE_NAME = "paramResource";

setParamIdx是指定第几个参数进行熔断,下标从0开始。本例中我们熔断地的是第一个参数。

setRecoveryTimeoutMs是当前参数的熔断时长为多少,单位为毫秒,本例中我们的熔断时长为3秒,3秒后解除熔断保护。

setCount是值需要触发熔断的保护条件阈值为多少,本例中的条件为QPS为5(也可以拓展为线程数,读者自行拓展)。

ParamDegradeItem设置例外参数

setObject("paramA"):对paramA这个参数做单独保护

setClassType(String.class.getName()):paramA的参数类型为String

setCount(2):paramA触发熔断保护的QPS为2

规则讲解完毕之后我们看业务代码

业务代码在两个线程中执行,其中一个线程代码如下,另一个线程除了传入参数不同,其他逻辑没什么不同

Runnable task1 = () -> {
            while (true) {
                handleRequest("paramA");
                try {
                    Thread.sleep(300);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        };

业务代码handleRequest("paramA");的方法体如下

 private static void handleRequest(String param) {
        ContextUtil.enter(RESOURCE_NAME);
        Entry entry = null;
        try {
            SphU.entry(RESOURCE_NAME, EntryType.IN, 1, param);
            // 业务代码
            System.out.println("正常" + param + "==>" + "时间" + System.currentTimeMillis());
        } catch (BlockException ex) {
            System.out.println("熔断: " + param + "==>" + System.currentTimeMillis());
        } finally {
            if (entry != null) {
                entry.exit(1, param);
            }
        }
    }

业务代码中主要通过SphU.entry(RESOURCE_NAME, EntryType.IN, 1, param);来执行。对这行代码不熟悉的小伙伴直接求助于chatgpt

SphU.entry(RESOURCE_NAME, EntryType.IN, 1, param); 的含义:

  1. SphU.entry():这是 Sentinel 提供的 API,用于标记一个资源开始访问,它会返回一个 Entry 对象。如果当前访问被限制(例如超出了流量限制),则会抛出 BlockException
  2. RESOURCE_NAME:资源名称,它是一个字符串,用于标识一个具体的资源。例如,你可能会为每一个 HTTP API 路径定义一个不同的资源名称。
  3. EntryType.IN:标识流量的类型。在 Sentinel 中,主要有两种流量类型:INOUTIN 通常表示进入的流量(例如请求进来的流量),而 OUT 表示出去的流量(例如对外部服务的调用)。
  4. 1:表示一个请求。在大多数情况下,这个值都是 1,代表一个请求。但是在某些情境下,如批量请求,这个值可能会大于 1。
  5. param:这是一个可选参数,用于传入一些附加的参数信息。这在某些高级的流量控制策略中可能会用到,例如当你需要基于参数来做流量控制时。

总的来说,这段代码的作用是标记一个资源开始被访问,并且这个访问是进入的流量,数量为 1,同时还传入了一些附加参数。如果当前资源的访问超出了设置的流量控制规则,那么 SphU.entry() 会抛出异常。

规则定义完成,业务代码check完成。执行main方法

观察控制台输出
在这里插入图片描述

从控制台输出观察到,paramA参数被熔断,paramB参数正常方法,请求直接被放行。计算一下paramA最后一次请求正常的时间和相邻的最后一次熔断熔断时间分别为1696920565241和1696920562114之差为3127ms

至此,改造与测试均完成

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

O_OMoon

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

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

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

打赏作者

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

抵扣说明:

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

余额充值