基于Spring Boot的Java在线编译运行工具

 

目录

项目运行流程

 

程序运行流程图如下
在这里插入图片描述
接下来开始具体分析每一步的实现方法

一个Java程序是怎样运行起来的

想要实现在线运行Java代码的需求,我们首先需要了解Java程序正常的编译和运行流程。

  • 首先源代码文件(.java)经由编译器编译成字节码
    • 例如JDK中的javac命令就是实现字节码生成技术的程序
  • 接下来有Java虚拟机解释并运行字节码文件,运行过程有分为两个步骤
    • 类的加载
      • 应用程序运行后,系统会启动一个虚拟机进程。JVM进程在类的加载阶段首先会通过一个类的全限定类名获取定义此类的二进制字节流,然后将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,并且在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。
      • 类加载的相关的内容比较复杂,生成对应的Class对象后还会进行验证、准备、解析、初始化等一系列步骤才算加载完成,但考虑到篇幅问题这里就不再展开说明了。
    • 类的执行
      • 当类加载完成后JVM就可以找到main方法执行了。
      • 本项目中使用反射来完成这一步骤。

明确了以上步骤后,我们发现有三个问题需要解决:

  • 如何编译提交到服务器的Java代码?
    • 在本地运行Java代码的时候我们可以选用Javac命令编译。对于本项目而言,这种方式需要我们先将源代码写入一个.java文件,再编译得到.class文件。但是这样一来不仅非常耗时,而且还会生成额外的文件,导致服务器环境被污染。因此我们选择使用JDK1.6以后添加的动态编译API来解决这一问题。
  • 如何执行编译之后的代码?
    • 一段程序往往不是编写、运行一次就能达到效果的。同一个类可能需要反复的修改、提交、运行。另外,提交的类也要能访问服务端的其他类库才行,对于这一问题,需要我们自己编写类加载器来实现需求。
  • 如何收集Java代码的执行结果?
    • 我们需要把程序向标准输出(System.out)和标准错误输出(System.err)中打印的信息收集起来返回给客户端。但是标准输出设备是整个虚拟机进程全局共享的资源。如果使用System.setOut()/System.setErr()方法将输出流重定向到自己定义的PrintStream上固然可以收集信息,但在多线程情况下这样会连带其他线程的信息一起收集了,这显然不是我们希望看到的。因此我们选择将程序中的System替换为我们自己写的HackSystem类。

也就是说,我们的重点在于实现编译模块和运行模块。 在理清以上思路后,我们就可以正式开始代码的编写了。

Spring Boot相关

在正式开始编码前还要罗嗦一下,本项目选择使用Spring Boot仅仅是看中了它在开发web应用时的方便、快捷,项目中并不会涉及太多框架方面的知识。
如果对于Spring Boot的自动配置原理感兴趣,可以阅读下笔者写的另一篇文章,记录了笔者对于Spring Boot自动配置原理的一些粗浅认识,欢迎各位大神斧正。

编译模块:compile

使用动态编译的方式可以直接在内存中对一个Java程序进行编译并输出到内存中,提高程序运行效率的同时还不会污染服务器环境,可谓一举两得。具体实现步骤如下。

动态编译

关于动态编译的API全部放在javax.tools包下,本项目中主要涉及到的类和接口如下所示:

  • 编译器:
    • JavaCompiler
    • ToolProvider
  • 源代码文件:
    • JavaFileObject
    • SimpleJavaFIleObject
  • 文件管理器:
    • JavaFileManager
    • StandardJavaFileManager
    • ForwardingJavaFileManager
  • 收集诊断信息:
    • DiagnosticListener
    • DiagnosticCollector

接下来开始具体介绍实现动态编译的步骤

准备编译器对象

只有一种方法:

//获取Java语言编译器
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
//开始执行编译,通过传入自己的JavaFileManager为编译器创建存放字节码的JavaFIleObject对象
Boolean result = compiler.getTask(null,javaFileManager,compileCollector,
                null,null, Arrays.asList(sourceJavaFileObject)).call();

