Java 开发者的 Scala 指南(二)

原文:zh.annas-archive.org/md5/c584bca13e5834b9dff0***638131c885

译者:飞龙

协议:*** BY-NC-SA 4.0

第四章. 测试工具

无论你使用哪种编程语言,都应该非常小心地进行测试,因为测试不仅以一致的方式记录了你的代码,而且对于重构和维护活动,如修复错误,也将大有裨益。

Scala 生态系统在很大程度上遵循 Java 在所有级别的测试趋势,但也有一些不同之处。在许多地方,我们会看到 Scala 正在使用 DSLs领域特定 语言),这使得测试代码非常易于阅读和理解。实际上,测试可以是介绍 Scala 时一个很好的起点,逐步从现有的 Java 项目迁移过来。

在本章中,我们将通过一些代码示例介绍一些主要的测试工具及其用法。我们已经在 第三章 中编写了一个微型的 JUnit 风格的测试,即 理解 Scala 生态系统,因此我们将从这里开始,专注于属于 行为驱动开发BDD)的 BDD 风格测试。BDD 对所使用的任何技术栈都是中立的,在过去的几年中,它已成为在 Gherkin 语言(它是 cucumber 框架的一部分,并在 cukes.info/gherkin.html 中解释)中编写清晰规范的一个合规选择,说明代码应该如何表现。在 Java 和许多其他语言中已经使用,这种风格的测试通常更容易理解和维护,因为它们更接近于普通英语。它们更接近于 BDD 的真正采用,旨在使业务分析师能够以结构化的方式编写测试规范,程序可以理解和实现。它们通常代表唯一的文档;因此,保持它们最新并与领域紧密相关非常重要。

Scala 主要提供了两个框架来编写测试,ScalaTest (www.scalatest.org) 和 Specs2 (etorreborre.github.io/specs2/)。由于它们彼此之间非常相似,我们只将介绍 ScalaTest,并对感兴趣的读者说明如何通过查看 Specs2 文档来了解它们之间的差异。此外,我们还将探讨使用 ScalaCheck 框架(www.scalacheck.org)进行的基于属性的自动化测试。

使用 ScalaTest 编写测试

为了能够快速开始可视化使用 ScalaTest 可以编写的某些测试,我们可以利用前一章中介绍的类型安全激活器(Typesafe Activator)中的 test-patterns-scala 模板。它包含了一系列示例,主要针对 ScalaTest 框架。

设置test-patterns-scala激活器项目只需要你前往我们之前安装 Typesafe Activator 的目录,然后,通过> activator ui命令启动 GUI,或者输入> activator new来创建一个新项目,并在提示时选择适当的模板。

模板项目已经包含了sbteclipse插件;因此,你只需在项目的根目录中通过命令提示符输入,就可以生成与 Eclipse 相关的文件,如下所示:

> activator eclipse

一旦成功创建了 Eclipse 项目,你可以通过选择文件 | 导入… | 通用 | 现有项目将其导入到你的 IDE 工作区。作为前一章的提醒,你也可以为 IntelliJ 或其他 IDE 创建项目文件,因为 Typesafe Activator 只是 SBT 的一个定制版本。

你可以查看src/test/scala中的各种测试用例。由于一些测试使用了我们尚未覆盖的框架,如 Akka、Spray 或 Slick,我们将暂时跳过这些测试,专注于最直接的测试。

在其最简单形式中,一个ScalaTest类(顺便说一下,它也可能测试 Java 代码,而不仅仅是 Scala 代码)可以通过扩展org.scalatest.FunSuite来声明。每个测试都表示为一个函数值,这已在Test01.scala类中实现,如下所示:

package scalatest
import org.scalatest.FunSuite

class Test01 extends FunSuite {
  test("Very Basic") {
    assert(1 == 1)
  }
  test("Another Very Basic") {
    assert("Hello World" == "Hello World")
  }
}

要仅执行这个单个测试类,你应该在命令提示符中输入以下命令:

> activator
> test-only <full name of the class to execute>

在我们的情况下,这个命令将是以下这样:

> test-only scalatest.Test01   (or scalatest.Test01.scala)
[info] Test01:
[info] - Very Basic (38 milliseconds)
[info] - Another Very Basic (0 milliseconds)
[info] ScalaTest
[info] Run ***pleted in 912 milliseconds.
[info] Total number of tests run: 2
[info] Suites: ***pleted 1, aborted 0
[info] Tests: su***eeded 2, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[info] Passed: Total 2, Failed 0, Errors 0, Passed 2
[su***ess] Total time: 9 s, ***pleted Nov 11, 2013 6:12:14 PM

test-patterns-scala项目下的src/test/scala/scalatest/Test02.scala中给出的示例非常相似,但使用===而不是==会在测试失败时提供额外的信息。如下所示:

class Test02 extends FunSuite {
 test("pass") {
 assert("abc" === "abc")
 }
 test("fail and show diff") {
 assert("abc" === "abcd") // provide reporting info
 }
}

再次运行测试可以通过输入以下命令来完成:

> test-only scalatest.Test02
[info] Test02:
[info] - pass (15 milliseconds)
[info] - fail and show diff *** FAILED *** (6 milliseconds)
[info]   "abc[]" did not equal "abc[d]" (Test02.scala:10)
[info][info] *** 1 TEST FAILED ***
[error] Failed: Total 2, Failed 1, Errors 0, Passed 1

在修复失败的测试之前,这次我们可以在连续模式下执行测试,使用test-only前的~字符(从激活器提示符中),如下所示:

>~test-only scalatest.Test02

连续模式会在每次编辑并保存Test02类时,让 SBT 重新运行test-only命令。SBT 的这个特性可以通过在后台运行测试或程序而不需要显式编写命令,为你节省大量时间。在第一次执行Test02时,你可以看到一些红色文本,指示"abc[]" 不等于 "abc[d]" (Test02.scala:10)

一旦你更正了abdc字符串并保存文件,SBT 将自动在后台重新执行测试,你可以看到文本变成绿色。

连续模式也适用于其他 SBT 命令,如~run~test

Test03展示了如何期望或捕获异常:

class Test03 extends FunSuite {
  test("Exception expected, does not fire, FAIL") {
    val msg = "hello"
    intercept[IndexOutOfBoundsException] {
      msg.charAt(0)
    }
  }
  test("Exception expected, fires, PASS") {
    val msg = "hello"
    intercept[IndexOutOfBoundsException] {
      msg.charAt(-1)
    }
  }
}

第一个场景失败,因为它期望一个IndexOutOfBoundsException,但代码确实返回了一个有效的h,即hello字符串索引为 0 的字符。

为了能够将 ScalaTest 测试套件作为 JUnit 测试套件运行(例如,在 IDE 中运行或在 Maven 中构建的现有基于 JUnit 的项目中扩展,或者向构建服务器报告时),我们可以使用可用的JUnitRunner类以及@RunWith注解,如下面的示例所示:

import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest.FunSuite
@RunWith(classOf[JUnitRunner])
class MyTestSuite extends FunSuite {
  // ...
}

BDD 风格的测试

Test06是另一种风格的测试示例,即 BDD。简而言之,你几乎用纯英文指定某种用户故事来描述你想要测试的场景的行为。这可以在以下代码中看到:

class Test06 extends FeatureSpec with GivenWhenThen {

  feature("The user can pop an element off the top of the stack") 
  {
info("As a programmer")
  info("I want to be able to pop items off the stack")
  info("So that I can get them in last-in-first-out order")

  scenario("pop is invoked on a non-empty stack") {

    given("a non-empty stack")
    val stack = new Stack[Int]
    stack.push(1)
    stack.push(2)
    val oldSize = stack.size

  when("when pop is invoked on the stack")
  val result = stack.pop()

  then("the most recently pushed element should be returned")
  assert(result === 2)

  and("the stack should have one less item than before")
  assert(stack.size === oldSize - 1)
  }

  scenario("pop is invoked on an empty stack") {

    given("an empty stack")
    val emptyStack = new Stack[Int]

    when("when pop is invoked on the stack")
    then("NoSuchElementException should be thrown")
    intercept[NoSuchElementException] {
    emptyStack.pop()
    }

  and("the stack should still be empty")
  assert(emptyStack.isEmpty)
  }
}
}

BDD 风格的测试比 JUnit 测试具有更高的抽象层次,更适合集成和验收测试以及文档,对于了解该领域的知识的人来说。你只需要扩展FeatureSpec类,可选地使用GivenWhenThen特质,来描述验收需求。有关 BDD 风格测试的更多详细信息,请参阅en.wikipedia.org/wiki/Behavior-driven_development。我们在这里只想说明,在 Scala 中可以编写 BDD 风格的测试,但我们不会进一步深入它们的细节,因为它们在 Java 和其他编程语言中已经得到了大量文档记录。

ScalaTest 提供了一个方便的 DSL,可以以接近纯英文的方式编写断言。org.scalatest.matchers.Matchers特质包含许多可能的断言,你应该查看其 ScalaDoc 文档以查看许多使用示例。Test07.scala表达了一个非常简单的匹配器,如下面的代码所示:

package scalatest

import org.scalatest._
import org.scalatest.Matchers

class Test07 extends FlatSpec with Matchers {
"This test" should "pass" in {
    true should be === true
  }
}

注意

虽然是用 ScalaTest 的 2.0 版本构建的,但 activator 项目中的原始示例使用的是现在已弃用的org.scalatest.matchers.ShouldMatchers特质;前面的代码示例实现了相同的行为,但更加更新。

让我们使用 Scala 工作表编写一些更多的断言。右键单击包含所有之前审查过的测试文件的scalatest包,然后选择new | Scala Worksheet。我们将把这个工作表命名为ShouldWork。然后我们可以通过扩展带有Matchers特质的FlatSpec规范来编写和评估匹配器,如下面的代码所示:

package scalatest
import org.scalatest._
object ShouldWork extends FlatSpec with Matchers {

  true should be === true

}

保存此工作表不会产生任何输出,因为匹配器通过了测试。然而,尝试通过将一个true改为false来让它失败。这在上面的代码中显示:

package scalatest
import org.scalatest._

object ShouldWork extends FlatSpec with Matchers {

  true should be === false

}

这次,我们在评估过程中得到了完整的堆栈跟踪,如下面的屏幕截图所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_04_03.jpg

我们可以开始评估更多的should匹配器,如以下代码所示:

package scalatest
import org.scalatest._

object ShouldMatchers extends FlatSpec with Matchers {

  true should be === true

  List(1,2,3,4) should have length(4)

  List.empty should be (Nil)

  Map(1->"Value 1", 2->"Value 2") should contain key (2)
  Map(1->"Java", 2->"Scala") should contain value ("Scala")

  Map(1->"Java", 2->"Scala") get 1 should be (Some("Java"))

  Map(1->"Java", 2->"Scala") should (contain key (2) and not contain value ("Clojure"))

  3 should (be > (0) and be <= (5))

  new java.io.File(".") should (exist)
}

当我们遇到测试失败时,工作表的评估就会停止。因此,我们必须修复它才能在测试中继续前进。这与我们之前使用 SBT 的test命令运行整个测试套件是相同的,如下面的代码所示:

object ShouldMatchers extends FlatSpec with Matchers {

"Hello" should be ("Hello")

"Hello" should (equal ("Hej")
               or equal ("Hell")) //> org.scalatest.exceptions.TestFailedException:

"Hello" should not be ("Hello")
}

在上一个例子中,最后一个语句(与第一个语句相反)应该失败;然而,它没有被评估。

功能测试

ScalaTest 与 Selenium(它是用于自动化浏览器测试的工具,可在www.seleniumhq.org找到)很好地集成,通过提供完整的 DSL,使得编写功能测试变得简单直接。Test08是这种集成的明显例子:

class Test08 extends FlatSpec with Matchers with WebBrowser {

  implicit val webDriver: WebDriver = new HtmlUnitDriver
go to "http://www.amazon.***"
click on "twotabsearchtextbox"
textField("twotabsearchtextbox").value = "Scala"
submit()
pageTitle should be ("Amazon.***: Scala")
pageSource should include("Scala Cookbook: Recipes")
}

让我们尝试直接在工作表中运行类似的调用。由于工作表会对每个语句评估提供反馈,因此它们非常适合直接识别问题,例如,如果链接、按钮或内容没有按预期找到。

只需在已存在的ShouldWork工作表旁边创建另一个名为Functional的工作表。右键单击scalatest包,然后选择New | Scala Worksheet

工作表可以按照以下方式填写:

package scalatest
import org.scalatest._
import org.scalatest.selenium.WebBrowser
import org.openqa.selenium.htmlunit.HtmlUnitDriver
import org.openqa.selenium.firefox.FirefoxDriver
import org.openqa.selenium.WebDriver
object Functional extends FlatSpec with Matchers with WebBrowser {
implicit val webDriver: WebDriver = new HtmlUnitDriver
  go to "http://www.packtpub.***/"
  textField("keys").value = "Scala"
  submit()
  pageTitle should be ("Search | Packt Publishing")
  pageSource should include("Akka")
}

在保存操作(Ctrl + S)后,工作表将被评估,并且可能为每个语句显示一些输出信息,除了最后两个带有should匹配器的语句,因为它们应该评估为true

尝试将("Search | Packt Publishing")更改为不同的值,例如Results或仅仅是Packt Publishing,并注意控制台输出提供了关于不匹配内容的有用信息。这在上面的屏幕截图中有展示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_04_04_revised.jpg

这个功能测试只是触及了可能性的表面。由于我们使用的是 Java Selenium 库,在 Scala 中,你可以继承 Java 中可用的 Selenium 框架的力量。

使用 ScalaMock 进行模拟

模拟是一种可以在不需要所有依赖项都到位的情况下测试代码的技术。Java 在编写测试时提供了几个用于模拟对象的框架。最著名的是 JMock、EasyMock 和 Mockito。随着 Scala 语言引入了新元素,如特性和函数,基于 Java 的模拟框架就不够用了,这就是 ScalaMock(www.scalamock.org)发挥作用的地方。

ScalaMock 是一个本地的 Scala 模拟框架,通常用于 ScalaTest(或 Specs2)中,通过在 SBT(build.sbt)文件中导入以下依赖项:

libraryDependencies +="org.scalamock" %% "scalamock-scalatest-support" % "3.0.1" % "test"

在 Specs2 中,需要导入以下依赖项:

libraryDependencies +=
"org.scalamock" %% "scalamock-specs2-support" % "3.0.1" % "test"

自从 Scala 版本 2.10 发布以来,ScalaMock 已经被重写,ScalaMock 版本 3.x是我们将通过模拟特例的示例简要介绍的版本。

让我们先定义我们将要测试的代码。它是一个微型的货币转换器(可在www.luketebbs.***/?p=58找到),从欧洲中央银行获取货币汇率。检索和解析货币汇率 XML 文件只需几行代码,如下所示:

trait Currency {
  lazy val rates : Map[String,BigDecimal] = {
  val exchangeRates =
    "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
  for (
    elem <- xml.XML.load(exchangeRates)\"Cube"\"Cube"\"Cube")
  yield
    (elem\"@currency").text -> BigDecimal((elem\"@rate").text)
  }.toMap ++ MapString,BigDecimal

  def convert(amount:BigDecimal,from:String,to:String) =
    amount / rates(from) * rates(to)
}

在这个例子中,货币汇率是通过 xml.XML.load 方法从 URL 中获取的。由于 XML 是 Scala 标准库的一部分,这里不需要导入。load 方法解析并返回 XML 汇率作为一个不可变的 Elem 类型结构,Elem 是一个表示 XML 元素的案例类。这在下述代码中显示:

<gesmes:Envelope 
>
  <gesmes:subject>Reference rates</gesmes:subject>
    <gesmes:Sender>
      <gesmes:name>European Central Bank</gesmes:name>
    </gesmes:Sender>
    <Cube>
      <Cube time="2013-11-15">
      <Cube currency="USD" rate="1.3460"/>
      <Cube currency="JPY" rate="134.99"/>
      <Cube currency="BGN" rate="1.9558"/>
      <Cube currency="CZK" rate="27.155"/>
      <Cube currency="DKK" rate="7.4588"/>
      <Cube currency="GBP" rate="0.83770"/>
           ...
         ...
    </Cube>
  </Cube>
</gesmes:Envelope>

从这个 XML 文档中访问货币汇率列表是通过 XPath 表达式在 Cube 节点内部导航完成的,因此有 xml.XML.load(exchangeRates) \ "Cube" \ "Cube" \ "Cube" 表达式。需要一个单行 for 推导(我们在上一章中引入的 for (…) yield (…) 构造)来遍历货币汇率,并返回一个 key -> value 对的集合,在我们的情况下,键将是一个表示货币名称的字符串,而 value 将是一个表示汇率的 BigDecimal 值。注意信息是如何从 <Cube currency="USD" rate="1.3460"/> 中提取的,通过写入 (elem \ "@currency").text 来捕获货币属性,以及 (elem \ "@rate").text 来分别捕获汇率。后者将通过从给定的字符串创建一个新的 BigDecimal 值来进一步处理。

