使用 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

具身智能端到端大模型VLA (Vision Language Action)

在语言为条件的机器人任务中,策略必须具备理解语言指令、视觉感知环境并生成适当动作的能力,这就需要VLA的。例如,在自动驾驶领域,传统的做法是将感知、预测、规划等步骤分开处理,而VLA则试图用一个统一的框架来替代这种分立的方法。另外,VLA的模型对于算力的需求很大,单片AI算力1000Tops的Thor大概率会延期发布,加上英伟达芯片的量产时间与成本挑战,以及在国内的应用情况,对车企而言是个大问题。以谷歌DeepMind推出的RT-2为例,该模型可以在新的物体、背景和环境中表现出显著改善的性能。

具身智能机器人 1万+

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

SPL06气压温度传感器SPI驱动与补偿算法详解

气压传感器与温度传感器是嵌入式环境感知系统的核心组件,其测量精度高度依赖于MEMS芯片的校准参数与非线性补偿机制。SPL06作为集成式高精度数字传感器,采用24位Σ-Δ ADC与片内补偿引擎,需结合唯一出厂校准系数(即‘芯片指纹’)进行多阶数学修正,才能将原始ADC值转化为符合计量要求的帕斯卡(Pa)与摄氏度(℃)物理量。该过程涵盖SPI通信协议解析、寄存器时序控制、大端数据拼接、二进制补码处理及浮点/定点混合运算等典型嵌入式工程实践。广泛应用于无人机高度计、智能穿戴海拔监测与IoT气象终端中。

weixin_34640289的博客 143

存储关键技术·RAID技术·RAID2.0·华为RAID2.0+·华为服务器·云计算

一.RAID基本概念 常见的RAID级别组合RAID级别 二.RAID的数据组织形式 三.RAID的数据保护功能 方法一:镜像方法二:奇偶校验算法(XOR) 四.RAID热备和重构 1.热备(Hot Spare)的定义2.热备的分类3.热备的工作原理 五.RAID 2.0技术 1.RAID 2.02.RAID 2.0+3.华为RAID 2.0+ 六.RAID 2.0+块虚拟化技术 1.什么会出现RAID 2.0+技术?2.什么是RAID 2.0?3.RAID 2.0+的原理是什么?

qq_47480932的博客 3320

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万+

NoC架构深度解析:从拓扑结构到流量控制的全面指南

本文深入解析了片上网络(NoC)架构,从总线架构的瓶颈出发,阐述了NoC作为芯片内部“交通网”的核心优势。文章详细探讨了主流拓扑结构(如Mesh、Torus、Ring)的选型权衡,并系统介绍了数据包格式、路由算法、路由器流水线、流量控制机制以及网络接口(NI)设计,为理解现代多核SoC的通信基石提供了全面指南。

gg901的博客 1042

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

使用vite+react+ts+Ant Design开发后台管理项目(四)

本文将引导开发者从零基础开始,运用、react、react-router、react-redux、Ant Design、less、tailwindcss、axios等前沿技术栈,构建一个高效、响应式的后台管理系统。通过详细的步骤和实践指导,文章旨在为开发者揭示如何利用这些技术工具,从项目构思到最终实现的全过程,提供清晰的开发思路和实用的技术应用技巧。使用vite+react+ts+Ant Design开发后台管理项目(一)使用vite+react+ts+Ant Design开发后台管理项目(二)

lbking666666的专栏 2007

从.har文件到Postman集合:一键转换的完整教程(附避坑指南)

本文详细介绍了如何将.har文件高效转换为Postman集合的完整教程,涵盖基础操作到高级技巧,包括认证信息处理、环境变量提取等痛点问题的解决方案。通过抓包工具记录的.har文件,开发者可以快速生成Postman集合,提升API调试与测试效率,特别适合前后端分离开发模式下的团队协作。

weixin_26795617的博客 384

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

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

weixin_30905133的博客 704

已解决-Parallels Desktop 16 网络初始化失败:您的虚拟机将继续正常运作,但将无法连接网络

我是月球挖掘机,相信不少打工人为了尝鲜已经更新了MacOS 11 Big Sur,当然,苹果每次的系统大更新,都会给我们软件的功能无法使用惊喜,这次的也不例外。 这个问题是我在更新macOS Big Sur之后,Parallels Desktop打开虚拟机后,提示: 报错信息 网络初始化失败:您的虚拟机将继续正常运作,但将无法连接网络 这是我的软件版本: 起初我以为是我的虚拟机坏了,结果重装了一遍虚拟机,没有任何用处。我开始在网上找方法,只能找到那个使用命令行打开软件的命令,除此之外没有了

月球挖掘机 5万+

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

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

fuyun6363的博客 5469

Wi-Fi数据帧类别

QoS

1042484520@qq.com(邮箱) 3156

最新的sdio 4.0 spec 协议标准 带书签 高清完整PDF版

Part-E1-SDIO-Specification-Ver4.00-Final-120220.pdf:最新的sdio 4.0 spec 协议标准 带书签 高清完整PDF版,包括SDIO 接口的外设,SDIO 接口官方协议等。

从Scala宏开始:简短教程

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

721

SAP B1中文培訓教材_SDK

公司内部培训资料,对开发工具 ,DIAPI, UIAPI,Add-ON有详细解说

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

VB摄像头监控

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

SIMATIC S7-1200, S7-1500 PID 控制[手册]

SIMATIC S7-1200, S7-1500 PID 控制[手册]

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、付费专栏及课程。

余额充值