第七章:连接到数据库

主要内容:

  1. SBT构建数据库。
  2. webKanban中,通过Squeryl连接数据库。
  3. 完善weKanban。

第六章,我们学习了如何使用SBT(Simple Build Tool)和Scalaz HTTP模组构建一个简单的Web应用。但这个应用在前面章节中未算竣工。原因是:构建一个函数式的Kanban应用,你的应用需要存储信息,即存储到持久化环境中。

注意 本章是第六章的延续,如果你未曾读过上一章节,一些本章节的相关部分很难跟进。

本章将完成上一个章节未完成的部分。你将学习如何检索数据并存储到关系型数据中。我将介绍一个Scala ORM(Object Relational Mapping)工具——Squeryl。并将探索如何安全地模拟数据字典。你将构建一个应用存储,以及在Kanban面板上展示该存储数据。在此过程中,你将探索Scala中如何与数据库工作。尽管本章关注于数据库方面的内容,也会介绍到Scalaz和SBT在数据库方面的使用。在构建应用之前,让我们回顾一下我们所需要实现weKanban应用的用户故事:

As a customer I want to create a new user story so that I can add stories to the ready phase.
As a developer I want to move cards (stories) from one phase to another to signal progress.

下面开始构建基于用户故事的weKanban应用。

7~1〖Adding a new story to a weKanban board〗P194

首先要做的是在面板中添加案例,如果不添加根本做不了任何事情。添加案例意味着你需要担心持久化存储。企业开发者使用关系型数据库将信息存储到表中,这里建议使用开源的Java数据库 H2。实际上,你还可以自由地选取下面的数据库:PostgresOracleMySQLDB2 。这里之所以列出这几个数据库的原因是,你所使用的ORM库Squeryl,仅支持这几种数据。

注意 使用诸如MongoDB这种模式自由的数据库,在这里有些许争议,这里希望专注于一个事务型的关系型数据库的解决方案,以及展示Scala是如何在关系型数据库管理系统(RDBMS)中工作。你也可以自由地尝试其它类型的数据库。

711〖Connecting to a database using Squeryl〗P194

那么,为什么使用Squeryl来访问数据库?首先是它在Scala社区内非常流行,以及它提供了一个良好的、简单的DSL对接数据库。尽管直接在Scala中使用JDBC也未尝不可,使用Squeryl,你则直接学习了一整个ORM工具。Scala强大的类型系统正很好地用来创建一个类型安全的ORM工具。这里鼓励你使用其它的Scala ORM,如ScalaQuery1、Querulous2作为选择。

现在,Squeryl和H2作为weKanban的项目依赖,添加到构建文件中,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name := "weKanban"
organization := "scalainaction"
version := "0.2"
scalaVersion := "2.10.0"
scalacOptions ++= Seq("-unchecked", "-deprecation")
libraryDependencies ++= Seq(
"org.scalaz" %% "scalaz-core" % "6.0.3",
"org.scalaz" %% "scalaz-http" % "6.0.3",
"org.eclipse.jetty" % "jetty-servlet" % "7.3.0.v20110203" % "container",
"org.eclipse.jetty" % "jetty-webapp" % "7.3.0.v20110203" % "test,container",
"org.eclipse.jetty" % "jetty-server" % "7.3.0.v20110203" % "container",
"com.h2database" % "h2" % "1.2.137",
"org.squeryl" % "squeryl_2.10" % "0.9.5-6"
)
seq(com.github.siasia.WebPlugin.webSettings :_*)

现在,你要做的是update构建依赖。对于weKanban项目,面板案例应该有三个属性:唯一标识,标题描述,以及案例段落。下面你如何构建的案例类:

1
class Story(val number: String, val title: String, val phase:String)

要使该类在Squeryl下工作,你还必须进行大量的设置。首先,你需要告诉Squeryl你需要一个table存储所有这些story。Squeryl中的实现是,创建org.squeryl.Schema的子类。该类等效地看作是数据库的schema,下面代码定义了一个schema表为"STORIES"的Story类:

1
2
3
4
5
6
7
package com.kanban.models

import org.squeryl._

object KanbanSchema extends Schema {
val stories = table[Story]("STORIES")
}

将该文件保存为KanbanSchema.scala,置于src/main/scala/com/kanban/models。这里要注意的是我定义了一个类型安全的管理器。stories表示数据库表"STORIES",你可以对stories执行各种各样的查询,而不用担心story类型对象的返回问题。

注意 大多数ORM工具使用了各种内建配置文件来指定数据库的schema和domain model映射信息。因为Scala是一个DSL友好的表达式语言,Scala工具通常都使用Scala语言本身作为配置。目前所看到的SBT、Squeryl就是个以Scala语言为DSL的明显的例子。下一次,你想你应该需要一个内置配置、属性文件,以及如何用Scala语言表述这些配置信息。

下一步,为Squeryl配置使用H2数据库。在连接数据库之前,首先有确保H2数据库服务已经运行。开启H2数据库服务很简单,你需要把h2.jar文件添加到路径:

1
java -cp ~/.ivy2/cache/com.h2database/h2/jars/h2*.jar org.h2.tools.Server

这看起来很丑陋,因为你需要在本地存储ivy中运行你的依赖。如果可以想Jetty那样运行H2数据库就好了,不幸的是,SBT内建(built-in)不支持H2,所以你要为H2数据库创建tasks。一个好消息是,SBT构建tasks是十分容易的,所以,我们需要修改build.scala文件。SBT提供了大量的辅助方法来构建自定义tasks,但这里的tasks由方法实现。代码清单如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import sbt._
import Keys._

object H2TaskManager {
var process: Option[Process] = None
lazy val H2 = config("h2") extend Compile

val startH2 = TaskKey[Unit]("start","Starts H2 database")
val startH2Task = startH2 in H2 <<= (fullClasspath in Compile) map {
cp =>
startDatabase {
cp.map(_.data)
.map(_.getAbsolutePath())
.filter(_.contains("h2database"))
}
}

def startDatabase(paths: Seq[String]) = {
process match {
case None =>
val cp = paths.mkString(System.getProperty("path.separator"))
val command = "java -cp " + cp + " org.h2.tools.Server"
println("Starting Database with command: " + command)
process = Some(Process(command).run())
println("Database started ! ")
case Some(_) =>
println("H2 Database already started")
}
}
val stopH2 = TaskKey[Unit]("stop", "Stops H2 database")
val stopH2Task = stopH2 in H2 :={
process match {
case None => println("Database already stopped")
case Some(_) =>
println("Stopping database...")
process.foreach{_.destroy()}
process = None
println("Database stopped...")
}
}
}

object MainBuild extends Build {
import H2TaskManager._
lazy val scalazVersion = "6.0.3"
lazy val jettyVersion = "7.3.0.v20110203"
lazy val wekanban = Project(
"wekanban",
file(".")).settings(startH2Task, stopH2Task)
}

这里的build.scala文件做了两件事。一是,定义了项目的namelocationssettings。项目工程的settings通过继承Build获得默认值,并添加两个新任务:startH2TaskstopH2Task。这两个任务现在作为项目settings的一部分,并可以用开启和停止H2数据库。

二是,scalazVersion和jettyVersion被声明为lazy val。这个好处是,你不用在build.sbt文件中重复声明多次:

1
2
3
4
5
6
7
8
9
libraryDependencies ++= Seq(
"org.scalaz" %% "scalaz-core" % scalazVersion,
"org.scalaz" %% "scalaz-http" % scalazVersion,
"org.eclipse.jetty" % "jetty-servlet" % jettyVersion % "container",
"org.eclipse.jetty" % "jetty-webapp" % jettyVersion % "test, container",
"org.eclipse.jetty" % "jetty-server" % jettyVersion % "container",
"com.h2database" % "h2" % "1.2.137",
"org.squeryl" % "squeryl_2.10" % "0.9.5-6"
)

是的,你可以共享build.scala的settings和vals到build.sbt文件。事实上,常规做法是,build.scala通常用于声明,build.sbt则使用其声明内容用于构建。最好,所有settings由多个文件组合成为一个settings队列中。

重载构建定义之后,你可以看到两个新的tasks,h2:starth2:stop

1
2
> h2:st
start stop

How h2:start and h2:stop tasks are implemented

在SBT中创建一个新的任务是简单的:创建一个TaskKey,委派一个闭包实现这个task。因为你需要和其它自定义tasks和内建tasks做的更贴切,为H2任务创建一个新的范围:

1
lazy val H2 = config("h2") extend (Compile)

这里创建一个新的配置"h2",继承自Compile。Compile配置会提供必需的classpath设置setting。新的配置"h2"创建了一个新的范围,即Compile,以及"h2"在该范围起作用。

