UDF

本文详细介绍了Hive中的自定义函数,包括UDF(单行函数)、UDAF(聚合函数)和UDTF(多行函数)的开发和使用。UDF用于处理单个数据行,UDAF处理多个数据行,UDTF则能从一行数据生成多行。文章通过示例讲解了如何编写和注册自定义函数,并提供了不同类型UDF的API实现细节和注意事项。

  UDF函数用户自定义函数,在查询执行过程就是在Hive转换成MapReduce程序后,执行java方法,类似于像MapReduce执行过程中加入一个插件,方便扩展。Hive中有3种UDF:

  • UDF:操作单个数据行,产生单个数据行,如:upper、substr函数
  • UDAF:操作多个数据行,产生一个数据行,如sum、min函数
  • UDTF:操作一个数据行,产生多个数据行一个表作为输出,如 lateral view 、explode函数

  如果函数读和返回都是基础数据类型,即 Hadoop 和 Hive 的基本类型,如Text、IntWritable、LongWritable、DoubleWritable 等,那么继承 org.apache.hadoop.hive.ql.exec.UDF 。如果用来操作内嵌数据结构,如 Map、List 和 Set,则继承 org.apache.hadoop.hive.ql.udf.generic.GenericUDF。

用户构建的UDF使用过程如下:

  1. 继承UDF或者UDAF或者UDTF,实现特定的方法
  2. 将写好的类打包为jar,这里是WordTransferUDF.jar
  3. 进入到Hive shell环境中,输入命令add jar /home/hadoop/WordTransferUDF.jar注册该jar文件;或者把WordTransferUDF.jar上传到HDFS,hadoop fs -put WordTransferUDF.jar /home/hadoop/WordTransferUDF.jar,再输入命令add jar hdfs://hadoop01:8020/user/home/WordTransferUDF.jar
  4. 为该类起一个别名,create temporary function lower_udf as ‘UDF.lowerUDF’;注意,这里UDF只是为这个Hive会话临时定义的
  5. 在select中使用lower_udf()

自定义UDF

pom.xml依赖

<dependency>
    <groupId>org.apache.hive</groupId>
    <artifactId>hive-exec</artifactId>
    <version>1.2.1</version>
</dependency>
<dependency>
    <groupId>org.apache.hadoop</groupId>
    <artifactId>hadoop-common</artifactId>
    <version>2.7.3</version>
</dependency>

编写UDF代码

package UDF;
import org.apache.commons.lang.StringUtils;
import org.apache.hadoop.hive.ql.exec.UDF;
import org.apache.hadoop.io.Text;

public class WordTransferUDF extends UDF{
    /**
     * 1. Implement one or more methods named "evaluate" which will be called by Hive.
     * 2. "evaluate" should never be a void method. However it can return "null" if needed.
     */
    public Text evaluate(Text str){
        // input parameter validate
        if(null == str){
            return null ;
        }
        // validate
        if(StringUtils.isBlank(str.toString())){
            return null ;
        }
        // lower
        return new Text(str.toString().toLowerCase()) ;
    }
}

打包
注意 工程所用的jdk要与Hadoop集群使用的jdk是同一个版本
  
注册UDF

hive> add jar /home/hadoop/LowerUDF.jar
hive> create temporary function lower_udf as "UDF.LowerUDF";

测试

hive> create table test (id int ,name string);
hive> insert into test values(1,'TEST');
hive> select lower_udf(name) from test;

注意事项:

  1. 一个用户UDF必须继承org.apache.hadoop.hive.ql.exec.UDF
  2. 一个UDF必须要包含有evaluate()方法,但是该方法并不存在于UDF中。evaluate的参数个数以及类型都是用户自定义的。在使用的时候,Hive会调用UDF的evaluate()方法

GenericUDF

  GenericUDF API 提供了一种方法去处理那些不是可写类型的对象,例如:struct,map 和 array 类型。这个 API 需要用户亲自为函数的参数管理对象存储格式,验证接收的参数的数量与类型。这个 API 要求实现以下方法:

// 这个类似于简单 API 的 evaluate 方法,它可以读取输入数据和返回结果
abstract Object evaluate(GenericUDF.DeferredObject[] arguments);  
// 该方法应当是描述该 UDF 的字符串,显示函数的提示信息
abstract String getDisplayString(String[] children);  
// 只调用一次,在任何 evaluate() 调用之前,可以接收到一个可以表示函数输入参数类型的 object inspectors 数组
// 是用来验证该函数是否接收正确的参数类型和参数个数的地方
abstract ObjectInspector initialize(ObjectInspector[] arguments);  

例子来自 《Hive 编程指南》,编写一个用户自定义函数,称之为nvl(),这个函数传入的值如果是 null,那么就返回一个默认值。函数 nvl() 要求有 2 个参数。如果第 1 个参数是非null值,那么就返回这个值;如果第 1 个参数是 null,那么就返回第 2 个参数的值。

import org.apache.hadoop.hive.ql.exec.Description;
import org.apache.hadoop.hive.ql.exec.UDFArgumentException;
import org.apache.hadoop.hive.ql.exec.UDFArgumentTypeException;
import org.apache.hadoop.hive.ql.metadata.HiveException;
import org.apache.hadoop.hive.ql.udf.generic.GenericUDF;
import org.apache.hadoop.hive.ql.udf.generic.GenericUDFUtils;
import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector;

public class GenericUDFNvl extends GenericUDF {
    private GenericUDFUtils.ReturnObjectInspectorResolver returnOIResolver;
    private ObjectInspector[] argumentOIs;
    @Override
    public ObjectInspector initialize(ObjectInspector[] arguments) throws UDFArgumentException {
        argumentOIs = arguments;
        // 1.检验参数个数
        if (arguments.length != 2) {
            throw new UDFArgumentException("The operator 'NVL' accepts 2 arguments.");
        }
        // 2.检验参数类型
        returnOIResolver = new GenericUDFUtils.ReturnObjectInspectorResolver(true);
        if (!(returnOIResolver.update(arguments[0]) && returnOIResolver.update(arguments[1]))) {
            throw new UDFArgumentTypeException(2, "The 1st and 2nd args of function NLV should have the same type, "
                    + "but they are different: \"" + arguments[0].getTypeName() + "\" and \"" + arguments[1].getTypeName() + "\"");
        }
        // 3.返回类型,和传入的参数类型一致
        return returnOIResolver.get();
    }
    @Override
    public Object evaluate(DeferredObject[] arguments) throws HiveException {
        Object retVal = returnOIResolver.convertIfNecessary(arguments[0].get(), argumentOIs[0]);
        if (retVal == null) {
            retVal = returnOIResolver.convertIfNecessary(arguments[1].get(), argumentOIs[1]);
        }
        return retVal;
    }
    @Override
    public String getDisplayString(String[] children) {
        StringBuilder sb = new StringBuilder();
        sb.append("if ");
        sb.append(children[0]);
        sb.append(" is null ");
        sb.append("returns ");
        sb.append(children[1]);
        return sb.toString();
    }
}

//测试
public class GenericUDFNvlTest {
    @Test
    public void testGenericUDFNvl() throws HiveException {
        // 建立需要的模型
        GenericUDFNvl example = new GenericUDFNvl();
        ObjectInspector stringOI1 = PrimitiveObjectInspectorFactory.javaStringObjectInspector;
        ObjectInspector stringOI2 = PrimitiveObjectInspectorFactory.javaStringObjectInspector;
        StringObjectInspector resultInspector = (StringObjectInspector) example.initialize(new ObjectInspector[]{stringOI1, stringOI2});
        // 测试结果
        Object result1 = example.evaluate(new GenericUDF.DeferredObject[]{new GenericUDF.DeferredJavaObject(null), new GenericUDF.DeferredJavaObject("a")});
        Assert.assertEquals("a", resultInspector.getPrimitiveJavaObject(result1));
        // 测试结果
        Object result2 = example.evaluate(new GenericUDF.DeferredObject[]{new GenericUDF.DeferredJavaObject("dd"), new GenericUDF.DeferredJavaObject("a")});
        Assert.assertNotEquals("a", resultInspector.getPrimitiveJavaObject(result2));
    }
}

自定义UDAF

UDAF 开发主要涉及到以下两个抽象类:

org.apache.hadoop.hive.ql.udf.generic.AbstractGenericUDAFResolver
org.apache.hadoop.hive.ql.udf.generic.GenericUDAFEvaluator

  大致上,UDAF 函数读取数据(mapper),聚集一堆 mapper 输出到部分聚集结果(combiner),并且最终创建一个最终的聚集结果(reducer)。因为需要对多个combiner 进行聚集,所以需要保存部分聚集结果。UDAF是需要在Hive的SQL语句和group by联合使用,Hive的group by 对于每个分组,只能返回一条记录,这点和MySQL不一样。开发通用UDAF有两个步骤:

  1. 编写resolver类,负责类型检查,操作符重载,里面创建evaluator类对象
  2. 编写evaluator类真正实现UDAF的逻辑
    AbstractGenericUDAFResolver
      Resolver 要覆盖实现 getEvaluator 方法,该方法会根据 sql 传人的参数数据格式指定调用哪个 Evaluator 进行处理。

GenericUDAFEvaluator
  UDAF 逻辑处理主要发生在 Evaluator 中,要实现该抽象类的几个方法。 ObjectInspector 接口与 GenericUDAFEvaluator 中的内部类 Model。

  • ObjectInspector:主要是解耦数据使用与数据格式,使数据流在输入输出端可以切换不同的输入输出格式,不同的 Operator上使用不同的格式
  • Model:Model 代表了 UDAF 在 mapreduce 的各个阶段
public static enum Mode {
    /**
     * PARTIAL1: 这个是mapreduce的map阶段:从原始数据到部分数据聚合
     * 将会调用iterate()和terminatePartial()
     */
    PARTIAL1,
        /**
     * PARTIAL2: 这个是mapreduce的map端的Combiner阶段,负责在map端合并map的数据:从部分数据聚合到部分数据聚合
     * 将会调用merge() 和 terminatePartial() 
     */
    PARTIAL2,
        /**
     * FINAL: mapreduce的reduce阶段:从部分数据的聚合到完全聚合 
     * 将会调用merge()和terminate()
     */
    FINAL,
        /**
     * COMPLETE: 如果出现了这个阶段,表示mapreduce只有map,没有reduce,所以map端就直接出结果了:从原始数据直接到完全聚合
      * 将会调用 iterate()和terminate()
     */
    COMPLETE
  };

一般情况下,完整的 UDAF 逻辑是一个 MapReduce 过程,如果有Mapper 和Reducer,就会经历 PARTIAL1(Mapper),FINAL(Reducer),如果还有 combiner,那就会经历 PARTIAL1(Mapper),PARTIAL2(combiner),FINAL(Reducer)。而有一些情况下的 Mapreduce,只有Mapper,而没有 Reducer,所以就会只有COMPLETE 阶段,这个阶段直接输入原始数据,出结果。

GenericUDAFEvaluator 的方法

// 确定各个阶段输入输出参数的数据格式 ObjectInspectors,一般负责初始化内部字段,通常初始化用来存放最终结果的变量
public  ObjectInspector init(Mode m, ObjectInspector[] parameters) throws HiveException;
 
// 保存数据聚集结果的类
abstract AggregationBuffer getNewAggregationBuffer() throws HiveException;
 
// 重置聚集结果
public void reset(AggregationBuffer agg) throws HiveException;
 
// map阶段,迭代处理输入sql传过来的列数据
public void iterate(AggregationBuffer agg, Object[] parameters) throws HiveException;
 
// map与combiner结束返回结果,得到部分数据聚集结果
public Object terminatePartial(AggregationBuffer agg) throws HiveException;
 
// combiner合并map返回的结果,还有reducer合并mapper或combiner返回的结果。
public void merge(AggregationBuffer agg, Object partial) throws HiveException;
 
// reducer阶段,输出最终结果
public Object terminate(AggregationBuffer agg) throws HiveException;

Model与Evaluator关系:Model 各阶段对应 Evaluator 方法调用
在这里插入图片描述
Evaluator 各个阶段下处理 mapreduce 流程
在这里插入图片描述
demo

