Apache Commons Bridge For Scala

## 动机和准备

Apache Commons 是一组内容丰富的大型工具库。我前几年参与一个 Java 项目时开始接触到这个代码库,现在已经成为我开发 Java/Scala 项目必备的工具。

目前我主要是使用的是 commons lang3 。这个仓库包含了若干与 Java 标准库相关的扩展功能。对于我来说,其中的 StringUtils 工具特别有用。

这是一个巨大的,包含两百多个静态方法的类型,它包含了大量字符串常用操作。例如对于“空白(Blank)”,“空字符串(Empty)”的判定。这些工具函数虽然大多都不复杂,但是要写的安全可靠,也要付出相当的工作量。特别是对于 null 的处理,需要细致的覆盖各种边界条件。而 Apache Commons 的 StringUtils ,几乎所有的函数都是 null 安全的,它们要么可以安全的使其无效化(例如对于构造新字符串的函数,传入 null 会安全的返回 null,对于作为操作参数的 null ,安全的返回数据原文),要么可以给出足够合理的默认逻辑,和细致的控制条件(例如排序,一般在给出默认实现外,还有可以通过 nullIsLess 参数控制排序规则的版本)。

这个库对 java 的支持非常务实,例如 lang3 版本可以支持 java 1.8 以上的所有版本,更低的分支还可以支持更早的版本。尽管高版本的 Java ,字符串类型 String 已经自带了 isBlank 和 isEmpty,Apache Çommons 的版本仍是更安全实用的选择。这些可以支持 null,跨越广泛的Java 版本差异,提供一致和完备规则的工具函数,对于应用开发者有巨大的价值。

尽管近期我的工作主要使用 Scala ,我仍然非常喜欢充分利用 Apache Commons 仓库的资源,特别是对于一些运行在云平台上的 Flink 任务,其使用的 Java 和 Scala 版本都比较低,也没有什么选择,例如我现在正在写的这个项目,需要运行在 Java 1.8 和 Scala 2.11。StringUtils 工具提供的资源就更为宝贵。当然,Aapache Commons 的内容远远不止 StringUtils 。但是对于我日常的工作来说,StringUtils、Text、Math 和 IO 仍是最有用的。

所以,我就有了一个想法,为 Scala 写一个更符合 Scala 语言风格的封装,使其使用起来更简洁流畅。
最初,我在我的项目代码中简单的加入了一个 implicit class。利用它,将几个常用的函数做了一个封装。

object Strings {
  implicit class StringOpt(x: String) {
    def isBlank: Boolean = StringUtils.isBlank(x)

    def isNotBlank: Boolean = StringUtils.isNotBlank(x)

    def isNoneBlank: Boolean = StringUtils.isNoneBlank(x)

    def isEmpty: Boolean = StringUtils.isEmpty(x)

    def isNotEmpty: Boolean = StringUtils.isNotEmpty(x)

    def isNoneEmpty: Boolean = StringUtils.isNoneEmpty(x)
  }
}

这对我当前的项目足够用了,我可以将这些工具函数当作字符串成员方法来调用:

// 对,我知道,现在标准库有 .isBlank。但这个项目还在用 Java8
      refer = if (input("f0").isBlank) {
        None
      } else {
        Some(input("f0"))
      },

但是作为一个通用的函数库,它还过于简陋。

  • 因为StringUtils 的工具方法都是静态的,所以它允许数据本身就是 null 。如果简单的封装成String类型的 implicit class。显然对null不安全,例如 StringUtils.isBlank(null) 返回 true,而我的 null.isBlank 只会抛出空指针异常。
  • 最重要的,两百多个函数,我只封装了几个当前用到的
  • 这段代码我不需要考虑 scala 的版本兼容,但是如果做成一个通用工具库,我当然希望能在我的各种 scala 项目中都能发挥作用,它们从 scala 2.11(阿里云Flink 使用的 Scala )到 2.12(Figaro使用的Scala)再到2.13(目前 play 支持的最高版本)甚至 3(Jaskell Dotty 使用 scala 3)。我个人力量有限,当然希望这个移植过程中重复劳动越少越好。

周末我开始动手编写这个封装库。原本以为我会很快搭好架子然后进入机械化的移植过程。结果两天都用来折腾基本结构的实现了。

感谢 Scala 社区的同行们热心的指导,目前的功能实现可以说远超我的预期——也远超了我的能力,完全是老师手把手领着写出来的。

