Spark系列04,广播变量和累加器的使用以及常见Java关键字

1. 共享变量

1.1. 概述

​ 所谓共享变量,是为了解决task中使用到外部变量造成相关问题而出现的。spark提供了有限的两种共享变量:广播变量Broadcast变量和累加器Accumulator。

在这里插入图片描述

1.2. Broadcast

1.2.1. 使用说明

​ 使用的话,非常简单,只需要将普通的变量包装为Broadcast即可。

​ val xxBC:Broadcast[T] = sc.broadcast(t);其中T是被包装的变量t的类型。

​ 在transformation中使用的时候,只需要xxBC.value既可以获取被包装的值。

1.2.2. 警告

​ 广播变量不适合处理那些大变量,其二不适合处理那些频繁更新的值。

1.2.3. 案例

object _01BroadVariablesOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName("_01BroadVariablesOps")
            .setMaster("local[*]")
        val sc = new SparkContext(conf)
        val list = 1 to 7
        val listRDD = sc.parallelize(list)

        /*val bs:Int = 3

        val bsBC:Broadcast[Int] = sc.broadcast(bs)

        listRDD.map(num => num * bsBC.value).foreach(println)
        */
        joinOps(sc)

        sc.stop()
    }

    /**
      * 使用map或者flatmap来代替join操作
      *     join操作使用shuffle的
      *
      *     使用这种方式可以非常高效的完成类似大小表关联的操作,
      *     也就是说将reduce的join---->map的join
      *
      *  mr中的多表关联:
      *     map join
      *     reduce join
      */
    def joinOps(sc: SparkContext): Unit = {
        val stuMap = List(//小表
            "1 刘梦男  22  bd-1901-bj",
            "2 常国龙  25  bd-1901-bj",
            "3 张湟熹  24  bd-1901-sz",
            "4 胡盼盼(男) 18  bd-1901-wh"
        ).map(stuLine => {
            val sid = stuLine.substring(0, 1)
            val info = stuLine.substring(1).trim
            (sid, info)
        }).toMap
        //创建广播变量
        val stuBC:Broadcast[Map[String, String]] = sc.broadcast(stuMap)

        //大表
        val scores = List(
            "1 1 math 82",
            "2 1 english 0",
            "3 2 chinese 85.5",
            "4 3 PE 99",
            "5 10 math 99"
        )
        val scoreRDD: RDD[String] = sc.parallelize(scores)

        scoreRDD.map(scoreLine => {
            val fields = scoreLine.split("\\s+")
            val bcMap = stuBC.value
            val sid = fields(1)
            val info = bcMap.getOrElse(sid, null)
            s"$sid\t$info\t${fields(2)}\t${fields(3)}"
        }).foreach(println)
    }
}

1.3. Accumulator

1.3.1. 使用说明

​ 累加器的入口也是SparkContext,操作非常简单,通过sc.accumulator(初始化的值),返回值就是一个累加器,累加器允许的操作就是++(add)。并且我们应该在drive去获取对应的累加结果,

1.3.2. 警告

在指定累加器的时候可以根据需要指定一个累加器名称,方面我们可以在web-ui/4040页面查看具体信息,如果没有指定累加器名称,查看不到具体的累加信息。

累加器的执行,必须要使用action去触发,也就是说累加器的操作必须要在transformation来累加。

累加器值的调用,应该要在action之后。

多次触发action操作,可能会造成累加器的多次执行,所以需要做到一旦调用完累加器做好累加器数据的重置。

1.3.3. 案例

在将所有结果输出到文件的同时,额外的统计is和that出现了多少次,并将结果打印到控制台。
val conf = new SparkConf()
.setAppName("_02AccumulatorOps")
.setMaster("local[*]")
val sc = new SparkContext(conf)

val lines = sc.textFile("file:///E:/work/1901-bd/workspace/spark-study-1901/data/1行数据.txt")

//统计is 和that出现多少次,然后将结果打印到控制台
val pairs = lines.flatMap(_.split("\\s+")).map((_, 1))

val ret = pairs.reduceByKey(_+_)
ret.filter{case (word, count) => {
    word == "is" || word == "that"
}}.foreach(println)

ret.saveAsTextFile("file:///E:/data/out/accu")

Thread.sleep(100000L)
sc.stop()
经过这个操作我们触发了两次job

在这里插入图片描述spark中为了解决这个问题,提供了一个累加器的操作。

object _02AccumulatorOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName("_02AccumulatorOps")
            .setMaster("local[*]")
        val sc = new SparkContext(conf)
        val accu = sc.longAccumulator("isAccu")
        val lines = sc.textFile("file:///E:/work/1901-bd/workspace/spark-study-1901/data/1行数据.txt")

        //统计is 和that出现多少次,然后将结果打印到控制台
        val pairs = lines.flatMap(_.split("\\s+"))
            .map(word => {
                if(word == "is" || word == "that") {
                    accu.add(1)
                }
                (word, 1)
            })
        val ret = pairs.reduceByKey(_+_)
        ret.saveAsTextFile("file:///E:/data/out/accu")
        println("累加的结果:" + accu.value)

        Thread.sleep(100000L)
        sc.stop()
    }
}

在这里插入图片描述

问题说明:

object _02AccumulatorOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName("_02AccumulatorOps")
            .setMaster("local[*]")
        val sc = new SparkContext(conf)
        val accu = sc.longAccumulator("isAccu")
        val lines = sc.textFile("file:///E:/work/1901-bd/workspace/spark-study-1901/data/1行数据.txt")

        //统计is 和that出现多少次,然后将结果打印到控制台
        val pairs = lines.flatMap(_.split("\\s+"))
            .map(word => {
                if(word == "is" || word == "that") {
                    accu.add(1)
                }
                (word, 1)
            })

        val ret = pairs.reduceByKey(_+_)

        println("触发之前的累加的结果:" + accu.value)
        ret.saveAsTextFile("file:///E:/data/out/accu")

        println("触发之后累加的结果:" + accu.value)

        accu.reset()
        ret.saveAsTextFile("file:///E:/data/out/accu1")
        println("触发之后累加的结果2:" + accu.value)

        Thread.sleep(100000L)
        sc.stop()
    }
}

Spark提供的累加器变量,Task(executor)只有增加的权利,只有Driver才能获得累加器变量的值;而且累加器变量的值只有再执行一次Action算子(map,foreach等)操作之后才会更新,这就意味着如果想获得一个准确的累加值,必须保证在获取到累加值之前只执行了一次Action操作。
多次调用出现重复累加的现象:

object _03SparkAccumuatorOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName(s"${_03SparkAccumuatorOps.getClass.getSimpleName}")
            .setMaster("local[2]")
        val sc = new SparkContext(conf)

        val list = List(
            "hello you",
            "i hate you",
            "i miss you",
            "i love you",
            "fuck you"
        )

        val words = sc.parallelize(list).flatMap(_.split("\\s+"))

        /**
          * //求多个word,you,i
          * 为了完成上述操作,需要在原始操作基础之上,额外的提交一个spark作业
          * 就需要额外的分配一批资源,就有可能有数据在网络中进行传输
          * 效率不会特别高
          * 此时就有这个一个共享变量——累加器
          */

//        words.filter(word => word == "hello").count()
//        val ab = Array("hello", "i", "you")
//        words.filter(word => ab.contains(word)).map((_, 1)).reduceByKey(_+_)

        //在计算words的过程中统计you出现了多少次
        //创建累加器 选择无参版本,我们无法再sparkUI上面观察到相关的累加器的值
        val acc = sc.longAccumulator("helloAcc")
        val pairs = words.map(word => {
            if(word == "you") {
                acc.add(1)
            }
            (word, 1)
        })
        println("you出现的次数:" + acc.value + ", action执行之前")
        println("pairs中总共有多少条记录:" + pairs.count())
        println("you出现的次数:" + acc.value + ", action执行之后")
        acc.reset()//重置累加器的值
   

1.3.4. 自定义累加器

/*
    AccumulatorV2[IN, OUT]
        IN代表的是执行累加器add加入的指的类型
        OUT代表的是执行累加器value返回值的类型

    本例中IN使用String,其实代表的就是对应得要累加的字符串word
    返回值,不会是每一个word的次数k-v
 */
