Spring Boot with Scala (TLNR)

2017-03-26 16:20:44 +08:00
 sadhen

Github: https://github.com/sadhen/12-technologies-in-2017/tree/master/02-SpringBoot-with-Scala

非常欢迎 watch ,并在 issue 中和我讨论。文章中有些是我在平日里常用的,有些是现学现卖的,请老司机们多多指点。

仔细阅读可能需要花费 1 小时及以上,如果你刚刚接触 SpringBoot 的话。 Reading List 都是我读过一遍的,作者的工作经验基本都比我丰富,写得也比较好,如果你觉得本文不值一读, Reading List 或许能入你法眼。

不太满意排版的同学可以试试看这里,是我自己写的 Markdown Editor Render 的: http://fromwiz.com/share/s/2yNneO1LOQap2-bdmA21_AH13kXxsU1xSk3F29ZIWR0BfOSR

Spring Boot with Scala

前言

本文提供一个示例项目,如果你觉得代码更加亲切,请移步至Spring Boot with Scala

Bootstrap

第一步,我们从零开始构建一个五脏俱全的工程。注意,是工程,而不是代码片段。通常来说,一个工程需要开发、构建、测试、部署、监控等环节。

开发

大多数程序员并不聪明也不勤奋。一般而言,从零开始写一个工程实际上非常吃力。所以很多框架都会提供脚手架工程。 Spring Boot 官网就提供了start.spring.io,快速生成脚手架工程。可惜并没有提供 Scala 相关的脚手架工程。故而,本文的示例项目实际上也是一个脚手架项目。

本文将只采用 Maven 作为构建工具。

构建与部署

本节主要讨论构建工具。在阅读清单 2 中已经有比较详细的介绍了,这里讨论一些值得注意的细节和改进。

使用spring-boot-maven-plugin打包的话实际上会生成一个 executable jar ,我们执行java -jar target/spring-boot-with-scala-1.0.jar便可以运行。然而这并不美好。当我们在服务器上运行这个 jar 时,最好能有start.shstop.sh脚本管理程序的启动和中止。我们把事先编写好的启动和中止脚本放在 bin 目录下,然后用maven-jar-pluginmaven-dependency-pluginmaven-assembly-plugin生成spring-boot-with-scala-1.0-bin.tar.gz,然后部署到服务器上。

start.sh脚本中提供了一种覆盖 jar 中的 application.properites 的方法,即使用spring.config.location,在本文的项目中将这个配置文件的默认地址设置为conf/application.properties,如果没有这个文件,就使用 jar 中的配置。

日志

引入spring-boot-start-logging后, Spring Boot 会使用 slf4j-api 和 logback 作为应用日志框架。 Java 的日志系统非常混乱,建议阅读材料 10 ,理一下思路。

slf4j 很好地解决了日志的性能问题。在处理日志时,我们希望字符串的拼接是 lazy 的。使用 Java 8 可以这样解决问题:logger.debug(() -> "hello " + getValue())。然而略显啰嗦, slf4j 提供了这种方式:logger.info("hello {}", name),不失其优雅。

因为我们是用 Scala ,所以推荐使用log4s。这样就可以愉快地使用 Scala 的字符串插值特性,而不失其性能。如官网所言:

Log4s goes even further in that it uses macros to manipulate the execution so that the string interpolations are not even performed unless the logger is enabled. It does this by inspecting the structure of the argument that you pass into the logger.

材料 4 讲解了 Spring1.5.x 动态修改日志级别的新特性,本文的示例工程中提供了loglevel.sh脚本:

bin/loglevel.sh com.sadhen  # 显示 package com.sadhen 的日志级别
bin/loglevel.sh com.sadhen DEBUG # 将 package com.sadhen 的日志级别设置为 DEBUG

TDD

测试的话,主要是用 spring-boot-starter-test, JUnit 和 ScalaTest 。在 Maven 中声明这些依赖时需要指定 scope 为 test ,以表明这些依赖只对测试 classpath 有效。

从 Assert 开始

我们可以混用 ScalaTest 和 JUnit ,使用了 ScalaTest 并不意味着不使用 JUnit 。就像学习 Scala ,并不能放弃深入学习 Java 。而是在比较、揣摩两者的差异时,学习如何写出一手高质量的代码。

ScalaTest 的 assert 是一个宏,可以抛出非常可读的 Error Message :

import org.scalatest.Assertions._
assert(a == b || c >= d)
// Error message: 1 did not equal 2, and 3 was not greater than or equal to 4
assert(xs.exists(_ == 4))
// Error message: List(1, 2, 3) did not contain 4