虽然只实现了非常少的几个函数,不过确实接下来我只需要机械化的封装就好了——还有要根据原版编写封装版本的文档注释。这些注释在我看来,价值并不比代码本身更低。而这些工作没有任何取巧的空间,就是要一个一个函数去实现。

目前这个封装实现了:

  • 类型安全,代码库本身是基于 Option[String] 来封装的,充分利用 Scala 本身的函数式编程能力
  • 隐式的支持 String 变量(包含值为 null 的字符串变量)向 Option[String] 转换,所以在使用上,那些来自 json 的,来自数据库的,来自奇奇怪怪的数据管道的,可能为空的字符串变量,大部分情况下可以直接使用。这里面用到了一些高级的 Scala 类型技术,再次感谢社区同行的指导!
  • 高度的一致性,除了 Scala 2 和 Scala 3分别有一个自己实现的隐式规则,所有代码都是共用的。其实在一系列的兼容性修改后,Scala 2 和 Scala 只剩了一个功能调用 (通过 implicity/summon注入类型转换规则) 的名字不同,其它也几乎一样了。最初我希望 Scala 3版本使用新的 given/extension 来代替传统的 implicit class/def/var 。但是编码过程中发现 extension 不能定义成员变量、given 在 import implicit 的过程中出现了不识别的问题,最终关键功能几乎都采用了一样的代码,甚至将来,这点差异或许也会被完全消灭,Scala 2 和 Scala 合并为完全一致的实现。

关键功能

我在跟同行讨论这个库的设计时,朋友提出了关键性的建议,即基于 Option[String] 实现主要功能,那么需要为 Option 提供扩展方法,就会遇到一个问题。这个类型本身已经 sealed,不能在其源码文件之外扩展。朋友给出的建议是定义一个新的类型作为包装

trait ToStringOpt[-T] extends TypeMapping[T, Option[String]] {
  override def apply(i: T): Option[String]
}

object ToStringOpt {
  def apply[S](func: S => Option[String]): ToStringOpt[S] = (i: S) => func(i)

}

这个类型简单的依赖另一个非常微型的定义TypeMapping。用来规范类型转换映射

trait TypeMapping[-I, +O] {
  def apply(i: I): O
}

object TypeMapping {
  def apply[S, T](func: S => T): TypeMapping[S, T] = func(_)
}

那么通过两个隐式转换规则,ToStringOpt 就可以实现与 Option[String]和 String 的转换

    implicit val stringMappingImplicit: ToStringOpt[String] = ToStringOpt(i => Option(i))
    implicit val stringOptMappingImplicit: ToStringOpt[Option[String]] = ToStringOpt(identity)

这两行代码封装在 commons.lang3.scala.StringUtils 中。而仅有的 Scala 2 和 Scala 3 的区别仅仅是 implicit class 中的规则注入,下面是 Scala 2 版本:

object StringUtils {

  object bridge {
    import commons.lang3.scala.ToStringOpt

    implicit val stringMappingImplicit: ToStringOpt[String] = ToStringOpt(i => Option(i))
    implicit val stringOptMappingImplicit: ToStringOpt[Option[String]] = ToStringOpt(identity)

    implicit class StringOptExt[T: ToStringOpt](x: T)  {
// Scala 2 使用 implicitly 
      private def optFunc: ToStringOpt[T] = implicitly
      def strOpt: Option[String] = optFunc(x)
      val ops = new StringCommons[T](x)

    }

  }

这是 Scala 3 版本

object StringUtils {

  object bridge {

    import commons.lang3.scala.ToStringOpt

    implicit val stringMappingImplicit: ToStringOpt[String] = ToStringOpt(i => Option.apply[String](i))
    implicit val stringOptMappingImplicit: ToStringOpt[Option[String]] = ToStringOpt(identity)

    import org.apache.commons.lang3.{StringUtils => Strings}

    implicit class StringOptExt[T: ToStringOpt](x: T) {
// Scala 3 使用 summon
      private def optFunc: ToStringOpt[T] = summon

      def strOpt: Option[String] = optFunc(x)

      val ops: StringCommons[T] = new StringCommons(x)
    }

  }
}

没有直接将扩展方法实现在隐式/扩展类型中,是因为在实践中,我遇到了一些扩展方法与原始类型的内置方法重名的情况,具体来说就是 contains 方法,如果我实现为隐式类的方法

