Scala学习笔记(四)

一、高阶函数

scala 混合了面向对象和函数式的特性,在函数式编程语言中,函数是“头等公民”,它和Int、String、Class等其他类型处于同等的地位,可以像其他类型的变量一样被传递和操作。

  • 高阶函数包含
  • 作为值的函数
  • 匿名函数
  • 柯里化
  • 闭包函数等等

1. 作为值的函数

在scala中,函数就像和数字、字符串一样,可以将函数传递给一个方法。我们可以对算法进行封装,然后将具体的动作传递给方法。

  • 示例

    1. 创建一个函数,用于将数字乘以10
    2. 创建一个数组,调用map方法,打印转换后的结果
    object Demo {
      def main(args: Array[String]): Unit = {
        val f1 = (x: Int) => (x * 10)
        val array = Array(1, 2, 3)
        println(array.map(f1).toBuffer)
      }
    }
    

2. 匿名函数

观察上一道示例,发现可以通过匿名函数进行简化

object Demo {
  def main(args: Array[String]): Unit = {
    val array = Array(1, 2, 3)
    println(array.map(_ * 10).toBuffer)
  }
}

3. 柯里化

柯里化(Currying)是指将原先接受多个参数的方法转换为多个只有一个参数的参数列表的过程。
在这里插入图片描述

  • 柯里化过程解析
    在这里插入图片描述

  • 示例

    • 编写一个方法,用来完成两个Int类型数字的计算
    • 具体如何计算封装到函数中
    • 使用柯里化来实现上述操作
    object Demo {
    	// 柯里化:实现对两个数进行计算
    	def calc_carried(x:Double, y:Double)(func_calc:(Double, Double)=>Double) = {
    	    func_calc(x, y)
    	}
    	
    	def main(args: Intrray[String]): Unit = {
    	    println(calc_carried(10.1, 10.2){
    	        (x,y) => x + y
    	    })
    	    println(calc_carried(10, 10)(_ + _))
    	    println(calc_carried(10.1, 10.2)(_ * _))
    	    println(calc_carried(100.2, 10)(_ - _))
    	}
    }
    

4. 闭包函数

闭包其实就是一个函数,只不过这个函数的返回值依赖于声明在函数外部的变量。

可以简单认为,就是可以访问不在当前作用域范围的一个函数。

  • 示例一
object Demo {
	def main(args: Intrray[String]): Unit = {
		val y=10
		val sum=(x:Int)=>{x+y}
		println(sum(5)) // 结果15
	}
}
  • 示例二

柯里化就是一个闭包

  def sum(x:Int)(y:Int) = {
    x + y
  }

上述代码相当于

  def sum(x:Int) = {
    (y:Int) => x + y
  }

二、隐式转换和隐式参数

隐式转换和隐式参数是scala非常有特色的功能,也是java等其他编程语言没有的功能。我们可以很方便地利用隐式转换来丰富现有类的功能。

1. 定义

所谓隐式转换,是指以implicit关键字声明的带有单个参数的方法。它是自动被调用的,自动将某种类型转换为另外一种类型。

使用步骤

  1. 在object中定义隐式转换方法(使用implicit)
  2. 在需要用到隐式转换的地方,引入隐式转换(使用import)
  3. 自动调用隐式转化后的方法
  • 示例

    使用隐式转换,让File具备有read功能——实现将文本中的内容以字符串形式读取出来

    1. 创建RichFile类,提供一个read方法,用于将文件内容读取为字符串
    2. 定义一个隐式转换方法,将File隐式转换为RichFile对象
    3. 创建一个File,导入隐式转换,调用File的read方法
    class RichFile(val file:File) {
        // 读取文件为字符串
        def read() = {
            Source.fromFile(file).mkString
        }
    }
    
    object RichFile {
        // 定义隐式转换方法
        implicit def file2RichFile(file:File) = new RichFile(file)
    }
    
    def main(args: Array[String]): Unit = {
        // 加载文件
        val file = new File("./data/1.txt")
    
        // 导入隐式转换
        import RichFile.file2RichFile
    
        // file对象具备有read方法
        println(file.read())
    }
    

2. 隐式转换的时机

  • 当对象调用类中不存在的方法或者成员时,编译器会自动将对象进行隐式转换
  • 当方法中的参数的类型与目标类型不一致时