如果你也恰巧读过 Clean Code ,是否还记得函数那一章讲到没有参数的函数是最好的,一个参数的函数不复杂,两个参数的函数就需要程序员在时候的时候注意参数的顺序了。三个及以上参数的函数就不太妙了。即使 Intellij 如此智能,程序员还是很容易犯错。至少,你在使用 assertEquals 的时候,每一次都需要等 IDE 的提示出来才能愉快自信的了解的每个参数的真正含义。

有依赖注入的类怎么测试

很简单:

@RunWith(classOf[SpringRunner])
@SpringBootTest
class SampleTest {
  @Autowired
  var sampleService: SampleService = _

  def testSampleService = ???
}

下面这个例子演示了如何测试 Rest Controller ,其实也很简单,主要是利用了 spring-boot-starter-test 里面提供的 TestRestTemplate 。其中有些 json4s 的语法或许你没有接触过,且看下文。

@RunWith(classOf[SpringRunner])
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class HelloControllerTest {
  @Autowired
  var restTemplate: TestRestTemplate = _

  @Test
  def testHello(): Unit = {
    val body = restTemplate.getForObject("/api/hello", classOf[JsonNode])
    val expected = ("code" -> 0) ~
      ("data" -> ("hello" -> "中国") ~ ("year" -> 2017)) ~
      ("error" -> JNull)
    assert {
      fromJsonNode(body) == expected
    }
  }
}

但是这种测试有个弊端,由于需要初始化上下文,每次都需要等上好长一段时间。

ScalaMock

ScalaMock 就是用来解决上文提到的问题的。看代码:

class HelloControllerSpec extends FlatSpec with Matchers with MockFactory {

  "/api/hello" should "be ok" in {
    val worldService = stub[WorldService]
    (worldService.getCountry _).when().returns("法国")

    val helloController = new HelloController(worldService)

    val expected = ("code" -> 0) ~
      ("data" -> ("hello" -> "法国") ~ ("year" -> 2017)) ~
      ("error" -> JNull)

    assert {
      fromJsonNode(helloController.hello) == expected
    }
  }
}

这个例子中,我们 mock 了一个 WorldService ,通过指定 getCountry 方法的返回值定义了 worldService 的行为模式。从而不需要初始化上下文就可以完成 Rest Controller 的测试。

后记

这里面还有很多话题没有提及,比如异步的单元测试等等。为什么单元测试那么重要呢,因为实际上单元测试就是第一手的最准确的文档。如果你要用一个开源的库,恰好他的文档写得不够详细,那么多数情况下你都可以从单元测试中获得你的答案。如果遍历它的单元测试都没有找到库的正确用法,或许是你的水平没有到家,更可能的是这个库写得并不好,建议不要去使用它。

Scala

前面讲的比较多还是 Spring Boot 本身,那么为什么要 Scala 呢?已经有很多比较 Java 和 Scala 文章了,这里不赘述。阅读清单中 3 和 6 都值得一看。下面简单谈一谈那些尤为重要的 Scala 特性。

惰性求值

在程序语言理论中,惰性求值是一种求值策略,它将表达式的求值计算延迟到实际用到这个值的时刻,以避免重复计算(翻译自维基百科,见阅读清单 8 )。

用 Java 举个例子:

class LazyDemo {
  String lazyString = null;

  private String initialize() {
    // 比较耗时的初始化
    String result = ...
    return result;
  }

  void fun1() {
    // 一些和 lazyString 无关的代码
    if (lazyString == null) {
        lazyString = initialize()
    }
    // 下面开始需要使用 lazyString
  }

  void fun2() {
    // 一些和 lazyString 无关的代码
    if (lazyString == null) {
        lazyString = initialize(lazyString)
    }
    // 下面开始需要使用 lazyString
  }
}

维基百科里面提到惰性求值的优点有三:

  1. 提供了控制结构抽象化的能力
  2. 提供了定义无穷数据结构的能力
  3. 在对复合表达式的求值过程中避免了无谓的计算和错误处理

对于 1 ,实际上 Scala 中的Tryfuture是非常直观的例子,我们也可以自定义一些参数是代码块的函数,由于 Scala 允许最后一个参数体的()改写成{},形式上其实非常有美感。对于 2 ,在 Scala 中体现在 Stream 这个集合中。另外,lazy关键字也是至关重要的。比如下面这个例子:

// Java Code
Long ret = null;
if (noNeedToCalculateResult) {
  // branch 1
} else {
  ret = calculate();
  if (ret == -1) {
    // branch 2
  } else {
    // branch 3
  }
}

// Scala Code
lazy val ret = calculate()
if (noNeedToCalculateResult) {
  // branch 1
} else if (ret == -1) {
  // branch 2
} else {
  // branch 3
}