关于ToolProvider这里有一个坑,如果使用的是OpenJDK,tools.jar文件是放在%JAVA_HOME%/lib下的,运行起来就会报空指针异常。因为启动java的目录默认是%JAVA_HOME%/jre/bin/java.exe,这个目录的lib目录为%JAVA_HOME%/jre/lib,里面没有tools.jar。因此要么把文件拷到指定的lib下,要么干脆使用Oracle JDK也是一切正常。

可以看到执行编译这个方法要填一大堆参数,这些参数就是我们实现在内存中编译源代码的关键。
API中对于这个方法参数的解释如下

JavaCompiler.CompilationTask getTask(Writer out,
                                     JavaFileManager fileManager,
                                     DiagnosticListener<? super JavaFileObject> diagnosticListener,
                                     Iterable<String> options,
                                     Iterable<String> classes,
                                     Iterable<? extends JavaFileObject> compilationUnits)
  • out - 用于编译器的附加输出; 如果为null使用的就是使用System.err
  • fileManager - 文件管理器; 如果null使用编译器的标准文件管理器
  • diagnosticListener - 诊断信息收集器; 如果为null则使用编译器的默认方法来报告诊断
  • options - 编译器选项, null表示没有选项
  • classes - 通过注释处理类的名称, null表示没有类名
  • compilationUnits - 编译单元, null表示无编译单位

接下来我们依次来解释这些参数。

Iterable<? extends JavaFileObject> compilationUnits

这个参数将许多等待编译的源文件装进一个集合,交由编译器进编译。由于JDK提供的FIleObject及其子类或者子接口都无法直接使用,因此我们需要通过继承SimpleJavaFileObject来自定义我们的JavaFileObject。为了明确SimpleJavaFIleObject中有哪些方法是必须重写的,我们需要了解getTask()方法的运行过程。流程图如下:
在这里插入图片描述
1、 首先调用getCharContent方法获取需要编译的源代码,源码如下:

public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
  try {
    return clientFileObject.getCharContent(ignoreEncodingErrors);
  } catch (ClientCodeException e) {
    throw e;
  } catch (RuntimeException e) {
    throw new ClientCodeException(e);
  } catch (Error e) {
    throw new ClientCodeException(e);
  }
}

2、编译器将得到的源码进行编译,并将得到的字节码放入一个JavaFileObject对象。这一步通过调用我们传入的MyJavaFileManager对象的getJavaFileForOutput方法得到JavaFileObject对象。这一步就成功将存放字节码的JavaFileObject替换为我们自己指定的MyJavaFileObject了。
3、编译器通过MyJavaFileObject的openOutputStream方法创建输出流对象,将编译得到的字节码存入这个输出流中。
4、想要执行编译好的字节码文件我们还需要在MyJavaFileObject中定义一个方法将输出流中的内容转为byte[]字节数组。

所以,我们实现的MyJavaFileObject需要重写如下方法:

/**
 * 用于封装表示源码与字节码的对象
 */
public static class MyJavaFileObject extends SimpleJavaFileObject{
    private String source;
    private ByteArrayOutputStream byteArrayOutputStream;

    /**
     * 构造用于存放源代码的对象
     */
    public MyJavaFileObject(String name,String source){
        super(URI.create("String:///" + name + Kind.SOURCE.extension),Kind.SOURCE);
        this.source = source;
    }
    /**
     * 构建用于存放字节码的JavaFileObject
     */
    public MyJavaFileObject(String name,Kind kind){
        super(URI.create("String:///" + name + Kind.SOURCE.extension),kind);
    }

    /**
     * 获取源代码字符序列
     */
    @Override
    public CharSequence getCharContent(boolean ignoreEncodingErrors) throws IOException {
        if(source == null)
            throw new IllegalArgumentException("source == null");
        return source;
    }

    /**
     * 得到JavaFileObject中用于存放字节码的输出流
     */
    @Override
    public OutputStream openOutputStream() throws IOException {
        byteArrayOutputStream = new ByteArrayOutputStream();
        return byteArrayOutputStream;
    }

    /**
     * 将输出流的内容转化为byte数组
     */
    public byte[] getCompiledBytes(){
        return byteArrayOutputStream.toByteArray();
    }
}

JavaFileManager fileManager

文件管理器对象实现如下:

public static class MyJavaFileManager extends ForwardingJavaFileManager<JavaFileManager>{
    public MyJavaFileManager(JavaFileManager javaFileManager){
        super(javaFileManager);

    }

    /**
     * 这个方法的作用是从指定位置读取Java程序并生成JavaFIleObject对象供编译器使用,本项目中用处不大
     */
    @Override
    public JavaFileObject getJavaFileForInput(Location location, String className, JavaFileObject.Kind kind) throws IOException {
        JavaFileObject javaFileObject = fileObjectMap.get(className);
        if(javaFileObject == null){
            return super.getJavaFileForInput(location,className,kind);
        }
        return javaFileObject;
    }

    @Override
    public JavaFileObject getJavaFileForOutput(Location location, String className, JavaFileObject.Kind kind, FileObject sibling) throws IOException {
        JavaFileObject javaFileObject = new MyJavaFileObject(className,kind);
        fileObjectMap.put(className,javaFileObject);
        return javaFileObject;
    }

}

可以通过如下方法获取到我们自定义的MyJavaFileManager

//获取Java语言编译器的标准文件管理器实现的新实例
JavaFileManager j = compiler.getStandardFileManager(compileCollector,null,null);
//将文件管理器传入FordingJavaFileManager的构造器,获得一个我们自定义的JavaFileManager
JavaFileManager javaFileManager = new MyJavaFileManager(j);

DiagnosticListener<? super JavaFileObject> diagnosticListener

这个可以直接用JDK提供的DiagnosticCollector

DiagnosticCollector<JavaFileObject> compileCollector = new DiagnosticCollector<>();

Iterable options

在使用javac命令时可选的参数:

List<String> options = new ArrayList<>();
options.add("-target");
options.add("1.8");
options.add("-d");
options.add("/");

Writer out & Iterable classes

意义不明…直接添null就行

实现编译器

最终写成的编译器如下所示:

/**
 * 自定义编译模块
 */
public class StringSourceCompiler {
    private static Map<String,JavaFileObject> fileObjectMap = new ConcurrentHashMap<>();

    //预编译正则表达式,用来匹配源码字符串中的类名
    private static Pattern CLASS_PATTERN = Pattern.compile("class\\s+([$_a-zA-Z][$_a-zA-Z0-9]*)\\s*");

    public static byte[] compile(String source, DiagnosticCollector<JavaFileObject> compileCollector){
        //获取Java语言编译器
        JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();

        /**
         * 通过以下两步获得自己的JavaFIleManager
         */
        //获取Java语言编译器的标准文件管理器实现的新实例
        JavaFileManager j = compiler.getStandardFileManager(compileCollector,null,null);
        //将文件管理器传入FordingJavaFileManager的构造器,获得一个我们自定义的JavaFileManager
        JavaFileManager javaFileManager = new MyJavaFileManager(j);

        //从源码字符串中匹配类名
        Matcher matcher = CLASS_PATTERN.matcher(source);
        String className;
        if(matcher.find()) {
            className = matcher.group(1);
        }else {
            throw new IllegalArgumentException("No valid class");
        }

        //将源码字符串封装为一个sourceJavaFileObject,供自定义的编译器使用
        JavaFileObject sourceJavaFileObject = new MyJavaFileObject(className,source);
        /**
         * 1、编译器得到源码,进行编译,得到字节码,源码封装在sourceJavaFIleObject中
         * 2、通过调用JavaFileManager的getJavaFileForOutput()方法创建一个MyJavaFileObject对象,用于存放编译生成的字节码
         *       |----->然后将存放了字节码的JavaFileObject放在Map<className,JavaFileObject>中,以便后面取用。
         * 3、通过类名从map中获取到存放字节码的MyJavaFileObject
         * 4、通过MyJavaFileObject对象获取到存放编译结果的输出流
         * 5、调用getCompiledBytes()方法将输出流内容转换为字节数组
         */
        //开始执行编译,通过传入自己的JavaFileManager为编译器创建存放字节码的JavaFIleObject对象
        Boolean result = compiler.getTask(null,javaFileManager,compileCollector, null,null, Arrays.asList(sourceJavaFileObject)).call();
        //3、
        JavaFileObject byteJavaFileObject = fileObjectMap.get(className);
        if(result && byteJavaFileObject != null){
            //4、5、
//            System.out.print("获取到字节码数组");
//            String bytes = new String(((MyJavaFileObject)byteJavaFileObject).getCompiledBytes());
//            System.out.print(bytes);
            return ((MyJavaFileObject)byteJavaFileObject).getCompiledBytes();
        }
        return null;
    }