3. 自动导入隐式转换方法

前面,我们手动使用了import来导入隐式转换。是否可以不手动import呢?

在scala中,如果在当前作用域中有隐式转换方法,会自动导入隐式转换。

示例:将隐式转换方法定义在main所在的object中

class RichFile(val f:File) {
  // 将文件中内容读取成字符串
  def read() = Source.fromFile(f).mkString
}

object ImplicitConvertDemo {
  // 定义隐式转换方法
  implicit def file2RichFile(f:File) = new RichFile(f)

  def main(args: Array[String]): Unit = {
    val f = new File("./data/textfiles/1.txt")

    // 调用的其实是RichFile的read方法
    println(f.read())
  }
}

4. 隐式参数

方法可以带有一个标记为implicit的参数列表。这种情况,编译器会查找缺省值,提供给该方法。

定义

  1. 在方法后面添加一个参数列表,参数使用implicit修饰
  2. 在object中定义implicit修饰的隐式值
  3. 调用方法,可以不传入implicit修饰的参数列表,编译器会自动查找缺省值

[!NOTE]

  1. 和隐式转换一样,可以使用import手动导入隐式参数
  2. 如果在当前作用域定义了隐式值,会自动进行导入

示例

  • 定义一个方法,可将传入的值,使用一个分隔符前缀、后缀包括起来
  • 使用隐式参数定义分隔符
  • 调用该方法,并打印测试
// 使用implicit定义一个参数
def quote(what:String)(implicit delimiter:(String, String)) = {
    delimiter._1 + what + delimiter._2
}

// 隐式参数
object ImplicitParam {
    implicit val DEFAULT_DELIMITERS = ("<<<", ">>>")
}

def main(args: Array[String]): Unit = {
	// 导入隐式参数
    import ImplicitParam.DEFAULT_DELIMITERS

    println(quote("李雷和韩梅梅"))
}

三、Akka并发编程框架简介

1. Akka介绍

Akka是一个用于构建高并发、分布式和可扩展的基于事件驱动的应用的工具包。Akka是使用scala开发的库,同时可以使用scala和java语言来开发基于Akka的应用程序。

2. Akka特性

  • 提供基于异步非阻塞、高性能的事件驱动编程模型
  • 内置容错机制,允许Actor在出错时进行恢复或者重置操作
  • 超级轻量级的事件处理(每GB堆内存几百万Actor)
  • 使用Akka可以在单机上构建高并发程序,也可以在网络中构建分布式程序。

3. Akka通信过程

以下图片说明了Akka Actor的并发编程模型的基本流程:

  1. 学生创建一个ActorSystem
  2. 通过ActorSystem来创建一个ActorRef(老师的引用),并将消息发送给ActorRef
  3. ActorRef将消息发送给Message Dispatcher(消息分发器)
  4. Message Dispatcher将消息按照顺序保存到目标Actor的MailBox中
  5. Message Dispatcher将MailBox放到一个线程中
  6. MailBox按照顺序取出消息,最终将它递给TeacherActor接受的方法中

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iu0LmtFe-1573306011836)(assets/1552871108166.png)]

4. 创建Actor

Akka中,也是基于Actor来进行编程的。类似于之前学习过的Actor。但是Akka的Actor的编写、创建方法和之前有一些不一样。

5. API介绍

ActorSystem

在Akka中,ActorSystem是一个重量级的结构,它需要分配多个线程,所以在实际应用中,ActorSystem通常是一个单例对象,可以使用这个ActorSystem创建很多Actor。它负责创建和监督actor

Actor中获取ActorSystem

直接使用context.system就可以获取到管理该Actor的ActorSystem的引用