    implicit class StringOptExt[T: ToStringOpt](x: T) {

      private def optFunc: ToStringOpt[T] = summon

      def strOpt: Option[String] = optFunc(x)

      val ops: StringCommons[T] = new StringCommons(x)
    }

编译器会先找到 String 类型自己的 contains 方法,然后运行时抛出类型错误。同样还有一些方法会先匹配到 scala 内置的 StringOps 类型的成员。于是我选择了一个不是最精致,但是比较省事儿,也足够直观的方法,就是单独实现一个类型,将所有的功能实现都放到那里,然后将它定义为隐式类型 StringOptExt 的一个成员变量。这就是 StringCommons 类型。

class StringCommons[T: ToStringOpt](value: T) {
  import commons.lang3.scala.StringUtils.bridge.StringOptExt
  def contains[To: ToStringOpt](seq: To): Boolean = Strings.contains(value.strOpt.orNull, seq.strOpt.orNull)

  def contains(searchChar: Char): Boolean = Strings.contains(value.strOpt.orNull, searchChar)

  def abbreviate(maxWidth: Int): String = Strings.abbreviate(value.strOpt.orNull, maxWidth)

  def abbreviate(offset: Int, maxWidth: Int): String = Strings.abbreviate(value.strOpt.orNull, offset, maxWidth)
// 后面还有很多用同样规则实现的方法,就不一一列出了。
}

暂时我还没有发布这个项目,准备至少将 StringUtils的方法都移植过来——并补全文档和测试,之后,再发布第一个版本。也有可能会根据工作需要,先发布一个非正式的不完整版本。
因为它只是一个简单的封装,如果用到它没有涉及的功能,或者不方便的地方,我们仍可以直接使用原版的 Commons 库。例如这种封装可以识别值为 null 的字符串变量:

  import commons.lang3.scala.StringUtils.bridge._

  "Strings" should "test contains operators" in  {
// 这里可以通过测试
    val nullStr:String = null
    nullStr.ops.contains(' ') should be (false)
    "".ops.contains(' ') should be (false)

    "".ops.contains(nullStr) should be (false)
    nullStr.ops.contains(nullStr) should be (false)

    "abc".ops.contains('a') should be (true)
    "abc".ops.contains('b') should be (true)
    "abc".ops.contains('c') should be (true)

但是,不能识别 null 字面量

// 在代码中加入下面这行 import ,就可以使用其全部功能
  import commons.lang3.scala.StringUtils.bridge._

  "Strings" should "test contains operators" in  {
// 不用怀疑,这里会抛出异常
    null.ops.contains(' ') should be (false)
    "".ops.contains(' ') should be (false)

    "".ops.contains(nullStr) should be (false)
    nullStr.ops.contains(nullStr) should be (false)
//...

。不过,除非是测试,我们也几乎不会直接使用 null —— 特别是 Scala 已经非常好的支持了 Option 类型。如果真遇到这种场景,就用原版的静态方法吧,它已经非常细致的考虑了这些问题。
这个库使用起来非常简单,因为还没发布,就先不提供依赖配置了,代码中仅需要向上例一样,一行 import 。
最后,我们通过完整的测试代码看一下这个桥接库如何封装了两个 contains 方法:

package commons.lang3.scala

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class StringUtilsSpec extends AnyFlatSpec with Matchers{

  import commons.lang3.scala.StringUtils.bridge._

  "Strings" should "test contains operators" in  {

    val nullStr:String = null
    nullStr.ops.contains(' ') should be (false)
    "".ops.contains(' ') should be (false)

    "".ops.contains(nullStr) should be (false)
    nullStr.ops.contains(nullStr) should be (false)

    "abc".ops.contains('a') should be (true)
    "abc".ops.contains('b') should be (true)
    "abc".ops.contains('c') should be (true)
    "abc".ops.contains('z') should be (false)
  }

  "Options" should "test contains operators for option string" in {
    val noneStr: Option[String] = None
    noneStr.ops.contains(' ') should be(false)
    Some("").ops.contains(' ') should be(false)

    Some("").ops.contains(None) should be(false)

    None.ops.contains(None) should be(false)

    Some("abc").ops.contains('a') should be(true)
    Some("abc").ops.contains('b') should be(true)
    Some("abc").ops.contains('c') should be(true)
    Some("abc").ops.contains('z') should be(false)

  }
}

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ccat

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值