package cn.wisec.meerkat.analyseOnHive;
import org.apache.hadoop.hive.ql.exec.UDFArgumentException;
import org.apache.hadoop.hive.ql.exec.UDFArgumentLengthException;
import org.apache.hadoop.hive.ql.metadata.HiveException;
import org.apache.hadoop.hive.ql.parse.SemanticException;
import org.apache.hadoop.hive.ql.udf.generic.AbstractGenericUDAFResolver;
import org.apache.hadoop.hive.ql.udf.generic.GenericUDAFEvaluator;
import org.apache.hadoop.hive.ql.udf.generic.GenericUDAFParameterInfo;
import org.apache.hadoop.hive.ql.util.JavaDataModel;
import org.apache.hadoop.hive.serde2.objectinspector.ObjectInspector;
import org.apache.hadoop.hive.serde2.objectinspector.primitive.LongObjectInspector;
import org.apache.hadoop.hive.serde2.objectinspector.primitive.PrimitiveObjectInspectorFactory;
import org.apache.hadoop.hive.serde2.typeinfo.TypeInfo;
import org.apache.hadoop.io.IntWritable;
import org.apache.hadoop.io.LongWritable;

/**
 * 通常来说,顶层UDAF类继承{@link org.apache.hadoop.hive.ql.udf.generic.GenericUDAFResolver2}
 * 里面编写嵌套类evaluator实现UDAF的逻辑
 *
 * resolver通常继承org.apache.hadoop.hive.ql.udf.GenericUDAFResolver2,但是更建议继承AbstractGenericUDAFResolver,隔离将来hive接口的变化
 * GenericUDAFResolver和GenericUDAFResolver2接口的区别是:  后面的允许evaluator实现利用GenericUDAFParameterInfo可以访问更多的信息,例如DISTINCT限定符,通配符(*)。
 */
public class CountUDAF extends AbstractGenericUDAFResolver {
    @Override
    public GenericUDAFEvaluator getEvaluator(TypeInfo[] params) throws SemanticException {
        if (params.length > 1){
            throw new UDFArgumentLengthException("Exactly one argument is expected");
        }
        return new CountUDAFEvaluator();
    }
    /**
     * 这个构建方法可以判输入的参数是*号或者distinct
     */
    @Override
    public GenericUDAFEvaluator getEvaluator(GenericUDAFParameterInfo info) throws SemanticException {
        ObjectInspector[] parameters = info.getParameterObjectInspectors();
        boolean isAllColumns = false;
        if (parameters.length == 0){
            if (!info.isAllColumns()){
                throw new UDFArgumentException("Argument expected");
            }
            if (info.isDistinct()){
                throw new UDFArgumentException("DISTINCT not supported with");
            }
            isAllColumns = true;
        }else if (parameters.length != 1){
            throw new UDFArgumentLengthException("Exactly one argument is expected.");
        }
        return new CountUDAFEvaluator(isAllColumns);
    }
   