class MyAccumulator extends AccumulatorV2[String, mutable.Map[String, Int]] {

    var map = mutable.Map[String, Int]()

    //当前累加器是否有初始化的值
    override def isZero: Boolean = true

    //重置该累加器的值
    override def reset(): Unit = map.clear()

    //获取累加器的值
    override def value: mutable.Map[String, Int] = map

    //拷贝累加器
    override def copy(): AccumulatorV2[String, mutable.Map[String, Int]] = {
        val mAccu = new MyAccumulator
        mAccu.map = this.map
        mAccu
    }
    /**
      * 进行累加的操作
      * you
      * hate
      */
    override def add(word: String): Unit = {

//        val vOption = map.get(word)
//        var count = 1
        /*if(vOption.isDefined) {//map中已经存在该word
            map.put(word, 1 + vOption.get)
        } else {//map中没有该word
            map.put(word, 1)
        }*/
        /*if(vOption.isDefined) {
            count += vOption.get
        }
        map.put(word, count)*/

        map.put(word, map.getOrElse(word, 0) + 1)
    }

    //合并其他task中的对应累加器的值
    override def merge(other: AccumulatorV2[String, mutable.Map[String, Int]]): Unit = {
        val otherMap = other.value
        for ((word, count) <- otherMap) {
            map.put(word, map.getOrElse(word, 0) + count)
        }
    }
}

使用:

/**
  * 要处理哪些可能需要累加多个不同之的操作,是使用一个累加器解决不了的问题
  * 自定义:
  *     1、写一个类 extends某个类/trait
  *     2、复写其中的方法
  *     3、注册使用
  */
object _04SparkAccumuatorOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName(s"${_04SparkAccumuatorOps.getClass.getSimpleName}")
            .setMaster("local[2]")
        val sc = new SparkContext(conf)

        val list = List(
            "hello you",
            "i hate you",
            "i miss you",
            "i love you",
            "fuck you"
        )

        val words = sc.parallelize(list).flatMap(_.split("\\s+"))

        //自定义累加器
        val mAccu = new MyAccumulator
        sc.register(mAccu, "sparkAccu")
        val pairs = words.map(word => {
            if(word == "you" || "hate" == word) {
                mAccu.add(word)
            }
            (word, 1)
        })
        pairs.count()
        println("you出现的次数:" + mAccu.value + ", action执行之后")
        sc.stop()
    }
}

2. 高级排序

2.1. 普通排序

2.1.1. sortByKey

/**
  * Spark普通的排序
  *     sortByKey(只能处理那些[K, V]) 按照key进行排序
  *     sortBy(底层还是sortByKey)(可以处理没有key的数据)
  *
  */
object _01SparkSortOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName(s"${_01SparkSortOps.getClass.getSimpleName}")
            .setMaster("local[2]")
        val sc = new SparkContext(conf)

        val list = List(
            "hello you",
            "i hate you",
            "i miss you",
            "i love you",
            "fuck you"
        )

        val words = sc.parallelize(list).flatMap(_.split("\\s+"))

        val ret = words.map((_, 1)).reduceByKey(_+_)
        /*
            sortByKey接收两个参数:
                ascending: Boolean = true
                    默认升序排序,false为降序
                numPartitions:默认值为master中提供
                    参与排序操作的分区的个数
             这是一个局部排序,也就是是分区内有序,分区间无序
             但是如果想进行全局排序,所以就只能将numPartitions设置为1,但是风险很大,容易出现OOM异常
         */
//        ret.sortByKey(numPartitions = 1).foreach(println)//按照word排序
        ret.map{case (key, count) => (count, key)}//按照count进行排序
            .sortByKey(false, 1)
            .map{case (count, key) => (key, count)}
            .foreach(println)

        sc.stop()
    }
}

2.1.2 sortBy