最后,我们得到一个包含所有货币及其汇率的 Map[String, BigDecimal]。我们将 EUR(欧元)货币的映射添加到这个值中,它将代表参考汇率之一;这就是为什么我们使用 ++ 运算符合并两个映射,即我们刚刚创建的映射与只包含一个 key -> value 元素的新的映射一起。

在模拟之前,让我们使用 ScalaTest 和 FlatSpec 以及 Matchers 编写一个常规测试。我们将利用我们的 Converter 特质,将其集成到以下 MoneyService 类中:

package se.chap4

class MoneyService(converter:Converter ) {

  def sendMoneyToSweden(amount:BigDecimal,from:String): BigDecimal = {
    val convertedAmount = converter.convert(amount,from,"SEK")
    println(s" $convertedAmount SEK are on their way...")
    convertedAmount
  }

  def sendMoneyToSwedenViaEngland(amount:BigDecimal,from:String): BigDecimal = {
    val englishAmount = converter.convert(amount,from,"GBP")
    println(s" $englishAmount GBP are on their way...")
    val swedishAmount = converter.convert(englishAmount,"GBP","SEK")
    println(s" $swedishAmount SEK are on their way...")
    swedishAmount
  }
}

MoneyService 类派生出的一个可能的测试规范如下:

package se.chap4

import org.scalatest._
import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner

@RunWith(classOf[JUnitRunner])
class MoneyServiceTest extends FlatSpec with Matchers {

"Sending money to Sweden" should "convert into SEK" in {
    val moneyService = 
      new MoneyService(new ECBConverter)
    val amount = 200
    val from = "EUR"
    val result = moneyService.sendMoneyToSweden(amount, from)
    result.toInt should (be > (1700) and be <= (1800))
  }

"Sending money to Sweden via England" should "convert into GBP then SEK" in {
    val moneyService = 
      new MoneyService(new ECBConverter)
    val amount = 200
    val from = "EUR"
    val result = moneyService.sendMoneyToSwedenViaEngland(amount, from)
    result.toInt should (be > (1700) and be <= (1800))
  }
}

为了能够实例化 Converter 特质,我们使用在 Converter.scala 文件中定义的 ECBConverter 类,如下所示:

class ECBConverter extends Converter

如果我们从 SBT 命令提示符或直接在 Eclipse 中(作为 JUnit)执行测试,我们会得到以下输出:

> test
[info] ***piling 1 Scala source to /Users/thomas/projects/internal/HttpSamples/target/scala-2.10/test-classes...
 1792.2600 SEK are on their way...
 167.70000 GBP are on their way...
 1792.2600 SEK are on their way...
[info] MoneyServiceTest:
[info] Sending money to Sweden
[info] - should convert into SEK
[info] Sending money to Sweden via England
[info] - should convert into GBP then SEK
[info] Passed: : Total 2, Failed 0, Errors 0, Passed 2, Skipped 0
[su***ess] Total time: 1 s, ***pleted

如果我们从其中检索货币汇率的 URL 并非总是可用,或者如果某一天货币汇率变动很大,导致转换后的金额不在断言 should (be > (1700) and be <= (1800)) 给定的区间内,那么我们的测试可能会失败。在这种情况下,在我们的测试中对转换器进行模拟似乎是合适的,并且可以按照以下方式完成:

package se.chap4

import org.scalatest._
import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalamock.scalatest.MockFactory

@RunWith(classOf[JUnitRunner])
class MockMoneyServiceTest extends FlatSpec with MockFactory with Matchers {

"Sending money to Sweden" should "convert into SEK" in {

    val converter = mock[Converter]
    val moneyService = new MoneyService(converter)

    (converter.convert _).expects(BigDecimal("200"),"EUR","SEK").returning(BigDecimal(1750))

    val amount = 200
    val from = "EUR"
    val result = moneyService.sendMoneyToSweden(amount, from)
    result.toInt should be (1750)
  }
}

expects 方法包含了当我们的代码应该调用 convert 方法时我们期望的参数,而返回方法包含了用预期输出代替实际返回结果的预期值。

ScalaMock 在如何应用模拟代码方面有许多变体,并计划在未来的版本中使用 Macros 来增强模拟语法。简而言之,Macros 是在编译期间由编译器调用的函数。这是 Scala 从 2.10 版本开始添加的一个实验性功能,它使得开发者能够访问编译器 API 并对 AST抽象语法树)应用转换,即程序的树形表示。Macros 不在本书的范围之内,但它们在代码生成和领域特定语言(DSLs)方面非常有用。它们的用法将改进 ScalaMock 语法;例如,你可以在 inSequence {… }inAnyOrder {… } 代码块中,或者在这些块的嵌套组合中应用你的模拟期望,正如它们在文档中所展示的,该文档可在 scalamock.org 上找到。ScalaMock 还支持类似于 Mockito 的风格,使用 记录-然后-验证 循环而不是我们一直使用的 期望-首先 风格。

使用 ScalaCheck 进行测试

拥有一个完整且一致的测试套件,该套件由单元测试、集成测试或功能测试组成,这对于确保软件开发的整体质量至关重要。然而,有时这样的套件是不够的。在测试特定数据结构时,常常会遇到有太多可能值需要测试的情况,这意味着有大量的模拟或测试数据生成。自动基于属性的测试是 ScalaCheck 的目标,这是一个受 Haskell 启发的 Scala 库,它允许生成(或多或少随机地)测试数据来验证你正在测试的代码的一些属性。这个库可以应用于 Scala 项目,也可以应用于 Java 项目。

要快速开始使用 ScalaCheck,你可以在 build.sbt 文件中包含适当的库,就像我们之前经常做的那样。这如下所示:

resolver += Resolver.sonatypeRepo("releases")

libraryDependencies ++= Seq(
"org.scalacheck" %% "scalacheck" % "1.11.0" % "test")

从 SBT 提示符中,你可以输入 reload 而不是退出并重新启动 SBT,以获取构建文件的新版本,然后输入 update 来获取新的依赖项。一旦完成,你也可以输入 eclipse 来更新你的项目,以便依赖项成为你的类路径的一部分,并且编辑器将识别 ScalaCheck 类。

让我们先运行由 www.scalacheck.org 上的 快速入门 页面提出的 StringSpecification 测试:

import org.scalacheck.Properties
import org.scalacheck.Prop.forAll

object StringSpecification extends Properties("String") {

  property("startsWith") = forAll { (a: String, b: String) =>
    (a+b).startsWith(a)
  }

  property("concatenate") = forAll { (a: String, b: String) =>
    (a+b).length > a.length && (a+b).length > b.length
  }

  property("substring") = forAll { (a: String, b: String, c: String) =>
    (a+b+c).substring(a.length, a.length+b.length) == b
  }

}

在此代码片段中,ScalaCheck(随机)生成了一组字符串并验证了属性的正确性;第一个是直接的;它应该验证将两个字符串ab相加应该产生以a开头的字符串。这个测试听起来可能很明显会通过,无论字符串的值是什么,但第二个属性验证两个字符串连接的长度并不总是正确的;将ab都喂以空值""是一个反例,表明该属性没有被验证。我们可以通过以下方式通过 SBT 运行测试来展示这一点:

> test-only se.chap4.StringSpecification
[info] + String.startsWith: OK, passed 100 tests.
[info] ! String.concatenate: Falsified after 0 passed tests.
[info] > ARG_0: ""
[info] > ARG_1: ""
[info] + String.substring: OK, passed 100 tests.
[error] Failed: : Total 3, Failed 1, Errors 0, Passed 2, Skipped 0
[error] Failed tests:
[error] 	se.chap4.StringSpecification
[error] (test:test-only) sbt.TestsFailedException: Tests unsu***essful
[error] Total time: 1 s, ***pleted Nov 19, 2013 4:30:37 PM
>

ScalaCheck 方便地输出了一个反例,ARG_0: ""ARG_1: "",这导致测试失败。

我们可以在比字符串更复杂的对象上添加更多测试。让我们在我们的测试套件中添加一个新的测试类,命名为ConverterSpecification,以测试我们在使用 ScalaMock 进行模拟部分中创建的Converter

package se.chap4

import org.scalacheck._
import Arbitrary._
import Gen._
import Prop.forAll

object ConverterSpecification extends Properties("Converter") with Converter {

  val currencies = Gen.oneOf("EUR","GBP","SEK","JPY")

  lazy val conversions: Gen[(BigDecimal,String,String)] = for {
    amt <- arbitrary[Int] suchThat {_ >= 0}
    from <- currencies
    to <- currencies
  } yield (amt,from,to)

  property("Conversion to same value") = forAll(currencies) { c:String =>
    val amount = BigDecimal(200)
    val convertedAmount = convert(amount,c,c)
    convertedAmount == amount
  }

  property("Various currencies") = forAll(conversions) { c =>
    val convertedAmount = convert(c._1,c._2,c._3)
    convertedAmount >= 0
  }
}

如果我们在 SBT 中运行测试,将显示以下输出:

> ~test-only se.chap4.ConverterSpecification
[info] + Converter.Conversion to same value: OK, passed 100 tests.
[info] + Converter.Various currencies: OK, passed 100 tests.
[info] Passed: : Total 2, Failed 0, Errors 0, Passed 2, Skipped 0
[su***ess] Total time: 1 s, ***pleted Nov 19, 2013 9:40:40 PM
1\. Waiting for source changes... (press enter to interrupt)

在这个规范中,我们添加了两个特定的生成器;第一个名为currencies的生成器只能生成来自我们想要测试的有效货币列表的几个字符串,否则随机生成的字符串会产生不属于Map的字符串。让我们将无效项"DUMMY"添加到生成的列表中,以验证测试是否失败:

val currencies = Gen.oneOf("EUR","GBP","SEK","JPY","DUMMY")

保存后,测试会自动重新运行,因为我们指定了test-only前的~符号。如下所示:

[info] ! Converter.Conversion to same value: Exception raised on property evaluation.
[info] > ARG_0: "DUMMY"
[info] > Exception: java.util.NoSuchElementException: key not found: DUMMY
[info] ! Converter.Various currencies: Exception raised on property evaluation.
[info] > ARG_0: (1,,)
[info] > ARG_0_ORIGINAL: (1,DUMMY,SEK)
[info] > Exception: java.util.NoSuchElementException: key not found: 
[error] Error: Total 2, Failed 0, Errors 2, Passed 0, Skipped 0
[error] Error during tests:
[error] 	se.chap4.ConverterSpecification
[error] (test:test-only) sbt.TestsFailedException: Tests unsu***essful
[error] Total time: 1 s, ***pleted Nov 19, 2013 9:48:36 PM
2\. Waiting for source changes... (press enter to interrupt)

第二个名为conversions的生成器展示了如何构建一个更复杂的生成器,该生成器利用了 for 推导式的强大功能。特别是,请注意suchThat {_ >= 0}过滤方法确保任意选择的整数具有正值。此生成器返回一个包含所有必要值的Tuple3三元组,用于测试Converter.convert方法。

摘要

在本章中,我们介绍了 Scala 中可用的主要测试框架,这些框架在很大程度上继承了丰富的 Java 生态系统。此外,通过 ScalaCheck 应用属性测试,我们探索了提高测试质量的新方法。为了进一步提高软件质量,感兴趣的读者可以查看www.scala-sbt.org/网站上列出的其他 SBT 插件,特别是scalastyle-sbt-plugin用于检查编码风格或各种代码覆盖率插件。在下一章中,我们将深入探讨庞大的 Web 开发领域,并利用 Scala 语言的力量使门户和 Web 应用的开发变得高效且有趣。

第五章. Play 框架入门

本章开始了我们在 Scala 中进行 Web 开发的旅程。Web 开发已经成为一个选择架构和框架非常多的领域。找到合适的工具并不总是件简单的事,因为它涵盖了从传统的 Java EE 或 Spring 基础架构风格到更近期的类似 Ruby on Rails 的方法。大多数现有的解决方案仍然依赖于采用 servlet 容器模型,无论它们使用的是轻量级容器如 Jetty/Tomcat,还是支持 EJBs企业 JavaBeans)如 JBoss、Glassfish、WebSphere 或 WebLogic。许多在线文章和会议演讲都试图比较一些替代方案,但随着这些框架的快速发展以及有时关注不同的方面(如前端与后端),编制一个公平准确的列表仍然很困难。在 Scala 世界中,创建 Web 应用的替代方案从轻量级框架如 Unfiltered、Spray 或 Scalatra 到功能齐全的解决方案如 Lift 或 Play 框架都有。

我们选择专注于 Play 框架,因为它包含了我们认为对可维护的现代软件开发至关重要的重要特性。Play 框架的一些优点包括:

  • Play 框架可扩展且稳健。它能够处理大量负载,因为它建立在完全异步模型之上,该模型基于能够处理多核架构的技术,如 Akka,这是一个用于构建并发和分布式应用的框架,我们将在第八章《现代应用的基本特性 – 异步性和并发性》中进行介绍。

  • 通过提高易用性,推广DRY(即不要重复自己)原则,并利用 Scala 的表达性和简洁性,它为我们提供了增强的开发者生产力。除此之外,Play 的击中刷新工作流程,通过简单地刷新浏览器即可获得对所做更改的即时反馈,与 Java servlet 和 EJB 容器的较长的部署周期相比,这实际上提高了生产力。

  • 它与基于 JVM 的现有基础设施遗产具有良好的集成。

  • 它与现代客户端开发趋势具有良好的集成,这些趋势高度依赖于 JavaScript/CSS 及其周边生态系统,包括 AngularJS 或 WebJars 等框架。此外,Play 框架还支持LESS(即更简洁的 CSS)动态样式表语言以及 CoffeeScript,这是一种小型而优雅的语言,编译成 JavaScript,而无需任何额外的集成。

Play 框架版本 2.x 既有 Java 版本,也有 Scala 版本,这对于 Java 开发者来说是一个额外的优势,因为他们可能会更快地熟悉这些差异,并且在转向 Scala 之前可能已经对 Java 版本有了一些经验。

提供了几个选择,以快速开始使用 Play 框架并创建一个极简的 helloworld 项目。请注意,所有这些选择都是基于 SBT 创建项目的,正如我们在第三章理解 Scala 生态系统中简要提到的。

开始使用经典 Play 发行版

www.playframework.***/download 下载经典的 Play 发行版,并将 .zip 压缩包解压到您选择的目录中。将此目录添加到您的路径中(这样在文件系统上的任何位置运行 play 命令都会创建一个新的应用程序)。使用这种替代方案,您可以在终端窗口中输入以下命令:

> play new <PROJECT_NAME>   (for example  play new playsample)

将显示以下输出:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_01.jpg

我们只需要按 Enter 键,因为我们已经在之前的命令中给出了项目名称。按 Enter 键后,将显示以下内容:

Which template do you want to use for this new application? 
 1             - Create a simple Scala application
 2             - Create a simple Java application
> 1
OK, application playsample is created.

Have fun!

就这些;不到一分钟,我们已经有了一个完全工作的网络应用程序,现在我们可以执行它。由于这是一个 SBT 项目(其中 sbt 命令已被重命名为 play),我们可以直接导航到创建项目的根目录,并开始我们的 Play 会话,就像我们在处理一个 SBT 项目一样。这可以按照以下步骤进行:

> cd playsample
> play run
[info] Loading project definition…
--- (Running the application from SBT, auto-reloading is enabled) ---

[info] play - Listening for HTTP on /0:0:0:0:0:0:0:0:9000

(Server started, use Ctrl+D to stop and go back to the console...)

注意,应用程序默认在端口 9000 上启动。如果您想使用不同的端口,可以输入以下命令代替:

> play

这将带您进入 Play (SBT) 会话,然后您可以从那里选择要监听的端口。这可以按照以下步骤进行:

[playsample] $ run 9095
[info] play - Listening for HTTP on /0:0:0:0:0:0:0:0:9095

另一个选择是在终端中输入 > play "run 9095"

http://localhost:9095/(如果您使用的是默认端口则为 9000)启动浏览器,您应该在运行的门户上看到 欢迎使用 Play 页面:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_02.jpg

开始使用 Typesafe Activator

使用本书前面提到的基于 Activator 模板创建项目的方法,通过 Activator 开始 Play 项目的操作非常简单。只需转到 Typesafe Activator 安装目录,并输入以下命令:

> ./activator ui

这将在浏览器窗口中启动 activator。最基础的 Scala Play 项目位于 hello-play-scala 模板中。一旦您选择了模板,请注意默认位置指示了项目将被创建的位置,然后点击 创建

让我们从激活器浏览器视图或通过导航到创建的项目根目录并在命令提示符中输入以下命令,直接运行我们的示例项目:

> ./activator run

一旦服务器在 9000 端口上监听,你可以在浏览器中打开http://localhost:9000/ URL。编译只有在访问该 URL 时才会触发,因此应用程序显示可能需要几秒钟。你浏览器中出现的界面应该类似于以下截图:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_03.jpg

Play 应用的架构

为了更好地理解如何构建 Play 应用程序,我们首先需要了解其一些架构方面。

可视化框架栈

在我们开始探索典型样本 Play 应用程序背后的代码之前,让我们通过几个图表来可视化框架的架构。首先,展示的是由 Play 组成的技术栈的整体图如下:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_12.jpg