    public static class CountUDAFEvaluator extends GenericUDAFEvaluator{
        private boolean isAllColumns = false;
        /**
         * 合并结果的类型
         */
        private LongObjectInspector aggOI;
        private LongWritable result;
        public CountUDAFEvaluator() {}
        public CountUDAFEvaluator(boolean isAllColumns) {
            this.isAllColumns = isAllColumns;
        }
        /**
         * 负责初始化计算函数并设置它的内部状态,result是存放最终结果的
         * @param m 代表此时在map-reduce哪个阶段,因为不同的阶段可能在不同的机器上执行,需要重新创建对象partial1,partial2,final,complete
         * @param parameters partial1或complete阶段传入的parameters类型是原始输入数据的类型
         *                   partial2和final阶段(执行合并)的parameters类型是partial-aggregations(既合并返回结果的类型),此时parameters长度肯定只有1了
         * @return ObjectInspector
         *  在partial1和partial2阶段返回局部合并结果的类型,既terminatePartial的类型
         *  在complete或final阶段返回总结果的类型,既terminate的类型
         * @throws HiveException
         */
        @Override
        public ObjectInspector init(Mode m, ObjectInspector[] parameters) throws HiveException {
            super.init(m, parameters);
            //当是combiner和reduce阶段时,获取合并结果的类型,因为需要执行merge方法
            //merge方法需要部分合并的结果类型来取得值
            if (m == Mode.PARTIAL2 || m == Mode.FINAL){
                aggOI = (LongObjectInspector) parameters[0];
            }
            //保存总结果
            result = new LongWritable(0);
            //局部合并结果的类型和总合并结果的类型都是long
            return PrimitiveObjectInspectorFactory.writableLongObjectInspector;
        }
        /**
         * 定义一个AbstractAggregationBuffer类来缓存合并值
         */
        static class CountAgg extends AbstractAggregationBuffer{
            long value;
            /**
             * 返回类型占的字节数,long为8
             */
            @Override
            public int estimate() {
                return JavaDataModel.PRIMITIVES2;
            }
        }
        /**
         * 创建缓存合并值的buffer
         */
        @Override
        public AggregationBuffer getNewAggregationBuffer() throws HiveException {
            CountAgg countAgg = new CountAgg();
            reset(countAgg);
            return countAgg;
        }
        /**
         * 重置合并值
         */
        @Override
        public void reset(AggregationBuffer agg) throws HiveException {
            ((CountAgg) agg).value = 0;
        }
        /**
         * map时执行,迭代数据
         */
        @Override
        public void iterate(AggregationBuffer agg, Object[] parameters) throws HiveException {
            //parameters为输入数据
            //parameters == null means the input table/split is empty
            if (parameters == null){
                return;
            }
            if (isAllColumns){
                ((CountAgg) agg).value ++;
            }else {
                boolean countThisRow = true;
                for (Object nextParam: parameters){
                    if (nextParam == null){
                        countThisRow = false;
                        break;
                    }
                }
                if (countThisRow){
                    ((CountAgg) agg).value++;
                }
            }
        }
        /**
         * 返回buffer中部分聚合结果,map结束和combiner结束执行
         */
        @Override
        public Object terminatePartial(AggregationBuffer agg) throws HiveException {
            return terminate(agg);
        }
        /**
         * 合并结果,combiner或reduce时执行
         */
        @Override
        public void merge(AggregationBuffer agg, Object partial) throws HiveException {
            if (partial != null){
                //累加部分聚合的结果
                ((CountAgg) agg).value += aggOI.get(partial);
            }
        }
        /**
         * 返回buffer中总结果,reduce结束执行或者没有reduce时map结束执行
         */
        @Override
        public Object terminate(AggregationBuffer agg) throws HiveException {
            //每一组执行一次(group by)
            result.set(((CountAgg) agg).value);
            //返回writable类型
            return result;
        }
    }
}

使用:

hive> add jar /root/udf.jar
hive> create temporary function mycount as 'udf.CountUDAF'
hive> select call, mycount(*) as cn from beauty group by call order by cn desc
hive> select tag, mycount(tag) as cn from beauty lateral view explode(tags) lve_beauty as tag group by tag order by cn desc

自定义UDTF

UDTF用来解决输入一行输出多行的需求。限制:

  1. No other expressions are allowed in SELECT不能和其他字段一起使用:SELECT pageid,explode(adid_list) AS myCol… is not supported
  2. UDTF’s can’t be nested 不能嵌套:SELECT explode(explode(adid_list)) AS myCol… is not supported
  3. GROUP BY/ CLUSTER BY/ DISTRIBUTE BY/ SORT BY is not supported:SELECT explode(adid_list) AS myCol…GROUP BY myCol is not supported

继承org.apache.hadoop.hive.ql.udf.generic.GenericUDTF,实现initialize,process,close三个方法。执行过程:

  1. UDTF首先会调用initialize方法,确定传入参数的类型并确定 UDTF 生成表的每个字段的数据类型(即输入类型和输出类型),主要是判断输入类型并确定返回的字段类型
  2. process:调用了 initialize() 后,Hive 将把 UDTF 参数传给 process() 方法,处理一条输入记录,输出若干条结果记录,该方法中,每一次调用 forward() 产生一行;如果产生多列可以将多个列的值放在一个数组中,然后将该数组传入到 forward() 函数
  3. close:在 process 调用结束后调用,用于进行其它一些额外操作,只执行一次,对需要清理的方法进行清理

给出实现explode函数的demo:explode会将一个数组中每个元素都输出一行,map中每对key-value都输出一行,实现对数据展开

package cn.wisec.meerkat.analyseOnHive;
import org.apache.hadoop.hive.ql.exec.TaskExecutionException;
import org.apache.hadoop.hive.ql.exec.UDFArgumentException;
import org.apache.hadoop.hive.ql.exec.UDFArgumentLengthException;
import org.apache.hadoop.hive.ql.metadata.HiveException;
import org.apache.hadoop.hive.ql.udf.generic.GenericUDTF;
import org.apache.hadoop.hive.serde2.objectinspector.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

