Scala3 浅尝

Posted July 10, 2022 ‐ 12 min read

Scala3 从2021-05-14 正式发布 3.0.0 至今,已发布了 3.0.1, 3.0.2, 3.1.0, 3.1.1, 3.1.2, 3.1.3 等6个小版本, 预计7月份很快就迎来3.2.0的版本。之前有不少的Scala2.12/2.13的使用经验,但由于最近没有实际应用的项目,故一直没有机会动手体会Scala3。 最近闲暇时间,动手把之前的几个库迁移到Scala3上,将部份经验记录一下。

2024-08-21 补充 这篇文章写于2022-05,现在时间又过去了2年多,Scala3 LTS 版本也出来了,社区已经基本转向 Scala3了,IdeaJ 对 Scala3 的支持 也趋于稳定。我也在23年开始带领了一个团队,将一个核心的产品转儿使用 Scala3 进行了重构,取得了良好的成绩。

可能是我个人有偏见:使用Java,太容易受不良习惯的左右,是在很难编写出好的代码呢,而应用Scala,我们通过施加一些简单的限制原则, 就可以强迫开发人员改变习惯,编写成可读性、可维护性显著改进的代码。

很希望后续能够在这方面总结经验,分享,并进一步推广函数式编程实践。

IDE支持

学习一个新的编程语言,如果有一个好的IDE支持,会顺手很多。之前一直在 ideaj 这个工具中编写 scala 2 的代码。目前的 ideaj 对 Scala3 的支持算得上“能用”的阶段,还有不少瑕疵,期待在新的版本中能够加速优化。说实在的,目前的IDE水平对Scala3的推广还是会有很多的阻碍: 大家对IDE的依赖是相当强的。

一些瑕疵:

  1. 对新的缩进语法的编辑支持一般,比如 Copy - Paste 操作不能很好的处理 indent 语法。
  2. 对 V3 的一些语法,如extension等,语法导航能力较弱,也缺乏类型提示的功能。很多在IDE中的类型推导都弱化到了 any
  3. 对 Macro 的部份语法支持不够,例如 '[t] 这样的类型模式匹配。

整体评估:ideaj 的 Scala3 支持,“可用”,不够优秀。

Macro 迁移

大部份的Scala2 的代码都可以较为简单的迁移到 Scala3,甚至于直接把源代码复制过来。官方提供了一个很好的迁移指导: Compatibility Reference

不过,我在迁移 scala-sql 库的过程中,还是强迫自己放弃了Scala2的兼容语法, 强迫自己来体会 Scala3 的新风格,比如:

  1. 新的缩进语法。写的越多,确实会越喜欢,这个倒是很符合“简洁”的味道。
  2. 使用 enum 来替代传统的 ADT 实现。(更好)
  3. 使用新的 given/using 替代 implicit。 (个人是有些怀念2.13的一个关键词,搞定一切的模式的。)
  4. 新的 extension。(更好)

上述的转变并不难,最难的还是macro的迁移:scala-sql 项目中广泛使用 Macro 来简化重复性代码的编写工作,也是框架既“简单实用”,又“功能强大”的关键措施。 Scala3的Macro可以说是一次颠覆性的实现,完全不兼容于Scala2,概念虽然有很多是相同的,但API却完全不同了。 我是差不多花了2个周末才完成了第一个Macro的迁移,不过,后面的速度就越来越快了,慢慢摸到了一些门道。这一块,我也整理了一些文档,后续可以最进一步的分享。

Scala3 Macro 核心对象关系图

如何调试Macro:

编写Macro的重要方式是善用调试器,在调试中,了解各个数据结构。这相比啃文档或者直接看源代码,可能会更有效很多。

sbt -J-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005

开启 IDEA 的远程调试,这样,在sbt中执行 compile 命令时,就可以调试到我们的macro代码了。

试用 Inline + Macro

结合 Macro 和 inline,是一个非常有意思的实践,在scala-sql 的2.0.X版本实践中,生成的 ResultSetMapper[T] 实现, 如果从最终实现的字节码来看,是不够理想的:

  1. 需要在运行期完成 数据库字段名 和 对象字段名 的映射关系,以满足 诸如从 is_active 到 isActive的映射。
  2. 调用 caseFieldGet 方法,完成从 rs 中获取值,以及没有该字段时,default 值的处理。

这个过程时有额外的开销的。虽然相对数据库IO而言,可以忽略。

在迁移到 Scala3的过程中,我尝试结合Macro + inline 的方式,最终实现了一个“zero-cost”的ResultSetMapper实现。

眼下Java的框架风格是无视cost,每一个框架喜欢在调用栈上层层加码,随便看看那个Spring的StackTrace都是几十层楼高。再回到 zero-cost 的时代, 感觉是那么的清爽。

譬如:对如下的Case Class类,

@UseColumnMapper(classOf[Camel2UnderscoreMapper])
    case class Test1(
        id: Int,
        name: String,
        isActive: Boolean,
        tinyInt: Byte,
        smallInt: Short,
        normalInt: Int,
        bigInt: Long,
        floatValue: Float,
        doubleValue: Double,
        decimalValue: BigDecimal,
        birthday: java.sql.Date,
        createdAt: java.sql.Timestamp,
        updatedAt: java.sql.Timestamp,
        blobValue: Array[Byte],
        emptyValue: String|Null,   // or using Option[String]
        emptyInt: Int|Null // or using Option[Int]
     ) derives ResultSetMapper