    /**
     * 用于管理JavaFileObject
     */
    public static class MyJavaFileManager extends ForwardingJavaFileManager<JavaFileManager>{
        ...
    }

    /**
     * 用于封装表示源码与字节码的对象
     */
    public static class MyJavaFileObject extends SimpleJavaFileObject{
        ...
    }
}

运行模块:execute

实现步骤分为以下几步:

  • 创建类加载器
  • 修改Class文件中常量池常量部分的内容
  • 实现HackSystem类
  • 创建JavaClassExecuter类作为外部调用的入口

下面开始对每一步骤进行讲解

HotSwapClassLoader

首先来看一下系统是如何判断两个类是否相等的:
1、 是同一个.class文件
2、 被同一个JVM加载
3、 被同一个类加载器加载
前面已经提到过,为了能让客户端传来的代码在不修改类名的情况下被多次执行,我们必须要让系统判定两次提交的代码不是同一个类。在以上三条条件中第三条是最好被绕过的,我们只需要在客户端每提交一次代码时都为其新建一个类加载器就可以实现热加载。
但这里还有一个问题,我们希望这个类中调用的其他类库方法还是依照双亲委派模型来加载
最终代码实现如下:

/**
 * 为了多次载入执行类而加入的加载器
 * 设计一个loadByte()方法将defineClass()方法开放出来,只有我们调用loadByte()方法时才使用自己的类加载器
 * 虚拟机调用HotSwapClassLoader时还是按照双亲委派模型使用loadClass方法进行类加载
 */
public class HotSwapClassLoader extends ClassLoader{
    //使用指定的父类加载器创建一个新的类加载器进行委派
    public HotSwapClassLoader(){
        super(HotSwapClassLoader.class.getClassLoader());
    }
    public Class loadByte(byte[] classByte){
        return defineClass(null,classByte,0,classByte.length);
    }
}

ClassModifier & ByteUtils

这两个类实现将java.lang.System和java.util.Scanner替换为我们自己指定的HackSystem和HackScanner的过程。思路是直接修改Class文件格式的byte[]数组中的常量池部分,并将对byte[]数组的操作方法封装到ByteUtils中。

关于常量池部分的知识可以阅读《深入理解Java虚拟机》中第六章6.3节的内容,或者可以阅读xinjing_wangtao大神的Class文件中的常量池详解(上)以及Class文件中的常量池详解(下)这两篇博文。
在这里只简单介绍一下我们会用到的相关内容

常量池

常量池从Class文件的第9个字节开始,也就是常量池入口。第9、10个字节记录了常量池中常量的个数constant_pool_count。要注意这个计数从1开始而非从0开始,因此如果cpc为22则说明常量池中有21项常量,索引值为1~21

常量池中主要存放两大类常量:字面量以及符号引用
字面量比较接近Java语言中常量的概念,如文本字符串、声明为final的常量值等
而符号引用则属于编译原理方面的概念,包括以下三种常量:

  • 类和接口的全限定类名
  • 字段的名称和描述符
  • 方法的名称和描述符

常量池中的每一项常量都是一个表,共有14中不同结构的表。这14中文表都有一个共同的特点,就是表的一开始是一个u1(一个字节)类型的标志位,代表当前常量属于那种常量类型。

本项目中仅需要了解CONSTANT_Class_infoCONSTANT_Utf8_info即可。
这两种常量项的结构如下:

  • CONSTANT_Class_info
项目类型描述
tagu1值为7
indexu2指向全限定类名的索引

其中index指向常量池中的一个CONSTANT_Utf8_info类型常量所在的索引值

  • CONSTANT_Utf8_info
项目类型描述
tagu1值为1
lengthu2UTF-8编码字符串占用的字节数
bytesu1 长度不确定长度为length的UTF-8编码的字符串

了解了这些知识我们就可以着手将值为java/lang/System和java/util/Scanner的CONSTANT_Utf8_info的常量修改为我们的类的全限定类名。

实现

public class ClassModifier {
    //Class文件中常量池的起始偏移
    private static final int CONSTANT_POOL_COUNT_INDEX = 8;
    //CONSTANT_Utf8_info 常量的tag标志
    private static final int CONSTANT_Utf8_info = 1;
    /**
     * 记录常量池中11种常量的长度,CONSTANT_Utf8_info除外,因为它不是定长的。
     * 此外,CONSTANT_MethodType_info,CONSTANT_MethodHandle_info,CONSTANT_InvokeDynamic_info
     * 这三项只在极特别的情况会用到,因此长度标识为-1
     */
    private static final int[] CONSTANT_ITEM_LENGTH = {-1, -1, -1, 5, 5, 9, 9, 3, 3, 5, 5, 5, 5};
    /**
     * u1代表tag,u2代表length
     */
    private static final int u1 = 1;
    private static final int u2 = 2;

    private byte[] classByte;
    public ClassModifier(byte[] classByte){
        this.classByte = classByte;
    }
    /**
     * 获取常量池中常量的数量
     * 返回值为常量池中项数+1
     */
    public int getConstantPoolCount(){
        return ByteUtils.bytes2Int(classByte,CONSTANT_POOL_COUNT_INDEX,u2);
    }
    /**
     * 修改常量池中CONSTANT_Utf8_info常量的内容
     */
    public byte[] modifyUTF8Constant(String oldStr,String newStr){
        int cpc = getConstantPoolCount();
        //常量池入口位置
        int offset = CONSTANT_POOL_COUNT_INDEX + u2;
        for(int i = 1;i < cpc;i++){//注意索引要从1开始
            int tag = ByteUtils.bytes2Int(classByte,offset,u1);//将字节数组所表示的值转为int值
            if(tag == CONSTANT_Utf8_info){//如果tag与CONSTANT_Utf8_info常量的tag值相等
                //计算CONSTANT_Utf8_info中内容的长度
                int len = ByteUtils.bytes2Int(classByte,offset + u1,u2);
                //下标移动到CONSTANT_Utf8_info的内容部分
                offset += u1 + u2;//还记得前面说的吗?tag为u1,length为u2
                //读取内容
                String str = ByteUtils.bytes2String(classByte,offset,len);
                if(str.equals(oldStr)){
                    //获取用字节数组表示的指定的新字符串的内容以及长度,并替换到classByte中的相应位置
                    byte[] strBytes = ByteUtils.string2Bytes(newStr);
                    byte[] strLen = ByteUtils.int2Bytes(strBytes.length,u2);
                    classByte = ByteUtils.byteReplace(classByte,offset - u2,u2,strLen);
                    classByte = ByteUtils.byteReplace(classByte,offset,len,strBytes);
                    /**
                     * 因为修改的是CONSTANT_Utf8_info的值,.class文件中所有全限定类名为java/lang/System
                     * 的常量的index索引都指向同一个CONSTANT_Utf8_info,所以只需要修改一次即可返回
                     */
                    return classByte;
                }else {
                    offset += len;
                }
            }else{
                offset += CONSTANT_ITEM_LENGTH[tag];
            }
        }
        return classByte;
    }
}

HackSystem & HackScanner

前面已经提过了为什么要替换System类,这里来实现相应的HackSystem以及HackScanner

不过在这之前还有一个问题需要解决。在System中有三个共有属性,其中out和err都是PrintStream
PrintStream的作用是修饰其他输出流,为其他输出流扩展了许多功能,是他们能够方便地打印各种数据值的表示。但是因为PrintStream只修饰了一个输出流,一旦出现多个线程同时向这个输出流写入内容的情况就会出现线程安全的问题。因此PringStream中对所有操作输出流的部分都进行了同步

public void write(byte buf[], int off, int len) {
    try {
        synchronized (this) {
            ensureOpen();
            out.write(buf, off, len);
            if (autoFlush)
                out.flush();
        }
    }
    catch (InterruptedIOException x) {
        Thread.currentThread().interrupt();
    }
    catch (IOException x) {
        trouble = true;
    }
}