实现Actor类

  • 继承Actor(注意:要导入akka.actor包下的Actor

  • 实现receive方法,receive方法中直接处理消息即可,不需要添加loop和react方法调用。Akka会自动调用receive来接收消息

  • 【可选】还可以实现preStart()方法,该方法在Actor对象构建后执行,在Actor声明周期中仅执行一次

加载Akka Actor

  1. 要创建Akka的Actor,必须要先获取创建一个ActorSystem。需要给ActorSystem指定一个名称,并可以去加载一些配置项(后面会使用到)
  2. 调用ActorSystem.actorOf(Props(Actor对象), “Actor名字”)来加载Actor

Actor Path

每一个Actor都有一个Path,就像使用Spring MVC编写一个Controller/Handler一样,这个路径可以被外部引用。路径的格式如下:

Actor类型路径示例
本地Actorakka://actorSystem名称/user/Actor名称akka://SimpleAkkaDemo/user/senderActor
远程Actorakka.tcp://my-sys@ip地址:port/user/Actor名称akka.tcp://192.168.10.17:5678/user/service-b

6. 入门案例

案例说明

基于Akka创建两个Actor,Actor之间可以互相发送消息。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WvIMQQek-1573306011836)(assets/1552879431645.png)]

实现步骤

  1. 创建Maven模块
  2. 创建并加载Actor
  3. 发送/接收消息

6.1 创建Maven模块

使用Akka需要导入Akka库,我们这里使用Maven来管理项目

  1. 创建Maven模块
  2. 打开pom.xml文件,导入akka Maven依赖和插件

6.2 创建并加载Actor

创建两个Actor

  • SenderActor:用来发送消息
  • ReceiveActor:用来接收,回复消息

创建Actor

  1. 创建ActorSystem
  2. 创建自定义Actor
  3. ActorSystem加载Actor

6.3 发送/接收消息

  • 使用样例类封装消息
  • SubmitTaskMessage——提交任务消息
  • SuccessSubmitTaskMessage——任务提交成功消息
  • 使用类似于之前学习的Actor方式,使用!发送异步消息
case class SubmitTaskMessage(msg:String)
case class SuccessSubmitTaskMessage(msg:String)

// 注意:要导入的是Akka下的Actor
object SenderActor extends Actor {

  override def preStart(): Unit = println("执行SenderActor的preStart()方法")

  override def receive: Receive = {
    case "start" =>
      val receiveActor = this.context.actorSelection("/user/receiverActor")
      receiveActor ! SubmitTaskMessage("请完成#001任务!")
    case SuccessSubmitTaskMessage(msg) =>
      println(s"接收到来自${sender.path}的消息: $msg")
  }
}

object ReceiverActor extends Actor {

  override def preStart(): Unit = println("执行ReceiverActor()方法")

  override def receive: Receive = {
    case SubmitTaskMessage(msg) =>
      println(s"接收到来自${sender.path}的消息: $msg")
      sender ! SuccessSubmitTaskMessage("完成提交")
    case _ => println("未匹配的消息类型")
  }
}

object SimpleAkkaDemo {
  def main(args: Array[String]): Unit = {
    val actorSystem = ActorSystem("SimpleAkkaDemo", ConfigFactory.load())

    val senderActor: ActorRef = actorSystem.actorOf(Props(SenderActor), "senderActor")
    val receiverActor: ActorRef = actorSystem.actorOf(Props(ReceiverActor), "receiverActor")

    senderActor ! "start"
      
  }
}

程序输出:

接收到来自akka://SimpleAkkaDemo/user/senderActor的消息: 请完成#001任务!
接收到来自akka://SimpleAkkaDemo/user/receiverActor的消息: 完成提交

7. Akka定时任务

如果我们想要使用Akka框架定时的执行一些任务,该如何处理呢?

使用方式

Akka中,提供一个scheduler对象来实现定时调度功能。使用ActorSystem.scheduler.schedule方法,可以启动一个定时任务。

schedule方法针对scala提供两种使用形式:

第一种:发送消息

def schedule(
    initialDelay: FiniteDuration,		// 延迟多久后启动定时任务
    interval: FiniteDuration,			// 每隔多久执行一次
    receiver: ActorRef,					// 给哪个Actor发送消息
    message: Any)						// 要发送的消息
(implicit executor: ExecutionContext)	// 隐式参数:需要手动导入

第二种:自定义实现

def schedule(
    initialDelay: FiniteDuration,			// 延迟多久后启动定时任务
    interval: FiniteDuration				// 每隔多久执行一次
)(f: ⇒ Unit)								// 定期要执行的函数,可以将逻辑写在这里
(implicit executor: ExecutionContext)		// 隐式参数:需要手动导入

示例一

  • 定义一个Actor,每1秒发送一个消息给Actor,Actor收到后打印消息
  • 使用发送消息方式实现
 // 1. 创建一个Actor,用来接收消息,打印消息
  object ReceiveActor extends Actor {
    override def receive: Receive = {
      case x => println(x)
    }
  }

  // 2. 构建ActorSystem,加载Actor
  def main(args: Array[String]): Unit = {
    val actorSystem = ActorSystem("actorSystem", ConfigFactory.load())
    val receiveActor = actorSystem.actorOf(Props(ReceiveActor))

    // 3. 启动scheduler,定期发送消息给Actor
    // 导入一个隐式转换
    import scala.concurrent.duration._
    // 导入隐式参数
    import actorSystem.dispatcher

    actorSystem.scheduler.schedule(0 seconds,
      1 seconds,
      receiveActor, "hello")
  }

示例二

  • 定义一个Actor,每1秒发送一个消息给Actor,Actor收到后打印消息
  • 使用自定义方式实现
object SechdulerActor extends Actor {
  override def receive: Receive = {
    case "timer" => println("收到消息...")
  }
}

object AkkaSchedulerDemo {
  def main(args: Array[String]): Unit = {
    val actorSystem = ActorSystem("SimpleAkkaDemo", ConfigFactory.load())

    val senderActor: ActorRef = actorSystem.actorOf(Props(SechdulerActor), "sechdulerActor")

    import actorSystem.dispatcher
    import scala.concurrent.duration._

    actorSystem.scheduler.schedule(0 seconds, 1 seconds) {
      senderActor ! "timer"
    }
  }
}

[!NOTE]

  1. 需要导入隐式转换import scala.concurrent.duration._才能调用0 seconds方法
  2. 需要导入隐式参数import actorSystem.dispatcher才能启动定时任务

8. 实现两个进程之间的通信

8.1 案例介绍

基于Akka实现在两个进程间发送、接收消息。Worker启动后去连接Master,并发送消息,Master接收到消息后,再回复Worker消息。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GpzGDYyL-1573306011837)(assets/1552886264753.png)]

8.2 Worker实现

步骤

  1. 创建一个Maven模块,导入依赖和配置文件
  2. 创建启动WorkerActor
  3. 发送"setup"消息给WorkerActor,WorkerActor接收打印消息
  4. 启动测试

参考代码

Worker.scala

val workerActorSystem = ActorSystem("actorSystem", ConfigFactory.load())
val workerActor: ActorRef = workerActorSystem.actorOf(Props(WorkerActor), "WorkerActor")

// 发送消息给WorkerActor
workerActor ! "setup"

WorkerActor.scala

object WorkerActor extends Actor{
  override def receive: Receive = {
    case "setup" =>
      println("WorkerActor:启动Worker")
  }
}

8.3 Master实现

步骤

  1. 创建Maven模块,导入依赖和配置文件
  2. 创建启动MasterActor
  3. WorkerActor发送"connect"消息给MasterActor
  4. MasterActor回复"success"消息给WorkerActor
  5. WorkerActor接收并打印接收到的消息
  6. 启动Master、Worker测试

参考代码

Master.scala

val masterActorSystem = ActorSystem("MasterActorSystem", ConfigFactory.load())
val masterActor: ActorRef = masterActorSystem.actorOf(Props(MasterActor), "MasterActor")

MasterActor.scala

object MasterActor extends Actor{
  override def receive: Receive = {
    case "connect" =>
      println("2. Worker连接到Master")
      sender ! "success"
  }
}

WorkerActor.scala

object WorkerActor extends Actor{
  override def receive: Receive = {
    case "setup" =>
      println("1. 启动Worker...")
      val masterActor = context.actorSelection("akka.tcp://MasterActorSystem@127.0.0.1:9999/user/MasterActor")

      // 发送connect
      masterActor ! "connect"
    case "success" =>
      println("3. 连接Master成功...")
  }
}

9. 简易版spark通信框架案例

9.1 案例介绍

模拟Spark的Master与Worker通信

  • 一个Master
    • 管理Worker
  • 若干个Worker(Worker可以按需添加)
    • 注册
    • 发送心跳

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VEZSBAPI-1573306011837)(assets/1552890302701.png)]

9.2 实现思路

  1. 构建Master、Worker阶段
  • 构建Master ActorSystem、Actor
  • 构建Worker ActorSystem、Actor
  1. Worker注册阶段
  • Worker进程向Master注册(将自己的ID、CPU核数、内存大小(M)发送给Master)
  1. Worker定时发送心跳阶段
  • Worker定期向Master发送心跳消息
  1. Master定时心跳检测阶段
  • Master定期检查Worker心跳,将一些超时的Worker移除,并对Worker按照内存进行倒序排序
  1. 多个Worker测试阶段
  • 启动多个Worker,查看是否能够注册成功,并停止某个Worker查看是否能够正确移除

9.3 工程搭建

项目使用Maven搭建工程

步骤

  1. 分别搭建几下几个项目
工程名说明
spark-demo-common存放公共的消息、实体类
spark-demo-masterAkka Master节点
spark-demo-workerAkka Worker节点
  1. 导入依赖(资料包中的pom.xml)
  • master/worker添加common依赖
  1. 导入配置文件(资料包中的application.conf)
  • 修改Master的端口为7000
  • 修改Worker的端口为7100

9.4 构建Master和Worker

分别构建Master和Worker,并启动测试

步骤

  1. 创建并加载Master Actor
  2. 创建并加载Worker Actor
  3. 测试是否能够启动成功

参考代码

Master.scala

val sparkMasterActorSystem = ActorSystem("sparkMaster", ConfigFactory.load())
val masterActor = sparkMasterActorSystem.actorOf(Props(MasterActor), "masterActor")

MasterActor.scala

object MasterActor extends Actor{
  override def receive: Receive = {
    case x => println(x)
  }
}

Worker.scala

val sparkWorkerActorSystem = ActorSystem("sparkWorker", ConfigFactory.load())
sparkWorkerActorSystem.actorOf(Props(WorkerActor), "workerActor")

WorkerActor.scala

object WorkerActor extends Actor{
  override def receive: Receive = {
    case x => println(x)
  }
}

9.5 Worker注册阶段实现

在Worker启动时,发送注册消息给Master

步骤

  1. Worker向Master发送注册消息(workerid、cpu核数、内存大小)
  • 随机生成CPU核(1、2、3、4、6、8)
  • 随机生成内存大小(512、1024、2048、4096)(单位M)
  1. Master保存Worker信息,并给Worker回复注册成功消息
  2. 启动测试

参考代码

MasterActor.scala

object MasterActor extends Actor{

  private val regWorkerMap = collection.mutable.Map[String, WorkerInfo]()

  override def receive: Receive = {
    case WorkerRegisterMessage(workerId, cpu, mem) => {
      println(s"1. 注册新的Worker - ${workerId}/${cpu}核/${mem/1024.0}G")
      regWorkerMap += workerId -> WorkerInfo(workerId, cpu, mem, new Date().getTime)
      sender ! RegisterSuccessMessage
    }
  }
}

WorkerInfo.scala

/**
  * 工作节点信息
  * @param workerId workerid
  * @param cpu CPU核数
  * @param mem 内存多少
  * @param lastHeartBeatTime 最后心跳更新时间
  */
case class WorkerInfo(workerId:String, cpu:Int, mem:Int, lastHeartBeatTime:Long)

MessagePackage.scala

/**
  * 注册消息
  * @param workerId
  * @param cpu CPU核数
  * @param mem 内存大小
  */
case class WorkerRegisterMessage(workerId:String, cpu:Int, mem:Int)

/**
  * 注册成功消息
  */
case object RegisterSuccessMessage

WorkerActor.scala

object WorkerActor extends Actor{

  private var masterActor:ActorSelection = _
  private val CPU_LIST = List(1, 2, 4, 6, 8)
  private val MEM_LIST = List(512, 1024, 2048, 4096)

  override def preStart(): Unit = {
    masterActor = context.system.actorSelection("akka.tcp://sparkMaster@127.0.0.1:7000/user/masterActor")

    val random = new Random()
    val workerId = UUID.randomUUID().toString.hashCode.toString
    val cpu = CPU_LIST(random.nextInt(CPU_LIST.length))
    val mem = MEM_LIST(random.nextInt(MEM_LIST.length))

    masterActor ! WorkerRegisterMessage(workerId, cpu, mem)
  }

  ...
}

9.6 Worker定时发送心跳阶段

Worker接收到Master返回注册成功后,发送心跳消息。而Master收到Worker发送的心跳消息后,需要更新对应Worker的最后心跳时间。

步骤

  1. 编写工具类读取心跳发送时间间隔
  2. 创建心跳消息
  3. Worker接收到注册成功后,定时发送心跳消息
  4. Master收到心跳消息,更新Worker最后心跳时间
  5. 启动测试

参考代码

ConfigUtil.scala

object ConfigUtil {
  private val config: Config = ConfigFactory.load()

  val `worker.heartbeat.interval` = config.getInt("worker.heartbeat.interval")
}

MessagePackage.scala

package com.itheima.spark.common

...

/**
  * Worker心跳消息
  * @param workerId
  * @param cpu CPU核数
  * @param mem 内存大小
  */
case class WorkerHeartBeatMessage(workerId:String, cpu:Int, mem:Int)

WorkerActor.scala

object WorkerActor extends Actor{
  ...

  override def receive: Receive = {
    case RegisterSuccessMessage => {
      println("2. 成功注册到Master")

      import scala.concurrent.duration._
      import context.dispatcher

      context.system.scheduler.schedule(0 seconds,
        ConfigUtil.`worker.heartbeat.interval` seconds){
        // 发送心跳消息
        masterActor ! WorkerHeartBeatMessage(workerId, cpu, mem)
      }
    }
  }
}

MasterActor.scala

object MasterActor extends Actor{
  ...

  override def receive: Receive = {
	...
    case WorkerHeartBeatMessage(workerId, cpu, mem) => {
      println("3. 接收到心跳消息, 更新最后心跳时间")
      regWorkerMap += workerId -> WorkerInfo(workerId, cpu, mem, new Date().getTime)
    }
  }
}

9.7 Master定时心跳检测阶段

如果某个worker超过一段时间没有发送心跳,Master需要将该worker从当前的Worker集合中移除。可以通过Akka的定时任务,来实现心跳超时检查。

步骤

  1. 编写工具类,读取检查心跳间隔时间间隔、超时时间
  2. 定时检查心跳,过滤出来大于超时时间的Worker
  3. 移除超时的Worker
  4. 对现有Worker按照内存进行降序排序,打印可用Worker

参考代码

ConfigUtil.scala

object ConfigUtil {
  private val config: Config = ConfigFactory.load()

  // 心跳检查时间间隔
  val `master.heartbeat.check.interval` = config.getInt("master.heartbeat.check.interval")
  // 心跳超时时间
  val `master.heartbeat.check.timeout` = config.getInt("master.heartbeat.check.timeout")
}

MasterActor.scala

  override def preStart(): Unit = {
    import scala.concurrent.duration._
    import context.dispatcher

    context.system.scheduler.schedule(0 seconds,
      ConfigUtil.`master.heartbeat.check.interval` seconds) {
      // 过滤出来超时的worker
      val timeoutWorkerList = regWorkerMap.filter {
        kv =>
          if (new Date().getTime - kv._2.lastHeartBeatTime > ConfigUtil.`master.heartbeat.check.timeout` * 1000) {
            true
          }
          else {
            false
          }
      }

      if (!timeoutWorkerList.isEmpty) {
        regWorkerMap --= timeoutWorkerList.map(_._1)
        println("移除超时的worker:")
        timeoutWorkerList.map(_._2).foreach {
          println(_)
        }
      }

      if (!regWorkerMap.isEmpty) {
        val sortedWorkerList = regWorkerMap.map(_._2).toList.sortBy(_.mem).reverse
        println("可用的Worker列表:")
        sortedWorkerList.foreach {
          var rank = 1
          workerInfo =>
            println(s"<${rank}> ${workerInfo.workerId}/${workerInfo.mem}/${workerInfo.cpu}")
            rank = rank + 1
        }
      }
    }
  }
  ...
}

9.8 多个Worker测试阶段

修改配置文件,启动多个worker进行测试。

步骤

  1. 测试启动新的Worker是否能够注册成功
  2. 停止Worker,测试是否能够从现有列表删除
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值