Scala中Stream的应用场景及其实现原理

后端开发的 Scala 流式处理技术 本文旨在为后端开发者提供全面的Scala流式处理技术指南。Scala语言特性对流式处理的支持Akka Stream框架的核心架构流式处理系统的设计模式和最佳实践实际生产环境中的性能调优技巧首先介绍流式处理的基本概念和Scala的支持特性然后深入分析Akka Stream的核心架构和设计原理接着通过实际代码示例展示各种流处理模式最后探讨高级主题和实际应用场景流式处理(Stream Processing):一种数据处理范式,数据以连续流的形式被处理,而不是作为离散的批量作业。 阅读详情

欢迎访问我的独立博客:http://cuipengfei.me/blog/2014/10/23/scala-stream-application-scenario-and-how-its-implemented/


假设一个场景

需要在50个随机数中找到前两个可以被3整除的数字。

听起来很简单,我们可以这样来写:

1
2
3
4
5
6
7
8
9
def randomList = (1 to 50).map(_ => Random.nextInt(100)).toList

def isDivisibleBy3(n: Int) = {
  val isDivisible = n % 3 == 0
  println(s"$n $isDivisible")
  isDivisible
}

randomList.filter(isDivisibleBy3).take(2)

一个产生50个随机数的函数;

一个检查某数字是否能被3整除的函数;

最后,对含有50个随机数的List做filter操作,找到其中所有能够被3整除的数字,取其中前两个。

把这段代码在Scala的console里面跑一下,结果是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
scala> randomList.filter(isDivisibleBy3).take(2)
31 false
71 false
95 false
7 false
38 false
48 true
88 false
52 false
2 false
27 true
90 true
55 false
96 true
91 false
82 false
83 false
8 false
51 true
96 true
27 true
12 true
76 false
17 false
53 false
54 true
70 false
29 false
49 false
12 true
83 false
18 true
6 true
7 false
76 false
51 true
95 false
76 false
85 false
87 true
84 true
44 false
44 false
89 false
84 true
42 true
44 false
0 true
23 false
35 false
55 false
res34: List[Int] = List(48, 27)

其最终结果固然是没有问题,找到了48和27这两个数字。但是非常明显的可以看出,isDivisibleBy3被调用了50次,找到了远多于两个的能被3整除的数字,但是最后我们只关心其中前两个结果。

这似乎有点浪费,做了很多多余的运算。

对于这个例子来说,这还没什么,我们的List很小,判断整除于否也不是什么耗时操作。

但是如果List很大,filter时所做的运算很复杂的话,那这种做法就不可取了。

现有解法的优缺点

1
randomList.filter(isDivisibleBy3).take(2)

这行代码有一个优点:

用描述性、声明性的语言描述了我们要做的事是什么,而无需描述怎么做。我们只需说先用filter过滤一下,然后拿前两个,整件事就完成了。

但是它同时也有一个缺点:

做了多余的运算,浪费资源,而且这个缺点会随着数据量的增大以及计算复杂度的增加而更为凸显。

试着解决其缺点

解决多余运算的思路很简单,不要过滤完整个List之后再取前两个。而是在过滤的过程中如果发现已经找到两个了,那剩下的就忽略掉不管了。

顺着这个思路很容易写出如下很像Java的代码:

1
2
3
4
5
6
7
8
9
10
  def first2UsingMutable: List[Int] = {
    val result = ListBuffer[Int]()

    randomList.foreach(n => {
      if (isDivisibleBy3(n)) result.append(n)
      if (result.size == 2) return result.toList
    })

    result.toList
  }

创建一个可变的List,开始遍历随机数,找到能被3整除的就把它塞进可变List里面去,找够了两个就返回。

执行的结果如下:

1
2
3
4
5
6
7
scala> first2UsingMutable
31 false
89 false
21 true
29 false
12 true
res35: List[Int] = List(21, 12)

可以看到,运算量确实变少了,找够了两个就直接收工了。

但是这实在很糟糕,显式使用了return同时还引入了可变量。

有什么东西像是一个foreach循环而又可以不引入可变量呢?fold

1
2
3
4
5
6
7
  def first2UsingFold: List[Int] = {
    randomList.foldLeft(Nil: List[Int])((acc, n) => {
      if (acc.size == 2) return acc
      if (isDivisibleBy3(n)) n :: acc
      else acc
    })
  }

执行:

1
2
3
4
5
6
7
scala> first2UsingFold
98 false
77 false
68 false
93 true
93 true
res36: List[Int] = List(93, 93)

效果和上面一段代码类似,没有多余的运算。但是由于需要early termination,所以还是摆脱不了return。

这两种解法在去除多余运算这个缺点的同时也把原来的优点给丢掉了,我们又退化回了描述如何做而不是做什么的程度了。

如何保持代码的表意性而又不用做多余运算呢?

其实类似的问题是有套路化的解决方案的:使用Stream。

1
randomList.toStream.filter(isDivisibleBy3).take(2).toList

这行代码执行的结果:

1
2
3
4
5
6
7
scala> randomList.toStream.filter(isDivisibleBy3).take(2).toList
86 false
15 true
53 false
20 false
93 true
res42: List[Int] = List(15, 93)

可见没有多余运算了,而且这行代码和最初代码极为相似,都是通过描述先做filter再做take来完成任务的。缺点没有了,优点也保留了下来。

这同样都是filter和take,代码跟代码的差距咋就这么大呢?

答案就是:因为Stream利用了惰性求值(lazy evaluation),或者也可以称之为延迟执行(deferred execution)。

接下来就看一下这两个晦涩的名词是如何帮助Stream完成工作的吧。

实现原理

在这里我借用一下Functional programming in Scala这本书里对Stream实现的代码,之所以不用Scala标准库的源码是因为我们只需要实现filter,take和toList这三个方法就可以展示Stream的原理,就不需要动用重型武器了。

先假设我们自己实现了一个MyStream,它的用法和Stream是类似的:

1
MyStream(randomList: _*).filter(isDivisibleBy3).take(2).toList

以这一行代码为引子,我们来开始解剖MyStream是如何工作的。

类型签名

1
2
3
4
5
6
7
trait MyStream[+A] {
  . . . . . .
}

case object Empty extends MyStream[Nothing]

case class Cons[+A](h: () => A, t: () => MyStream[A]) extends MyStream[A]

一个trait叫做MyStream,其中的内容我们暂时忽略掉。

它有两个子类,一个Cons,一个Empty。Empty当然是代表空Stream了。

而Cons则是头尾结构的,头是Stream中的一个元素,尾是Stream中余下的元素。请注意头和尾这两个参数的类型并不是A,头的类型是一个能够返回A的函数,尾的类型是一个能够返回MyStream[A]的函数。

初始化

有了以上的类型定义以及头尾结构,我们就可以把很多个Cons加一个Empty(或者是无限多个Cons,没有Empty)连起来就构成一个Stream了,比如这样:

1
Cons(()=>1,()=>Cons(()=>2,()=>Empty))

这样就可以构造一个含有1,2的Stream了。

不过,请注意,上面的说法并不严谨,实际上它是一个包含着两个分别会返回1和2的函数的Stream。

也就是说当上面的代码在构造Cons的时候,1和2还没有“出生”,它们被包在一个函数里,等着被释放出来。

如果说我们通常熟知的一些集合包含的是花朵的话,那Stream所包含的就是花苞,它本身不是花,但是有开出花来的能力。

Smart初始化

当然,如果直接暴露Cons的构造函数出去给别人用的话,那这API也未免太不友好了,所以Stream需要提供一个易用的初始化的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object MyStream {

  def apply[A](elems: A*): MyStream[A] = {
    if (elems.isEmpty) empty
    else cons(elems.head, apply(elems.tail: _*))
  }

  def cons[A](hd: => A, tl: => MyStream[A]): MyStream[A] = {
    lazy val head = hd
    lazy val tail = tl
    Cons(() => head, () => tail)
  }

  def empty[A]: MyStream[A] = Empty
}

这个没有太多好解释的,我们就是用apply和小写的cons这两个方法来把客户代码原本要写的一大堆匿名函数给代劳掉。

需要注意的一点是apply方法看似是递归的,好像是你调用它的时候如果给它n个元素的话,它会自己调用自己n-1次。事实上它确实会调用自己n-1次,但是并不是立即发生的,为什么呢?

因为小写的cons方法所接受的第二个参数不是eager evaluation的,这就会使得apply(elems.tail: _*)这个表达式不会立即被求值。这就意味着,apply缺失会被调用n次,但是这n次并不是一次接一次连续发生的,它只会在我们对一个Cons的尾巴求值时才会发生一次。

如果说普通的集合中包含的是数据的话,那Stream中所包含的就是能够产生数据的算法。

如何?是不是花朵花苞的感觉又回来了?

还记得我们开始剖析的时候那句代码是什么吗?

1
MyStream(randomList: _*).filter(isDivisibleBy3).take(2).toList

现在我们算是把MyStream(randomList: _*)这一小点说清了。

接下来看MyStream(randomList: _*).filter(isDivisibleBy3)是如何work的。

filter

1
2
3
4
5
6
7
8
9
10
11
12
13
14
trait MyStream[+A] {

  def filter(p: A => Boolean): MyStream[A] = {
    this match {
      case Cons(h, t) =>
        if (p(h())) cons(h(), t().filter(p))
        else t().filter(p)
      case Empty => empty
    }
  }

. . . . . .

}

这个方法定义在基类里,又是一个看似递归的实现。

为什么说是看似呢?因为在

1
if (p(h())) cons(h(), t().filter(p))

这行代码中我们又用到了小写的cons,它所接受的参数不会被立即求值。也就是说filter一旦找到一个合适的元素,它就不再继续跑了,剩下的计算被延迟了。

比较值得提一下的是:这里的h()是什么呢?h是构造Cons时的第一个参数,它是什么类型的?()=>A。它就是之前提到的能够生产数据的算法,就是那个能够开出花朵的花苞。在这里我们说h(),就是在调用这个函数来拿到它所生产的数据,就是让一个花苞开出花朵。

take

1
MyStream(randomList: _*).filter(isDivisibleBy3).take(2)

接下来就该说take是如何work的了。在这里我们可以回顾一下,MyStream(randomList: _*)返回一个类型为MyStream[Int],其中包含很多个可以返回Int的函数的容器。然后我们调用了这个容器的filter方法,filter又返回一个包含很多个可以返回Int的函数的容器。请注意,到这里为止,真正的计算还没有开始,真正的计算被包含到了一个又一个的函数(花苞)中,等待着被调用(绽放)。

那对filter的结果调用take又会怎样呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
trait MyStream[+A] {

  . . . . . .

  def take(n: Int): MyStream[A] = {
    if (n > 0) this match {
      case Cons(h, t) if n == 1 => cons(h(), MyStream.empty)
      case Cons(h, t) => cons(h(), t().take(n - 1))
      case _ => MyStream.empty
    }
    else MyStream()
  }

  . . . . . .

}

看过了前面的apply和filter之后,take就显得顺眼了很多。我们又见到了小写的cons,条件反射一般,我们就可以意识到,只要看见cons,那就意味着作为它的参数的表达式不会被立即求值,那这就意味着计算被放到了函数里,稍后再执行。那稍后到底是什么时候呢?

那就得看下面的toList了。

toList

1
2
3
4
5
6
7
8
9
10
11
12
trait MyStream[+A] {

  . . . . . .

  def toList: List[A] = {
    this match {
      case Cons(h, t) => h() :: t().toList
      case Empty => Nil
    }
  }

}

又是一个递归实现,但是这次可不是看似递归了,这次是实打实的递归:只要还没有遇到空节点,就继续向后遍历。这次没有使用cons,没有任何计算被延迟执行,我们通过不断地对h()求值,来把整个Stream中每一个能够生产数据的函数都调用一遍以此来拿到我们最终想要的数据。

总结

要把以上的代码细节全部load进脑子跑一遍确实不太容易,我们人类的大脑栈空间太浅了。

所以我们试着从上面所罗列出的纷繁的事实中抽象出一些适合人脑理解的描述性语句吧:

  • List(1,2,3)会构造一个容器,容器中包含数据
  • List(1,2,3).filter(n=>n>1)会构造出一个新的容器,其中包含2和3,这两块具体的数据
  • List(1,2,3).filter(n=>n>1).take(1)会把上一步中构造成的容器中的第一块数据取出,放入一个新容器

  • MyStream(1,2,3)也会构造一个容器,但是这个容器中不包含数据,它包含能够生产数据的算法

  • MyStream(1,2,3).filter(n=>n>1)也会构造出一个新的容器,这个容器中所包含的仍然是算法,是基于上一步构造出的能生产1,2,3的算法之上的判断数字是否大于1的算法
  • MyStream(1,2,3).filter(n=>n>1).take(1)会把上一步中构造成的算法容器中的第一个算法取出,放入一个新容器
  • MyStream(1,2,3).filter(n=>n>1).take(1).toList终于把上面所有步骤构造出的算法执行了,从而得到了最终想要的结果

上面对List和Stream的应用的区别在哪儿呢?

就在于List是先把数据构造出来,然后在一堆数据中挑选我们心仪的数据。

而Stream是先把算法构造出来,挑选心仪的算法,最后只执行一大堆算法中我们需要的那一部分。

这样,自然就不会执行多余的运算了。

my_stream:My Stream 是一个应用程序,用于存储所有租赁服务上可用的电影的综合观看列表。 它还允许您跟踪您看过的电影 我的直播 My Stream 是一个应用程序,用于存储所有租赁服务上可用的电影的综合观看列表。 它还允许您跟踪您看过的电影。 GitHub 链接: : 部署的应用程序: : 目录 安装 我的 用法 用户必须注册一个帐户才能使用此服务。 登录后,将加载一个搜索表单,供用户搜索他们想要的电影。 搜索后,在加载时单击电影海报以显示详细信息和添加到列表的选项。 执照 这个项目是在 MIT 许可下获得许可的。 贡献 不 测试 目前没有对该项目进行测试。 截图 问题 如果您有任何问题,可以在http://github.com/MeganGerwick下的 Github 上找到我。 立即下载

相关推荐

mystreamtv:在线视频流应用

mystreamtv 在线视频流应用

scala的Stream流

scala的Stream流 转stream流 惰性数据结构 .toStream 例子 val list1: List[Int] = List(1,2,3,4,5) val s1: Stream[Int] = list1.toStream println(s1) //Stream(1, ?) println(s1.head) //1 println(s1.tail.head) //2 println(s1) //Stream(1,

qq_43494678的博客 523

数字孪生实战:如何用Blender+Unity快速搭建未来工厂3D模型(附资源包)

本文提供了一套利用Blender进行三维建模,再结合Unity构建虚拟仿真环境的实用方案,旨在帮助中小型制造企业低成本、高效率地搭建未来工厂数字孪生原型系统。文章详细介绍了从数据规划、模型优化到场景构建、逻辑仿真与数据对接的全流程,并附有资源包,助力技术团队快速实现从静态展示到动态交互的数字孪生应用。

weixin_29191669的博客 341

Scala中Stream讲解

//使用#::得到一个stream:numsForm(参数:参数类型) : 返回值类型 = 参数 #:: numsForm(m+1) ==>> 两个参数必须一致 def numsForm(n: BigInt) : Stream[BigInt] = n #:: numsForm(n + 1) //传递一个值,并打印stream集合 val one = numsForm(0) print...

weixin_42771366的博客 1699

OpenCV实战:5分钟搞定图像拼接(附Python代码)

本文通过OpenCV实战教程,详细讲解如何使用Python在5分钟内完成图像拼接。重点介绍`findHomography`函数和单应性矩阵的应用,提供完整的代码示例和常见问题解决方案,帮助开发者快速掌握图像拼接技术。

weixin_26833503的博客 121

两轮差速小车循线控制原理分析

硬件资料设定:小车驱动来自于两个相同的电机,转向依靠两轮差速实现,小车前后左右安装超声波传感器,前后各一个,左右各两个; 功能目标:假设小车左侧有墙壁,通过超声波测距实现按指定距离沿墙行驶 参数设定:车轮半径r=3cm,车轮间距wd=10cm,车身长length=16cm,侧边超声波传感器前后各一个,假设一个在车头一个在车尾,间距=车身=length=16cm,小车线速度恒定v = 0.2m/s,距离墙d=10cm,假设超声波传感器以10Hz的频率采集距离。 在以上条件下,设定小车出发点,方向与墙平行

aibingjin的博客 1万+

Java基础-Stream流

Stream流的获取单列集合集合对象.stream();双列集合不能直接获取,需要间接获取。集合对象.entrySet().stream();发现非常好用的一个刷题网站!集合对象.keySet().stream();在线编程出答案,(也可自行查看答案)非常方便。Arrays.stream(数组名);题目难度可以自行选择。.........

weixin_43474701的博客 765

2 利用ITK实现多帧Dicom读取,VTK实现数据显示

vtk中也有dicom读写类,但是只能完成单帧DICOM数据读取,这对于实际应用是非常不利的。而ITK可以实现dicom多帧读取,通常情况ITK与VTK也会结合使用(ITK处理,VTK显示),所以就一起学习下吧!

buerbuhuitou的博客 1564

scala的stream流

Scala的流的实现在Stream中。 主要用到的实现类是Cons类。 @SerialVersionUID(-602202424901551803L) final class Cons[+A](hd: A, tl: => Stream[A]) extends Stream[A] { override def isEmpty = false override def head =...

不说话装高手 3589

【C2000系列DSP的Bootloader详解】如何利用脚本自动合并Bin文件

本文通过OutconvertBin.py实现了 C2000 Bootloader+UserApp 的「ELF 转 BIN+BIN 合并」全流程自动化,核心优势如下: 架构适配:严格遵循 C28x 16 位大端格式,生成的 BIN 文件可直接烧录; 自动化程度高:一键完成清理、转换、合并,无需手动执行多个命令; 可追溯性:生成 CRC32 校验值,便于验证文件完整性; 量产友好:合并后的merged.bin支持一次性烧录,提升量产效率。

昔时扬尘处 183

绕过Cloudflare的两种简便方案

绕过Cloudflare的两种简便方案

自成背后的博客 1万+

Scala中Stream的应用场景及事实上现原理

欢迎訪问我的独立博客:http://cuipengfei.me/blog/2014/10/23/scala-stream-application-scenario-and-how-its-implemented/ 如果一个场景 须要在50个随机数中找到前两个能够被3整除的数字。 听起来非常easy,我们能够这样来写: 1 2 3 ...

weixin_30399821的博客 93

PCL 基于法线微分(DoN)的点云分割【2026最新版】

基于法线微分的分割。博客长期更新,本文最新更新时间为:2026年5月10日。

点云侠的博客 39万+

Requests-Scala与JDK 11 HttpClient:底层实现原理分析

Requests-Scala是一个Scala语言实现的HTTP客户端库,它移植自Python的Requests库,以灵活、直观且易于使用的特点著称。该库底层基于JDK 11引入的HttpClient API构建,通过封装和优化,为Scala开发者提供了更加友好的HTTP请求接口。本文将深入分析Requests-Scala如何与JDK 11 HttpClient协同工作,揭示其底层实现原理和性能优化

gitblog_00853的博客 980

微信小程序 - 最新实现项目适老化设计全流程及源码,老年模式大字版与常规版页面样式切换,老年人大字模式字体与布局更换关怀版解决方案(新老项目均适用的方案,全局页面统一修改文本字号/DOM排版元素等)

微信小程序,vue2,vue3,老年大字版设计,小程序开发如何实现适老化方案,放大字体字号及改变页面的布局,小程序关怀模式实现思路及代码,统一将布局都改了,全局修改字体大小适配老年人看不清问题,小程序怎么接入老年模式并根据模式改变页面的UI设计,实现全局修改字体大小适老化/关怀版改造,最新详细示例代码全流程,字体放大/缩小,老项目怎么适配老年人模式方案,小程序适老化设计指南,当切换成老年模式时页面所有排版都会发生变化,小程序vue开发,新手详细教程,老年模式字体设置思路和UI布局的设计,开发者可以通过wx.

🏆 前端领域优质创作者 7309

精雕软件(JDpaint) 5.5 绿色版

精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版精雕软件(JDpaint) 5.5 绿色版

play scala 1 用stream表示全体自然数

1.在创建一个List的时候,tail是立即被evaluated,如果把构造函数中的tail改成call by name的参数,而可以在用到tail的时候才计算tail。stream的实现就是用了这个思想Steam的简单实现defcons[T](hd:T,tl:=>Stream[T])=newStream[T]{ defisEmpty=...

weixin_34192732的博客 149

win10 跳过U8环境检测的IE WebControls.rar

win10 安装u8的时候 提示IE WebControls 不通过,这个工具可以欺骗u8的检查。 跳过U8环境检测的IE WebControls。提示导入失败后,重新检测通过。

Requests-Scala流式处理:大文件上传下载的最佳实践

Requests-Scala是一个灵活直观的Scala HTTP客户端,作为Python Requests库的Scala移植版本,它提供了强大的流式处理能力,让大文件上传下载变得简单高效。本文将介绍如何利用Requests-Scala的流式API实现大文件的高效传输,避免内存溢出问题,提升应用性能。 ## 为什么选择流式处理大文件? 在处理大文件传输时,传统的一次性加载整个文件到内存的方式往往

gitblog_00672的博客 1036

蓝牙模块HM-10手册

蓝牙模块HM-10手册,该手册对蓝牙模块HM-10进行了详细的介绍和使用方法以及一些AT指令。

文档:亲测可用解决vnc灰屏,黑屏,鼠标大黑叉情况

主要解决vnc-viewer灰屏,黑屏,鼠标大黑叉等现象,详细见文档。

Requests-Scala多部分文件上传:实现高效文件传输

Requests-Scala作为Python Requests HTTP客户端的Scala移植版本,提供了灵活直观的文件上传功能。本文将详细介绍如何使用Requests-Scala实现高效的多部分文件传输,帮助开发者轻松处理各类文件上传场景。 ## 多部分文件上传基础 多部分文件上传(Multipart File Upload)是HTTP协议中用于传输二进制文件的标准方式,广泛应用于表单提交、

gitblog_00136的博客 914

Scala实战理解之集合

一,集合list[T] [T]表示泛型,Scala会进行类型推导,所以一般没有必要显示指定类型T。 使用list的一些例子 1,定义一个list a 2,用”::” 连接操作符,左边为元素,右边为list,将元素添加进list成新的list b。   3,用”::” 连接操作符,先”z”连接Nil,再”y”连接”z和Nil”,以此类推。   等同于下: 4,“:::”

free356的博客 1092

scala 系列 ---- 流 Stream、视图 View、线程安全的集合、并行集合的使用详解用法及代码示例

流 Stream stream是一个集合。这个集合,可以用于存放无穷多个元素,但是这无穷个元素并不会一次性生产出来,而是需要用到多大的区间,就会动态的生产,末尾元素遵循lazy规则(即:要使用结果才进行计算的) 。 创建Stream对象 def numsForm(n: BigInt) : Stream[BigInt] = n #:: numsForm(n + 1) val stream1 = numsForm(1) 说明 Stream 集合存放的数据类型是BigInt numsForm 是自定

mojolang 826

Stream的#::实现

本文来自fair-jm.iteye.com 转截请注明出处   前端时间买了本 FP in scala(电子版 可以paypal付 双币信用卡就可以) 粗粗地看 看到了Stream惰性求值那章就想自己写一写 按照这本书写了写 于是想了想scala类库中的scala.collection.immutable.Stream中的 #::操作是如何实现的呢:   1 #:: {printl...

Fair_Jm的专栏 297

Stream创建斐波那契数列

Stream 流 是递归的数据结构,包含一个表头(当前元素)和一个表尾(集合的其余部分)。可以利用一个函数和函数的递归调用来构建流。 Stream.cons(head,tail) head是Stream中的表头,tail是余下的元素

thelostxxx的博客 878

【Scala】基础16--集合5

1、迭代器 通过iterator方法从集合获得一个迭代器,通过while循环和for表达式对集合进行遍历。 object IteratorDemo { def main(args: Array[String]): Unit = { val iterator = List(1, 2, 3, 4, 5).iterator // 得到迭代器 /* 这里我们看看iterator...

fu-jw的博客 5232
上一篇: Desugar Scala(17) -- Option和for,以及脑子里发生的事情
下一篇: Spark RDD的fold和aggregate为什么是两个API?为什么不是一个foldLeft?
崔鹏飞
博客等级 码龄19年 441粉丝 128原创
评论 5
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值