也就是说,PrintStream的本质是将多个输出格式化后写入到一个流中。如果仅在HackSystem中将out(err同理)改为
public final static PrintStream out = new PrintStream(new ByteArrayOutputStream());
的话是无法满足我们同时运行多个客户端的需求的。因此我们还需要将HackSystem的PrintStream out以及PrintStream err的本质替换为我们所写的HackPrintStream实例。

HackSystem

这个类看起来方法很多,但实际上只是将out和err属性的实际类型改为我们所写的HackPrintStream对象。除此之外还新增了两个方法用来关闭当前线程的输入输出流以及获取输出流中的内容。

/**
 * 获取当前线程输出流中的内容
 */
public static String getBufferString(){
    return ((HackPrintStream)out).toString();
}

/**
 * 关闭当前线程的输入输出流
 */
public static void closeBuffer(){
    ((HackInputStream)in).close();
    out.close();
}

然后对一些危险的方法进行修改,比如:

public static void exit(int status){
    throw new SecurityException("Use hazardous method: System.exit().");
}

对于其余的方法直接在内部调用System:

public static long currentTimeMillis(){
    return System.currentTimeMillis();
}

HackPrintStream

HackPrintStream首先还得是一个PrintStream,因此要继承PrintStream。
为了能让HackPrintStream支持多线程,我们使用Threadlocal为每一个线程都创建一个私有的输出流来保存输出内容。

private ThreadLocal<ByteArrayOutputStream> out;
private ThreadLocal<Boolean> trouble;//每个标准输出流执行过程中是否抛异常

ThreadLocal 实现原理:

  • 每一个 ThreadLocal 都有一个唯一的的 ThreadLocalHashCode;
  • 每一个线程中有一个专门保存这个 HashCode 的 Map<ThreadLocalHashCode, 对应变量的值>;
  • 当 ThreadLocal#get() 时,实际上是当前线程先拿到这个 ThreadLocal 对象的 ThreadLocalHashCode,然后通过这个 ThreadLocalHashCode 去自己内部的 Map 中去取值。
    • 即每个线程对应的变量不是存储在 ThreadLocal 对象中的,而是存在当前线程对象中的,线程自己保管封存在自己内部的变量,达到线程封闭的目的。
    • 也就是说,ThreadLocal 对象并不负责保存数据,它只是一个访问入口。

因为使用了Threadlocal,我们还需要重写所有对流进行操作的方法。详见HackPrintStream.java
同理,还应将InputStream替换为HackInputStream
最后,既然替换了InputStream那就把Scanner也替换一下吧。HackScanner不需要做什么变动,只需要将参数为InputStream的构造函数修改一下即可,其余的就整个复制Scanner吧。。。

/**
 * 修改一下这个构造函数,判断是否传入了HackInputStream,是的话就调用一下Threadlocal的get方法
 */
public HackScanner(InputStream source) {
    this(new InputStreamReader(
            (source instanceof HackInputStream) ? ((HackInputStream) source).get() : source),
            WHITESPACE_PATTERN);
}

JavaClassExecuter

在这个类中只有一个execute()方法,用输入的复合Class文件格式的byte[]数组替换java.lang.System以及java.util.Scanner的所有符号引用

//替换System和Scanner
byte[] modiBytes = classModifier.modifyUTF8Constant("java/lang/System", "cn/gq/executerdemo/execute/HackSystem");
modiBytes = classModifier.modifyUTF8Constant("java/util/Scanner", "cn/gq/executerdemo/execute/HackScanner");

使用HotSwapClassLoader加载生成一个Class对象

HotSwapClassLoader classLoader = new HotSwapClassLoader();
Class clazz = classLoader.loadByte(modiBytes);

以反射的方式调用这个Class对象的main()方法,运行期间出现任何异常就将异常信息打印到HackSystem.out中,最后将结果返回

try{
    Method mainMethod = clazz.getMethod("main",new Class[] {String[].class});
    mainMethod.invoke(null,new String[]{null});
}catch (Throwable e){
    e.printStackTrace(HackSystem.out);
}

当然别忘了关输出流

HackSystem.closeBuffer();

大功告成,最后再编写相应的Controller以及Service即可

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值