public class MyExplodeUDTF extends GenericUDTF {
    private transient ObjectInspector inputOI = null;
    /**
     * 初始化
     * 构建一个StructObjectInspector类型用于输出
     * 其中struct的字段构成输出的一行
     * 字段名称不重要,因为它们将被用户提供的列别名覆盖
     */
    @Override
    public StructObjectInspector initialize(StructObjectInspector argOIs) throws UDFArgumentException {
        //得到结构体的字段
        List<? extends StructField> inputFields = argOIs.getAllStructFieldRefs();
        ObjectInspector[] udfInputOIs = new ObjectInspector[inputFields.size()];
        for (int i = 0; i < inputFields.size(); i++){
            //字段类型
            udfInputOIs[i] = inputFields.get(i).getFieldObjectInspector();
        }
        if (udfInputOIs.length != 1){
            throw new UDFArgumentLengthException("explode() takes only one argument");
        }
        List<String> fieldNames = new ArrayList<>();
        List<ObjectInspector> fieldOIs = new ArrayList<>();
        switch (udfInputOIs[0].getCategory()){
            case LIST:
                inputOI = udfInputOIs[0];
                //指定list生成的列名,可在as后覆写
                fieldNames.add("col");
                //获取list元素的类型
                fieldOIs.add(((ListObjectInspector) inputOI).getListElementObjectInspector());
                break;
            case MAP:
                inputOI = udfInputOIs[0];
                //指定map中key的生成的列名,可在as后覆写
                fieldNames.add("key");
                //指定map中value的生成的列名,可在as后覆写
                fieldNames.add("value");
                //得到map中key的类型
 fieldOIs.add(((MapObjectInspector)inputOI).getMapKeyObjectInspector());
                //得到map中value的类型
 fieldOIs.add(((MapObjectInspector)inputOI).getMapValueObjectInspector());
                break;
            default:`在这里插入代码片`
                throw new UDFArgumentException("explode() takes an array or a map as a parameter");
        }
        //创建一个Struct类型返回
        return ObjectInspectorFactory.getStandardStructObjectInspector(fieldNames, fieldOIs);
    }
    //输出list
    private transient Object[] forwardListObj = new Object[1];
    //输出map
    private transient Object[] forwardMapObj = new Object[2];
    /**
     * 每行执行一次,输入数据args
     * 每调用forward,输出一行
     */
    @Override
    public void process(Object[] args) throws HiveException {
        switch (inputOI.getCategory()){
            case LIST:
                ListObjectInspector listOI = (ListObjectInspector) inputOI;
                List<?> list = listOI.getList(args[0]);
                if (list == null){
                    return;
                }
                //list中每个元素输出一行
                for (Object o: list){
                    forwardListObj[0] = o;
                    forward(forwardListObj);
                }
                break;
            case MAP:
                MapObjectInspector mapOI = (MapObjectInspector) inputOI;
                Map<?, ?> map = mapOI.getMap(args[0]);
                if (map == null){
                    return;
                }
                //map中每一对输出一行
                for (Map.Entry<?, ?> entry: map.entrySet()){
                    forwardMapObj[0] = entry.getKey();
                    forwardMapObj[1] = entry.getValue();
                    forward(forwardMapObj);
                }
                break;
            default:
                throw new TaskExecutionException("explode() can only operate on an array or a map");
        }
    }
    @Override
    public void close() throws HiveException {
    }
}

使用

hive> add jar /root/udtf.jar
hive> create temporary function myexplode as 'udf.MyExplodeUDTF'
hive> select myexplode(tags) as tag from beauty
hive> select myexplode(props) as (k,v) from beauty
hive> select tag, count(tag) as cn from beauty lateral view myexplode(tags) lve_beauty as tag group by tag order by cn desc 

永久函数

需要上传 jar 包到 HDFS目录中:

hadoop fs -put hive-udf.jar /user/hive/jars

然后进入 hive 中,创建函数:

hive> create function myFunction as 'udf' using jar 'hdfs:/user/hive/jars/hive-udf.jar';
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值