任务startH2通过startDatabase方法实现。该方法要求一个指向H2数据库 .jar文件的路径序列。因为"h2"配置继承了Compile,你可以使用来自于Compile的settings键fullClasspath接入到classpath中。方法<==则帮助创建一个新setting,该setting依赖于其它settings。下面代码片段中,将Compile 范围的fullCpasspath映射到新的任务中,并创建一个新的函数,该函数将在该任务开启是被执行:

1
2
3
4
5
6
7
8
val startH2Task = startH2 in H2 <<= (fullClasspath in Compile) map {
cp =>
startDatabase {
cp.map(_.data)
.map(_.getAbsolutePath())
.filter(_.contains("h2database"))
}
}

方法startDatabase存储了进程对象的引用,这样可以在stopDatabase方法中使用。stopDatabaseh2:stop任务相关联。

这会帮助你不用离开舒适的SBT控制台,就可以在使用H2进行工作。当你执行h2:start,它会自动在端口为8082上开启H2数据库,并在默认浏览器上开启H2登录视窗,如图Figure 7.1。

The H2 Console

(如果浏览器没有打开,需要直接在浏览器输入 http://localhost:8082 )因为H2服务已经在运行,我们专注于Squeryl连接到该服务的处理上。要连接到H2服务,使用下面的Driver和URL:

  • [x] JDBC Driver class: org.h2.Driver
  • [x] Database URL: jdbc:h2:tcp://localhost/~/test
  • [x] User name: sa

更多有关这方面的信息,请参考 www.h2database.com/html/quickstart.html。下面列出一个init方法,该方法用于连接 H2 数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package com.kanban.models

import java.sql.DriverManager

import org.squeryl.PrimitiveTypeMode._
import org.squeryl._
import org.squeryl.adapters._

/**
* @author Barudisshu
*/
object KanbanSchema extends Schema {

val stories = table[Story]("STORIES")

def init() = {
Class.forName("org.h2.Driver")
if (SessionFactory.concreteFactory.isEmpty) {
SessionFactory.concreteFactory = Some(() =>
Session.create(DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", ""), new H2Adapter))
}
}
}

KanbanSchema表示weKanban应用数据库schema定义。第一件事要做的是,把Story DOM用方法table定义的STORIES进行映射。

方法init为与正在运行的H2数据库进行连接。在该方法中,import org.squeryl.SessionFactory。SessionFactory类似于数据库连接工厂,它用于创建新的连接。下一步,用Class.forName("org.h2.Driver")载入H2 jdbc驱动。该驱动在创建一个新的连接时使用。

在Squeryl创建一个新的连接,意味着创建一个新的Session。这种方式和Java流行ORM框架Hibernate很相似,即把连接封装为Session。把Squeryl的session看作为一个基于数据库的连接转换器,该连接可以控制数据库的事务。其中Squeryl 的 session实例提供了额外的方法如log,这些方法用于绑定/解绑session到当前线程。注意,Session将这些数据库连接保存到一个ThreadLocal3变量中,这样,每个线程都可以获取到它自己的连接。这在web应用中的好处是,可以有多个用户在任何节点访问应用。

在Squeryl中,创建一个新的session机制是需要定义一个concreteFactory变量,它定义在SessionFactory对象中。默认地,该值为None。如果不为None,则说明它已经被初始化。Squeryl要求concreteFactory作为一个函数用于创建新的sessions。下面为该函数的使用:

1
2
() =>
Session.create(DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", ""), new H2Adapter))

这里真正被调用的方法,是Session对象里面的create方法,由传递一个数据库连接和一个适配器adapter执行。其中,Java DriverManager接收URL、用户名和密码。Squeryl提供的适配器用于它所支持的数据库类型,这里的适配器为H2Adapter,表示数据库为H2的连接。因为concreteFactory的类型是Option[()=>Session],你需要用Option值Some进行转换。

因为你定义了stories对象来表示"STORIES"表,它不会在数据库中创建表。某些情况下你可能会用SQL脚本来创建数据库表结构,但这里你可以使用Squeryl来创建这些Schema。添加一个main方法入口,如下:

1
2
3
4
5
def main(args: Array[String]){
println("initializing the weKanban schema")
init
inTransaction {drop;crete}
}

定义在Squeryl的方法inTransaction在数据库事务中执行并关闭。这里表示drop所有的表,并重新创建。现在,唯一定义的表是"STORIES",你可以在SBT控制台下运行这个main方法,它将在数据库中创建一个新的Schema。在运行main之前,请确保H2数据库服务正在运行。下面继续讲解如何将数据存储到数据库上。

712〖Saving a new story to the database〗P200

要通过Squeryl向数据库插入一条新的记录,你需要调用定义在org.squeryl.Table类里面的方法insert,该方法接收一个model实例,并存储到数据库中。Schema中已经定义了执行数据库表的"STORIES"。你现在需要创建一个传递给insert方法的实例Story,该实例代码如下:

1
class Story(val number: String, val title: String, val phase: String)

在将Story实例存储到数据库之前,你需要为其添加验证。例如,Story的两个属性numbertitle 都不能为空,以及number是唯一标识的,下面为该验证的实现:

1
2
3
4
5
6
class ValidationException(message: String) extends RuntimeException(message)
private[this] def validate = {
if(number.isEmpty || title.isEmpty) {
throw new ValidateException("Both number and title are required")
}
}

将方法validate添加到Story类中,并在save动作之前调用。这里创建了一个自定义异常ValidationException用于所有验证失败处理。

注意 这里没有定义主键,而仅仅是把number声明为唯一键。在小应用中这样做没什么问题,但是,在真实环境中,你应该将它代替为主键。要给你的domain类添加一个自动递增的主键id,你可以继承KeyedEntity[A]特质,你也可以使用KeyedEntity来创建组合keys。关于更多信息内容,参考Squeryl官方文档。

为了检查number字段的唯一性,你需要检查"STORIES"表,确保没有其它记录有相同的number。Squeryl提供了一个很好的方法where,在表对象中,你可以很容易地实现。where方法接收一个断言函数,并过滤得出结果。下面是使用where方法来检查number的唯一性:

1
2
3
if(!stories.where(a => a.number === number).isEmpty) {
throw new ValidationException ("The story number is not unique")
}

这里使用了函数a => a.number === number (=== Squeryl定义的等价操作符),这表示你查询的结果匹配给定number。如果查询的结果为空,则给定的number不是唯一的。注意where方法返回一个lazy iterable 叫做 Query ,它定义在org.squeryl.Query。查询仅在动作开始时向数据库发送信息。添加完这些验证后,validate方法现在看起来像下面:

1
2
3
4
5
6
7
8
private[this] def validate() = {
if (number.isEmpty || title.isEmpty) {
throw new ValidationException("Both number and title are required")
}
if (stories.where(a => a.number === number).nonEmpty) {
throw new ValidationException("The story number is not unique")
}
}

现在,在插入Story记录到数据库之前,你需要调用这个validate方法,以确保是否有效。你还需要在数据库事务上提交或者回滚。现在,我们为该Story类添加一个save方法。但,这个方法应该返回什么?当创建记录成功时,你可以返回成功信息,但创建失败时怎么表示好?在这里我推荐使用scala.Either,它可以允许你同时返回成功和失败信息。它使得save方法可以优雅地处理问题。下面是完整的Story类方法,它包含validatesave方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package com.kanban.models

import com.kanban.models.KanbanSchema._
import org.squeryl.KeyedEntity
import org.squeryl.PrimitiveTypeMode._

class Story(val id: Long, val number: String, val title: String, val phase: String) extends KeyedEntity[Long]{

private[this] def validate() = {
if (number.isEmpty || title.isEmpty) {
throw new ValidationException("Both number and title are required")
}
if (stories.where(a => a.number === number).nonEmpty) {
throw new ValidationException("The story number is not unique")
}
}

def save(): Either[Throwable, String] = {
tx {
try {
validate()
stories.insert(this)
Right("Story is created successfully")
} catch {
case exception: Throwable => Left(exception)
}
}
}
}

object Story {
def apply(number: String, title: String) = new Story(0, number, title, "ready")
}

class ValidationException(message: String) extends RuntimeException(message)

number 和 title两个字段都不能为空,如果为空,你可以抛出一个异常。第二个验证是从数据库检验number是否是唯一的。这里使用了一个内建方法where,该方法用用于所有表对象,它接收一个函数作为参数,函数参数是一个Boolean表达式,并根据表达式过滤搜索查询。代码中匹配 a => a.number === number,用于查询数据库中和number相等的记录,如果存在该记录,将抛出异常,表示不能插入不唯一的记录。

在save方法之前,最先调用validate方法,以此验证是否插入重复数据。这两个方法都处于闭包环境tx方法中。方法tx表示初始化SessionFactory.concrete 和 数据库事务。该方法在 KanbanSchema里面定义。因为save返回结果包含成功和失败,所以这个使用了Either作为save方法的返回类型。在Scala公共方法中,使用scala.Either作为返回 更通常于抛出异常。另外,你创建了Story的伴生对象Story来创建新的实例,phase的默认值为"Ready"。

方法tx在前面的代码片段中,确保了SessionFactory被正确初始化,以及开启了一个新的事务。方法tx接收一个函数作为参数,并以函数作为返回类型。函数参数可以是任何闭包或者代码块,下面是完整的KanbanSchema对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package com.kanban.models

import java.sql.DriverManager

import org.squeryl.PrimitiveTypeMode._
import org.squeryl._
import org.squeryl.adapters._
/**
* @author Barudisshu
*/
object KanbanSchema extends Schema {

val stories = table[Story]("STORIES")

def init() = {
Class.forName("org.h2.Driver")
if (SessionFactory.concreteFactory.isEmpty) {
SessionFactory.concreteFactory = Some(() =>
Session.create(DriverManager.getConnection("jdbc:h2:tcp://localhost/~/test", "sa", ""), new H2Adapter))
}
}
def tx[A](a: => A): A = {
init()
inTransaction(a)
}
def main(args: Array[String]) {
println("initializing the weKanban schema")
init()
inTransaction {
drop; create
}
}
}

方法tx接收一个函数,并在事务中执行该函数。方法inTransaction定义在Squeryl,它会检查线程中是否有其它事务,如果有,加入进程中的事务,否则创建一个新的事务。方法tx最先会执行init以确保SessionFactory.concrete和事务被初始化;以及方法inTransaction用于执行事务处理。

713〖Building the Create Story web page〗P204

接下来完成Story的新增页面内容,并在weKanban应用中完成下面这些特性:

As a customer, I want to create a new user story so I can add stories to the ready phase.

在web页面中,你可以有很多方式创建动态web页面。你可以使用JSP,Scala模版引擎 Scalate 4,或者使用Scala内建的XML支持来创建XHTML。这里使用Scala的内建XML来生成XHTML web页面,它的特点是简单、易测试、以及有明显的Scala XML性能。对于复杂的大型的应用,这种方式不能扩展。在Chapter12你会探索Scala web框架如何更容易地构建大型web应用。

为了用XML来表示每一个页面dom,你需要创建一个视图对象,用于WeKanbanApplication类来发送响应内容。图Figure 7.2为我们需要构建的页面内容。

Figure 7.2

要创建上图页面内容,你需要创建下列代码CreateStory,置于src/main/com/kanban/views:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
package com.kanban.views

object CreateStory {
def apply(message: String = "") =
<html>
<head>
<title>Create new Story</title>
<link rel="stylesheet" href="/css/main.css" type="text/css" media="screen" charset="utf-8"/>
</head>
<body>
<span class="message">
{message}
</span>
<div class="createStory">
<form action="/card/save" method="post" accept-charset="utf-8">
<fieldset>
<legend>Create a new Story</legend>
<div class="section">
<label for="storyNumber">Story Number
<span class="subtle">(uniquely identifies a story)</span>
</label>
<input type="text" size="10" maxlength="10" minlength="3" name="storyNumber" id="storyNumber"/>
</div>
<div class="section">
<label for="title">Title
<span class="subtle">(describe the story)</span>
</label>
<textarea rows="5" cols="30" name="title" id="title"></textarea>
</div>
<div class="section">
<button type="submit">Save</button>
</div>
</fieldset>
</form>
<span class="linkLabel">
<a href="/kanban/board">Go to Kanban board</a>
</span>
</div>
</body>
</html>
}

这里的apply方法为创建页面所需要的HTML代码,尽管它是HTML,在Scala代码中,不论是XML或HTML都是有效的。这里的apply方法的返回类型是scala.xml.NodeSeq,它是一系列XML节点,NodeSeq会被渲染为String,并返回真是的HTML代码。现在要为其指定一个URL。其中,静态链接内容分别在webapp/js 和 webapp/css目录下。

到目前为止,Scalaz中的application已经使用resource方法处理了这些静态资源:

1
2
3
4
5
def application(implicit servlet: HttpServlet, servletRequest:
HttpServletRequest, request: Request[Stream]) = {
def found(x: Iterator[Byte]) : Response[Stream] = OK << x.toStream
resource(found, NotFound.xhtml)
}

要处理这些静态资源,在application方法中调用handle方法,该方法接收application方法相同的参数,并在请求对象上匹配URL。典型地,web框架使用了分割配置文件来将URL映射为一个资源或函数。基于约定(convention-based)框架如:Rails、playframework 中,URL包含了足够的信息来映射到 合适的函数或动作中。Scalaz使用了一个不同的方式——Scala强大的类型匹配,当URL进入到HTTP方法,URL被分成List。例如,一个URL地址为 http://localhost:8080/card/create 匹配为:

1
2
3
request match {
case MethodParts(GET,"card"::"create"::Nil) => ...
}

MethodParts是一个Extractor object,它接收一个Scalaz请求Request,并返回HTTP方法的Option,以及URL的List对象。前面提供的代码片段中,GET为HTTP请求资源方法,第二个参数为拆分为List的URL。

How an Extractor object works

第三章中介绍了case class是如何进行模式匹配的,但模式匹配并不仅限于case class。几乎任何对象都可以用做模式匹配,只要它定义了方法unapply。包含unapply方法的对象称为Extractor object。例如,Scalaz中定义的MethodParts代码为:

1
2
3
4
5
6
object MethodParts {
def unapply[IN[_]](r : Request[IN]) : Option[(Method,
List[String])] = {
Some(r.method, r.parts)
}
}

这里的unapply方法接收一个Scalaz 请求实例,并返回一个包含method 和 parts的 Some。parts方法返回所有由 / 分割的路径。

当Scala遇到诸如MethodParts(...) 这种模式,会转换为一个MethodParts.unapply方法的调用,即由模式匹配引用传递对象(这里是Scalaz的request实例)。注意,apply方法对于模式匹配(pattern matching)不是必须的。典型使用最小构造器,例如,在Story实例中使用apply来创建一个新的Story实例。

我们规定,如果你想要从unapply方法中得到返回值,则返回类型必须为scala.Option的转换。

有时候,你会通过请求参数来请求资源。例如,当一个Story被成功创建后,你会回到原来的页面,并附带一个创建成功消息。从URL中获取请求参数,需要用到Scalaz Request对象中的方法 !。下面我们创建一个私有方法param,该方法用于返回一个字符串值。

1
def param(name: String)(implicit request: Request[Stream]) = (request ! name).getOrElse(List[Char]()).mkString("")

方法 ! 返回Option[List[Char]],这里把它转换为字符串。下面创建handle方法将它们组合起来:

1
2
3
4
5
6
7
8
def handle(implicit request: Request[Stream], servletRequest: HttpServletRequest): Option[Response[Stream]] = {
request match {
case MethodParts(GET, "card" :: "create" :: Nil) =>
Some(OK(ContentType, "text/html") << strict << CreateStory(param("message")))
Some(moveCard)
case _ => None
}
}

这里,当request匹配HTTP method类型和URL,会创建一个调用CreateStory产生的Response。OK(ContentType,"text/html")创建一个空的Response,该Response带有HTTP响应标头(header)类型content-type。方法 << 表示向Response 添加额外内容。因为Scala鼓励创建不可变对象(immutable objects),所以,每次调用 << 方法时,都会创建一个新的Response对象。

注意 handle方法里面使用的 strict 称为 doctype 。文档类型不是一个HTML标签。它用于告诉浏览器当前页面使用的是哪个版本的标记语言。这里使用了strict HTML 标准,在例子中使用 strict 。

接下来,我们需要在application方法中使用handle方法。原来方法中保留resource方法,用来加载静态资源。要在application方法中添加handle方法处理,Scalaz内核提供了一个方法 | 用于Option类,使用它可以组合handle方法和resource方法,这样,当handle方法返回None时,则调用resource方法作为静态资源处理。下面是改变之后的application方法。

1
2
3
4
5
def application(implicit servlet: HttpServlet,
servletRequest: HttpServletRequest, request: Request[Stream]) = {
def found(x: Iterator[Byte]) : Response[Stream] = OK << x.toStream
handle | resource(found, NotFound.xhtml)
}

因为所有传递给handle方法的参数都是隐式的,你可以不用显式给它传递参数。使用了 | 方法,如果handle方法返回的是None,则方法resource会被调用。当URL找不到匹配的资源时,则会返回NotFound.xhtml。

注意 Scalaz使用Scala的隐式方法转换将 | 方法添加到 Option类中。如果你曾经使用过Ruby,Groovy或其它编程语言的 元程序(metaprogramming),隐式转换是Scala实现 元程序(metaprogramming) 的方式,但有更多控制。你会在下一章节了解更多隐式转换的详细内容。

在运行应用之前,先看看经过修改后的完整代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import scalaz._
import Scalaz._
import scalaz.http._
import response._
import request._
import servlet._
import HttpServlet._
import Slinky._
import com.kanban.views._
import com.kanban.models._

final class WeKanbanApplication extends StreamStreamServletApplication {
import Request._
import Response._
implicit val charset = UTF8

def param(name: String)(implicit request: Request[Stream]) =
(request ! name).getOrElse(List[Char]()).mkString("")

def handle(implicit request: Request[Stream],
servletRequest: HttpServletRequest): Option[Response[Stream]] = {
request match {
case MethodParts(GET, "card" :: "create" :: Nil) =>
Some(OK(ContentType, "text/html") << strict <<
CreateStory(param("message")))
case _ => None
}
}

val application = new ServletApplication[Stream, Stream] {

def application(implicit servlet: HttpServlet,
servletRequest: HttpServletRequest, request: Request[Stream]) = {
def found(x: Iterator[Byte]) : Response[Stream] = OK << x.toStream
handle | resource(found, NotFound.xhtml)
}
}
}

这里添加了两个新的方法handle和param,handle方法用于将匹配请求到函数。它使用了一个Scalaz 的Extractor对象 MethodParts,该对象将Scalaz请求拆分为请求类型和URL部分。HTTP GET请求 http://localhost:8080/card/create 会被拆分为 GET请求类型 和 URL "card"::"crate"::Nil 这两部分。当请求匹配后,创建一个Scalaz Response 来渲染页面 Some(OK(ContentType, "text/html") << strict << CreateStory(param("message")))。其中 CreateStory为一个view对象,OK(ContentType, "text/html") << strict 创建一个空的Response,并粘滞有strict HTML文档类型。

方法param用于获取request请求参数,这里使用了Scalaz定义的 ! 方法,方法param将 ! 方法处理的结果转换为String类型。Scalaz默认使用List[Char]表示请求参数值的类型。

当请求URL不匹配定义的handle方法时,返回默认的None。再由 | 方法进行 “或” 处理,执行resource方法载入静态资源。

现在进入SBT控制台,执行 jetty-run命令开启服务器,在浏览器中输入 http://localhost:8080/card/create 进行访问,并点击上面的按钮进行操作。

单击save按钮时,还没有任何动作,因此,要在application中添加save URL的匹配case:

1
2
3
4
5
6
7
8
9
10
11
def handle(implicit request: Request[Stream],
servletRequest: HttpServletRequest): Option[Response[Stream]] = {
request match {
case MethodParts(GET, "card" :: "create" :: Nil) =>
Some(OK(ContentType, "text/html") << strict <<
CreateStory(param("message")))
case MethodParts(POST, "card" :: "save" :: Nil) =>
Some(saveStory)
case _ => None
}
}

方法saveStory会读取request里面post过来的参数,并实例化一个Story,然后调用save方法。为了读取post里面的方法,需要添加一个工具方法,但和param方法不同,因为是post请求,通常意味着有副作用,所以改用 ! 方法来处理。

1
2
def param_!(name: String)(implicit request: Request[Stream]) =
(request | name).getOrElse(List[Char]()).mkString("")

Story里面的save方法的返回类型是scala.Either[Throwable, String],就是说如果出现错误返回左边的异常;否则返回右边的成功信息。Left和Right是Either的唯一子类型。因此可以用模式匹配进行处理。当save成功时,你便重定向到应用的创建页面,并带有成功信息;同样,当创建失败时,则在页面显示失败信息。下面是saveStory方法的实现:

1
2
3
4
5
6
7
8
9
10
11
private def saveStory(implicit request: Request[Stream],
servletRequest: HttpServletRequest) = {
val title = param_!("title")
val number = param_!("storyNumber")
Story(number, title).save match {
case Right(message) =>
redirects[Stream, Stream]("/card/create", ("message", message))
case Left(error) => OK(ContentType, "text/html") << transitional <<
CreateStory(error.toString)
}
}

方法redirects定义在Response对象中,它表示页面的重定向。

在运行应用之前,请确保H2数据库服务处于开启状态,下面列出完整的application类代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
final class WeKanbanApplication extends StreamStreamServletApplication {
import Request._
import Response._
implicit val charset = UTF8

def param_!(name: String)(implicit request: Request[Stream]) = (request | name).getOrElse(List[Char]()).mkString("")
def param(name: String)(implicit request: Request[Stream]) = (request ! name).getOrElse(List[Char]()).mkString("")

def handle(implicit request: Request[Stream], servletRequest: HttpServletRequest): Option[Response[Stream]] = {
request match {
case MethodParts(GET, "card" :: "create" :: Nil) =>
Some(OK(ContentType, "text/html") << strict << CreateStory(param("message")))
case MethodParts(POST, "card" :: "save" :: Nil) =>
Some(saveStory)
case MethodParts(GET, "kanban" :: "board" :: Nil) =>
Some(OK(ContentType, "text/html") << strict << KanbanBoard())
case MethodParts(POST, "card" :: "move" :: Nil) =>
Some(moveCard)
case _ => None
}
}

private def moveCard(implicit request: Request[Stream], servletRequest: HttpServletRequest) = {
val number = param_!("storyNumber")
val toPhase = param_!("phase")
val story = Story.findByNumber(number)
story.moveTo(toPhase) match {
case Right(message) => OK(ContentType, "text/html") << strict << message
case Left(error) => OK(ContentType, "text/html") << strict << error.getMessage
}
}

private def saveStory(implicit request: Request[Stream], servletRequest: HttpServletRequest) = {
val title = param_!("title")
val number = param_!("storyNumber")
Story(number, title).save() match {
case Right(message) => redirects[Stream, Stream]("/card/create", ("message", message))
case Left(error) => OK(ContentType, "text/html") << strict << CreateStory(error.toString)
}
}

val application = new ServletApplication[Stream, Stream] {
override def application(implicit servlet: HttpServlet, servletRequest: HttpServletRequest, request:
Request[Stream]): Response[Stream] = {
def found(x: Iterator[Byte]): Response[Stream] = OK << x.toStream
handle | HttpServlet.resource(found, NotFound.xhtml)
}
}
}

现在已经实现了Story的新增功能,删除、修改功能与之类似。这样,我们就可以开始构建完整的weKanban项目了。

7~2〖Building the Kanban board page〗P212

(略… 剩余部分内容为上述内容的重复操作,此处省略)

7~3〖Summary〗p223

在第6、7章我们开始构建我们的第一个中小型Scala应用。我们也由此从REPL环境切换到了SBT环境。你学习了如何使用SBT,如何配置SBT,以及如何通过SBT导入依赖、管理依赖来构建我们的项目。紧接着,我们学习了使用Scalaz HTTP 模组来构建我们的web应用,以及学习了函数式编程的概念是如何运用到web应用中。Scalaz使用了Scala的高阶函数(higher-order functions)和模式匹配暴露了优秀的APIs。构建企业级应用程序意味着你需要和关系型数据库打交道。我们学习了Squeryl,Scala的ORM工具,并学习了如何在Scala应用中构建关系型数据模型。

本章已经提供了足够的基础使用各种Scala工具来构建我们的应用。我鼓励使用不同的Scala ORM工具、视图模版引擎、web框架来构建或扩展这些应用。我希望这两章学习的这些概念和工具之后,可以使你更舒适地在各种Scala工具市场中驾驭。

在本章,我们稍稍瞥见了隐式转换和隐式参数。下一章我们将深入到Scala的类型系统,以及如何使用类型来构建抽象的层面。


  1. A fork of SLICK to keep old links to the ScalaQuery repository alive, http://github.com/szeiger/scala-query.
  2. Querulous, an agreeable way to talk to your database, http://github.com/nkallen/querulous.
  3. “Class ThreadLocal,” Java Platform Standard Ed. 6, http://mng.bz/cqt0.
  4. “Scalate: Scala Template Engine,” Scalate 1.5.3, http://scalate.fusesource.org.
  5. “Group and Aggregate Queries,” http://squeryl.org/group-and-aggregate.html.