在 Scala 中使用 lazy ,便可以减少if-else分支结构的层次,使代码逻辑更加清晰可读。对于LazyDemo,改写成 Scala ,也简化了许多:

class LazyDemo {
  lazy val lazyString: String = initialize

  def initialize: String  = ???

  def fun1() {
    // 一些和 lazyString 无关的代码
    // 下面开始需要使用 lazyString
  }

  def fun2() {
    // 一些和 lazyString 无关的代码
    // 下面开始需要使用 lazyString
  }
}

最初用于演示延迟计算的例子中,如果 fun1 和 fun2 并发执行,会带来严重的问题。涉及到双重检测锁(DCL),请参考阅读清单 7 和 9 。

代码风格

使用 ScalaStyle 和 scalafmt 。 scalafmt 有 IntelliJ 的插件。如果是在公司,可以使用 SonarQube 配置一套团队公用的 ScalaStyle 配置。

Utilities

这里简单谈一谈对一些工具库的选择。基本上我都会选择那些基于久经考验的相关 Java 库的封装。这些库一般都会提供一些 Scala 语言特性上的适配,然后提供一些比较友好的 DSL 。那么为什么不选择 pure scala 呢?通常情况下,那些 pure scala 的库会重度依赖 Akka , Scalaz 等著名的库,由于很多是新造的轮子,并没有经历时间的考验,其实非常 buggy 。如果你使用它们,你就得做好撸起袖管 fork 的准备。

mybatis

因为大家都习惯用 druid 和 mybatis 的组合。所以这里我选择用 mybatis 。其实 slick 也非常好用,只不过没有和 Spring Boot 的集成。写 Java 的话,大家习惯用 lombok ,在 Scala 里面没法用。我们可以用 @BeanProperty 这个注释做到类似的效果(可惜没法用 case class)。

class SQLStatDO {
  @BeanProperty var id: Long = _
  @BeanProperty var user: String = _
  @BeanProperty var age: Int = _
  @BeanProperty var sex: String = _
}

Mybatis 的 Scala 支持好久没有更新了,所以我不用。

json4s(JSON)

推荐使用 json4s 的 jackson support 。用好 json4s ,最好了解一下 Scala 的模式匹配和隐式转换这两个语言特性。

为什么选择 json4s 呢,因为 json4s 提供了非常友好的 DSL 。

在构建 Rest Controller 时,由于 Scala 的集合和 case class 不支持直接序列化,我们可以引入

<dependency>
  <groupId>com.fasterxml.jackson.module</groupId>
  <artifactId>jackson-module-scala_${scala.compat.version}</artifactId>
  <version>2.8.7</version>
</dependency>

做一些配置:

@Configuration
@EnableWebMvc
class WebConfig extends WebMvcConfigurerAdapter {
  override def configureMessageConverters(converters: util.List[HttpMessageConverter[_]]): Unit =
    converters.add(jackson2HttpMessageConverter())

  @Bean
  def jackson2HttpMessageConverter(): MappingJackson2HttpMessageConverter =
    new MappingJackson2HttpMessageConverter(objectMapper())

  @Bean
  def objectMapper(): ObjectMapper =
    new ObjectMapper() {
      setVisibility(PropertyAccessor.FIELD, Visibility.ANY)
      registerModule(DefaultScalaModule)
    }
}

配置完成之后,我们直接在 Rest Controller 里面返回普通的 Scala 对象就可以由 jackson 将其序列化。另外一种情况是我们自己构造的 JValue ,则需要转换成 JsonNode 才能被正确地序列化。对于 Http Post 中的值的解析,这里也简单举个例子。

@RestController
@RequestMapping(value = Array("/api"))
class HelloController {
  implicit def jvalue2jsonnode(value: JValue): JsonNode = asJsonNode(value)
  @RequestMapping(value = Array("/hello"))
  def hello: JsonNode = {
    val world = "世界"
    val ret =
      ("code" -> 0) ~
        ("data" -> ("hello" -> world) ~ ("year" -> 2017)) ~
        ("error" -> null)

    asJsonNode(ret)
  }

  @RequestMapping(value = Array("/echo"), method = Array(RequestMethod.POST))
  def echo(@RequestBody body: JsonNode): JsonNode = {
    val json = fromJsonNode(body)
    (json \ "hello", json \ "year") match {
      case (JString(world), JInt(year)) =>
        val ret =
          ("code" -> 0) ~
            ("data" -> ("hello" -> world) ~ ("year" -> year)) ~
            ("error" -> null)
        asJsonNode(ret)
      case _ =>
        val ret =
          ("code" -> 1) ~
            ("data" -> null) ~
            ("error" -> "invalid post body")
        asJsonNode(ret)
    }
  }
}