其对应的ResultSetMapper代码如下(对 生成的代码进行反编译的效果:):

ResultSetMapper var5 = new ResultSetMapper(this) {

 public Test1 from(final ResultSet rs) {
    int var2 = rs.getInt("id");
    String var3 = rs.getString("name");
    boolean var4 = rs.getBoolean("is_active");
    byte var5 = rs.getByte("tiny_int");
    short var6 = rs.getShort("small_int");
    int var7 = rs.getInt("normal_int");
    long var8 = rs.getLong("big_int");
    float var10 = rs.getFloat("float_value");
    double var11 = rs.getDouble("double_value");
    BigDecimal it = rs.getBigDecimal("decimal_value");
    scala.math.BigDecimal var13 = it != null ? scala.package..MODULE$.BigDecimal().apply(it) : null;
    Date var15 = rs.getDate("birthday");
    Timestamp var16 = rs.getTimestamp("created_at");
    Timestamp var17 = rs.getTimestamp("updated_at");
    byte[] var18 = rs.getBytes("blob_value");

    Object var10000;
    try {
       String vx = rs.getString("empty_value");
       var10000 = vx == null ? scala.None..MODULE$ : scala.Some..MODULE$.apply(vx);
    } catch (SQLException var26) {
       var10000 = scala.None..MODULE$;
    }

    Object var19 = var10000;

    try {
       int v = rs.getInt("empty_int");
       var10000 = rs.wasNull() ? scala.None..MODULE$ : scala.Some..MODULE$.apply(BoxesRunTime.boxToInteger(v));
    } catch (SQLException var25) {
       var10000 = scala.None..MODULE$;
    }

    Object var22 = var10000;
    return new Test1(this.$outer.wsql_test$WsqlTest$_$_$Test1$$$$outer(), var2, var3, var4, var5, var6, var7, var8, var10, var11, var13, var15, var16, var17, var18, (Option)var19, (Option)var22);
 }

};

这个生成的代码质量,基本上是 zero-cost 的了,达到了“手写代码”的质量。相比 Java的基于反射的大部分框架,scala-sql 生成的代码质量更优, 更享受了编译时期的静态类型检查福利。

实现如上的清洁代码生成,我们并不需要编写复杂的Macro代码(如果这么做,Macro的代码会复杂很多,而且也不便于调试), 而是,在Macro中生成对 inline 函数的调用,利用inline展开能力,实现最终的清爽代码。 例如,对基础数据类型(值类型),且有缺省值时(deff),其使用如下的inline代码,实际上并不会产生一次函数调用, 而是将如下的代码嵌入到 from(rs: ResultSet)中。

private inline def withDefaultOptionAnyVal[T](inline name:String, inline deff: Option[T], rs: ResultSet)
                                       (using JdbcValueAccessor[T]): Option[T] =
  try
    val v = rs.get[T](name)
    if rs.wasNull then deff else Some(v.nn)
  catch
    case ex: SQLException => deff

基于新的Context Functions 创建 DSL

最近在规划开发一个新的接口自动化测试平台, 规划的特性非常有吸引力:

  • 能够支持 HTTP 接口 和 Dubbo 接口。
  • 能够配置单接口的测试用例 和 多接口流程的测试用例
  • 可以配置请求、配置对结果的断言(基于 wjson 提供的强大的 JSON 表达能力)
  • 可以配置依赖的数据库、以及对服务执行完后的数据库结果进行校验。
  • 可以为接口调用配置 Mock 依赖,Mock依赖只需要使用 JSON 进行配置即可(基于wjson提供的简单而强大的模式匹配能力)

在研发上述原形信息时,我们苦于如何提供一个有好的 User Interface 以让研发、测试人员能够简单的使用上述能力? 虽然我们的最终版本会提供基于WEB的用户界面,但作为一个原型产品,我们选择 Scala DSL 来达成上述目标。

testcase( name="test1", business="..", project="...", service=".." method="...") {

  reuse(atomic = 6600)
  prepareDB {
    dataset(ref = "base-dataset1" )
    sql"""
    insert into table1 values(...);
    """
  }

  prepareMocks {
    mockset( ref = "basic-mocks" )
    mock( service="..", method="..." ){
      request( rejson""" {...}
      """)
      response( json"""....""" ) 
    }
  }

}

Scala3的Context Function让 DSL变得更为简单,这一块目前还没有投产,等我们的案例完成后,我再补充上具体的demo。

Null Safe

这个特性目前还是实验性质的,也是我非常感兴趣的一个特性。在Java中, null 和 NPE 真是一个普遍的错误使用模式。 Kotlin/Dart等语言都拥抱 Null Safe了。所以 Explicit Null 这个特性刚刚出来,我是非常兴奋的。 也乘着这次的机会,把 scala-sql 3 编译切到这个模式,还真的发现了之前存在一些代码,是没有很好的处理 null的。 这个暂时放到 scala3-nullsafe 分支之中,稳定后会合并到 master.