导购场景下的Groovy脚本引擎实战

1.前言

因为在项目中使用了Groovy对业务能力进行一些扩展,效果比较好,所以记录分享一下,这里你可以了解:

  • 为什么使用脚本语言

  • 为什么选择Groovy

  • 如何在项目中集成Groovy

  • Groovy的原理是什么和性能优化

  • 实际使用的一些建议

2.为什么使用脚本语言

随着产品迭代、更新的速度越来越快,个性化需求也是越来越多,如:营销活动的查询与展示、商品优惠标签的透出、购物车各种优惠金额计算规则等。办法通常有如下几个方面:

  • 最常见的方式是用代码枚举所有情况,即所有查询维度、所有可能的规则组合通过策略模式进行匹配

  • 使用动态脚本引擎,例如Groovy,Python、JavaScript等。

引入动态脚本引擎对业务进行抽象可以满足定制化需求,大大提升项目效率。例如,现在开发的营销活动中,利用脚本引擎的动态解析执行,使用规则脚本将查询条件以及下发策略抽象出来,提升开发效率。

3.为什么选择Groovy

3.1.Groovy简介

Groovy是构建在JVM上的一个轻量级却强大的动态语言, 它结合了Python、Ruby和Smalltalk的许多强大的特性.

Groovy就是用Java写的 , Groovy语法与Java语法类似, Groovy 代码能够与 Java 代码很好地结合,也能用于扩展现有代码, 相对于Java, 它在编写代码的灵活性上有非常明显的提升,Groovy 可以使用其他 Java 语言编写的库.

3.2Groovy的优势

选型时需要考虑性能、稳定性、灵活性,综合考虑后选择Groovy,有如下几点原因:

  • 学习曲线平缓,有丰富的语法糖,对于Java开发者非常友好;

  • 技术成熟,可以无缝衔接Java代码,可以调用Java所有的库

  • 动态语言,用Groovy构建DSL支持较好,部署运维方便(运行在JVM上不需要其他特殊环境);

  • 优化后性能和原生JAVA接近,同时经过优化可以解决GC相关问题。

3.3.Groovy语法特性

  • 结尾不需要分号
  • 类的默认作用域是public, 不需要getter/setter方法

  • def关键字定义的变量类型都是Object, 任何变量, 方法都能用def定义/声明 , 在 Groovy 中 “一切都是对象 "

  • 导航操作符 ( ?. )可帮助实现对象引用不为空时方法才会被调用

// java
if (object != null) {
    object.getFieldA();
}
// Groovy
object?.getFieldA()
  • 命令链, Groovy 可以使你省略顶级语句方法调用中参数外面的括号。“命令链”功能则将这种特性继续扩展,它可以将不需要括号的方法调用串接成链,既不需要参数周围的括号,链接的调用之间也不需要点号

def methodA(String name) {
    println("A: " + name)
    return this
}
def methodB(String name) {
    println("B: " + name)
    return this
}
def methodC() {
    println("C")
    return this
}
def methodD(String name) {
    println("D: " + name)
    return this
}

methodA("xiaoming")
methodB("zhangsan")
methodC()
methodD("lisi")

// 不带参数的链中需要用括号 
methodA "xiaoming" methodB "zhangsan" methodC() methodD "lisi"
  • 闭包. 闭包是一个短的匿名代码块。每个闭包会被编译成继承Groovy.lang.Closure类的类,这个类有一个叫call方法,通过该方法可以传递参数并调用这个闭包.

def hello = {println "Hello World"}
hello.call()

// 包含形式参数
def hi = {
    person1, person2 -> println "hi " + person1 + ", "+ person2
}
hi.call("xiaoming", "xiaoli")

// 隐式单个参数, 'it'是Groovy中的关键字
def hh = {
    println("haha, " + it)
}
hh.call("zhangsan")
  • 数据结构的原生语法, 写法更便捷

def list = [11, 12, 13, 14] // 列表, 默认是ArrayList
def list = ['Angular', 'Groovy', 'Java'] as List // 字符串列表
// 同list.add(8)
list << 8

[1, 2, [3, 4], 5] // 嵌套列表
['Groovy', 21, 2.11] // 异构的对象引用列表
[] // 一个空列表

def set = ["22", "11", "22"] as Set // LinkedHashSet, as运算符转换类型

def map = ['TopicName': 'Lists', 'TopicName': 'Maps'] // map, LinkedHashMap

// 循环
map.each {
    print it.key
}
  • Groovy Truth 所有类型都能转成布尔值,比如null, void 对象, 等同于 0 或空的值,都会解析为false,其他则相当于true

  • Groovy支持DSL(Domain Specific Languages领域特定语言), DSL旨在简化以Groovy编写的代码,使得它对于普通用户变得容易理解

  • 借助命令链编写DSL

// Groovy代码
Map<String, String> result = new HashMap<>()

show = { result.put("rightGoods", it)}
square_root = { Math.sqrt(it) }

def please(action) {
  [the: { what ->
    [of: { n -> action(what(n)) }]
  }]
}

// DSL 语言: please show the square_root of 100  (请显示100的平方根)

// 调用, 等同于:please(show).the(square_root).of(100)
please show the square_root of 100
// ==> 10.0
return result
  • Java 的 == 实际相当于 Groovy 的 is() 方法,而 Groovy 的 == 则是一个更巧妙的 equals()。 在Groovy中要想比较对象的引用,不能用 ==,而应该用 a.is(b)

  3.4Groovy与Java的对比

  • Groovy是一门基于JVM的动态语言,同时也是一门面向对象的语言,语法上和Java非常相似。它结合了Python、Ruby和Smalltalk的许多强大的特性,Groovy 代码能够与 Java 代码很好地结合,也能用于扩展现有代码。Java作为一种通用、静态类型的编译型语言有很多优势,但同样存在一些负担:重新编译太费工; 静态类型不够灵活,重构起来时间可能比较长; 部署的动静太大; java的语法天然不适用生产dsl;

  • 相对于Java,它在编写代码的灵活性上有非常明显的提升,对于一个长期使用Java的开发者来说,使用Groovy时能够明显地感受到负身上的“枷锁”轻了。Groovy是动态编译语言,广泛用作脚本语言和快速原型语言,主要优势之一就是它的生产力。Groovy 代码通常要比 Java 代码更容易编写,而且编写起来也更快,这使得它有足够的资格成为开发工作包中的一个附件。

  • Java不是解决动态层问题的理想语言,这些动态层问题包括原型设计、脚本处理等。可以把Groovy看作给Java静态世界补充动态能力的语言,同时Groovy已经实现了java不具备的语言特性如:函数字面值、对集合的一等支持、 对正则表达式的一等支持、 对xml的一等支持

  更多参考:

  • Differences with Java: http://www.Groovy-lang.org/differences.html

    4.Groovy的原理和使用优化

    4.1Groovy原理

    所有的groovy代码都运行在JVM中并且使用的是java对象模型,不管你写的是groovy类,或者是groovy脚本,它们都作为java类在JVM中运行。 在JVM中运行groovy类有两种方式:

  • 使用groovyc编译所有的*.groovy为java的*.class文件,把这些*.class文件放在java类路径中,通过java类加载器来加载这些类。

  • 通过groovy的类加载器在运行时直接加载*.groovy文件并且生成对象,在这种方式下,没有生成任何*.class,但是生成了一个java.lang.Class对象的实例,一个MyClass的类型将被产生并且增加到类加载器中,在代码中将像从*.class一样获取到MyClass对象。

其实这一切都要归功于 Groovy 编译器,Groovy 编译器在编译 Groovy 代码的时候,并不是像 Java 一样,直接编译成字节码,而是编译成 “动态调用的字节码”

例如下面这一段 Groovy 代码

package groovy 
println("Hello World!")

当我们用Groovy编译器编译之后,就会变成

package groovy;

......

public class HelloGroovy extends Script {
    private static /* synthetic */ ClassInfo $staticClassInfo;
    public static transient /* synthetic */ boolean __$stMC;
    private static /* synthetic */ ClassInfo $staticClassInfo$;
    private static /* synthetic */ SoftReference $callSiteArray;
    ......
    public static void main(String ... args) {
        // 调用run()方法
        CallSite[] arrcallSite = HelloGroovy.$getCallSiteArray();
        arrcallSite[0].call(InvokerHelper.class, HelloGroovy.class, (Object)args);
    }

    public Object run() {
        CallSite[] arrcallSite = HelloGroovy.$getCallSiteArray();
        return arrcallSite[1].callCurrent((GroovyObject)this, (Object)"Hello World!");
    }
    ......
    private static /* synthetic */ void $createCallSiteArray_1(String[] arrstring) {
        arrstring[0] = "runScript";
        arrstring[1] = "println";
    }
    ......
}

Groovy动态方法执行原理

在Groovy中动态地注入方法、调用方法、属性就是使用元类metaClass来完成的(类似于Java的反射机制),请求的方法会被委托到这个类。

与java编译成字节码时处理方法调用不同,Groovy编译时对方法调用通用都是通过invokeMethod实现,这样提供了极强的动态方法植入能力.

所有groovy 脚本生成的class 都会实现 GroovyObject 接口,Groovy 里面所有方法调用都会通过 invokeMethod 来调用.

MetaClass and MetaClassRegistry

MetaClassRegistry 存储了所有MetaClass 包含java 的,groovy 的GroovyObject 的invokeMethod 实际是由MetaClassImpl来执行的

4.2Groovy的使用方式

Groovy在java项目中的使用方式基本上分为三种:GroovyScriptEngine、GroovyShell和GroovyClassLoader。

4.2.1 GroovyScriptEngine

GroovyScriptEngine从指定的位置(文件系统,URL,数据库,等等)加载Groovy脚本并执行

//定义FunGroove.groovy文件 
package com.chy.groovy
void print(){
    System.out.println("没有参数!!!!");
}
//执行方法
print();

 // GroovyScriptEngine的根路径,如果参数是字符串数组,说明有多个根路径
GroovyScriptEngine engine = new GroovyScriptEngine("src/main/java/com/chy/groovy/");
 Binding binding1 = new Binding();
 Object result1 = engine.run("FunGroove.groovy", binding1);
 if(null!=result1) {
   System.out.println(result1);
 }

4.2.2 GroovyShell

Groovy官方提供GroovyShell,执行Groovy脚本片段,GroovyShell每一次执行时代码时会动态将代码编译成Java Class,然后生成Java对象在Java虚拟机上执行,所以如果使用GroovyShell会造成Class太多,性能较差。

final String script = "Runtime.getRuntime().availableProcessors()";
Binding intBinding = new Binding();
GroovyShell shell = new GroovyShell(intBinding);
final Object eval = shell.evaluate(script);
System.out.println(eval);

4.2.3 GroovyClassLoader

Groovy官方提供GroovyClassLoader类,支持从文件、url或字符串中动态地加载一个脚本并执行它的行为,实例化对象,反射调用指定方法。GroovyClassLoader是一个定制的类装载器,负责解释加载Java类中用到的Groovy类。

GroovyClassLoader groovyClassLoader = new GroovyClassLoader();
  String helloScript = "package com.vivo.groovy.util" +  // groovy或者Java代码
          "class Hello {" +
            "String say(String name) {" +
              "System.out.println(\"hello, \" + name)" +
              " return name;"
            "}" +
          "}";
Class helloClass = groovyClassLoader.parseClass(helloScript);
GroovyObject object = (GroovyObject) helloClass.newInstance();
Object ret = object.invokeMethod("say", "world"); // 输出"hello, world"
System.out.println(ret.toString()); // 打印

4.3Groovy使用优化

当JVM中运行的Groovy脚本存在大量并发时,如果按照默认的策略,每次运行都会重新编译脚本,调用类加载器进行类加载。不断重新编译脚本会增加JVM内存中的CodeCache和Metaspace,引发内存泄露,最后导致Metaspace内存溢出问题

什么时候会触发Metaspace的垃圾回收?

  • Metaspace在没有更多的内存空间的时候,比如加载新的类的时候;

  • JVM内部有一个叫做_capacity_until_GC的变量,一旦Metaspace使用的空间超过这个变量的值,就会对Metaspace进行回收;

  • FGC时会对Metaspace进行回收。

就算Class数量过多,只要Metaspace触发GC,那应该就不会溢出了。为什么上面会给出Metaspace溢出的结论呢?这里引出下一个问题:

JVM回收Class对象的条件是什么

  • 该类所有的实例都已经被GC,也就是JVM中不存在该Class的任何实例;

  • 加载该类的ClassLoader已经被GC;

  • java.lang.Class对象没有在任何地方被引用。

针对上述问题,增加了对脚本语言的一个缓存的优化

private final TimedCache<String, Class<?>> groovyScriptClassCache = 
        CacheUtil.newTimedCache(1000L * 60 * 60 * 24);
private void compileScriptBinary(ScriptExecuteContext context, GroovyStandardScriptExecuteContext groovyContext) {
    Class<?> scriptClass = groovyScriptClassCache.get(context.getScriptImplDTO().getScriptSource(), true, () -> {
        try (GroovyClassLoader classLoader = initializeGroovyClassLoader()) {
            return classLoader.parseClass(context.getScriptImplDTO().getScriptSource());
        }
    });
    Assert.that(Objects.nonNull(scriptClass), "ScriptClass不能为空");
    groovyContext.setScriptClass(scriptClass);
}

4.4Groovy安全问题

Groovy会自动引入java.util,java.lang包,方便用户调用,但同时也增加了系统的风险。为了防止用户调用System.exit或Runtime等方法导致系统宕机,以及自定义的Groovy片段代码执行死循环或调用资源超时等问题,Groovy提供了SecureASTCustomizer安全管理者。

final SecureASTCustomizer groovyStandardSecureASTCustomizer;
public GroovyClassLoader initializeGroovyClassLoader() {
    CompilerConfiguration compilerConfiguration = new CompilerConfiguration();
    //自定义CompilerConfiguration,设置groovy 编译选项,
    比如设置基类,设置默认导包,安全校验AST等等等,其他自定义操作
    compilerConfiguration.addCompilationCustomizers(groovyStandardSecureASTCustomizer);
    ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
    return new GroovyClassLoader(contextClassLoader, compilerConfiguration);
}

虽然SecureASTCustomizer可以通过控制允许的代码结构来保护源代码,但是对于脚本的编写仍然存在一定的安全风险,容易造成cpu暴涨、占用磁盘空间较大等影响系统运行的问题。所以需要一些被动安全手段,比如采用线程池隔离,对脚本执行进行有效的实时监控、统计和封装等。

5.项目与Groovy的集成

5.1引入Groovy依赖

<dependency>
    <groupId>org.codehaus.Groovy</groupId>
    <artifactId>Groovy</artifactId>
    <version>3.0.9</version>
</dependency>

5.2封装脚本框架所需参数

5.2.1脚本框架上下文信息

/*** 脚本框架上下文*/
public class ScriptExecuteContext {

    /**
     * 脚本定义code
     */
    private String scriptCode;

    /**
     * 脚本版本号
     */
    private Integer version;

    /**
     * 脚本定义
     */
    private ScriptDTO scriptDTO;

    /**
     * 脚本实现
     */
    private ScriptImplDTO scriptImplDTO;

    /**
     * 脚本扩展定义
     */
    private ScriptFeatureDTO scriptFeatureDTO;

    /**
     * 脚本扩展实现
     */
    private ScriptImplFeatureDTO scriptImplFeatureDTO;

    /**
     * 脚本预约执行器
     */
    private ScriptFramework scriptFramework;

    /**
     * 脚本执行参数
     */
    private ScriptExecuteParam scriptExecuteParam;

    /**
     * 脚本执行结果
     */
    private ScriptExecuteResult scriptExecuteResult;
}

5.2.2Groovy脚本的上下文信息

/*** Groovy脚本上下文*/
public class GroovyStandardScriptExecuteContext {

    /**
     * 脚本名称
     */
    private String scriptClassName;

    /**
     * 脚本源码
     */
    private String scriptClassSource;

    /**
     * 脚本依赖的参数 log 参数
     */
    private GroovyStandardScriptSandBoxContext GroovyStandardScriptSandBoxContext;

    /**
     * 脚本依赖的参数 log
     */
    private Map<String, Object> scriptFrameworkParam;

    /**
     * 依赖的feign
     */
    private Map<String, Object> scriptFeignDependencyParam;

    /**
     * 依赖的参数 service
     */
    private Map<String, Object> scriptDependencyParam;

    /**
     * 脚本参数
     */
    private Map<String, Object> scriptParam;

    /**
     * 系统参数
     */
    private Map<String, Object> systemParam;

    /**
     * 作用域的参数和变量
     */
    private Binding scriptBinding;

    /**
     * 脚本转化的类信息
     */
    private Class<?> scriptClass;
}

 5.3执行Groovy的调用流程

 5.1.1.请求对应接口

    @PostMapping("execute")
    @ApiOperation(value = "小程序-脚本引擎调用")
    @ApiMonitor(name = "小程序-脚本引擎调用")
    public Result<?> execute(@RequestBody ScriptExecuteRequest request) {
        StopWatch stopWatch = StopWatch.createStarted();
        Date requestTime = new Date();
        Assert.notBlank(request.getScriptCode(), "ScriptCode不可为空");
            //参数map
            ScriptExecuteParam scriptExecuteParam = new ScriptExecuteParam();
            scriptExecuteParam.setParamMap(request.getScriptParam());
            BaseContextHandler.set(CommonConstants.CONTEXT_SELLER_ID, request.getSellerId());
            ScriptExecuteContext scriptExecuteContext = new ScriptExecuteContext();
            scriptExecuteContext.setScriptCode(request.getScriptCode());
            scriptExecuteContext.setScriptExecuteParam(scriptExecuteParam);
            //执行脚本
            scriptExecuteTemplate.execute(scriptExecuteContext);
            Object result = Optional.ofNullable(scriptExecuteContext.getScriptExecuteResult())
                    .map(ScriptExecuteResult::getResult)
                    .orElse(null);
            SlsEntity slsEntity = SlsEntity.builder()
                    .put("scriptCode", request.getScriptCode())
                    .put("businessStatus", "SUCCESS")
                    .put("requestTime", DateUtil.formatDateTime(requestTime))
                    .put("consumingTime", String.valueOf(stopWatch.getTime(TimeUnit.MILLISECONDS)));
            SLog.info(
                    slsEntity,
                    "{} | {} ",
                    request.getScriptCode(), "SUCCESS"
            );
            return Result.success(result);
    }

 5.1.2.执行脚本语言

public void execute(ScriptExecuteContext context){
    //加载脚本代码
    loadScript(context);
    //设置脚本代码及实现
    loadScriptFeature(context);
    loadScriptImplFeature(context);
    //加载脚本框架对应的脚本语言的实现
    loadScriptFramework(context);
    //执行脚本
    executeScript(context);
}

 5.1.2.1加载脚本代码

public void loadScript(ScriptExecuteContext context) {
    try {
        ScriptCache scriptCache = SCRIPT_CACHE.get(context.getScriptCode());
        cn.hutool.core.lang.Assert.notNull(scriptCache, "脚本代码不存在");
        if (ObjectUtil.isNull(context.getVersion())) {
            // 正式版本代码
            context.setScriptDTO(scriptCache.getScriptDTO());
            context.setScriptImplDTO(scriptCache.getScriptImplDTO());
        } else {
            // 指定版本代码
            context.setScriptDTO(scriptCache.getScriptDTO());
            context.setScriptImplDTO(getScriptImplByScriptIdAndVersion(scriptCache.getScriptDTO().getId(), context.getVersion()));
        }
    } catch (Exception e) {
        log.error("get script error params = {} ", JSON.toJSONString(context), e);
        cn.hutool.core.lang.Assert.isTrue(false, "执行脚本获取失败");
    }
}

 5.1.2.2设置脚本代码及实现

public void loadScriptFeature(ScriptExecuteContext context) {
    ScriptDTO scriptDTO = context.getScriptDTO();
    context.setScriptFeatureDTO(scriptDTO.getFeatures());
}

public void loadScriptImplFeature(ScriptExecuteContext context) {
    ScriptImplDTO scriptImplDTO = context.getScriptImplDTO();
    context.setScriptImplFeatureDTO(scriptImplDTO.getFeatures());
}

5.1.2.3加载脚本框架对应的脚本语言的实现

public void loadScriptFramework(ScriptExecuteContext context) {
    ScriptImplDTO scriptImplDTO = context.getScriptImplDTO();
    Integer scriptFrameworkId = scriptImplDTO.getScriptFrameworkId();
    ScriptFramework scriptFramework = scriptFrameworkFactory.getInstance(scriptFrameworkId);
    Assert.that(Objects.nonNull(scriptFramework), "脚本框架不存在");
    //目前只有Groovy的实现
    context.setScriptFramework(scriptFramework);
}

5.1.2.4使用对应脚本语言执行脚本代码

public void executeScript(ScriptExecuteContext context) throws ExecutionException, InterruptedException, TimeoutException {
    //设置Groovy脚本的上下文
    GroovyStandardScriptExecuteContext GroovyContext = new GroovyStandardScriptExecuteContext();
    //设置上下文的执行脚本
    computeClassSource(context, GroovyContext);
    //设置上下文执行脚本的类名
    computeClassName(context, GroovyContext);
    //加载执行脚本并转化成类信息
    compileScriptBinary(context, GroovyContext);
    //设置系统参数(header)
    computeSystemParam(context, GroovyContext);
    //设置请求参数(param)
    computeScriptParam(context, GroovyContext);
    //设置依赖属性 feign或service
    computeScriptDependencyParam(context, GroovyContext);
    //设置脚本参数 log
    computeScriptFrameworkParam(context, GroovyContext);
    //绑定作用域参数和变量
    computeScriptBinding(context, GroovyContext);
    //执行脚本并返回结果
    executeScriptBinary(context, GroovyContext);
}
private void executeScriptBinary(ScriptExecuteContext context, GroovyStandardScriptExecuteContext GroovyContext) {
    Runnable scriptRunTask = instanceScriptRunTask(context, GroovyContext);
    scriptRunTask.run();
}

private Runnable instanceScriptRunTask(ScriptExecuteContext context, GroovyStandardScriptExecuteContext GroovyContext) {
    return () -> {
        try {
            //获取转化后的Groovy文件
            Class<?> scriptClass = GroovyContext.getScriptClass();
            //获取脚本作用域的参数和变量
            Binding scriptBinding = GroovyContext.getScriptBinding();
            //获取Groovy的构建类
            Constructor<?> constructor = scriptClass.getConstructor(Binding.class);
            //初始化Groovy类实例
            Object scriptClassInstance = constructor.newInstance(scriptBinding);
            //获取Groovy中的run方法
            Method run = scriptClass.getDeclaredMethod("run");
            //执行Groovy
            Object result = run.invoke(scriptClassInstance);
            //设置返回结果
            ScriptExecuteResult scriptExecuteResult = new ScriptExecuteResult(result);
            context.setScriptExecuteResult(scriptExecuteResult);
        } catch (Exception e) {
            if (e instanceof InvocationTargetException && ((InvocationTargetException) e).getTargetException() instanceof IllegalArgumentException) {
                throw new BaseException(((InvocationTargetException) e).getTargetException().getMessage());
            } else {
                log.error(LogUtil.exceptionMessage("scriptRunTaskException", e));
                throw new BaseException("脚本执行时发生异常");
            }
        } finally {
            log.info(LogUtil.message("GroovyStandardScriptSandBoxContext", GroovyContext.getGroovyStandardScriptSandBoxContext()));
        }
    };
}

5.1.3封装返回结果

Object result = Optional.ofNullable(scriptExecuteContext.getScriptExecuteResult())
        .map(ScriptExecuteResult::getResult)
        .orElse(null);
SlsEntity slsEntity = SlsEntity.builder()
        .put("scriptCode", request.getScriptCode())
        .put("businessStatus", "SUCCESS")
        .put("requestTime", DateUtil.formatDateTime(requestTime))
        .put("consumingTime", String.valueOf(stopWatch.getTime(TimeUnit.MILLISECONDS)));
SLog.info(
        slsEntity,
        "{} | {} ",
        request.getScriptCode(), "SUCCESS"

6.总结

  • Groovy是一种动态脚本语言,适用于业务变化多又快以及配置化的需求,目前项目在导购场景上,例如:营销活动信息的动态展示、购物车优惠信息的设置、商品优惠信息和标签的设置等,除此之外,Groovy也适用于一些推荐系统的内容下发,自动化构建工具Gradle和其他一些个性化场景。

  • Groovy易上手,其本质也是运行在JVM的Java代码。可以使用Groovy在提高开发效率,加快响应需求变化,为更快的交付需求提供更好的支持。

  • 2
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值