用 curl 做一下简单测试:

➜  ~ curl -d '{"hello": "世界", "year": 2017}' -H "Content-Type: application/json" -X POST  http://localhost:8080/api/echo
{"code":0,"data":{"hello":"世界","year":2017},"error":null}
➜  ~ curl http://localhost:8080/api/hello
{"code":0,"data":{"hello":"世界","year":2017},"error":null}

这里 asJsonNode 比较繁琐,可以用隐式转换让代码更加简洁。

@RestController
@RequestMapping(value = Array("/api"))
class HelloController {
  implicit def autoAsJsonNode(value: JValue): JsonNode = asJsonNode(value)

  @RequestMapping(value = Array("/hello"))
  def hello: JsonNode = {
    val world: String = "世界"

    ("code" -> 0) ~
      ("data" -> ("hello" -> world) ~ ("year" -> 2017)) ~
      ("error" -> null)
  }

  @RequestMapping(value = Array("/echo"), method = Array(RequestMethod.POST))
  def echo(@RequestBody body: JsonNode): JsonNode = {
    val json = fromJsonNode(body)
    (json \ "hello", json \ "year") match {
      case (JString(world), JInt(year)) =>
        ("code" -> 0) ~
          ("data" -> ("hello" -> world) ~ ("year" -> year)) ~
          ("error" -> null)
      case _ =>
        ("code" -> 1) ~
          ("data" -> null) ~
          ("error" -> "invalid post body")
    }
  }
}

gigahorse(HTTP Client)

之前写过http4s client 的学习笔记,因为官网的文档语焉不详,所以翻看了测试用例才知道 http4s client 怎么用。

当然,如果只是翻一下测试用例就能愉快的使用,倒是很好,只不过后来在用 http4s 的时候碰到一个 HTTP 1.1 的 chunked 响应相关的一个坑。鼓捣了很久发现搞不定。而且, http4s 默认返回的结果是在 scalaz 的 Task 里面的,并不是 Scala 标准库里面的 Future 。 scalaz 又在能力范围之外,所以弃用。

其实,就我的需求很简单:

  1. 这个 Http Client 支持的方法要完整,比如 scalaj-http 就不支持 PATCH
  2. 使用足够简单,直观,不需要在使用时引入 AKKA 。 play-ws 就是一个反例。
  3. 支持返回 Future
  4. 在定义 URI 的时候能够使用直观的 DSL ,避免字符串拼接

gigahorse 满足前三个条件,至于 URI 的 DSL ,用 scala-uri 解决。 gigahorse 背后是著名的 AsyncHttpClient ,其实现会比 http4s 完整很多,不至于会遇到各种 bug 。

在使用 Http Client 的时候会涉及到 Client 的生命周期管理,一般在 SpringBoot 中,我们可以实现 DisposableBean 中的方法,在对象销毁的时候关闭 Client 。

Reading List

  1. Building "Bootiful" Scala Web Applications with Spring Boot
  2. Scala 开发者的 SpringBoot 快速入门指南
  3. Scala with a human face
  4. Spring Boot 1.5.x 新特性:动态修改日志级别
  5. Spring Boot Actuator 监控端点小结
  6. Scala: The Good Parts by 扶墙老师
  7. lazy 变量与双重检测锁(DCL)
  8. Lazy Evaluation
  9. 双重检查锁定与延迟初始化
  10. Java 日志系统详解
  11. Scala URI
3809 次点击
所在节点    程序员
9 条回复
sadhen
2017-03-26 16:23:33 +08:00
预览的时候还以为没有代码高亮,所以贴了为知笔记的链接
HLT
2017-03-26 16:31:10 +08:00
zacard
2017-03-26 19:32:47 +08:00
厉害
sadhen
2017-03-27 00:31:48 +08:00
为啥收藏的人比评论的人多呢?

大家多多吐槽啊

不知道有没有人把代码 clone 下去,尝试一下
sadhen
2017-03-27 00:35:41 +08:00
在公司维护 Play 写的项目,但是觉得还是 Spring Boot 比较好用
dif
2017-03-27 15:07:42 +08:00
一直想用 scala 做个例子,感谢楼主
jack80342
2017-10-20 10:17:16 +08:00
这几天翻译了 Spring Boot 最新的官方文档,https://www.gitbook.com/book/jack80342/spring-boot/details
jack80342
2017-10-20 10:17:41 +08:00
欢迎 fork
sadhen
2019-04-01 19:50:08 +08:00

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/350396

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX