¶主要内容:
- SBT构建数据库。
- webKanban中,通过Squeryl连接数据库。
- 完善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。实际上,你还可以自由地选取下面的数据库:Postgres
、Oracle
、MySQL
,DB2
。这里之所以列出这几个数据库的原因是,你所使用的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 | name := "weKanban" |
现在,你要做的是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 | package com.kanban.models |
将该文件保存为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 | import sbt._ |
这里的build.scala文件做了两件事。一是,定义了项目的name
,locations
,settings
。项目工程的settings通过继承Build获得默认值,并添加两个新任务:startH2Task
和stopH2Task
。这两个任务现在作为项目settings的一部分,并可以用开启和停止H2数据库。
二是,scalazVersion和jettyVersion被声明为lazy val
。这个好处是,你不用在build.sbt文件中重复声明多次:
1 | libraryDependencies ++= Seq( |
是的,你可以共享build.scala的settings和vals到build.sbt文件。事实上,常规做法是,build.scala通常用于声明,build.sbt则使用其声明内容用于构建。最好,所有settings由多个文件组合成为一个settings队列中。
重载构建定义之后,你可以看到两个新的tasks,h2:start 和 h2:stop:
1 | h2:st |
¶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
方法中使用。stopDatabase
和h2:stop
任务相关联。
这会帮助你不用离开舒适的SBT控制台,就可以在使用H2进行工作。当你执行h2:start
,它会自动在端口为8082上开启H2数据库,并在默认浏览器上开启H2登录视窗,如图Figure 7.1。
(如果浏览器没有打开,需要直接在浏览器输入 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 | package com.kanban.models |
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 | () => |
这里真正被调用的方法,是Session对象里面的create方法,由传递一个数据库连接和一个适配器adapter执行。其中,Java DriverManager接收URL、用户名和密码。Squeryl提供的适配器用于它所支持的数据库类型,这里的适配器为H2Adapter,表示数据库为H2的连接。因为concreteFactory的类型是Option[()=>Session]
,你需要用Option值Some进行转换。
因为你定义了stories对象来表示"STORIES"表,它不会在数据库中创建表。某些情况下你可能会用SQL脚本来创建数据库表结构,但这里你可以使用Squeryl来创建这些Schema。添加一个main方法入口,如下:
1 | def main(args: Array[String]){ |
定义在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的两个属性number
和 title
都不能为空,以及number
是唯一标识的,下面为该验证的实现:
1 | class ValidationException(message: String) extends RuntimeException(message) |
将方法validate添加到Story类中,并在save动作之前调用。这里创建了一个自定义异常ValidationException用于所有验证失败处理。
注意 这里没有定义主键,而仅仅是把number声明为唯一键。在小应用中这样做没什么问题,但是,在真实环境中,你应该将它代替为主键。要给你的domain类添加一个自动递增的主键id,你可以继承KeyedEntity[A]
特质,你也可以使用KeyedEntity
来创建组合keys
。关于更多信息内容,参考Squeryl官方文档。
为了检查number字段的唯一性,你需要检查"STORIES"表,确保没有其它记录有相同的number。Squeryl提供了一个很好的方法where
,在表对象中,你可以很容易地实现。where
方法接收一个断言函数,并过滤得出结果。下面是使用where
方法来检查number的唯一性:
1 | if(!stories.where(a => a.number === number).isEmpty) { |
这里使用了函数a => a.number === number
(===
Squeryl定义的等价操作符),这表示你查询的结果匹配给定number。如果查询的结果为空,则给定的number不是唯一的。注意where
方法返回一个lazy iterable
叫做 Query ,它定义在org.squeryl.Query
。查询仅在动作开始时向数据库发送信息。添加完这些验证后,validate
方法现在看起来像下面:
1 | private[this] def validate() = { |
现在,在插入Story记录到数据库之前,你需要调用这个validate方法,以确保是否有效。你还需要在数据库事务上提交或者回滚。现在,我们为该Story类添加一个save
方法。但,这个方法应该返回什么?当创建记录成功时,你可以返回成功信息,但创建失败时怎么表示好?在这里我推荐使用scala.Either
,它可以允许你同时返回成功和失败信息。它使得save方法可以优雅地处理问题。下面是完整的Story类方法,它包含validate
和save
方法:
1 | package com.kanban.models |
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 | package com.kanban.models |
方法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为我们需要构建的页面内容。
要创建上图页面内容,你需要创建下列代码CreateStory,置于src/main/com/kanban/views:
1 | package com.kanban.views |
这里的apply方法为创建页面所需要的HTML代码,尽管它是HTML,在Scala代码中,不论是XML或HTML都是有效的。这里的apply方法的返回类型是scala.xml.NodeSeq,它是一系列XML节点,NodeSeq会被渲染为String,并返回真是的HTML代码。现在要为其指定一个URL。其中,静态链接内容分别在webapp/js 和 webapp/css目录下。
到目前为止,Scalaz中的application已经使用resource方法处理了这些静态资源:
1 | def application(implicit servlet: HttpServlet, servletRequest: |
要处理这些静态资源,在application方法中调用handle方法,该方法接收application方法相同的参数,并在请求对象上匹配URL。典型地,web框架使用了分割配置文件来将URL映射为一个资源或函数。基于约定(convention-based)框架如:Rails、playframework 中,URL包含了足够的信息来映射到 合适的函数或动作中。Scalaz使用了一个不同的方式——Scala强大的类型匹配,当URL进入到HTTP方法,URL被分成List。例如,一个URL地址为 http://localhost:8080/card/create 匹配为:
1 | request match { |
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 | def handle(implicit request: Request[Stream], servletRequest: HttpServletRequest): Option[Response[Stream]] = { |
这里,当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 | def application(implicit servlet: HttpServlet, |
因为所有传递给handle方法的参数都是隐式的,你可以不用显式给它传递参数。使用了 |
方法,如果handle方法返回的是None,则方法resource会被调用。当URL找不到匹配的资源时,则会返回NotFound.xhtml。
注意 Scalaz使用Scala的隐式方法转换将 |
方法添加到 Option类中。如果你曾经使用过Ruby,Groovy或其它编程语言的 元程序(metaprogramming),隐式转换是Scala实现 元程序(metaprogramming) 的方式,但有更多控制。你会在下一章节了解更多隐式转换的详细内容。
在运行应用之前,先看看经过修改后的完整代码:
1 | import scalaz._ |
这里添加了两个新的方法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 | def handle(implicit request: Request[Stream], |
方法saveStory会读取request里面post过来的参数,并实例化一个Story,然后调用save方法。为了读取post里面的方法,需要添加一个工具方法,但和param方法不同,因为是post请求,通常意味着有副作用,所以改用 !
方法来处理。
1 | def param_!(name: String)(implicit request: Request[Stream]) = |
Story里面的save方法的返回类型是scala.Either[Throwable, String]
,就是说如果出现错误返回左边的异常;否则返回右边的成功信息。Left和Right是Either的唯一子类型。因此可以用模式匹配进行处理。当save成功时,你便重定向到应用的创建页面,并带有成功信息;同样,当创建失败时,则在页面显示失败信息。下面是saveStory方法的实现:
1 | private def saveStory(implicit request: Request[Stream], |
方法redirects定义在Response对象中,它表示页面的重定向。
在运行应用之前,请确保H2数据库服务处于开启状态,下面列出完整的application类代码:
1 | final class WeKanbanApplication extends StreamStreamServletApplication { |
现在已经实现了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的类型系统,以及如何使用类型来构建抽象的层面。
- A fork of SLICK to keep old links to the ScalaQuery repository alive, http://github.com/szeiger/scala-query. ↩
- Querulous, an agreeable way to talk to your database, http://github.com/nkallen/querulous. ↩
- “Class ThreadLocal
,” Java Platform Standard Ed. 6, http://mng.bz/cqt0. ↩ - “Scalate: Scala Template Engine,” Scalate 1.5.3, http://scalate.fusesource.org. ↩
- “Group and Aggregate Queries,” http://squeryl.org/group-and-aggregate.html. ↩