object _01SparkSortOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName(s"${_01SparkSortOps.getClass.getSimpleName}")
            .setMaster("local[2]")
        val sc = new SparkContext(conf)

        val list = List(
            "hello you",
            "i hate you",
            "i miss you",
            "i love you",
            "fuck you"
        )

        val words = sc.parallelize(list).flatMap(_.split("\\s+"))

        val ret = words.map((_, 1)).reduceByKey(_+_)

        /*
            sortBy --->count
              def sortBy[K](
                  f: (T) => K,
                  ascending: Boolean = true,
                  numPartitions: Int = this.partitions.length)
              (implicit ord: Ordering[K], ctag: ClassTag[K])
         */
        var sortedRDD:RDD[(String, Int)] = ret.sortBy(t => t._2, true, 1)(
            new Ordering[Int](){
                override def compare(x: Int, y: Int) = {
                    y.compareTo(x)
                }
            },
            ClassTag.Int.asInstanceOf[ClassTag[Int]]
        )
        sortedRDD.foreach(println)

        println("------------------^_^-----------------------")

        sortedRDD = ret.sortBy(t => t, true, 1)(
            new Ordering[(String, Int)](){
                override def compare(x: (String, Int), y: (String, Int)) = {
                    var ret = y._2.compareTo(x._2)
                    if(ret == 0) {
                        ret = y._1.compareTo(x._1)
                    }
                    ret
                }
            },
            ClassTag.Object.asInstanceOf[ClassTag[(String, Int)]]
        )

        sortedRDD.foreach(println)
        sc.stop()
    }

2.2. TopN

​ 就是获取集合中的前N个值。

​ take(n),要想有序,可以使用sortBy/sortByKey之后再获取,或者只用takeOrdered()来完成。

​ 参见昨天的transformation–take

2.3. 二次排序

​ 所谓二次排序,那就是按照两(多)列进行排序,如果第一列排序结果相同,就开始考虑第二列排序。

​ 具体可以参见上述sortBy,但是如果要基于sortByKey来进行二次排序呢?

​ 使用自定义的对象来实现二次排序:

object _02SparkSecondSortOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName(s"${_01SparkSortOps.getClass.getSimpleName}")
            .setMaster("local[2]")
//            .set("spark.serializer", classOf[KryoSerializer].getName)
//            .registerKryoClasses(Array(classOf[MySecondSort]))
        val sc = new SparkContext(conf)

        val lines = sc.textFile("file:///E:/data/spark/secondsort.csv")
        //先按照第一列排序,第一列相同再按照第二列排序

        val pairs:RDD[(MySecondSort, Int)] = lines.map(line => {
            val fields = line.split("\\s+")
            val mss = new MySecondSort(fields(0).toInt, fields(1).toInt)
            (mss, 1)
        })

        pairs.sortByKey(numPartitions = 1).foreach{case (mss, c) => println(mss)}
//        pairs.foreach(println)
        sc.stop()
    }
}

/**
  * NotSerializableException: com.desheng.bigdata.job.p3.sort.MySecondSort
  */
class MySecondSort extends Comparable[MySecondSort] with Serializable {
    var first:Int = _
    var second:Int = _

    def this(first:Int, second:Int) {
        this()
        this.first = first
        this.second = second
    }

    override def toString: String = first + "\t" + second

    override def compareTo(other: MySecondSort) = {
        var ret = this.first.compareTo(other.first)
        if(ret == 0) {
            ret = other.second.compareTo(this.second)
        }
        ret
    }
}

2.4. 分组TopN

​ 在mr、hive中投处理过的操作,分组的topn

​ 比如要从10个文件,每个文件都有100w个数字,找出最大的10数字。

​ 比如有很多部分,比如研发部、设计部、市场部、行政部等等,要求找出每个部分年龄最小的三个小姐姐。

​ 这就是分组TopN的问题。

object _03SparkGroupTopNOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName(s"${_01SparkSortOps.getClass.getSimpleName}")
            .setMaster("local[2]")
        val sc = new SparkContext(conf)

        val lines = sc.textFile("file:///E:/data/spark/topn.txt")

        val course2Info:RDD[(String, String)] = lines.map(line => {
            val fields = line.split("\\s+")
            val course = fields(0)
            val name = fields(1)
            val score = fields(2)
            (course, s"$name|$score")
        })
        //就需要将每门课程的所有信息弄到一起才能排序
        val course2Infos:RDD[(String, Iterable[String])] = course2Info.groupByKey()
        /*
            排序
               k,是科目
               v:该科目对应的所有的成绩信息
               经过排序之后返回三个人的成绩信息,还是一个集合
               [k, Iterable[String]] --> [k, Iterable[String]]只不过后面Iterable[String]的size为3
               还是one-2-many的操作
               one-2-one--->map
         */
        val top3:RDD[(String, mutable.TreeSet[String])] = course2Infos.map{case (course, infos) => {
            var top3Infos = mutable.TreeSet[String]()(new Ordering[String](){
                //name|score
                override def compare(x: String, y: String) = {
                    val xScore = x.substring(x.indexOf("|") + 1).toInt
                    val yScore = y.substring(y.indexOf("|") + 1).toInt
                    var ret = xScore.compareTo(yScore)
                    if(ret == 0) {
                        1
                    } else {
                        ret
                    }
                }
            })

            //排序的操作 top3Infos是有序的,但是最后只要3个
//            top3Infos.dropRight(top3Infos.size - 3)
            for(info <- infos) {
                top3Infos.add(info)
                if(top3Infos.size > 2) {
                    top3Infos = top3Infos.dropRight(1)
                }
            }
            (course, top3Infos)
        }}

        top3.foreach{case (course, infos) => {
            println(s"$course---->$infos")
        }}
        /*
        english---->TreeSet(ww|56, ys|67, mz|77, ts|87, zq|88, gk|96)
        chinese---->TreeSet(sj|74, zl|76, zs|90, ls|91, wb|95, yj|98)
         */
        sc.stop()
    }
}

升级:

​ 因为groupByKey的性能太差了,所以需要使用combineByKey模拟,怎么?

第一步,将groupByKey的处理方式,转化为combineByKey

object _04SparkGroupTopNOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName(s"${_04SparkGroupTopNOps.getClass.getSimpleName}")
            .setMaster("local[2]")
        val sc = new SparkContext(conf)

        val lines = sc.textFile("file:///E:/data/spark/topn.txt")

        val course2Info:RDD[(String, String)] = lines.map(line => {
            val fields = line.split("\\s+")
            val course = fields(0)
            val name = fields(1)
            val score = fields(2)
            (course, s"$name|$score")
        })
        //就需要将每门课程的所有信息弄到一起才能排序
        val course2Infos:RDD[(String, ArrayBuffer[String])] = course2Info
            .combineByKey(createCombiner, mergeValue, mergeCombiners)

        val top3:RDD[(String, mutable.TreeSet[String])] = course2Infos.map{case (course, infos) => {
            var top3Infos = mutable.TreeSet[String]()(new Ordering[String](){
                //name|score
                override def compare(x: String, y: String) = {
                    val xScore = x.substring(x.indexOf("|") + 1).toInt
                    val yScore = y.substring(y.indexOf("|") + 1).toInt
                    var ret = xScore.compareTo(yScore)
                    if(ret == 0) {
                        1
                    } else {
                        ret
                    }
                }
            })

            //排序的操作 top3Infos是有序的,但是最后只要3个
//            top3Infos.dropRight(top3Infos.size - 3)
            for(info <- infos) {
                top3Infos.add(info)
                if(top3Infos.size > 2) {
                    top3Infos = top3Infos.dropRight(1)
                }
            }
            (course, top3Infos)
        }}

        top3.foreach{case (course, infos) => {
            println(s"$course---->$infos")
        }}
        /*
        english---->TreeSet(ww|56, ys|67, mz|77, ts|87, zq|88, gk|96)
        chinese---->TreeSet(sj|74, zl|76, zs|90, ls|91, wb|95, yj|98)
         */
        sc.stop()
    }
    def createCombiner(info:String):ArrayBuffer[String] = {
        val ab = ArrayBuffer[String]()
        ab.append(info)
        ab
    }

    def mergeValue(ab:ArrayBuffer[String], info:String):ArrayBuffer[String] = {
        ab.append(info)
        ab
    }
    def mergeCombiners(ab1:ArrayBuffer[String], ab2:ArrayBuffer[String]):ArrayBuffer[String] = {
        ab1 ++ ab2
    }
}

第二步:

​ 第一步和普通的groupByKey并没有什么两样,性能亦然很差,没有没有本地预聚合,所以在重写的时候做一下本地的top3,这样最后做分区间的top3的时候,每个分区最多提供3条记录,这样在网络中传输的数据量少很多,性能得到了提升。

object _05SparkGroupTopNOps {
    def main(args: Array[String]): Unit = {
        val conf = new SparkConf()
            .setAppName(s"${_05SparkGroupTopNOps.getClass.getSimpleName}")
            .setMaster("local[2]")
        val sc = new SparkContext(conf)

        val lines = sc.textFile("file:///E:/data/spark/topn.txt")

        val course2Info:RDD[(String, String)] = lines.map(line => {
            val fields = line.split("\\s+")
            val course = fields(0)
            val name = fields(1)
            val score = fields(2)
            (course, s"$name|$score")
        })
        //就需要将每门课程的所有信息弄到一起才能排序
        val course2Infos:RDD[(String, mutable.TreeSet[String])] = course2Info
            .combineByKey(createCombiner, mergeValue, mergeCombiners)


        course2Infos.foreach{case (course, infos) => {
            println(s"$course---->$infos")
        }}
        /*
        english---->TreeSet(ww|56, ys|67, mz|77, ts|87, zq|88, gk|96)
        chinese---->TreeSet(sj|74, zl|76, zs|90, ls|91, wb|95, yj|98)
         */
        sc.stop()
    }
    def createCombiner(info:String):mutable.TreeSet[String] = {
        val ab = mutable.TreeSet[String]()(new Ordering[String](){
            //name|score
            override def compare(x: String, y: String) = {
                val xScore = x.substring(x.indexOf("|") + 1).toInt
                val yScore = y.substring(y.indexOf("|") + 1).toInt
                var ret = xScore.compareTo(yScore)
                if(ret == 0) {
                    1
                } else {
                    ret
                }
            }
        })
        ab.add(info)
        ab
    }
    
    //分区内的排序,并获取top3
    def mergeValue(ab:mutable.TreeSet[String], info:String):mutable.TreeSet[String] = {
        ab.add(info)
        if(ab.size > 3) {
            ab.take(3)
        } else {
            ab
        }
    }
    //分区间合并的时候,获取top3
    def mergeCombiners(ab1:mutable.TreeSet[String], ab2:mutable.TreeSet[String]):mutable.TreeSet[String] = {
        for(info<- ab2) {
            ab1.add(info)
        }
        if(ab1.size > 3) {
            ab1.take(3)
        } else {
            ab1
        }
    }
}

作业:

​ 简答题:比较mr和spark的区别。

3.常见java关键字:

**native:**一个方法被native修饰,那么说明该方法就不属于java体系,是由c/c++编写的,在java中被调用而已。java中专门一个规范来处理和底层操作系统的交互–JNI(java native interface)。

**transient:**瞬时的、短暂的。一旦某个变量被transient所修饰,就意味着该变量不会被序列化。有啥用,实现了有选择的序列化!比如在客户端和服务端进行交互,支付过程需要将用户的敏感信息不序列化,以确保用户数据之安全,所以此时敏感信息就应该要使用transient修饰。

volatile:解决的问题,就是共享数据可见的问题,作用相当于一把轻量级的锁。

在这里插入图片描述

**Atomic:**原子性操作

​ AtomicInteger,AtomicLong、AtomicBoolean,底层没有使用锁,但是基于一个CAS算法实现了原子性操作。

在这里插入图片描述

代码:

public class AtomicOps {
    public static void main(String[] args) {
        AtomicThread vt = new AtomicThread();
        for (int i = 0; i < 10; i++) {
            new Thread(vt).start();
        }
    }
}

class AtomicThread implements Runnable {

   private AtomicInteger number = new AtomicInteger();
    @Override
    public void run() {
//        synchronized (AtomicThread.class) {
        try {
            Thread.sleep(200);
            System.out.println(number.getAndIncrement());//i++
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
//        }
    }

}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值