在 JVM 之上运行的是 Akka 框架,这是一个基于 actor 模型来管理并发操作的平台,我们将在第八章中详细讨论,即现代应用程序的基本特性 – 异步和并发。尽管如今大多数 Web 框架仍然依赖于如 Tomcat 或 JBoss 这样的 servlet 容器,但 Play 框架的新颖之处在于通过专注于在代码可以进行热替换时使应用程序无状态,从而避免遵循这一模型,即可以在运行时替换。尽管在商业环境中广泛使用和部署,但 servlet 容器存在额外的开销,例如每个请求一个线程的问题,这可能会在处理大量负载时限制可伸缩性。对于开发者来说,每次代码更改时避免重新部署部分或完整的.ear.war存档所节省的时间可能是相当可观的。

在 Akka 之上,有一个基于 Spray(一个用于构建基于 REST/HTTP 的集成层的开源工具包,现称为 Akka-Http)的 REST/HTTP 集成层,它产生并消费可嵌入的 REST 服务。这使得 Play 与现代编写 Web 应用程序的方式相关,在这种方式中,后端和前端通过 HTTP REST 服务进行通信,交换主要是 JSON/XML 消息,这些消息可以被渲染为 HTML5,因此可以充分利用前端 JavaScript 框架的全部功能。

最后,为了能够与各种其他技术集成,例如基于关系型或 NoSQL 的数据库、安全框架、社交网络、基于云或大数据解决方案,www.playmodules.***列出了大量 Play 插件和模块。

探索请求-响应生命周期

Play 遵循着众所周知的 MVC 模式,Web 请求的生命周期可以如下所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_05.jpg

为了了解这个工作流程的各个步骤,我们将探索一个作为 Play 发行版一部分的示例helloworld应用程序。这个helloworld应用程序比我们之前通过 Typesafe Activator 或直接使用> play new <project>命令从头创建的项目示例要复杂一些,因此更有趣。

我们在这里考虑的helloworld应用程序可以在<play 安装根目录>/samples/scala/helloworld目录下找到(在撰写本文时,我们使用了 Play 2.2.1 发行版)。

对于任何已经包含sbteclipse插件的 Play 项目,我们可以在命令提示符中直接输入以下命令来生成 Eclipse 相关的文件(在项目根目录级别):

> play eclipse

注意,由于 Play 命令只是 SBT 顶层的一个薄层,我们可以重用相同的语法,即> play eclipse而不是> sbt eclipse。一旦这些被导入到 IDE 中,你可以在左侧的包资源管理器面板中看到 Play 应用程序的一般源布局,如下面的截图所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_06.jpg

首先,让我们使用以下命令运行应用程序,看看它的样子:

> play run

http://localhost:9000/打开浏览器,你应该会看到一个类似于以下截图的小型网页表单:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_07.jpg

输入所需信息,然后点击提交以验证是否能够显示指定次数的您的名字。

请求流程的第一步出现在conf/routes文件中,如下所示:

# Routes
# This file defines all application routes (Higher priority routes first)
# ~~~~

# Home page
GET    /              controllers.Application.index

# Hello action
GET    /hello         controllers.Application.sayHello

# Map static resources from the /public folder to the /assets URL path
GET    /assets/*file  controllers.Assets.at(path="/public", file)

这是我们可以定义 HTTP 请求 URL 与需要在 Play 服务器上处理请求的控制器代码之间映射的地方,如下所示:

<REQUEST_TYPE(GET, POST...)> <URL_RELATIVE_PATH> <CONTROLLER_METHOD>

例如,在浏览器中访问http://localhost:9000/hello URL 与以下路由相匹配:

GET  /  controllers.Application.index

不带任何参数的index方法将在controller.Application.scala类上被调用。

这种将 URL 路由到控制器的方式与在 JAX-RS 或 Spring MVC 中找到的标准 Java 方式不同,在那里每个控制器都被注解。我们认为,路由文件方法给我们提供了一个清晰的概述,即 API 支持什么,也就是文档,并且它使得 Play 应用程序默认就是 RESTful 的。

即使看起来 routes 文件是一个配置文件,它实际上也是编译过的,任何拼写错误或对不存在的控制器方法的引用都会很快被识别。将 controllers.Application.index 替换为 controllers.Application.wrongmethod,保存文件,然后在浏览器中点击重新加载按钮(Ctrl + R)。你应该会在浏览器中看到错误被很好地显示,如下面的屏幕截图所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_08.jpg

注意错误消息的精确性和文件中失败行的确切指出。这种在浏览器重新加载时显示错误消息的出色方式是许多使程序员更高效的功能之一。同样,即使路由文件中没有映射错误,访问开发中的未映射 URL(例如 http://localhost:9000/hi)也会显示错误以及 routes 文件的内容,以显示哪些 URL 可以调用。这可以在以下屏幕截图中看到:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_09.jpg

在控制器中处理请求

接下来,让我们看看接收并处理 GET 请求的 Application 类:

object Application extends Controller {
  /**
   * Describes the hello form.
   */
  val helloForm = Form(
    tuple(
      "name" -> nonEmptyText,
      "repeat" -> number(min = 1, max = 100),
      "color" -> optional(text)
    )
  )

  // -- Actions
  /**
   * Home page
   */
  def index = Action {
    Ok(html.index(helloForm))
  }

  /**
   * Handles the form submission.
   */
  def sayHello = Action { implicit request =>
    helloForm.bindFromRequest.fold(
      formWithErrors => BadRequest(html.index(formWithErrors)),
      {case (name, repeat, color) => Ok(html.hello(name, repeat.toInt, color))}
    )
  }
}

index 方法执行 Action 块,这是一个函数(Request[AnyContent] => Result),它接受请求并返回一个 Result 对象。Request 类型的输入参数在这里的 index 方法中没有显示,因为它被隐式传递,并且在函数体中没有使用;如果我们想的话,可以写成 def index = Action { implicit request =>。单行 Ok(html.index(helloForm)) 表示返回的结果应该有一个 HTTP 状态码等于 200,即 Ok,并且将 html.index 视图绑定到 helloForm 模型。

在这个小型示例中,模型由在文件中较早定义的 Form 对象组成。如下所示:

val helloForm = Form(
  tuple(
    "name" -> nonEmptyText,
    "repeat" -> number(min = 1, max = 100),
    "color" -> optional(text)
  )
)

每个参数都描述为一个 key -> value 对,其中 key 是参数的名称,value 是应用于参数的函数的结果,该函数将生成一个 play.api.data.Mapping 对象。这种映射函数非常有用,可以执行对表单参数的验证。在这里,Form 参数被表示为一个元组对象,但我们可以创建更复杂的对象,例如案例类。Play 分发中的名为 forms 的示例项目包含了这种更高级处理验证方式的示例。在控制器中的 sayHello 方法中遇到的 fold 方法是一种累积验证错误的方法,以便能够一次性报告所有这些错误。让我们在填写表单时输入一些错误(例如,将 name 字段留空或在需要数字时输入字符)来验证错误是如何显示的。这可以在以下屏幕截图中看到:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_10.jpg

渲染视图

用于渲染视图的模板位于views/index.scala.html文件下。该模板如下所示:

@(helloForm: Form[(String,Int,Option[String])])
@import helper._

@main(title = "The 'helloworld' application") {

    <h1>Configure your 'Hello world':</h1>

    @form(action = routes.Application.sayHello, args = 'id -> "helloform") {
        @inputText(
            field = helloForm("name"),
            args = '_label -> "What's your name?", 'placeholder -> "World"
        )

        @inputText(
            field = helloForm("repeat"),
            args = '_label -> "How many times?", 'size -> 3, 'placeholder -> 10
        )

        @select(
            field = helloForm("color"), 
            options = options(
                "" -> "Default",
                "red" -> "Red",
                "green" -> "Green",
                "blue" -> "Blue"
            ),
            args = '_label -> "Choose a color"
        )

        <p class="buttons">
            <input type="submit" id="submit">
        <p>
    }
}

Play 模板引擎的一个优点是它基于 Scala 语言本身。这是一个好消息,因为我们不需要学习任何新的模板语法;我们可以重用 Scala 结构,无需任何额外的集成。此外,模板被编译,以便我们每次犯错时都能在编译时得到错误提示;错误将以与路由或纯 Scala 控制器代码相同的方式显示在浏览器中。这种快速的反馈与使用 Java Web 开发中更传统的JSPs(JavaServer Pages)技术相比可以节省我们大量时间。

模板顶部的声明包含将在整个模板中填充的绑定变量。模板标记可以生成任何类型的输出,如 HTML5、XML 或纯文本。模板还可以包含其他模板。

在上一个示例中,@main(title = "The 'helloworld' application'){ <block> ...}语句指的是main.scala.html视图文件本身,如下所示:

@(title: String)(content: Html)

<!DOCTYPE html>
<html>
    <head>
        <title>@title</title>
        <link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")">
        <link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")">
        <script src="img/@routes.Assets.at("javascripts/jquery-1.6.4.min.js")" type="text/javascript"></script>
    </head>
    <body>
        <header>
            <a href="@routes.Application.index">@title</a>
        </header>

        <section>
            @content
        </section>
    </body>
</html>

如您所见,此文件顶部定义的@(title: String)(content: Html)与上一个模板中的(title = "The 'helloworld' application'){ <block of template with code> ...}相匹配。这就是模板相互调用的方式。

@符号表示 Scala 代码后面直接跟一个变量名或要调用的方法,或者是一个括号内给出的完整代码块,即@{ …code … }

在表单提交后,响应(views/hello.scala.html)模板包含一个for循环来显示name字段多次。如下所示:

@(name: String, repeat: Int, color: Option[String])
@main("Here is the result:") {
    <ul style="color: @color.getOrElse("inherited")">
        @for(_ <- 1 to repeat) {
 <li>Hello @name!</li>
 }
    </ul>
    <p class="buttons">
        <a href="@routes.Application.index">Back to the form</a>
    </p>
}

与认证玩耍

在设计新的 Web 应用时,经常需要的功能之一涉及认证和授权。认证通常要求用户以用户名/密码的形式提供凭证以登录到应用程序。授权是系统确保用户只能执行其有权执行的操作的机制。在本节中,我们将通过扩展我们的helloworld示例,添加 Play 发行版中的安全功能,以展示 Scala 中特质的用法如何为传统问题提供优雅的解决方案。

让我们定义一个新的控制器,我们将称之为Authentication,它包含一些常用方法,如login用于获取登录页面,authenticatecheck用于执行认证验证,以及logout用于返回登录页面。以下是实现方式:

object Authentication extends Controller {

  val loginForm = Form(
    tuple(
      "email" -> text,
      "password" -> text
    ) verifying ("Invalid email or password", result => result match {
      case (email, password) => check(email, password)
    })
  )

  def check(username: String, password: String) = {
    (username == "thomas@home" && password == "1234")  
  }

  def login = Action { implicit request =>
    Ok(html.login(loginForm))
  }

  def authenticate = Action { implicit request =>
    loginForm.bindFromRequest.fold(
      formWithErrors => BadRequest(html.login(formWithErrors)),
      user => Redirect(routes.Application.index).withSession(Security.username -> user._1)
    )
  }

  def logout = Action {
    Redirect(routes.Authentication.login).withNewSession.flashing(
      "su***ess" -> "You are now logged out."
    )
  }
}

与上一节中属于 Application 控制器的 index 方法类似,这里的 login 方法包括将一个表单(命名为 loginForm)绑定到一个视图(命名为 html.login,对应于文件 views/login.scala.html)。以下是一个简单的视图模板,它包含两个文本字段来捕获电子邮件/用户名和密码:

@(form: Form[(String,String)])(implicit flash: Flash)

@main("Sign in") {

        @helper.form(routes.Authentication.authenticate) {

            @form.globalError.map { error =>
                <p class="error">
                    @error.message
                </p>
            }

            @flash.get("su***ess").map { message =>
                <p class="su***ess">
                    @message
                </p>
            }

            <p>
                <input type="email" name="email" placeholder="Email" id="email" value="@form("email").value">
            </p>
            <p>
                <input type="password" name="password" id="password" placeholder="Password">
            </p>
            <p>
                <button type="submit" id="loginbutton">Login</button>
            </p>

        }

        <p class="note">
            Try login as <em>thomas@@home</em> with <em>1234</em> as password.
        </p>

}

注意到 thomas@@home 用户名显示你可以通过输入两次来转义特殊的 @ 字符。

现在我们有了处理带有待验证凭据提交的 HTML 登录页面的逻辑,但我们仍然缺少将常规方法调用封装到任何我们想要保护的控制器的缺失部分。此外,如果用户名(存储在我们的 request.session 对象中并从 cookie 中检索)不存在,此逻辑将重定向我们到登录页面。这可以用以下方式描述为特质:

trait Secured {

  def username(request: RequestHeader) = request.session.get(Security.username)

  def onUnauthorized(request: RequestHeader) = Results.Redirect(routes.Authentication.login)

  def withAuth(f: => String => Request[AnyContent] => SimpleResult) = {
    Security.Authenticated(username, onUnauthorized) { user =>
      Action(request => f(user)(request))
    }
  }
}

我们可以将这个特质添加到同一个 Authentication.scala 控制器类中。withAuth 方法通过在它们周围应用 Security.Authenticated 方法来封装我们的 Action 调用。为了能够使用这个特质,我们只需要将其混合到我们的控制器类中,如下所示:

object Application extends Controller with Secured {}

一旦特质成为我们控制器的一部分,我们可以用 withAuth 方法替换 Action 方法。例如,在调用 index 方法时,我们替换 Action 方法,如下所示:

/**
 * Home page
 */
def index = withAuth { username => implicit request =>
  Ok(html.index(helloForm))
}

为了能够执行我们的新功能,我们不应该忘记将 Authentication.scala 控制器的额外方法添加到路由定义中(如果省略它们,编译器会标记出来):

# Authentication
GET    /login    controllers.Authentication.login
POST   /login    controllers.Authentication.authenticate
GET    /logout   controllers.Authentication.logout

让我们重新运行应用程序并调用 http://localhost:9000/ 页面。我们应该被路由到 login.html 页面而不是 index.html 页面。这在上面的屏幕截图中有显示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_05_11.jpg

尝试使用错误和正确的电子邮件/密码组合进行登录,以验证认证是否已正确实现。

这个基本的认证机制只是展示了你如何轻松扩展 Play 中的应用程序。它演示了使用动作组合技术,这项技术也可以应用于许多其他方面——例如,记录或修改请求——并且是拦截器的一个很好的替代方案。

当然,如果你需要通过其他服务实现认证,你可以使用与 Play 兼容的外部模块;例如,基于 OAuth、OAuth2 或 OpenID 等标准的模块。SecureSocial 模块是一个很好的例子,可在 securesocial.ws 获取。

使用 Play 的实用技巧

我们将以几条有助于 Play 每日使用的建议来结束这一章。

使用 Play 进行调试

由于函数式编程的声明性特性和编译器的强大类型检查机制,在处理 Scala 代码时,调试应该发生的频率较低。然而,如果你需要在某种情况下调试一个 Play 应用程序,你不妨像在 Java 中一样运行一个远程调试会话。为了实现这一点,只需使用额外的调试命令启动你的 Play 应用程序:

> play debug run

你应该在输出中看到一条额外的信息行,显示以下命令行:

Listening for transport dt_socket at address: 9999

从这里,你可以在你的代码中添加断点,并通过导航到名为运行 | **调试配置…**的菜单在 Eclipse 中启动远程调试配置。

右键单击远程 Java 应用程序并选择新建。只需确保你在连接属性表单中输入端口:9999,然后通过点击调试按钮开始调试。

处理版本控制

在维护代码时,可以忽略的典型文件位于以下位置,例如使用 GIT 等版本控制工具:

  • logs

  • project/project

  • project/target

  • target

  • tmp

  • dist

  • .cache

摘要

在本章中,我们介绍了 Play 框架,并涵盖了请求按照众所周知的 MVC 模式路由到控制器并通过视图渲染的典型示例。我们看到了在路由和模板的定义中使用 Scala 语法的用法给我们带来了编译时安全性的额外好处。这种帮助极大地提高了程序员的效率,并在重构时避免了拼写错误,使整个体验更加愉快。

我们还向一个helloworld应用程序示例添加了一些基本的 HTTP 身份验证。在下一章中,我们将解决持久性/ORM 的问题,这是任何 Web 应用程序中必不可少的部分,涉及到在后端使用数据库来存储和检索数据。我们将看到如何集成 Java 中使用的现有持久性标准,如 JPA,并介绍通过 Slick 框架的持久性的一种新颖但强大的方法。

第六章. 数据库访问和 ORM 的未来

几乎任何 Web 应用程序都包含的一个基本组件是在持久化存储中存储和检索数据。无论是基于关系型还是 NoSQL,数据库通常占据最重要的位置,因为它持有应用程序数据。当一个技术栈成为遗留技术并需要重构或移植到新的技术栈时,数据库通常是起点,因为它持有领域知识。

在本章中,我们首先将研究如何集成和重用从 Java 继承的持久化框架,例如支持Java 持久化 APIJPA)的 Hibernate 和 EclipseLink 等,这些框架处理对象关系映射ORM)。然后,我们将实验 Play 框架中默认的持久化框架 Anorm。最后,我们将介绍 Scala 的 ORM 替代方案和一种相当新颖的方法,它为更传统的基于 SQL 的查询添加了类型安全和组合,即 Slick 框架。我们将在 Play 网络开发环境中实验 Slick。我们还将涵盖从现有关系数据库生成类似 CRUD 的应用程序,这对于从遗留数据库开始时提高生产力非常有帮助。

集成现有的 ORM – Hibernate 和 JPA

如维基百科所定义:

“在计算机软件中,对象关系映射(ORM,O/RM 和 O/R 映射)是一种编程技术,用于在面向对象编程语言中转换不兼容的类型系统中的数据”。

ORM 框架在 Java 中的广泛应用,如 Hibernate,主要归功于持久化和查询数据所需编写的代码的简单性和减少。

在 Scala 中提供 JPA

虽然 Scala 有其自己的现代数据持久化标准(即我们稍后将要介绍的 Slick),但在本节中,我们将通过构建一个使用 JPA 注解 Scala 类在关系数据库中持久化数据的 SBT 项目,来介绍 Scala 世界中 JPA(Java Persistence API,可在docs.oracle.***/javaee/6/tutorial/doc/bnbpz.html中找到)的可能的集成。它源自www.brainoverload.nl/scala/105/jpa-with-scala上的在线示例,这对于 Java 开发者来说应该特别有趣,因为它说明了如何在 Scala 项目中同时使用 Spring 框架进行依赖注入和 bean 配置。提醒一下,由 Rod Johnson 创建的 Spring 框架于 2002 年推出,作为一种提供控制反转(即依赖注入)的方式,依赖注入的流行度增加,现在成为一个包含 Java EE 7 许多方面的功能齐全的框架。有关 Spring 的更多信息可在projects.spring.io/spring-framework/找到。

我们将连接到我们在第二章中介绍的现有 CustomerDB 示例数据库,以展示如何读取现有数据以及创建新的实体/表以持久化数据。

如我们在第三章中看到的,理解 Scala 生态系统,创建一个空的 Scala SBT 项目只需打开命令终端,创建一个用于放置项目的目录,然后按照以下方式运行 SBT:

> mkdir sbtjpasample
> cd sbtjpasample
> sbt
> set name:="sbtjpasample"
> session save

我们可以导航到 SBT 创建的 project/ 文件夹,并添加一个包含以下单行语句的 plugins.sbt 文件,以导入 sbteclipse 插件,这样我们就可以在 Eclipse IDE 下工作:

addSbtPlugin("***.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.4.0")

由于我们将使用 Hibernate 和 Spring 相关的类,我们需要将这些依赖项包含到我们的 build.sbt 构建文件中(以及连接到 CustomerDB sample 数据库的 derby-client 驱动程序),使其看起来像以下代码片段:

name:="sbtjpasample"

scalaVersion:="2.10.3"

libraryDependencies ++= Seq(
   "junit" % "junit" % "4.11",
   "org.hibernate" % "hibernate-core" % "3.5.6-Final",
   "org.hibernate" % "hibernate-entitymanager" % "3.5.6-Final",
   "org.springframework" % "spring-core" % "4.0.0.RELEASE",
   "org.springframework" % "spring-context" % "4.0.0.RELEASE",
   "org.springframework" % "spring-beans" % "4.0.0.RELEASE",
   "org.springframework" % "spring-tx" % "4.0.0.RELEASE",
   "org.springframework" % "spring-jdbc" % "4.0.0.RELEASE",
   "org.springframework" % "spring-orm" % "4.0.0.RELEASE", 
   "org.slf4j" % "slf4j-simple" % "1.6.4",
   "org.apache.derby" % "derbyclient" % "10.8.1.2",
   "org.scalatest" % "scalatest_2.10" % "2.0.M7"
)

为了提醒使这些依赖项在 Eclipse 中可用,我们必须再次运行 > sbt eclipse 命令并刷新 IDE 中的项目。

现在,从项目的根目录进入 > sbt eclipse 并将项目导入 IDE。

现在,让我们添加几个领域实体(在新的包 se.sfjd 下),我们希望用基于 Java 的 JPA 注解来注解。在 se.sfjd 包中定义的 Customer 实体将(至少部分地)映射到现有的 CUSTOMER 数据库表:

import javax.persistence._
import scala.reflect.BeanProperty

@Entity
@Table(name = "customer")
class Customer(n: String) {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "CUSTOMER_ID")
    @BeanProperty
    var id: Int = _

    @BeanProperty
    @Column(name = "NAME")
    var name: String = n

    def this() = this (null)

    override def toString = id + " = " + name
}

注意下划线 (_) 在声明 var id: Int = _ 时代表默认值。默认值将根据变量的类型 T 设置,如 Scala 规范所定义:

  • 如果 TInt 或其子范围类型之一,则为 0

  • 如果 TLong,则为 0L

  • 如果 TFloat,则为 0.0f

  • 如果 TDouble,则为 0.0d

  • 如果 TBoolean,则为 false

  • 如果 TUnit,则为 ()

  • 对于所有其他类型的 T,都是 null

Language 实体对应于我们想要持久化的新概念的添加,因此需要一个新的数据库表,如下所示:

@Entity
@Table(name = "language")
class Language(l: String) {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    @Column(name = "ID")
    @BeanProperty
    var id: Int = _

    @BeanProperty
    @Column(name = "NAME")
    var name: String = l

    def this() = this (null)

    override def toString = id + " = " + name
}

如我们在第二章中看到的,代码集成@BeanProperty 注解是一种生成符合 Java 的 getter 和 setter 的方法,而 this() 方法是 Hibernate 需要的无参数构造函数。

接下来,控制器类或 DAO数据访问对象)类捕获我们想要为 Customer 实体提供的操作,例如通过接口的形式提供 savefind 方法的 CRUD 功能,或者在这种情况下,一个 Scala 特质:

trait CustomerDao {
    def save(customer: Customer): Unit
    def find(id: Int): Option[Customer]
    def getAll: List[Customer]
}

CustomerDao 类的实现依赖于我们作为 Java 开发者可能熟悉的 JPA 实体管理器的各种方法:

import org.springframework.beans.factory.annotation._
import org.springframework.stereotype._
import org.springframework.transaction.annotation.{Propagation, Transactional}
import javax.persistence._
import scala.collection.JavaConversions._

@Repository("customerDao")
@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
class CustomerDaoImpl extends CustomerDao {

    @Autowired
    var entityManager: EntityManager = _

    def save(customer: Customer):Unit = customer.id match{
        case 0 => entityManager.persist(customer)
        case _ => entityManager.merge(customer)
    }

    def find(id: Int): Option[Customer] = {
        Option(entityManager.find(classOf[Customer], id))
    }

    def getAll: List[Customer] = {
        entityManager.createQuery("FROM Customer", classOf[Customer]).getResultList.toList
    }
}

以类似的方式,我们可以定义一个 Language 特质及其实现,如下所示,并添加了一个 getByName 方法:

trait LanguageDao {
    def save(language: Language): Unit
    def find(id: Int): Option[Language]
    def getAll: List[Language]
    def getByName(name : String): List[Language]
}

@Repository("languageDao")
@Transactional(readOnly = false, propagation = Propagation.REQUIRED)
class LanguageDaoImpl extends LanguageDao {

  @Autowired
  var entityManager: EntityManager = _

  def save(language: Language): Unit = language.id match {
    case 0 => entityManager.persist(language)
    case _ => entityManager.merge(language)
  }

  def find(id: Int): Option[Language] = {
    Option(entityManager.find(classOf[Language], id))
  }

  def getAll: List[Language] = {
    entityManager.createQuery("FROM Language", classOf[Language]).getResultList.toList
  }

  def getByName(name : String): List[Language] = {
    entityManager.createQuery("FROM Language WHERE name = :name", classOf[Language]).setParameter("name", name).getResultList.toList
  }
}

在我们可以执行项目之前,我们还需要遵循几个步骤:首先我们需要一个测试类,因此我们可以创建一个遵循 ScalaTest 语法(如我们之前在 第四章 中看到的)的 CustomerTest 类:

import org.junit.runner.RunWith
import org.scalatest.junit.JUnitRunner
import org.scalatest.FunSuite
import org.springframework.context.support.
lassPathXmlApplicationContext

@RunWith(classOf[JUnitRunner])
class CustomerTest extends FunSuite {

  val ctx = new ClassPathXmlApplicationContext("application-context.xml")

  test("There are 13 Customers in the derby DB") {

    val customerDao = ctx.getBean(classOf[CustomerDao])
    val customers = customerDao.getAll
    assert(customers.size === 13)
    println(customerDao
      .find(3)
      .getOrElse("No customer found with id 3")) 
  }

  test("Persisting 3 new languages") {
    val languageDao = ctx.getBean(classOf[LanguageDao])
    languageDao.save(new Language("English"))
    languageDao.save(new Language("French"))
    languageDao.save(new Language("Swedish"))
    val languages = languageDao.getAll
    assert(languages.size === 3) 
    assert(languageDao.getByName("French").size ===1) 
  }
}

最后但同样重要的是,我们必须定义一些配置,包括一个 JPA 所需的 META-INF/persistence.xml 文件,我们可以将其放在 src/main/resources/ 目录下,以及一个 Spring 的 application-context.xml 文件,其中所有豆类都已连接,并定义了数据库连接。persistence.xml 文件将看起来像以下这样:

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.0"  
    xsi:schemaLocation="http://java.sun.***/xml/ns/persistence http://java.sun.***/xml/ns/persistence/persistence_2_0.xsd">

    <persistence-unit name="JpaScala" transaction-type="RESOURCE_LOCAL">
        <provider>org.hibernate.ejb.HibernatePersistence</provider>
    </persistence-unit>
</persistence>

application-context.xml 文件,位于 src/main/resources/ 目录下,内容较为详细,具体如下:

<?xml version="1.0" encoding="UTF-8"?>
<beans 

       xsi:schemaLocation="
            http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
            http://www.springframework.org/schema/context http://www.springframework.org/schema/context/spring-context.xsd
            http://www.springframework.org/schema/tx http://www.springframework.org/schema/tx/spring-tx.xsd
        ">

    <tx:annotation-driven transaction-manager="transactionManager"/>

    <context:***ponent-scan base-package="se.sfjd"/>

    <bean id="dataSource"
          class="org.springframework.jdbc.datasource.DriverManagerDataSource"
          p:driverClassName="org.apache.derby.jdbc.ClientDriver" p:url="jdbc:derby://localhost:1527/sample"
          p:username="app" p:password="app"/>

    <bean id="entityManagerFactory"
          class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
        <property name="persistenceUnitName" value="JpaScala"/>
        <property name="persistenceProviderClass" value="org.hibernate.ejb.HibernatePersistence"/>
        <property name="jpaDialect">
            <bean class="org.springframework.orm.jpa.vendor.HibernateJpaDialect"/>
        </property>

        <property name="dataSource" ref="dataSource"/>
        <property name="jpaPropertyMap">
            <map>
                <entry key="hibernate.dialect" value="org.hibernate.dialect.DerbyDialect"/>
                <entry key="hibernate.connection.charSet" value="UTF-8"/>
                <entry key="hibernate.hbm2ddl.auto" value="update"/>
                <entry key="hibernate.show.sql" value="true"/>
            </map>
        </property>
    </bean>

    <bean id="entityManager"
          class="org.springframework.orm.jpa.support.SharedEntityManagerBean">
        <property name="entityManagerFactory" ref="entityManagerFactory"/>
    </bean>

    <bean id="transactionManager" class="org.springframework.orm.jpa.JpaTransactionManager">
        <property name="entityManagerFactory" ref="entityManagerFactory"/>
        <property name="dataSource" ref="dataSource"/>
    </bean>
</beans>

在运行测试之前,我们需要确保数据库服务器正在运行;这已在 第二章 中解释,代码集成,当时使用 ***Beans IDE。

现在,我们可以通过右键单击 CustomerTest 类并导航到 Debug As | Scala JUnit Test 或从命令提示符中输入以下命令来执行示例:

> sbt test
3 = Nano Apple
[info] CustomerTest:
[info] - There are 13 Customers in the derby DB
[info] - Persisting 3 new languages
[info] Passed: : Total 2, Failed 0, Errors 0, Passed 2, Skipped 0
[su***ess] Total time: 3 s

在 Play 框架中处理持久化

Play 框架可以使用任何类型的 ORM 运行,无论是基于 Java 的 JPA 还是 Scala 特定的。该框架有相关的但独立的 Java 和 Scala 版本。如 Play 文档所述,Java 版本使用 Ebean 作为其 ORM,而 Scala 替代方案不使用 ORM,而是通过 JDBC 之上的 Scala 风格抽象层 Anorm 运行。

使用 Anorm 的简单示例

为了说明 Anorm 的用法,我们将创建一个小的 Play 示例,连接到之前章节中使用的 ***Beans 分发的现有 CustomerDB 数据库,并在 第二章 中介绍,代码集成

最直接的方法是从终端窗口创建一个默认的 Play Scala 项目,输入以下命令:

> play new anormsample

一旦创建并导入到 Eclipse 中(再次使用 > play eclipse 命令创建 Eclipse 相关文件;如需更多细节,请参阅 第五章, Play 框架入门),我们可以看到 Anorm 的依赖已经包含在 built.sbt 文件中。然而,我们需要将 derby-client 数据库驱动程序的依赖添加到该文件中,以便通过 jdbc 与数据库通信。依赖项可以按以下方式添加:

libraryDependencies ++= Seq(
  jdbc,
  anorm,
  cache,
  "org.apache.derby" % "derbyclient" % "10.8.1.2"
)  

现在,我们可以定义一个 Customer case 类,它将代表数据库中的 CUSTOMER 表,并在其伴生对象中实现一些方法形式的行为,如下所示:

package models

import play.api.db._
import play.api.Play.current
import anorm._
import anorm.SqlParser._
import scala.language.postfixOps

case class Customer(id: Pk[Int] = NotAssigned, name: String)

object Customer {
  /**
   * Retrieve a Customer from an id.
   */
  def findById(id: Int): Option[Customer] = {
    DB.withConnection { implicit connection =>
      println("Connection: "+connection)
      val query = SQL("SELECT * from app.customer WHERE customer_id = {custId}").on('custId -> id)
      query.as(Customer.simple.singleOpt)
    }
  }

  /**
   * Parse a Customer from a ResultSet
   */
  val simple = {
    get[Pk[Int]]("customer.customer_id") ~
    getString map {
      case id~name => Customer(id, name)
    }
  }
}

Anorm SQL 查询符合基于字符串的 SQL 语句,其中变量绑定到值。在这里,我们将 customer_id 列绑定到 id 输入参数。由于我们希望返回一个 Option[Customer] 来处理 SQL 查询没有返回任何结果的情况,我们首先需要解析 ResultSet 对象以创建一个 Customer 实例并调用 singleOpt 方法,这将确保我们将结果包装在一个 Option 中(它可以返回 None 而不是潜在的错误)。

Application 控制器如下所示:

package controllers

import play.api._
import play.api.mvc._
import play.api.db._
import play.api.Play.current
import models._

object Application extends Controller {
  def index = Action {
    val inputId = 2  //  Hardcoded input id for the example
    val result = 
      DB.withConnection { implicit c =>
        Customer.findById(inputId) match {
          case Some(customer) => s"Found the customer: ${customer.name}"
          case None => "No customer was found."
      }
    }
    Ok(views.html.index(result))
  }
}

它只是将数据库查询包围在数据库连接中,并对 Option[Customer] 实体进行一些模式匹配,以显示查询的客户 id 是否找到的不同消息。

您可能在阅读 Scala 代码时注意到了关键字 implicit,例如在之前的代码示例中给出的 implicit c 参数。正如 Scala 文档中明确解释的那样:

“具有隐含参数的方法可以像普通方法一样应用于参数。在这种情况下,隐含标签没有效果。然而,如果这样的方法遗漏了其隐含参数的参数,这些参数将被自动提供”。

在我们之前的例子中,我们可以省略这个隐含参数,因为我们没有在方法体中进一步使用数据库连接 c 变量。

使用 inputId=2 运行应用程序可以替换为 inputId=3000; 例如,以演示没有找到客户的情况。为了避免在视图中进行任何更改,我们重用了默认 index.html 页面的欢迎信息位置;因此,您将在浏览器的绿色页眉中看到结果。

此示例仅展示了 Anorm 的基本用法;它源自 Play 框架发行版样本中的更完整的 ***puter-database 示例。如果您需要深入了解 Anorm 框架,可以参考它。

替换 ORM

作为 Java 开发者,我们习惯于通过使用成熟且稳定的 JPA 框架,如 Hibernate 或 EclipseLink,来处理关系型数据库的持久化。尽管这些框架使用方便,并且隐藏了跨多个表检索或更新数据的许多复杂性,但对象关系映射仍然存在 对象关系阻抗不匹配 问题;在面向对象模型中,您通过对象之间的关系遍历对象,而在关系型数据库中,您将表的数据行连接起来,有时会导致数据检索效率低下且繁琐。(这进一步在维基百科页面中解释,en.wikipedia.org/wiki/Object-relational_impedance_mismatch。)

相反,Typesafe 栈中的 Slick 框架提出通过函数式关系映射来解决数据到关系数据库的持久化问题,力求更自然地匹配。Slick 的额外好处包括以下两个方面:

  • 简洁性和类型安全:Slick 不是通过在 Java 代码中用字符串表达 SQL 查询来运行 SQL 查询,而是使用纯 Scala 代码来表达查询。在 JPA 中,Criteria API 或如 JPQL(Java Persistence Query Language)或 HQL(Hibernate Query Language)之类的语言长期以来一直试图使基于字符串的查询具有更强的类型检查,但仍然难以理解并生成冗长的代码。使用 Slick,查询通过 Scala 的 for ***prehensions 功能简洁地编写。SQL 查询的类型安全在 .*** 世界中通过流行的 LINQ 框架很久以前就已经引入。

  • 可组合和可重用查询:Slick 采取的函数式方法使组合成为一种自然的行为,这是当考虑将纯 SQL 作为 ORM 的替代品时缺乏的特性。

了解 Slick

让我们通过代码示例来探索 Slick 框架的行为,看看我们如何可以增强和替换更传统的 ORM 解决方案。

我们可以研究的第一个例子是我们在第四章测试工具中分析的 test-patterns-scala activator 模板项目的一部分。项目中的 scalatest/Test012.scala 文件展示了 Slick 的典型用法如下:

package scalatest

import org.scalatest._
import scala.slick.driver.H2Driver.simple._
import Database.threadLocalSession

object Contacts extends Table(Long, String) {
  def id = columnLong
  def name = columnString
  def gender = columnString
  def * = id ~ name
}

class Test12 extends FunSuite {
  val dbUrl = "jdbc:h2:mem:contacts"
  val dbDriver = "org.h2.Driver"

  test("Slick, H2, embedded") {
    Database.forURL(dbUrl, driver = dbDriver) withSession {
    Contacts.ddl.create
    Contacts.insertAll(
      (1, "Bob"),
      (2, "Tom"),
      (3, "Salley")
    )

    val nameQuery = 
      for( 
        c <- Contacts if c.name like "%o%"
      ) yield c.name 
    val names = nameQuery.list     
    names.foreach(println)
    assert(names === List("Bob","Tom"))
    }
  }
}

代码中最有趣的部分与 SQL 查询有关。不可变变量 names 包含对数据库的查询结果;而不是将 SQL 查询表达为 String 或通过 Java Criteria API,而是通过 for ***prehension 使用纯 Scala 代码,如下面的截图所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_06_05.jpg

与基于字符串的 SQL 查询不同,任何拼写错误或对不存在表或字段的引用都会立即由编译器指出。与 JPA Criteria API 生成的冗长且难以阅读的输出代码相比,更复杂的查询将以非常自然的方式转换为可读的 for 表达式。

此示例仅包含一个表,即 Contacts,我们通过扩展 scala.slick.driver.H2Driver.simple.Table 类来定义它。CONTACTS 数据库表包括三个列,一个作为 Long 数据类型定义的主键 id,以及两个其他类型为 String 的属性,分别是 namegender。在 Contacts 对象中定义的 * 方法指定了一个默认投影,即我们通常感兴趣的所有列(或计算值)。表达式 id ~ name(使用 ~ 连接序列运算符)返回一个 Projection2[Long, String],可以将其视为 Tuple2,但用于关系表示。默认投影 (Int, String) 导致简单查询的 List[(Int, String)]

由于关系数据库中列的数据类型与 Scala 类型不同,因此需要映射(类似于处理 ORM 框架或纯 JDBC 访问时所需的映射)。如 Slick 文档所述,开箱即用的原始类型支持如下(根据每个数据库类型使用的数据库驱动程序,有一些限制):

  • 数值类型: Byte, Short, Int, Long, BigDecimal, Float, Double

  • LOB 类型: java.sql.Blob, java.sql.Clob, Array[Byte]

  • 日期类型: java.sql.Date, java.sql.Time, java.sql.Timestamp

  • Boolean

  • String

  • Unit

  • java.util.UUID

定义领域实体之后,下一步是创建数据库,向其中插入一些测试数据,然后运行查询,就像我们使用任何其他持久化框架一样。

我们在 Test12 测试中运行的代码都被以下代码块包围:

Database.forURL(dbUrl, driver = dbDriver) withSession {
  < code a***essing the DB...>
}

forURL 方法指定了一个 JDBC 数据库连接,这通常包括一个对应于要使用的特定数据库的驱动程序类和一个由其 hostportdatabase name 以及可选的 username/password 定义的连接 URL。在示例中,使用了一个名为 contacts 的本地内存数据库(H2),因此连接 URL 是 jdbc:h2:mem:contacts,这与我们在 Java 中编写的方式完全相同。请注意,Slick 的 Database 实例仅封装了创建连接的“如何做”,实际的连接仅在 withSession 调用中创建。

Contacts.ddl.create 语句将创建数据库模式,而 insertAll 方法将使用包含其主键 idname 的三行数据填充 Contacts 表。

我们可以通过在 test-patterns-scala 项目的根目录下的终端窗口中输入以下命令来单独执行此测试,以验证它是否按预期运行:

> ./activator
> test-only scalatest.Test12
Bob
Tom
[info] Test12:
[info] - Slick, H2, embedded (606 milliseconds)
[info] ScalaTest
[info] Run ***pleted in 768 milliseconds.
[info] Total number of tests run: 1
[info] Suites: ***pleted 1, aborted 0
[info] Tests: su***eeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[su***ess] Total time: 1 s, ***pleted Dec 7, 2013 1:43:28 PM

目前,test-patterns-scala 项目包含对 SLF4J 日志框架的 slf4j-nop 实现的依赖,该实现禁用了所有日志。由于可视化 Scala for ***prehension 语句产生的确切 SQL 语句可能很有用,让我们将 sl4j-nop 替换为 logback 实现。在你的 build.sbt 构建文件中,将 "org.slf4j" % "slf4j-nop" % "1.6.4" 这一行替换为对 logback 的引用,例如 "ch.qos.logback" % "logback-classic" % "0.9.28" % "test"

现在,如果你重新运行测试,你可能会看到比实际需要的更多日志信息。因此,我们可以在项目中添加一个 logback.xml 文件(在 src/test/resources/ 文件夹中),如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<configuration>

    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="debug">
        <appender-ref ref="STDOUT" />
    </root>

    <logger name="scala.slick.***piler"	level="${log.q***p:-warn}" />
    <logger name="scala.slick.***piler.Query***piler" level="${log.q***p.phases:-inherited}" /><logger name="scala.slick.***piler.CodeGen"                   level="${log.q***p.codeGen:-inherited}" />
    <logger name="scala.slick.***piler.Insert***piler"            level="${log.q***p.insert***piler:-inherited}" />
    <logger name="scala.slick.jdbc.JdbcBackend.statement"         level="${log.session:-info}" />

    <logger name="scala.slick.ast.Node$"                          level="${log.q***p.assignTypes:-inherited}" />
    <logger name="scala.slick.memory.HeapBackend$"                level="${log.heap:-inherited}" />
    <logger name="scala.slick.memory.QueryInterpreter"            level="${log.interpreter:-inherited}" />
</configuration>

这次,如果我们只启用 "scala.slick.jdbc.JdbcBackend.statement" 日志记录器,测试的输出将显示所有 SQL 查询,类似于以下输出:

> test-only scalatest.Test12
19:00:37.470 [ScalaTest-running-Test12] DEBUG scala.slick.session.BaseSession - Preparing statement: create table "CONTACTS" ("CONTACT_ID" BIGINT NOT NULL PRIMARY KEY,"CONTACT_NAME" VARCHAR NOT NULL)
19:00:37.484 [ScalaTest-running-Test12] DEBUG scala.slick.session.BaseSession - Preparing statement: INSERT INTO "CONTACTS" ("CONTACT_ID","CONTACT_NAME") VALUES (?,?)
19:00:37.589 [ScalaTest-running-Test12] DEBUG scala.slick.session.BaseSession - Preparing statement: select x2."CONTACT_NAME" from "CONTACTS" x2 where x2."CONTACT_NAME" like '%o%'
Bob
Tom
[info] Test12:
[info] - Slick, H2, embedded (833 milliseconds)
[info] ScalaTest
[info] Run ***pleted in 952 milliseconds.
[info] Total number of tests run: 1
[info] Suites: ***pleted 1, aborted 0
[info] Tests: su***eeded 1, failed 0, canceled 0, ignored 0, pending 0
[info] All tests passed.
[info] Passed: Total 1, Failed 0, Errors 0, Passed 1
[su***ess] Total time: 1 s, ***pleted Dec 10, 2013 7:00:37 PM
> 

最后,为了验证是否已强制执行数据库模式验证,让我们尝试修改插入数据的一个键,以便我们有重复的键,如下面的代码行所示:

Contacts.insertAll(
      (1, "Bob"),
      (2, "Tom"),
      (2, "Salley")
    )

如果我们再次运行测试,它将失败,并显示类似于以下的消息:

[info] Test12:
[info] - Slick, H2, embedded *** FAILED *** (566 milliseconds)
[info]   org.h2.jdbc.JdbcBatchUpdateException: Unique index or primary key violation: "PRIMARY_KEY_C ON PUBLIC.CONTACTS(CONTACT_ID)"; SQL statement:
[info] INSERT INTO "CONTACTS" ("CONTACT_ID","CONTACT_NAME") VALUES (?,?) [23505-166]

搭建 Play 应用程序

在本节中,我们将通过从关系型数据库自动创建一个具有基本 CRUD 功能的完整 Play 应用程序来进一步实验 Slick 和 Play,包括模型、视图、控制器,以及测试数据和配置文件,如 Play 路由。

任何需要连接到数据库的 Web 应用程序通常至少需要在后端实现大部分 CRUD 功能。此外,能够生成默认的前端可以避免你从头开始制作。特别是,由 HTML5 视图组成的 Play 前端具有高度的复用性,因为大多数列、字段、按钮和表单的显示都可以在 HTML 编辑器中进行有限的复制粘贴重新排列。

让我们将这种逆向工程应用于我们在第二章中已经介绍过的 ***Beans 分发的示例客户数据库,代码集成

Play 应用的生成分为两个步骤:

  1. 创建一个常规的 Play 项目。

  2. 使用名为 playcrud 的外部工具,它本身是一个 Play 应用程序,并将生成所有必需的 MVC 和配置文件,这些文件位于新的 Play 项目结构之上。

这种两步走的方法更有保证,生成的应用程序将遵循 Play 分发的最新变化,特别是关于 Play 每个新版本带来的外观和感觉的变化。

要开始使用 playcrud 工具,请在所选目录的命令行中克隆项目(假设已安装 GIT,如果没有,请访问 git-scm.***/):

> git clone https://github.***/ThomasAlexandre/playcrud

这将创建一个名为 playcrud 的目录,项目内容是一个常规的 Play 应用程序,包括生成 Eclipse 项目的插件。因此,我们可以运行以下命令:

> cd playcrud
> play eclipse

然后,将项目导入到 Eclipse 中以更好地可视化其组成。应用程序由位于 samplecrud\app\controllersApplication.scala 文件中的一个控制器组成,以及位于 samplecrud\app\views 下的 index.scala.html 中的相应视图。在 samplecrud\conf 下的 routes 文件中只定义了两个路由:

# Home page
GET    /    controllers.Application.index

# CRUD action
GET    /crud    controllers.Application.generateAll

第一条路由将在浏览器中显示一个表单,我们可以输入有关数据库的信息,从而创建一个 Play 应用程序。通过查看其模板,这个表单相当容易理解:

@(dbForm: Form[(String,String,String,String)])
@import helper._
@main(title = "The 'CRUD generator' application") {
    <h1>Enter Info about your existing database:</h1>
    @form(action = routes.Application.generateAll, args = 'id -> "dbform") {

        @select(
            field = dbForm("driver"), 
            options = options(
                "***.mysql.jdbc.Driver" -> "MySQL",
                "org.postgresql.Driver" -> "PostgreSQL",
                "org.h2.Driver" -> "H2",
                "org.apache.derby.jdbc.ClientDriver" -> "Derby"
            ),
            args = '_label -> "Choose a DB"
        )

        @inputText(
            field = dbForm("dburl"),
            args = '_label -> "Database url", 'placeholder -> "jdbc:mysql://localhost:3306/slick"
        )

        @inputText(
            field = dbForm("username"),
            args = '_label -> "DB username", 'size -> 10, 'placeholder -> "root"
        )

        @inputText(
            field = dbForm("password"),
            args = '_label -> "DB password", 'size -> 10, 'placeholder -> "root"
        )
        <p class="buttons">
            <input type="submit" id="submit">
        <p>
    }
} 

第二个是在提交表单后执行一次的 generateAll 动作,该动作将创建执行新创建的 Play 应用程序所需的所有文件。

为了能够在正确的位置生成所有文件,我们只需要编辑一个名为 baseDirectory 的配置属性,目前位于 utilities/ 文件夹中的 Config.scala 文件。该属性指定了我们想要生成的 Play 应用程序的根目录。在我们编辑它之前,我们可以生成一个空白 Play 项目,baseDirectory 变量将引用它:

> cd ~/projects/internal (or any location of your choice)
> play new samplecrud
…
What is the application name? [samplecrud]
> [ENTER]
Which template do you want to use for this new application? 
 1             - Create a simple Scala application
 2             - Create a simple Java application
> [Press 1]
Just to verify we have our blank Play application correctly created we can launch it with:
> cd samplecrud
> play run

现在,在网页浏览器中打开 http://localhost:9000 URL。

现在我们有了我们的 baseDirectory (~/projects/internal/samplecrud),我们可以将其添加到 Config.scala 文件中。其他关于数据库的属性只是默认值;我们在这里不需要编辑它们,因为我们将在运行 playcrud 应用程序时填写 HTML 表单时指定它们。

在一个新的终端窗口中,让我们通过输入以下命令来执行 playcrud 应用程序:

> cd <LOCATION_OF_PLAYCRUD_PROJECT_ROOT>
> play "run 9020" (or any other port than 9000)

这里,我们需要选择一个不同于 9000 的端口,因为它已被空白应用程序占用。

现在,将您的网络浏览器指向 playcrud URL,http://localhost:9020/。您应该会看到一个 HTML 表单,您可以在其中编辑源数据库的属性以进行 CRUD 生成,如下面的截图所示(此数据库将只进行读取):

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_06_01.jpg

提交表单很可能会在终端控制台中生成一些日志输出,一旦生成完成,浏览器将被重定向到端口 9000 以显示新生成的 CRUD 应用程序。由于这是我们第一次生成应用程序,它将失败,因为生成的应用程序的 build.sbt 文件已更新,需要重新加载一些新依赖项。

为了解决这个问题,通过按下 Ctrl + D 来中断当前运行的 Play 应用程序。一旦它停止,只需重新启动它:

> play run

如果一切顺利,你应该能够访问 http://localhost:9000 并看到从数据库生成的实体对应的可点击控制器列表,包括 ProductManufacturerPurchase Order

让我们打开其中一个,例如制造商视图,如下截图所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_06_02.jpg

结果屏幕显示了 CRUD 功能的READ部分,通过显示数据库中所有制造商行的列表。分页功能默认设置为3,这就是为什么一次只能看到 30 个制造商中的三个,但可以通过点击上一页下一页按钮导航到其他页面。这个默认页面大小可以在每个单独的控制器中编辑(查找pageSize val 声明),或者可以在代码生成之前修改控制器模板,以一次性更新所有控制器。此外,HTML 表格的标题是可点击的,可以根据每个特定的列对元素进行排序。

点击添加新制造商按钮将调用一个新屏幕,其中包含一个用于在数据库中创建新条目的表单。

导入测试数据

生成的应用默认使用 H2 内存数据库运行,启动时会填充一些测试数据。在生成过程中,我们使用 DBUnit 的功能将源数据库的内容导出到一个 XML 文件中,DBUnit 是一个基于 JUnit 的 Java 框架。当测试中涉及足够多的数据库数据,而你又想避免通过生成包含从真实数据库中提取的一些数据的 XML 样本文件来模拟所有内容时,DBUnit 非常有用。导出的测试数据存储在samplecrud\test\目录下的testdata.xml文件中。当运行生成的应用程序时,该文件将由 DBUnit 在Global.scalaonStart方法中加载,在创建数据库模式之后。

为了能够将数据持久化到真实的数据库中,从而避免每次重启时都导入 XML 文件,我们可以将内存中的数据替换为磁盘上的真实数据库。例如,我们可以将位于samplecrud\conf目录下的application.conf文件中的数据库驱动属性替换为以下几行:

db.default.driver=org.h2.Driver
db.default.url="jdbc:h2:tcp://localhost/~/customerdb"
db.default.user=sa
db.default.password=""

重启 play 应用后,新的数据库将被构建:

> play run

在浏览器中访问http://localhost:9000 URL 这次将在磁盘上创建数据库模式并填充测试数据。由于数据库在重启之间是持久化的,从现在开始我们必须在Global.scala中注释掉ddl.create语句以及引用testdata.xml的 DBUnit 导入的行。

在 H2browser 中可视化数据库

Play 的一个方便功能是,你可以直接从 SBT 访问h2-browser来在你的浏览器中可视化数据库内容。即使你使用的是除了 H2 之外的大多数数据库,这也是正确的。打开一个终端窗口并导航到生成的项目根目录:

> play
> h2-browser

通过填写以下截图所示的连接属性来连接到数据库:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_06_03.jpg

假设点击显示在上一张截图中的测试连接按钮后显示测试成功,我们可以可视化并发送 SQL 查询到customerdb数据库,如下一张截图所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_06_04.jpg

探索应用生成的代码背后的内容

源数据库中的每个表都会生成一些工件:

  • 一个模型,一个控制器,以及几个视图

  • conf.routes文件中插入了一组route条目,如下所示为PURCHASE_ORDER表:

    # PurchaseOrder
    # 
    # PurchaseOrder list (look at the default values for pagination parameters)
    
    GET     /purchaseorder               controllers.PurchaseOrderController.list(p:Int ?= 0, s:Int ?= 2, f ?= "")
    # Add purchaseorder
    GET     /purchaseorder/new           controllers.PurchaseOrderController.create
    POST    /purchaseorder               controllers.PurchaseOrderController.save
    # Edit existing purchaseorder
    GET     /purchaseorder/:pk           controllers.PurchaseOrderController.edit(pk:Int)
    POST    /purchaseorder/:pk           controllers.PurchaseOrderController.update(pk:Int)
    
    # Delete purchaseorder
    POST    /purchaseorder/:pk/delete    controllers.PurchaseOrderController.delete(pk:Int)
    

模型由域实体组成,每个实体都通过 Slick 定义,结合一个表示行的 case 类和一个特定驱动程序的slick.driver.H2Driver.simple.Table行。我们本可以避免使用 case 类,直接编写涉及列的元组,就像我们在早期的Test12示例中看到的test-patterns-scala激活器模板一样,但将列封装在 case 类中对于后续的模式匹配和视图中的使用来说更方便。代表PurchaseOrder实体的模型类生成如下:

package models

case class PurchaseOrderRow(orderNum : Option[Int], customerId : Int, productId : Int, quantity : Option[Int], shippingCost : Option[Int], salesDate : Option[Date], shippingDate : Option[Date], freight***pany : Option[String])

// Definition of the PurchaseOrder table
object PurchaseOrder extends TablePurchaseOrderRow {

  def orderNum = columnInt 
  def customerId = columnInt  
  def productId = columnInt   
  def quantity = column[Option[Int]]("QUANTITY") 
  def shippingCost = column[Option[Int]]("SHIPPING_COST") 
  def salesDate = column[Option[Date]]("SALES_DATE")  
  def shippingDate = column[Option[Date]]("SHIPPING_DATE") 
  def freight***pany = column[Option[String]]("FREIGHT_***PANY") 

  def * = orderNum.? ~ customerId ~ productId ~ quantity ~ shippingCost ~ salesDate ~ shippingDate ~ freight***pany <> (PurchaseOrderRow.apply _, PurchaseOrderRow.unapply _)

  def findAll(filter: String = "%") = {
    for {
      entity <- PurchaseOrder
      // if (entity.name like ("%" + filter))
    } yield entity
  }

  def findByPK(pk: Int) =
     for (
       entity <- PurchaseOrder if entity.orderNum === pk
     ) yield entity
     ...

PurchaseOrder实体的完整代码以及相应的PurchaseOrderController类的 CRUD 方法定义可以在 Packt Publishing 网站上下载,也可以通过在本节中解释的执行scaffolding playcrud GitHub 项目在CustomerDB样本数据库上重现。

最后,为特定实体生成视图的模板收集在同一个名为views.<entity_name>/的目录下,并包括三个文件,分别是list.scala.htmlcreateForm.scala.htmleditForm.scala.html,分别用于READCREATEUPDATE操作。它们嵌入了一种混合的纯 HTML5 标记和最小 Scala 代码,用于遍历和显示来自控制器查询的元素。注意在视图中添加了一个特定的play.api.mvc.Flash隐式对象:Play 的这个有用特性使得在生成的视图中显示一些信息成为可能,以通知用户执行操作的结果。您可以在控制器中看到,我们通过Home.flashing {... }语句引用它,其中根据操作的成功或失败显示各种信息。

playcrud 工具的限制

在当前实验性的playcrud工具版本中,发现了一些限制,如下所述:

  • playcrud 项目并不总是与所有 JDBC 数据库完美兼容,特别是由于某些数据库的映射是定制的。然而,只需进行少量更改,它就足够灵活,可以适应新的映射。此外,它允许通过指定需要生成的外部文件中的表来生成部分数据库。为了启用此功能,我们只需在我们的 playcrud 项目的 conf/ 目录下添加一个文件,命名为 tables,并写入我们想要包含的表的名称(文件中的每一行一个表名,不区分大小写)。例如,考虑一个包含以下代码的 tables 文件:

    product
    purchaseorder
    manufacturer
    

    此代码只为这三个表创建 MVC 类和 HTML 视图。

  • 如果特定数据库数据类型的映射没有被 playcrud 处理,你将在浏览器窗口中得到一个编译错误,这很可能会提到缺少的数据类型。处理映射的 playcrud 代码中的位置是 utilities/DBUtil.scala 类。playcrud 的后续版本应该使这些配置对每种数据库类型更加灵活,并将它们放在外部文件中,但到目前为止,它们是嵌入在代码中的。

  • 可用的代码生成是在两个已经存在的样本的基础上灵感和构建的,一个是 Play 框架分发的名为 ***puter-database 的样本(它展示了一个 CRUD 应用,但使用 Anorm 作为持久层,这是一个基于 SQL 的持久层框架,是 Play 的默认选项),另一个是 Typesafe 的 Slick 团队完成的 Slick 使用示例(带有 SuppliersCoffee 数据库,展示了多对一关系)。如果你希望以不同的方式生成代码,所有模板都可以在 views/ 目录下找到。其中一些主要包含静态数据,例如基于 build.scala.txt 模板生成 build.sbt

  • 在商业应用中,遇到具有超过 22 列的数据库表并不罕见。由于我们将这些列封装到案例类中,而 Scala 2.10 有一个限制,限制了超过 22 个元素的案例类的创建,因此目前无法生成超过该大小的 Slick 映射。希望从 Scala 2.11 开始,这个限制应该会被取消。

摘要

在本章中,我们介绍了处理关系型数据库持久化的几种方法。我们首先通过一个 Scala 与基于传统 JPA 的 ORM 持久化集成的例子进行了说明。该例子还展示了 Spring 框架与 Scala 代码库之间的集成。然后,我们介绍了 Anorm,这是 Play 框架中默认的持久化框架,它依赖于直接 SQL 查询。由于 ORM 的一些局限性,主要与可扩展性和性能相关,以及纯 SQL 查询在类型安全和可组合性方面的局限性,我们转向采用 Slick 框架,这是一种独特的持久化方法,旨在以更函数式的方式在关系型数据库中持久化数据。最后,我们考虑了通过从现有数据库生成具有基本 CRUD 功能的全功能 Play Web 应用程序,作为快速将 Slick 集成到 Play 中的方法。Slick 的未来版本从 2.0 开始增强了对代码生成的支持,并力求通过使用 Scala 宏使编写数据库查询的语法更加可读。

在下一章中,我们将探讨如何在使用 Scala 集成外部系统时使用 Scala,特别是通过 Web 服务和 REST API,支持 JSON 和 XML 等数据格式。

第七章. 使用集成和 Web 服务

由于技术堆栈不断演变,在开发商业软件时需要考虑的一个大领域是系统之间的集成。Web 的灵活性和可扩展性使得基于 HTTP 构建的服务以松散耦合的方式集成系统的数量激增。此外,为了能够通过防火墙和额外的安全机制导航到可通过这些机制访问的安全网络,HTTP 模型越来越受欢迎。在本章中,我们将介绍如何在通过 Web 服务或 REST 服务交换消息(如 XML 和 JSON 格式)时涉及 Scala。特别是,我们将考虑通过 Play 框架运行此类服务。

在本章中,我们将介绍以下主题:

  • 从 XML 模式生成数据绑定,以及从它们的 WSDL 描述中生成 SOAP Web 服务类

  • 在 Scala 和特别是在 Play 框架的上下文中操作 XML 和 JSON

  • 从 Play 调用其他 REST Web 服务,并验证和显示其响应

在 Scala 中绑定 XML 数据

即使由于 JSON 日益流行,XML 最近从无处不在的位置有所下降,但两种格式都将继续被大量用于结构化数据。

在 Java 中,使用 JAXB 库创建能够序列化和反序列化 XML 数据以及通过 API 构建 XML 文档的类的做法很常见。

以类似的方式,Scala 可用的scalaxb库可以生成用于处理 XML 和 Web 服务的帮助类。例如,让我们考虑一个小型的 XML 模式Bookstore.xsd,它定义了作为书店一部分的一组书籍,如下所示:

<?xml version="1.0" encoding="UTF-8"?>
<xsd:schema 
            targetNamespace="http://www.books.org"

            elementFormDefault="qualified">
    <xsd:element name="book_store">
        <xsd:***plexType>
            <xsd:sequence>
                <xsd:element name="book" type="book_type" 
                             minO***urs="1" maxO***urs="unbounded"/>
            </xsd:sequence>
        </xsd:***plexType>
    </xsd:element>
    <xsd:***plexType name="book_type">
         <xsd:sequence>
             <xsd:element name="title" type="xsd:string"/>
             <xsd:element name="author" type="xsd:string"/>
             <xsd:element name="date" minO***urs="0" type="xsd:string"/>
             <xsd:element name="publisher" type="xsd:string"/>
         </xsd:sequence>
         <xsd:attribute name="ISBN" type="xsd:string"/>
     </xsd:***plexType>
</xsd:schema>

一本书通常由其标题、作者、出版日期和 ISBN 号码定义,如下面的示例所示:

<book ISBN="9781933499185">
  <title>Madame Bovary</title>
  <author>Gustave Flaubert</author>
  <date>1857</date>
  <publisher>Fonolibro</publisher>
</book>

有几种方式可以在www.scalaxb.org网站上运行scalaxb,要么直接作为命令行工具,通过 SBT 或 Maven 的插件,或者作为托管在heroku上的 Web API。由于我们到目前为止基本上使用了 SBT 并且应该对它感到舒适,让我们使用 SBT 插件来创建绑定。

首先,通过在新终端窗口中运行以下命令创建一个名为wssample的新 SBT 项目:

> mkdir wssample
> cd wssample
> sbt
> set name:="wssample"
> session save
> 

现在我们需要在project/目录下的plugins.sbt文件中添加scalaxb插件依赖(同时我们还将添加sbteclipse插件,它使我们能够从 SBT 项目生成 Eclipse 项目)。生成的plugins.sbt文件将类似于以下代码:

addSbtPlugin("***.typesafe.sbteclipse" % "sbteclipse-plugin" % "2.4.0")

addSbtPlugin("org.scalaxb" % "sbt-scalaxb" % "1.1.2")

resolvers += Resolver.sonatypeRepo("public")

此外,我们还需要稍微修改build.sbt文件,以特别包括一个在用 SBT 编译时将生成scalaxb XML 绑定的命令。生成的build.sbt文件将类似于以下代码:

import ScalaxbKeys._

name:="wssample"

scalaVersion:="2.10.2"

scalaxbSettings

libraryDependencies += "***.databinder.dispatch" %% "dispatch-core" % "0.11.0"

libraryDependencies += "org.scalatest" %% "scalatest" % "2.0.M7" % "test"

sourceGenerators in ***pile <+= scalaxb in ***pile

packageName in scalaxb in ***pile := "se.wssample"

将之前显示的 Bookstore.xsd 架构添加到在项目中创建的新 src/main/xsd 目录中。从现在起,每次你调用 SBT 命令 > ***pilescalaxb 都将在 target/scala-2.10/src_managed 目录下生成一些 Scala 类(在 build.sbt 文件中指定的包中,即 se.wssample),除非没有进行更改。例如,在我们的小型示例中,scalaxb 生成以下案例类:

package se.wssample

case class Book_store(book: se.wssample.Book_type*)

case class Book_type(title: String,
  author: String,
  date: Option[String] = None,
  publisher: String,
  ISBN: Option[String] = None)

注意第一个案例类声明末尾的 *,它用于指定可变参数(即不确定数量的参数,因此这里的 Book_store 构造函数可以接受多个 Book_type 实例)。一个展示如何使用生成的代码解析 XML 文档的测试类示例在 BookstoreSpec.scala 类中如下所示:

package se.wssample

import org.scalatest._
import org.scalatest.matchers.Matchers

class BookstoreSpec extends FlatSpec with Matchers {
  "This bookstore" should "contain 3 books" in {

    val bookstore =
    <book_store >
        <book ISBN="9781933499185">
            <title>Madame Bovary</title>
            <author>Gustave Flaubert</author>
            <date>1857</date>
            <publisher>Fonolibro</publisher>
        </book>
        <book ISBN="9782070411207">
            <title>Le malade imaginaire</title>
            <author>Moliere</author>
            <date>1673</date>
            <publisher>Gallimard</publisher>
        </book>
        <book ISBN="1475066511">
            <title>Fables</title>
            <author>Jean de La Fontaine</author>
            <date>1678</date>
            <publisher>CreateSpace</publisher>
        </book>
    </book_store>

    val bookstoreInstance = scalaxb.fromXMLBook_store

    println("bookstoreInstance: "+ bookstoreInstance.book)

    bookstoreInstance.book.length should be === 3
  }
}

当调用 > sbt test 命令时,此测试的预期输出如下:

bookstoreInstance: List(Book_type(Madame Bovary,Gustave Flaubert,Some(1857),Fonolibro,Some(9781933499185)), Book_type(Le malade imaginaire,Molière,Some(1673),Gallimard,Some(9782070411207)), Book_type(Fables,Jean de La Fontaine,Some(1678),CreateSpace,Some(1475066511)))
[info] BookstoreSpec:
[info] This bookstore
[info] - should contain 3 books
[info] Passed: : Total 1, Failed 0, Errors 0, Passed 1, Skipped 0
[su***ess] Total time: 4 s

从 SOAP Web 服务运行 scalaxb

由于 scalaxb 支持 Web 服务描述语言WSDL),我们还可以生成完整的 Web 服务 API,而不仅仅是与 XML 数据相关的类。为了实现此功能,我们只需将我们的 WSDL 服务描述文件复制到 src/main/wsdl。所有具有 .wsdl 扩展名的文件将在编译时由 scalaxb 插件处理,它将创建以下三种类型的输出:

  • 专门针对你应用程序的服务 API。

  • 专门针对 SOAP 协议的类。

  • 负责通过 HTTP 将 SOAP 消息发送到端点 URL 的类。scalaxb 使用我们在 第三章 中介绍的调度库,即 理解 Scala 生态系统。这就是为什么我们将它添加到 build.sbt 文件中的依赖项中。

让我们以一个在线 SOAP Web 服务为例,来说明如何从 WSDL 描述中使用 scalaxb。www.webservicex.*** 是一个包含各种市场细分中许多不同此类 Web 服务样本的网站。在这里,我们将关注他们的股票报价服务,该服务返回由股票符号给出的报价。API 非常简单,因为它只包含一个请求方法,getQuote,并且返回的数据量有限。你可能想尝试任何其他可用的服务(稍后,因为你可以将多个 WSDL 文件放在同一个项目中)。它的 WSDL 描述看起来类似于以下代码:

<?xml version="1.0" encoding="utf-8"?>
<wsdl:definitions … // headers > 
  <wsdl:types>
    <s:schema elementFormDefault="qualified" targetNamespace="http://www.webserviceX.***/">
      <s:element name="GetQuote">
        <s:***plexType>
          <s:sequence>
            <s:element minO***urs="0" maxO***urs="1" name="symbol" type="s:string" />
          </s:sequence>
        </s:***plexType>
      </s:element>
      <s:element name="GetQuoteResponse">
        <s:***plexType>
          <s:sequence>
            <s:element minO***urs="0" maxO***urs="1" name="GetQuoteResult" type="s:string" />
          </s:sequence>
        </s:***plexType>
      </s:element>
      <s:element name="string" nillable="true" type="s:string" />
    </s:schema>
  </wsdl:types>
  <wsdl:message name="GetQuoteSoapIn">
    <wsdl:part name="parameters" element="tns:GetQuote" />
  </wsdl:message>
  <wsdl:message name="GetQuoteSoapOut">
    <wsdl:part name="parameters" element="tns:GetQuoteResponse" />
  </wsdl:message>
  ...

WSDL 文件的第一部分包含 XML 模式的描述。第二部分定义了各种 Web 服务操作如下:

  <wsdl:portType name="StockQuoteSoap">
    <wsdl:operation name="GetQuote">
      <wsdl:documentation >Get Stock quote for a ***pany Symbol</wsdl:documentation>
      <wsdl:input message="tns:GetQuoteSoapIn" />
      <wsdl:output message="tns:GetQuoteSoapOut" />
    </wsdl:operation>
  </wsdl:portType>
  <wsdl:portType name="StockQuoteHttpGet">
    <wsdl:operation name="GetQuote">
      <wsdl:documentation >Get Stock quote for a ***pany Symbol</wsdl:documentation>
      <wsdl:input message="tns:GetQuoteHttpGetIn" />
      <wsdl:output message="tns:GetQuoteHttpGetOut" />
    </wsdl:operation>
  </wsdl:portType>

  <wsdl:binding name="StockQuoteSoap12" type="tns:StockQuoteSoap">
    <soap12:binding transport="http://schemas.xmlsoap.org/soap/http" />
    <wsdl:operation name="GetQuote">
      <soap12:operation soapAction="http://www.webserviceX.***/GetQuote" style="document" />
      <wsdl:input>
        <soap12:body use="literal" />
      </wsdl:input>
      <wsdl:output>
        <soap12:body use="literal" />
      </wsdl:output>
    </wsdl:operation>
  </wsdl:binding>
  ...

最后,WSDL 文件的最后一部分定义了 Web 服务操作与物理 URL 之间的耦合:

  <wsdl:service name="StockQuote"><wsdl:port name="StockQuoteSoap12" binding="tns:StockQuoteSoap12">
      <soap12:address location="http://www.webservicex.***/stockquote.asmx" />
    </wsdl:port></wsdl:service>
</wsdl:definitions>

如你所见,WSDL 文件通常相当冗长,但由 scalaxb 生成的结果 Scala 合约简化为以下一个方法特质:

// Generated by <a href="http://scalaxb.org/">scalaxb</a>.
package se.wssample

trait StockQuoteSoap {
  def getQuote(symbol: Option[String]): Either[scalaxb.Fault[Any], se.wssample.GetQuoteResponse]
}

注意结果类型是如何优雅地封装在Either类中的,该类代表两种可能类型之一LeftRight的值,其中Right对象对应于服务的成功调用,而Left对象在失败情况下包含scalaxb.Fault值,正如我们在第二章,代码集成中简要描述的那样。

由于与 SOAP 协议相关的生成类以及与 HTTP 调度相关的类并不特定于我们正在定义的服务,因此它们可以被重用,因此它们已经被生成为可堆叠的特性,包括数据类型和接口、SOAP 绑定和完整的 SOAP 客户端。以下StockQuoteSpec.scala测试示例给出了这些特性的典型使用场景,以调用 SOAP 网络服务:

package se.wssample

import org.scalatest._
import org.scalatest.matchers.Matchers
import scala.xml.{ XML, PrettyPrinter }

class StockQuoteSpec extends FlatSpec with Matchers {
  "Getting a quote for Apple" should "give appropriate data" in {

    val pp = new PrettyPrinter(80, 2)

    val service = 
      (new se.wssample.StockQuoteSoap12Bindings 
        with scalaxb.SoapClients 
        with scalaxb.DispatchHttpClients {}).service

    val stockquote = service.getQuote(Some("AAPL"))

    stockquote match {
      case Left(err) => fail("Problem with stockquote invocation")
      case Right(su***ess) => su***ess.GetQuoteResult match {
        case None => println("No info returned for that quote")
        case Some(x) => {
          println("Stockquote: "+pp.format(XML.loadString(x)))
          x should startWith ("<StockQuotes><Stock><Symbol>AAPL</Symbol>")
        }
      }
    }
  }
}

在这个例子中,一旦我们实例化了服务,我们只需调用 API 方法service.getQuote(Some("AAPL"))来检索 AAPL 符号(苹果公司)的股票报价。然后我们对结果进行模式匹配,从服务返回的Either对象中提取 XML 数据。最后,由于检索到的数据是以 XML 字符串的形式给出的,我们解析它并格式化它以便更好地阅读。我们可以使用以下代码执行测试以查看结果:

> sbt
> test-only se.wssample.StockQuoteSpec
Stockquote: <StockQuotes>
 <Stock>
 <Symbol>AAPL</Symbol>
 <Last>553.13</Last>
 <Date>1/2/2014</Date>
 <Time>4:00pm</Time>
 <Change>-7.89</Change>
 <Open>555.68</Open>
 <High>557.03</High>
 <Low>552.021</Low>
 <Volume>8388321</Volume>
 <MktCap>497.7B</MktCap>
 <PreviousClose>561.02</PreviousClose>
 <PercentageChange>-1.41%</PercentageChange>
 <AnnRange>385.10 - 575.14</AnnRange>
 <Earns>39.75</Earns>
 <P-E>14.11</P-E>
 <Name>Apple Inc.</Name>
 </Stock>
</StockQuotes>
[info] StockQuoteSpec:
[info] Getting a quote for Apple
[info] - should give appropriate data
[info] Passed: : Total 1, Failed 0, Errors 0, Passed 1, Skipped 0
[su***ess] Total time: 3 s

处理 XML 和 JSON

XML 和 JSON 是结构化可以在系统部分之间交换的数据的占主导地位的格式,例如后端-前端或外部系统之间。在 Scala 中,Scala 库中提供了一些内置支持来操作这两种格式。

操作 XML

在本章以及第三章,理解 Scala 生态系统中,我们之前也简要地看到了,当使用 HTTP 时,XML 文档可以作为字面量创建,并以多种方式转换。例如,如果我们从 Play 项目根目录中键入> play console来启动 REPL,我们就可以开始对 XML 进行实验:

scala> val books =
 <Library>
 <book title="Programming in Scala" quantity="15" price="30.00" />
 <book title="Scala for Java Developers" quantity="10" price="25.50" />
 </Library>
books: scala.xml.Elem = 
<Library>
 <book title="Programming in Scala" quantity="15" price="30.00"/>
 <book title="Scala for Java Developers" quantity="10" price="25.50"/>
</Library>

books变量是Elem类型,它代表一个 XML 结构。我们不仅可以直接编写 XML 字面量,还可以使用实用方法通过解析文件或解析字符串来构建 XML Elem,如下所示:

scala> import scala.xml._
scala> val sameBooks = XML.loadString("""
 <Library>
 <book title="Programming in Scala" quantity="15" price="30.00"/>
 <book title="Scala for Java Developers" quantity="10" price="25.50"/>
 </Library>
 """)
sameBooks: scala.xml.Elem = 
<Library>
<book price="30.00" quantity="15" title="Programming in Scala"/>
<book price="25.50" quantity="10" title="Scala for Java Developers"/>
</Library>

在前面的命令中使用的三引号允许我们表达一个预格式化的字符串,其中字符被转义(例如,正则字符串中的"将被记为\")。

例如,处理这样的 XML 结构可能包括计算书籍的总价。这个操作可以通过 Scala 的for ***prehension实现,以下代码展示了如何实现:

scala> val total = (for {
 book <- books \ "book"
 price = ( book \ "@price").text.toDouble
 quantity = ( book \ "@quantity").text.toInt
 } yield price * quantity).sum
total: Double = 705.0

在处理与各种外部系统的集成时,检索和转换 XML 结构是经常发生的事情。通过 XPath 表达式访问我们之前所做的那样访问各种 XML 标签非常方便,并产生简洁且可读的代码。从 Excel 以 CSV 数据形式导出的信息程序化地创建 XML 也是一个常见的操作,可以按以下方式实现:

scala> val books = 
 <Library>
 { List("Programming in Scala,15,30.00","Scala for Java Developers,10,25.50") map { row => row split "," } map { b => <book title={b(0)} quantity={b(1)} price={b(2)} /> }}
 </Library>
books: scala.xml.Elem = 
<Library>
 <book title="Programming in Scala" quantity="15" price="30.00"/><book title="Scala for Java Developers" quantity="10" price="25.50"/>
</Library>

操作 JSON

Scala 库支持 JSON,你只需要导入适当的库。以下是一些 REPL 用法的示例:

scala> import scala.util.parsing.json._
import scala.util.parsing.json._
scala> val result = JSON.parseFull("""
 {
 "Library": {
 "book": [
 {
 "title": "Scala for Java Developers",
 "quantity": 10
 },
 {
 "title": "Programming Scala",
 "quantity": 20
 }
 ]
 }
 }
 """)
result: Option[Any] = Some(Map(Library -> Map(book -> List(Map(title -> Scala for Java Developers, quantity -> 10.0), Map(title -> Programming Scala, quantity -> 20.0)))))

任何有效的 JSON 消息都可以转换成由 MapsLists 构成的结构。然而,通常我们希望创建有意义的类,即从 JSON 消息中表达业务领域。可在 json2caseclass.cleverapps.io 提供的在线服务正好做到这一点;它是一个方便的 JSON 到 Scala case class 转换器。例如,我们可以将我们前面的 JSON 消息复制到 Json 粘贴 文本区域,然后点击 Let’s go! 按钮来尝试它,如下面的截图所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_07_05_New.jpg

转换器产生以下输出:

case class Book(title:String, quantity:Double)
case class Library(book:List[Book])
case class R00tJsonObject(Library:Library)

在我们已经在 第一章 中介绍过的 case classes 的非常有趣的功能中,在项目中交互式编程 是一个用于模式匹配的分解机制。一旦 JSON 消息被反序列化为 case classes,我们就可以使用这个机制来操作它们,如下面的命令序列所示:

scala> case class Book(title:String, quantity:Double)
defined class Book
scala> val book1 = Book("Scala for Java Developers",10)
book1: Book = Book(Scala for Java Developers,10.0)
scala> val book2 = Book("Effective Java",12)
book2: Book = Book(Effective Java,12.0)
scala> val books = List(book1,book2)
books: List[Book] = List(Book(Scala for Java Developers,10.0), Book(Effective Java,12.0))

首先,我们定义了两个书籍实例并将它们放入一个列表中。

scala> def bookAboutScala(book:Book) = book match {
 case Book(a,_) if a contains "Scala" => Some(book)
 case _ => None
 }
bookAboutScala: (book: Book)Option[Book]

之前定义的方法在 Book 构造函数上执行模式匹配,该构造函数还包含一个守卫(即 if 条件)。由于我们不使用第二个构造函数参数,所以我们用一个下划线代替了创建匿名变量。在之前定义的两个书籍实例上调用此方法将显示以下结果:

scala> bookAboutScala(book1)
res0: Option[Book] = Some(Book(Scala for Java Developers,10.0))
scala> bookAboutScala(book2)
res1: Option[Book] = None

我们可以将 case class 模式匹配与其他模式混合使用。例如,让我们定义以下正则表达式(注意三引号的使用以及使用 .r 来指定它是一个正则表达式):

scala> val regex = """(.*)(Scala|Java)(.*)""".r
regex: scala.util.matching.Regex = (.*)(Scala|Java)(.*)

此正则表达式将匹配包含 Scala 或 Java 的任何字符串。

scala> def whatIs(that:Any) = that match {
 case Book(t,_) if (t contains "Scala") =>
 s"${t} is a book about Scala"
 case Book(_,_) => s"$that is a book "
 case regex(_,word,_) => s"$that is something about ${word}"
 case head::tail => s"$that is a list of books"
 case _ => "You tell me !"
 }
whatIs: (that: Any)String

我们现在可以在多个不同的输入上尝试这个方法并观察结果:

scala> whatIs(book1)
res2: String = Scala for Java Developers is a book about Scala
scala> whatIs(book2)
res3: String = "Book(Effective Java,12.0) is a book "
scala> whatIs(books)
res4: String = List(Book(Scala for Java Developers,10.0), Book(Effective Java,12.0)) is a list of books
scala> whatIs("Scala pattern matching")
res5: String = Scala pattern matching is something about Scala
scala> whatIs("Love")
res6: String = You tell me !

使用 Play JSON

除了 Scala 库的默认实现之外,还有许多其他库可以用来操作 JSON。除了建立在已知 Java 库(如 Jerkson,建立在 Jackson 之上)和其他已知实现(如 sjson、json4s 或 Argonaut,面向函数式编程)之上的库之外,许多 Web 框架也创建了它们自己的,包括 lift-json、spray-json 或 play-json。由于在这本书中我们主要介绍 Play 框架来构建 Web 应用程序,我们将重点关注 play-json 实现。请注意,play-json 也可以作为独立程序运行,因为它只包含一个 jar 文件,没有其他对 Play 的依赖。从 Play 项目内部运行 REPL 控制台已经包含了 play-json 依赖项,因此我们可以在控制台终端窗口中直接进行实验。

注意

如果你想在一个不同于 Play 控制台(例如,一个常规的 SBT 项目或 Typesafe activator 项目)的 REPL 中运行以下示例,那么你将不得不将以下依赖项添加到你的 build.sbt 文件中:

libraryDependencies += "***.typesafe.play" %% "play-json" % "2.2.1"

scala> import play.api.libs.json._
import play.api.libs.json._ 
scala> val books = Json.parse("""
 {
 "Library": {
 "book": [
 {
 "title": "Scala for Java Developers",
 "quantity": 10
 },
 {
 "title": "Programming Scala",
 "quantity": 20
 }
 ]
 }
 }
 """)
books: play.api.libs.json.JsValue = {"Library":{"book":[{"title":"Scala for Java Developers","quantity":10},{"title":"Programming Scala","quantity":20}]}}

JsValue 类型是 play-json 中包含的其他 JSON 数据类型的超类型,如下所示:

  • 使用 JsNull 来表示 null 值

  • JsStringJsBooleanJsNumber 分别用来描述字符串、布尔值和数字:数字包括 short、int、long、float、double 和 BigDecimal,如以下命令所示:

    scala> val sfjd = JsString("Scala for Java Developers")
    sfjd: play.api.libs.json.JsString = "Scala for Java Developers"
    scala> val qty = JsNumber(10)
    qty: play.api.libs.json.JsNumber = 10
    
    
  • JsObject 表示一组名称/值对,如下所示:

    scala> val book1 = JsObject(Seq("title"->sfjd,"quantity"->qty))
    book1: play.api.libs.json.JsObject = {"title":"Scala for Java Developers","quantity":10}
    scala> val book2 = JsObject(Seq("title"->JsString("Programming in Scala"),"quantity"->JsNumber(15)))
    book2: play.api.libs.json.JsObject = {"title":"Programming in Scala","quantity":15}
    
    
  • JsArray 表示任何 JSON 值类型的序列(可以是异构的,即不同类型):

    scala> val array = 
     JsArray(Seq(JsString("a"),JsNumber(2),JsBoolean(true)))
    array: play.api.libs.json.JsArray = ["a",2,true]
    
    

从程序上讲,创建一个与我们的书籍列表等价的 JSON 抽象 语法树AST)可以表达如下:

scala> val books = JsObject(Seq(
 "books" -> JsArray(Seq(book1,book2))
 ))
books: play.api.libs.json.JsObject = {"books":[{"title":"Scala for Java Developers","quantity":10},{"title":"Programming in Scala","quantity":15}]}

Play 最近增强以在创建我们刚刚描述的 JSON 结构时提供稍微简单的语法。构建相同 JSON 对象的替代语法如下所示:

scala> val booksAsJson = Json.obj(
 "books" -> Json.arr(
 Json.obj(
 "title" -> "Scala for Java Developers",
 "quantity" -> 10 
 ),
 Json.obj(
 "title" -> "Programming in Scala",
 "quantity" -> 15 
 )
 )
 )
booksAsJson: play.api.libs.json.JsObject = {"books":[{"title":"Scala for Java Developers","quantity":10},{"title":"Programming in Scala","quantity":15}]}

将 JsObject 序列化为其字符串表示形式可以通过以下语句实现:

scala> val booksAsString = Json.stringify(booksAsJson)
booksAsString: String = {"books":[{"title":"Scala for Java Developers","quantity":10},{"title":"Programming in Scala","quantity":15}]}

最后,由于 JsObject 对象代表一个树结构,你可以通过使用 XPath 表达式在树中导航,以检索各种元素,例如以下示例用于访问我们书籍的标题:

scala> val titles = booksAsJson \ "books" \\ "title"
titles: Seq[play.api.libs.json.JsValue] = ArrayBuffer("Scala for Java Developers", "Programming in Scala")

由于返回类型是 JsValue 对象的序列,将它们转换为 Scala 类型可能很有用,.as[…] 方法将方便地实现这一点:

scala> titles.toList.map(x=>x.as[String])
res8: List[String] = List(Scala for Java Developers, Programming in Scala)

使用 XML 和 JSON 处理 Play 请求

现在我们已经熟悉了 JSON 和 XML 格式,我们可以开始使用它们来处理 Play 项目中的 HTTP 请求和响应。

为了展示这些行为,我们将调用一个在线网络服务,即 iTunes 媒体库,它可在 www.apple.***/itunes/affiliates/resources/documentation/itunes-store-web-service-search-api.html 获取并得到文档说明。

它在搜索调用时返回 JSON 消息。例如,我们可以使用以下 URL 和参数调用 API:

itunes.apple.***/search?term=angry+birds&country=se&entity=software

参数过滤器会过滤掉与 愤怒的小鸟 有关的图书馆中的每一项,而实体参数仅保留软件项。我们还应用了一个额外的过滤器,以查询仅限于瑞典的应用商店。

注意

如果你还没有在 build.sbt 文件中添加,你可能需要在此处添加 dispatch 依赖项,就像我们在第三章“理解 Scala 生态系统”中处理 HTTP 时所做的那样:

libraryDependencies += "***.databinder.dispatch" %% "dispatch-core" % "0.11.0"

scala> import dispatch._
import dispatch._
scala> import Defaults._
import Defaults._
scala> val request = url("https://itunes.apple.***/search")
request: dispatch.Req = Req(<function1>)

我们 GET 方法调用中将包含的参数可以用 Scala Map 中的 (key,value) 元组来表示:

scala> val params = Map("term" -> "angry birds", "country" -> "se", "entity" -> "software")
params: scala.collection.immutable.Map[String,String] = Map(term -> angry birds, country -> se, entity -> software)
scala> val result = Http( request <<? params OK as.String).either
result: dispatch.Future[Either[Throwable,String]] = scala.concurrent.impl.Promise$DefaultPromise@7a707f7c

在这种情况下,结果类型是 Future[Either[Throwable,String]],这意味着我们可以通过模式匹配提取成功的调用以及失败的执行,如下所示:

scala> val response = result() match {
 case Right(content)=> "Answer: "+ content
 case Left(StatusCode(404))=> "404 Not Found"
 case Left(x) => x.printStackTrace()
 }
response: Any = 
"Answer: 

{
 "resultCount":50,
 "results": [
{"kind":"software", "features":["gameCenter"], 
"supportedDevices":["iPhone5s", "iPad23G", "iPadThirdGen", "iPodTouchThirdGen", "iPadFourthGen4G", "iPhone4S", "iPad3G", "iPhone5", "iPadWifi", "iPhone5c", "iPad2Wifi", "iPadMini", "iPadThirdGen4G", "iPodTouchourthGen", "iPhone4", "iPadFourthGen", "iPhone-3GS", "iPodTouchFifthGen", "iPadMini4G"], "isGameCenterEnabled":true, "artistViewUrl":"https://itunes.apple.***/se/artist/rovio-entertainment-ltd/id298910979?uo=4", "artworkUrl60":"http://a336.phobos.apple.***/us/r30/Purple2/v4/6c/20/98/6c2098f0-f572-46bb-f7bd-e4528fe31db8/Icon.png", 
"screenshotUrls":["http://a2.mzstatic.***/eu/r30/Purple/v4/c0/eb/59/c0eb597b-a3d6-c9af-32a7-f107994a595c/screen1136x1136.jpeg", "http://a4.mzst... 

使用 JSON 模拟 Play 响应

当你需要将你的服务与你不拥有或直到在生产环境中部署它们才可用的外部系统集成时,测试发送和接收的消息交互可能会很麻烦。避免调用真实服务的一个有效方法是使用模拟消息来替换它,即硬编码的响应,这将缩短真实的交互,特别是如果你需要将测试作为自动化过程的一部分运行(例如,作为 Jenkins 作业的每日运行)。从 Play 控制器内部返回一个简单的 JSON 消息非常直接,如下例所示:

package controllers

import play.api.mvc._
import play.api.libs.json._
import views._

object MockMarketplaceController extends Controller {

  case class AppStoreSearch(artistName: String, artistLinkUrl: String)
  implicit val appStoreSearchFormat = Json.format[AppStoreSearch]

  def mockSearch() = Action {
    val result = List(AppStoreSearch("Van Gogh", " http://www.vangoghmuseum.nl/"), AppStoreSearch("Mo***", " http://www.claudemo***gallery.org "))
    Ok(Json.toJson(result))
  }
}

涉及 Reads、Writes 和 Format 的 Json.format[. . .] 声明将在我们调用网络服务时在本节稍后解释,因此我们可以暂时跳过讨论这部分内容。

要尝试这个控制器,你可以创建一个新的 Play 项目,或者,就像我们在上一章第六章的最后一节“数据库访问和 ORM 的未来”中所做的那样,只需将此控制器添加到我们从现有数据库生成的应用程序中。你还需要在 conf/ 目录下的 route 文件中添加一个路由,如下所示:

GET /mocksearch  controllers.MockMarketplaceController.mockSearch

一旦应用程序运行,在浏览器中访问 http://localhost:9000/mocksearch URL 将返回以下模拟 JSON 消息:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_07_02.jpg

另一种方便的方法是使用在线服务来获取一个 JSON 测试消息,你可以用它来模拟响应,该服务位于 json-generator.appspot.***。它包含一个 JSON 生成器,我们可以通过简单地点击 生成 按钮直接使用它。默认情况下,它将在浏览器窗口右侧的面板中生成一个包含随机数据的 JSON 示例,但遵循左侧面板中定义的结构,如下面的截图所示:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_07_03.jpg

你可以点击 复制到 剪贴板 按钮并将生成的模拟消息直接粘贴到 Play 控制器的响应中。

从 Play 调用 Web 服务

在上一节中,为了快速实验 App Store 搜索 API,我们使用了 dispatch 库;我们已经在 第三章 中介绍了这个库,理解 Scala 生态系统。Play 提供了自己的 HTTP 库,以便与其他在线 Web 服务交互。它也是在 Java AsyncHttpClient 库(github.***/AsyncHttpClient/async-http-client)之上构建的,就像 dispatch 一样。

在我们深入到从 Play 控制器调用 REST Web 服务之前,让我们先在 REPL 中对 Play Web 服务进行一点实验。在一个终端窗口中,要么创建一个新的 Play 项目,要么进入我们之前章节中使用的项目的根目录。一旦输入了 > play console 命令后获得 Scala 提示符,输入以下命令:

scala> import play.api.libs.ws._
import play.api.libs.ws._
scala> import scala.concurrent.Future
import scala.concurrent.Future

由于我们将异步调用 Web 服务,我们需要一个执行上下文来处理 Future 临时占位符:

scala> implicit val context = scala.concurrent.ExecutionContext.Implicits.global
context: scala.concurrent.ExecutionContextExecutor = scala.concurrent.impl.ExecutionContextImpl@44d8bd53

现在,我们可以定义一个需要调用的服务 URL。这里,我们将使用一个简单的 Web 服务,该服务根据以下签名返回作为参数的网站的地理位置:

http://freegeoip.***/{format}/{site}

格式参数可以是 jsonxml,而 site 将是对网站的引用:

scala> val url = "http://freegeoip.***/json/www.google.***"
url: String = http://freegeoip.***/json/www.google.***
scala> val futureResult: Future[String] = WS.url(url).get().map {
 response =>
 (response.json \ "region_name").as[String]
 }
futureResult: scala.concurrent.Future[String] = scala.concurrent.impl.Promise$DefaultPromise@e4bc0ba
scala> futureResult.on***plete(println)
Su***ess(California)

如我们之前在 第三章 中所见,理解 Scala 生态系统,当使用 dispatch 库时,Future 是一个包含异步计算结果的占位符,并且可以处于两种状态之一,即 完成未完成。这里,我们希望在结果可用时打印结果。

我们只从响应中提取了 region_name 项;整个 JSON 文档如下:

{
    "ip":"173.194.64.106",
    "country_code":"US",
    "country_name":"United States",
    "region_code":"CA",
    "region_name":"California",
    "city":"Mountain View",
    "zipcode":"94043",
    "latitude":37.4192,
    "longitude":-122.0574,
    "metro_code":"807",
    "areacode":"650"
}

如果我们想封装响应的一部分,可以通过创建一个 case 类来实现,如下所示:

scala> case class Location(latitude:Double, longitude:Double, region:String, country:String)
defined class Location

play-json库包括基于JsPathReads/Writes/Format组合器的支持,以便可以在运行时进行验证。如果您对使用这些组合器的所有细节感兴趣,您可能想阅读以下博客:mandubian.***/2012/09/08/unveiling-play-2-dot-1-json-api-part1-jspath-reads-***binators/

scala> import play.api.libs.json._
import play.api.libs.json._
scala> import play.api.libs.functional.syntax._
import play.api.libs.functional.syntax._
scala> implicit val locationReads: Reads[Location] = (
 (__ \ "latitude").read[Double] and
 (__ \ "longitude").read[Double] and
 (__ \ "region_name").read[String] and
 (__ \ "country").read[String]
 )(Location.apply _)
locationReads: play.api.libs.json.Reads[Location] = play.api.libs.json.Reads$$anon$8@4a13875b
locationReads: play.api.libs.json.Reads[Location] = play.api.libs.json.Reads$$anon$8@5430c881

现在,在 JSON 响应上调用验证方法将验证我们接收到的数据是否格式正确且具有可接受值。

scala> val futureResult: Future[JsResult[Location]] = WS.url(url).get().map {
 response => response.json.validate[Location]
 }
futureResult: scala.concurrent.Future[play.api.libs.json.JsResult[Location]] = scala.concurrent.impl.Promise$DefaultPromise@3168c842

scala> futureResult.on***plete(println)
Su***ess(JsError(List((/country,List(ValidationError(error.path.missing,WrappedArray()))))))

之前的JsError对象展示了验证失败的情况;它检测到响应中未找到country元素。实际上,正确的拼写应该是country_name而不是country,我们可以在locationReads声明中更正这一点。这次验证通过了,我们得到的是我们期望的包含纬度和经度信息的JsSu***ess对象:

scala> implicit val locationReads: Reads[Location] = (
 (__ \ "latitude").read[Double] and
 (__ \ "longitude").read[Double] and
 (__ \ "region_name").read[String] and
 (__ \ "country_name").read[String]
 )(Location.apply _)
locationReads: play.api.libs.json.Reads[Location] = play.api.libs.json.Reads$$anon$8@70aab9ed
scala> val futureResult: Future[JsResult[Location]] = WS.url(url).get().map {
 response => response.json.validate[Location]
 }
futureResult: scala.concurrent.Future[play.api.libs.json.JsResult[Location]] = scala.concurrent.impl.Promise$DefaultPromise@361c5860
scala> futureResult.on***plete(println)
scala> Su***ess(JsSu***ess(Location(37.4192,-122.0574,California,United States),))

现在,让我们创建一个示例控制器,该控制器调用网络服务从 App Store 检索一些数据:

package controllers

import play.api._
import play.api.mvc._
import play.api.libs.ws.WS
import scala.concurrent.ExecutionContext.Implicits.global
import play.api.libs.json._
import play.api.libs.functional.syntax._
import scala.concurrent.Future
import views._
import models._

object MarketplaceController extends Controller {

  val pageSize = 10
  val appStoreUrl = "https://itunes.apple.***/search"

  def list(page: Int, orderBy: Int, filter: String = "*") = Action.async { implicit request =>
    val futureWSResponse =
      WS.url(appStoreUrl)
        .withQueryString("term" -> filter, "country" -> "se", "entity" -> "software")
        .get()

      futureWSResponse map { resp =>
        val json = resp.json
        val jsResult = json.validate[AppResult]
        jsResult.map {
          case AppResult(count, res) =>
            Ok(html.marketplace.list(
              Page(res,
                page,
                offset = pageSize * page,
                count),
              orderBy,
              filter))
        }.recoverTotal {
          e => BadRequest("Detected error:" + JsError.toFlatJson(e))
        }
      } 
  }
}

在这里,通过在WS类上调用方法来展示对网络服务的调用,首先调用url方法给出 URL,然后调用withQueryString方法,输入参数以key->value对的序列给出。请注意,返回类型是Future,这意味着我们的网络服务是异步的。recoverTotal接受一个函数,在处理错误后会返回默认值。json.validate[AppResult]这一行使 JSON 响应与这里指定的AppResult对象进行验证(作为app/models/文件夹中Marketplace.scala文件的一部分):

package models

import play.api.libs.json._
import play.api.libs.functional.syntax._

case class AppInfo(id: Long, name: String, author: String, authorUrl:String,
    category: String, picture: String, formattedPrice: String, price: Double)
object AppInfo {
  implicit val appInfoFormat = (
    (__ \ "trackId").format[Long] and
    (__ \ "trackName").format[String] and
    (__ \ "artistName").format[String] and
    (__ \ "artistViewUrl").format[String] and
    (__ \ "primaryGenreName").format[String] and
    (__ \ "artworkUrl60").format[String] and
    (__ \ "formattedPrice").format[String] and
    (__ \ "price").format[Double])(AppInfo.apply, unlift(AppInfo.unapply))
}

case class AppResult(resultCount: Int, results: Array[AppInfo])
object AppResult {
  implicit val appResultFormat = (
    (__ \ "resultCount").format[Int] and
    (__ \\ "results").format[Array[AppInfo]])(AppResult.apply, unlift(AppResult.unapply))
}

AppResultAppInfo案例类被创建出来,用于封装我们关心的服务元素。正如您在首次尝试 API 时可能看到的,大多数对 App Store 的搜索查询都会返回大量元素,其中大部分我们可能不需要。这就是为什么,我们可以使用一些 Scala 语法糖和组合器,在运行时验证 JSON 响应并直接提取感兴趣的元素。在尝试这个网络服务调用之前,我们只需要在conf/目录下的routes文件中添加所需的路由,如下面的代码所示:

GET /marketplace  controllers.MarketplaceController.list(p:Int ?= 0, s:Int ?= 2, f ?= "*")

最后,在通过网络浏览器启动应用程序之前,我们还需要在MarketplaceController.scala文件中提到的示例视图,该视图在views/marketplace/目录下的list.scala.html文件中创建,并在几个部分中展示如下代码:

@(currentPage: Page[AppInfo], currentOrderBy: Int, currentFilter:
String)(implicit flash: play.api.mvc.Flash)
...
@main("Wel***e to Play 2.0") {

<h1>@Messages("marketplace.list.title", currentPage.total)</h1>

@flash.get("su***ess").map { message =>
<div class="alert-message warning">
  <strong>Done!</strong> @message
</div>
}
<div id="actions">

  @helper.form(action=routes.MarketplaceController.list()) { <input
    type="search" id="searchbox" name="f" value="@currentFilter"
    placeholder="Filter by name..."> <input type="submit"
    id="searchsubmit" value="Filter by name" class="btn primary">
  }
</div>
...

视图的第一个部分仅包含用于导航的辅助方法,其生成方式与我们在第六章中用于 CRUD 示例生成的相同,即数据库访问和 ORM 的未来。视图的第二部分包括我们从网络服务中检索到的 JSON 元素:

...
@Option(currentPage.items).filterNot(_.isEmpty).map { entities =>
<table class="***puters zebra-striped">
  <thead>
    <tr>
      @header(2, "Picture") 
      @header(4, "Name") 
      @header(5, "Author")
      @header(6, "IPO")     
      @header(7, "Category") 
      @header(8, "Price")
    </tr>
  </thead>
  <tbody>
    @entities.map{ entity =>
    <tr>
      <td>
        <img
         src="img/@entity.picture"
         width="60" height="60" alt="image description" />
      </td>
      <td>@entity.name</td>
      <td><a href="@entity.authorUrl" class="new-btn btn-back">@entity.author</a></td>
      <td>@entity.category</td>
      <td>@entity.formattedPrice</td>
    </tr>
    }
  </tbody>
</table>
...

视图的第三部分和最后一部分是处理分页:

...
<div id="pagination" class="pagination">
  <ul>
    @currentPage.prev.map { page =>
    <li class="prev"><a href="@link(page)">&larr; Previous</a></li>
    }.getOrElse {
    <li class="prev disabled"><a>&larr; Previous</a></li> }
    <li class="current"><a>Displaying @(currentPage.offset + 1)
        to @(currentPage.offset + entities.size) of @currentPage.total</a></li>
    @currentPage.next.map { page =>
    <li class="next"><a href="@link(page)">Next &rarr;</a></li>
    }.getOrElse {
    <li class="next disabled"><a>Next &rarr;</a></li> }
  </ul>
</div>
}.getOrElse {
<div class="well">
  <em>Nothing to display</em>
</div>
} }

一旦我们使用 > play run 重新启动 Play 应用程序并通过(通过网页浏览器)访问我们的本地 http://localhost:9000/marketplace?f=candy+crush URL(该 URL 包含来自应用商店的默认搜索,其中 f 参数代表 filter),我们将获得一个类似于以下截图的页面:

https://github.***/OpenDoc***/freelearn-java-zh/raw/master/docs/scl-java-dev/img/3637OS_07_04.jpg

摘要

在本章中,我们看到了一些关于如何在 Scala 中操作 XML 和 JSON 格式以及如何通过 Web 服务连接到其他系统的示例。在 XML 的情况下,我们还介绍了如何从 WSDL 描述中生成 SOAP 绑定以及 Scala 类来封装 XML 模式中包含的 XML 领域。Play 框架中的 Web 服务是异步运行的,这意味着调用者在他继续进行其他有用处理(例如服务其他请求)之前不需要等待答案返回。在下一章中,我们将更精确地研究这种异步调用的概念。它基于 FuturePromise 的概念,这些概念也在 Java 世界中兴起,用于处理并发代码的执行。特别是,我们将通过 Akka 框架,这是一个开源工具包和运行时,它简化了并发应用程序的构建。Akka 是由 Scala 设计和编写的,包含 Scala 和 Java API,是 Play 框架基础设施的基础,使 Play 框架成为在多核架构上运行可扩展 Web 应用的理想选择。

转载请说明出处内容投诉
CSS教程网 » Java 开发者的 Scala 指南(二)

发表评论

欢迎 访客 发表评论

一个令你着迷的主题!

查看演示 官网购买