使用 Scala 宏解决对象转换

8、Scala面向对象 Scala面向对象 阅读详情

背景

在 GrowingIO 服务端的开发中,我们使用 gRPC 进行微服务之间的数据通信,每个提供服务的项目,都要定义一套自己的 Protobuf 消息格式,然后利用 protoc 去生成对应语言的适配代码。在我们维护的项目中,主要使用 Scala 语言来实现各个服务,每个服务都会定义一套自己的领域模型(一般是一些 case class),而 protoc 默认生成的 JVM 平台的代码是 Java 语言的,对应的 Protobuf 消息格式与 Scala 项目中定义的领域模型会存在一些对应关系,并且他们的属性往往会高度一致。当我们需要在这两种类型做数据转换时,会出现许多 protobuf-java 和 scala case class之间的转换代码。

一般情况下,protobuf-java 和 Scala 之间都能找到对应类型,比如 java.util.List 和 Seq/List/Array,Timestamp 和 ZonedDateTime,同时一些 Scala 中 Option 类型也可以用 protobuf 是一封装类型去表示,比如 Option[String] 可以用 StringValue 去表示。因为每个类型都有自己对应的特性,类型嵌套又会增加极大地增加复杂度,我们一直在找一个通用的转换方案,使用起来类型安全,并尽可能地省掉臃肿无味的代码。

参考了
https://github.com/playframework/play-json 的 Reader、Writer 设计设计理念和 https://github.com/scalalandio/chimney用 Scala 宏对类型转换的处理方式,我们最终使用了 Scala 宏结合隐式参数 DSL 设计的思路实现了 https://github.com/changvvb/scala-protobuf-java 一套解决方案。

方案效果

我们先在这里定义一个 case class User 和 Protobuf UserPB 来对比使用该方案前后的效果。

case class User (
  id:Long,
  name:String,
  phoneNumber: Option[String],
  hobbies: Seq[String]
)
message UserPB (
    int64 id = 1,
    string name = 2,
    google.protobuf.StringValue phone_number = 3,
    repeated string hobbies = 4
)

如果我们自己手写 scala case class 到 protobuf-java 的转换,效果将是这样的:

val user = User(1,"Jack",Some("1234567890"),Seq("ping pong", "reading"))
​
val builder = UserPB.newBuilder               // 新建一个 builder
builder.setId(user.id).setName(user.Name)     // 设置 id、name 字段
if(user.phoneNumber.isDefined) {              // 这里也可以直接简写成 user.phoneNumber.map(StringValue.of).foreach(builder.setPhoneNumber)
  builder.setPhoneNumber(StringValue.of(user.phoneNumber.get))
}
builder.setAllHobbies(user.hobbies.asJava)    // 设置 hobbies 字段
val userPB = builder.build                    // 构建 UserPB 对象

如果将 protobuf-java 对象转换成 scala case class 对象也需要写差不多的代码,只不过是将一些 set 方法变成 get 方法的结果传入到 User 的构造方法中。
使用我们的解决方案后,代码将是这样的:

val user = User(1,"Jack",Some("1234567890"),Seq("ping pong", "reading"))
val userPB = Protoable[User, UserPB].toProto(user)  //一行代码就可以完成

可以看到,代码简洁了一个维度,用起来也会帮我们做类型安全检查,真正实现了简单、安全、易用的目标。
下面我们将具体介绍一下这个工具的设计方法与思路,以及其中的哲学思想。

DSL 设计

DSL 中最根本的两个特质是 Protoable[-S, +P] 和 Scalable[+S, -P],S 代表一个 Scala 类型,P 代表一个 protobuf-java 类型。Protoable 表示这是一个可以将 Scala 类型转换成 protobuf-java 类型的东西,Scalable 类型则相反。此处用到了协变和逆变。

trait Protoable[-S, +P] {
  def toProto(entity: S): P
}
​
trait Scalable[+S, -P] {
  def toScala(proto: P): S
}

紧接着,我们需要在他们的伴生对象里去写一些构造方法,和一些默认的转换器。这样,一些基本的东西就有了。

object Scalable {
​
  def apply[S, P](convert: P ⇒ S): Scalable[S, P] = x => convert(x)   // 类似 java lambda 的用法
​
  implicit val javaIntegerScalable = Scalable[Int, java.lang.Integer](_.toInt)             // 装箱类型的转换
  implicit val stringValueScalable = Scalable[String, StringValue](_.getValue)             // Protobuf 封装类型的转换
  implicit val zonedDateTimeProtoable = Scalable[ZonedDateTime, Timestamp] { proto ⇒       // 时间类型的转换
    Instant.ofEpochSecond(proto.getSeconds, proto.getNanos).atZone(ZoneId.systemDefault())
  }
}
​
object Protoable {
​
  def apply[S, P](convert: S ⇒ P): Protoable[S, P] = x => convert(x)
​
  implicit val javaDoubleProtoable = Protoable[Double, java.lang.Double](_.toDouble)
  implicit val stringValueProtoable = Protoable[String, StringValue](StringValue.of)
  implicit val zonedDateTimeProtoable = Protoable[ZonedDateTime, Timestamp] { entity ⇒
    Timestamp.newBuilder().setSeconds(entity.toEpochSecond).setNanos(entity.getNano).build()
  }
}

使用宏自动生成代码

还是在文章前面定义的两个 User 和 UserPB 类型,思考一下我们应该怎么去用上面的 DSL 去写他们之间的转换呢?
直接看结果,可以这样写:

new Protoable[User, UserPB] {   
  override def toProto(entity: User): UserPB = {
    val builder = UserPB.newBuilder()
    builder.setId(entity.id)
    builder.setName(entity.name)
    if(entity.phoneNumber.isDefined) {
      builder.setPhoneNumber(implicity[Protoable[String,StringValue]].toProto(entity.phoneNumber))
    }
    builder.addAllHobbies(implicitly[Protoable[Seq[String], java.util.List[String]]].toProto(entity.hobbies))
    builder.build
  }
}
​
new Scalable[User, UserPB] {   
  override def toScala(proto: UserPB): User = {
    new User(
      id = proto.getId,
      name = proto.getName,
      phoneNumber = if(proto.hasPhoneNumber) {
        Some(implicitly[Scalable[String,StringValue]].toScala(proto.getPhoneNumber))
      } else {
        None
      },
      hobbies = implicitly[Scalable[Seq[String, java.util.List[String]]].toScala(proto.getBobbiesList)
    )
  }
}

这就是我们需要 Scala 宏去生成的代码,这些代码充分使用了我们上面定义的 Protoable 和 Scalable 两个特质,以及 Scala 隐式参数的特性,这样设计可以让我们更加方便地去构造抽象语法树,优点包括:

  1. 数据的转换与处理全部在我们的 DSL 设计框架内,使问题都能通过 Protoable 和 Scalable 这两个特质去解决。
  2. 充分利用编译器隐式参数查找的特性,对于某个字段涉及不同类型的转换时,就让编译器在上下文中找一下这个字段的类型转换器。我们这里用到了 implicitly[T] 这个方法,这是 Scala 标准库中的方法,它可以帮我们从上下文中找到一个对应的 T 类型参数,比如这里我们需要找到一个 Scalable[String,StringValue]类型的隐式参数(在 traitScalable 中定义了)。
  3. 结合第2点,我们处理两个对象之间的转换时,不用递归去考虑子对象的问题,让我们去生成代码时,仅仅关注当前对象字段之间的关系就可以了,包括一些简单的Option 类型处理、集合类型处理,不用关心别的东西,其它的东西都通过隐式参数交给编译器去做,这会大大降低设计成本。
  4. 极易进行类型扩展。如果我们需要定义一个系统级的转换器,在 Protoable 和 Scalable 的伴生对象里添加一个就可以了;如果我们需要一个业务相关的转换器,在代码处能够够到的上下文中定义就可以了,Scala 的隐式参数查找规则都能帮你找到要扩展的类型转换器。

显然,这里我们似乎已经找到一个通用的规则去处理这里字段之间的转换了,现在我们可以让 Scala 的宏去帮我们在编译时反射,来生成需要的代码了。可以在他们各自的伴生对象里去定义宏的构造方法。

object Scalable {
  def apply[S <: Product, P]: Protoable[S, P] = macro ProtoScalableMacro.protosImpl[S, P]
}
​
object Protoable {
  def apply[S <: Product, P]: Scalable[S, P] = macro ProtoScalableMacro.scalasImpl[S, P]
}

可以看到,每个方法只需要两个类型参数就可以了。具体宏是怎么实现的,这里不展开讲了,总之就是根据上面的思路结合 Scala 宏在编译时反射各种类型去生成我们需要的代码的语法树(AST)。

我们使用它去做一次双向的转换,通过很简单的代码就可以完成。

val user = User(1,"Jack", Some("1234567890"), Seq("ping pong","coding"))
val userPb = Protoable[User,UserPB].toProto(user)       // 将 Scala case class 对象转换成 protobuf-java 对象
val user2 = Scalable[User,UserPB].toScala(user)         // 将 protobuf-java 对象转换成 Scala case class 对象
​
assert(user == user2)

可以看到,做一次转换,不管你字段有多少,只需要一行代码,这让我们代码数据减少一个数据级,而且是类型安全的,类型不对,参数不够都会在编译时报错。

对于嵌套类型,我们只需要先定义一个内部的转换,然后在编译器能找到的上下文中使用就可以了。我们假定这里 Outer 类型中有一个 Inner 类型的字段,就可以这样写。

implicit val innerScalable = Scalable[Inner,InnerPB]
Protoable[Outer,OuterPB].toScala(outerObj)

进一步优化,使用 Builder 方式去定制内部的转换逻辑

如果我们有一个这样的业务场景,比如我们需要在 UserPB 转 User 时,让 id 始终不小于 0,上面东西基本上不好实现这个简单的需求,即使实现了,可能也比较难看。我们引入 Builder 构造器来帮我们去做这件事情。Builder 构造器会帮我们注入定制的一些规则到宏生成的代码中。

val scalable = ScalableBuilder[User,UserPB]
                 .setField(_.id, userPB => if(userPB.getId < 0) 0 else userPB.getId)
         .setField(_.name, /*这里可以放一些别的逻辑,或不写这行,用之前默认的处理方式 */)
                 .build
scalable.toScala(...)

setField 有两个参数,第一个是字段选择器,第二个是一个 lambda 表达式,lambda 表达式用于表示输入一个对象,输出这个字段想要什么东西,最后调用 build 方法,可以生成一个的 Scalable 对象。

除了ScalableBuilder,同时我们还有 ProtoableBuilder 来做反方向转换的工作。这两个 Builder 都可以来做一些字段级别的逻辑控制,或生成一些缺失的字段,在很多时候非常有用。

Scala3 支持

我们知道,Scala3 在前两个月刚刚正式发布,关于这个工具 Scala3 带来的特性有:

  • 更简洁隐式参数定义
  • 基于 inline、Quotes 全新的宏设计 这使这们在 dsl 设计时可以更简洁,宏实现部分要全部重写。

Scala3 还有一些问题

  • 不能编译 protoc 生成的 java
    文件https://github.com/lampepfl/dotty/issues/6138,不过我提了一个 PR 正在修复这个问题
    https://github.com/lampepfl/dotty/pull/12884
  • 编译时反射的 API 要更少一点,导致一些类型涉及到类型推导的功能不好做 这些都是我们未来支持 scala3 需要克服的问题。

在这里插入图片描述

jsoniter-scala:Scala宏,可在编译时生成安全且超快速的JSON编解码器 乔尼特·Scala Scala宏可为案例类,标准类型和集合生成编解码器,以实现JSON解析和序列化的最佳性能。 该将jsoniter-scala的解析和序列化性能与 , , , , , , , 和库进行了,使用不同的JDK和GraalVM以下环境上的版本:Intel:registered:Core:trade_mark:i9-9880H CPU @ 2.3GHz(最大4.8GHz),RAM 16Gb DDR4-2400,macOS Mojave 10.14.6和最新版本的Amazon Corretto 8/11,OpenJDK 17(早期-访问构建) ,用于Java 8/11的GraalVM 21 CE(开发版本)和用 立即下载

相关推荐

protoless:Scala的Protobuf序列化库

protoless:Scala的Protobuf序列化库

Scala 数据类型及类型转换

一、数据类型 Scala与Java有着相同的数据类型,但是又有不一样的地方 1.1 Java数据类型 Java的数据类型包含基本类型和引用类型 基本类型:byte,short,char,int,long,float,double,boolean 引用类型:Object,数组,字符串,包装类,集合,POJO对象等 1.2 Scala数据类型 Scala是完全面向对象的语言,所以不存在基本数据类型的概念,有的只是任意值对象类型(AnyVal)和任意引用对象类型(AnyRef) 二、类型转换 2.1

mengxianglong123的博客 3297

ArcGISPro小白必看:5分钟搞定3D地形图制作(附免费数据源)

本文为ArcGISPro初学者提供5步制作专业3D地形图的完整指南,涵盖免费数据获取、项目初始化、数据预处理、3D场景构建和成果输出等关键步骤。特别推荐使用NASA的SRTM高程数据和吉林一号卫星影像,帮助用户快速掌握3D地形图制作技巧,适用于教学演示和科研分析。

weixin_28314575的博客 241

Scala(数据类型及其转换)

文章目录Scala简介1、Java代码与Scala代码的对比代码对比:2、变量和常量3、字符串输出3.1 使用双引号3.2 使用插值表达式3.3 使用三引号3.4 printf用法:字符串,通过%传值3.5 扩展: 惰性赋值4、键盘输入4.1 使用步骤4.2 示例5、数据类型6、 类型转换6.1 概述6.2 自动类型转换6.3 强制类型转换6.4 值类型和String类型之间的相互转换 Scala简介 ​ Scala是基于Java的,其将面向对象和函数式编程结合成一种简洁的高级语言。Scala的静态类型有

ZYN的博客 2626

保姆级教程:用Python+MM-Agent搭建自己的数学建模AI助手(支持GPT-4o/DeepSeek)

本文提供了一份详细的教程,教你如何使用Python和MM-Agent框架搭建数学建模AI助手,支持GPT-4o和DeepSeek-R1等LLM后端。从环境配置、HMML知识库集成到多LLM适配和建模流程实现,手把手教你构建智能建模系统,提升数学建模效率。

k6l7m8n9的博客 452

ENSP从下载到运行:用虚拟机搭建华为认证实验环境全流程(含USG6000V镜像配置)

本文提供了一份详细的华为ENSP仿真平台从下载、安装到配置USG6000V防火墙镜像的完整指南。内容涵盖前置软件安装、ENSP主程序配置、核心设备镜像导入及虚拟机资源分配策略,旨在帮助网络工程师和学习者快速搭建一个稳定、功能齐全的华为认证实验环境,为备考和模拟企业网络部署打下坚实基础。

weixin_29230865的博客 534

Scala数据类型转换

Scala中类似java数据类型转换的简单方法: var name = 1 1. name.toDouble 使用toDouble、toInt等方法,对象名.to方法可以实现简单的数据类型转换。 2.name.doubleValue 可以使用doubleValue、longValue、intValue三种方法,用于简单的数据类型转换。 3.Integer.valueOf(name) ...

Fortuna_i的博客 1万+

ClaudeCode 通过 CC Switch 配置 Ollama 本地大模型

本文介绍了如何让Claude Code使用本地免费模型qwen3.5:9b而无需Anthropic API Key的方法。主要内容包括:1)安装Claude Code、CC Switch代理工具和Ollama本地模型运行时;2)配置环境变量和设置文件将请求转发到本地服务;3)详细说明了系统架构和工作原理,展示请求如何从Claude Code经CC Switch代理到Ollama本地模型。该方案通过代理拦截和转发机制,实现了使用免费开源模型替代官方API的功能。

稚枭天卓 3221

Scala面向对象--类型的转换与检测

object Test17_Extends { def main(args: Array[String]): Unit = { val student17:Student17 = new Student17(" alice", 18) student17.study() student17.sayHi() val person:Person17 = new Student17(" bob", 13) //子类的对象赋值给父类的引用 person.sayHi()

那人独钓寒江雪 585

Android15音频进阶之4种调试线程死锁利器(八十九)

本篇目的:Android15音频进阶之4种调试线程死锁利器对于开发者来说,最令人头疼的,莫过于多线程交互出问题的时候,我们应该从哪里找突破口呢?下面来介绍一下调试Android音频中线程死锁的四种方案线程死锁是并发编程中常见的问题之一,它指的是在多个线程竞争资源的过程中,由于相互等待对方持有的资源而无法继续执行,从而导致所有线程都无法向前推进的现象。死锁不仅会导致系统性能严重下降,甚至可能导致整个系统崩溃。线程死锁的产生有四个必要条件:互斥条件:资源不能被多个线程共同使用,只能由一个线程独占。

Android系统攻城狮 1997

吊打原版YOLOv8!ASF-YOLO多尺度融合+注意力机制实战解析(遥感/医疗/质检均适用)

ASF-YOLO融合YOLOv8的创新目标检测框架摘要 ASF-YOLO是针对小目标检测优化的先进框架,采用三重特征编码器和尺度序列融合技术增强多尺度特征提取能力。核心创新包括: 三重特征编码器(TPE)融合跨层级信息 尺度序列特征融合(SSFF)模块通过3D卷积处理多尺度信息 通道-位置注意力机制(CPAM)聚焦关键特征区域 Zoom_cat模块实现自适应多尺度特征融合 该框架特别适用于医学影像、遥感检测等小目标密集场景,通过动态特征权重调整显著提升检测精度。关键技术实现包含多尺度特征统一、3D卷积序列处

FJN110的博客 528

Arm CoreSight SoC-600 TMC ETF调试技术详解

嵌入式系统调试中,跟踪技术是诊断复杂问题的关键。Arm CoreSight架构作为行业标准解决方案,通过非侵入式跟踪机制实现高效调试。其核心组件TMC(跟踪内存控制器)中的ETF(嵌入式跟踪FIFO)模块,在SoC-600架构中实现了性能突破。本文深入解析CTL、MODE等关键寄存器的工作原理,展示如何通过寄存器编程实现跟踪数据采集的精细控制。从环形缓冲区到硬件FIFO模式,不同配置适用于实时系统调试、性能分析等场景。结合ITATBDATA0等测试寄存器,开发者可以构建完整的验证方案,提升嵌入式系统开发效率

weixin_30905133的博客 704

【软考中级网络工程师】知识点之 ICMP 协议

本文聚焦软考中级网络工程师必备的 ICMP 协议知识,先概述其在 TCP/IP 协议族网络层的地位与无连接特性,再阐述差错报告、网络诊断等功能。解析报文格式,详解差错与查询类报文类型及工作机制,还介绍其在 Ping、Traceroute 命令中的应用,分析常见攻击与防范措施,最后总结核心要点,为网络工程师学习和应用 ICMP 协议提供全面指导。

邓邓子的博客 1404

【Scala进阶】Scala中如何将Java集合实例对象转换为Scala实例对象(互相转换)

目录scala、java双向转化通过asJava进行Scala到Java的单向转换通过asScala进行Java到Scala的单向转换具体描述性的名称显示调用Scala转JavaJava转Scala 目前开发语言众多,在开发中,可能会遇到Java,Scala混合系统,但因java,scala中的集合对象不一致,会致使在开发中遇到很多问题,得需要两种语言集合的互相转换。Scala从2.8.1开始引...

fuyun6363的博客 5496

安装openclaw时出现npm error code ENOENT npm error syscall spawn git报错的解决方案

本文主要介绍了安装openclaw时出现npm error code ENOENT npm error syscall spawn git报错的解决方案,希望能对使用openclaw的同学们有所帮助。 文章目录 1. 问题描述 2. 解决方案

weixin_43178406的博客 12万+

efm32 入门.pdf

本PDF共计22页,介绍的是操作驱动API,下载与否自己斟酌。

从Scala宏开始:简短教程

在周末花费了一些时间,我决定最终探索即将发布的Scala 2.10 宏中的一项新功能。 宏也是用Scala编写的,因此从本质上讲,宏是一段Scala代码,在编译时执行,可操纵和修改Scala程序的AST。 为了做一些有用的事情,我想实现一个简单的调试宏。 我想我不是一个人使用println-debugging,而是通过插入如下语句来进行调试: println('After regi...

721

VB摄像头监控

该VB模块可对摄像头捕获的画面进行录像等,可在创建的窗口进行控制

Java中使用Scala中的对象

准备Scala类 项目中引入 scala 2.11.12版本 2.11.8是经典版本,其实版本无所谓 /** * @Author: jsy * @Date: 2020/1/18 21:24 */ class ScalaUtils { private def initConfig(): Unit = { val str: String = "" str }...

j8130的博客 2270

jre9下载 jdk9

(Java SE Runtime Environment 9.0) 9.0u175 官方最新版 【x86|x64】

IDEA的使用指南

IDEA基于java的使用手册,可以最快速的上手,在使用中不断的丰富自己的使用技巧.

scala :::_从Scala宏开始:简短教程

scala ::: 在周末花费了一些时间,我决定最终探索即将发布的Scala 2.10 宏中的一项新功能。 宏也是用Scala编写的,因此实质上,宏是一段Scala代码,在编译时执行,可操纵和修改Scala程序的AST。 为了做一些有用的事情,我想实现一个简单的调试宏。 我想我并不孤单地使用println-debugging,即通过插入如下语句进行调试: println('After...

460

Agentic Awesome Skills 订阅制 dunning 恢复手册:从扣款失败到挽回流失的完整实战指南

本文以开源仓库 agentic-awesome-skills 中 [churn-prevention 技能包](https://pre-link.gitcode.com/i/b10c5fa82627a648b651dedf7fed27f0) 的 [dunning-playbook 参考文档](https://pre-link.gitcode.com/i/852c2e58adb9b9f959f952

gitblog_01404的博客 1000

scala 自带json_scala中json与对象的转换

遇到的问题因为要把spark从es读出来的json数据转换为对象,开始想用case class定义类型,通过fastjson做转换。如下case class Book (author: String, content: String, id: String, time: Long, title: String)val json = "{\"author\":\"hll\",\"content\":...

weixin_30217001的博客 708

Scala 隐式转换

隐式转换 我们需要某个类中的一个方法,但是这个类没有提供这样的一个方法,所以我们需要隐式转换,转换成提供了这个方法的类,然后再调用这个方法 第一步,需要一个增强的类,里面提供我们想要的方法,接收的参数的类型一定要是被增强类的类型。 第二步,还需要在单例对象中写明隐式转换 第三步,把隐式转换函数导进来 在spark中隐式转换都写在伴生对象中,因为类的实例肯定能找到伴生对象的,在一个作用域当中 import scala.io.Source import java.io.File

Li_Jian_Hui_的博客 3655

Scala隐式转换

我们需要某个类中的一个方法,但是这个类没有提供这样的一个方法,所以我们需要隐式转换,转换成提供了这个方法的类,然后再调用这个方法 隐式参数使用关键字implicit进行标识,当进行使用时,可以不给此标识变量进行传值,而从上下文中进行查找。 ...

Drug discovery 7948

【Scala】Scala的Predef对象

隐式引用(Implicit Import)Scala会自动为每个程序加上几个隐式引用,就像Java程序会自动加上java.lang包一样。Scala中,以下三个包的内容会隐式引用到每个程序上。所不同的是,Scala还会隐式加进对Predef的引用,这极大方便了程序员的工作。import java.lang._ // in JVM projects, or system namespace in .N

Jason Ding的专栏 2113

scala 隐式转换

文章目录作用解决什么问题使用 implicits 的一些规则3.1.1 标记规则3.1.2 范围规则3.1.3 一次规则3.1.4 优先规则3.1.5 命名规则3.1.6 编译器使用 implicit 的几种情况3.2 隐含类型转换3.3 转换被方法调用的对象3.3.1 支持新的类型3.3.2 模拟新的语法结构实验总结implicit基本含义隐式转换隐式转换的另一个优点是它们支持目标类型转换.隐式转换操作规则隐式参数和spring 的依赖注入之前关系与区别隐式转换类(Implicit Classes)隐式类

大怀特的博客 2770

JScala:让Scala代码安全地生成JavaScript

JScala:让Scala代码安全地生成JavaScript 项目介绍 JScala 是一个强大的Scala宏,它能够将Scala代码转换为JavaScript代码,从而实现类型安全的前端开发。通过JScala,开发者可以在Scala中编写类型安全的代码,并将其无缝转换为JavaScript,避免了手动编写JavaScript时常见的类型错误和语法问题。 项目技术分析 JScala的核心技术是Sc...

gitblog_00317的博客 1004

scala安装与使用

scala的安装与使用

m0_63797754的博客 1万+
上一篇: Taro3无埋点的探索与实践
下一篇: GrowingIO Design 组件库搭建之Select组件
GrowingIO_
博客等级 码龄8年 56粉丝 61原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

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

抵扣说明:

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

余额充值