精通-Scala-Play-框架-全-
精通 Scala Play 框架(全)
原文:
zh.annas-archive.org/md5/079c5d0ec77661c34a79029f5b3207aa
译者:飞龙
前言
Play 框架是一个用 Java 和 Scala 编写的开源 Web 应用程序框架。它遵循模型-视图-控制器(Model-View-Controller)的架构模式。
它使用户能够在保持 Play 框架的关键属性和功能的同时使用 Scala 进行应用程序开发。这导致 Web 应用程序更快且可扩展。此外,它使用更函数式和“Scala 惯用”的编程风格,而不牺牲简单性和开发者友好性。
本书将提供有关使用 Play 框架开发 Scala Web 应用程序的高级信息。这将帮助 Scala Web 开发者掌握 Play 2.0,并用于专业的 Scala Web 应用程序开发。
本书涵盖的内容
第一章,Play 入门,解释了如何使用 Play 框架构建简单应用程序。我们还探讨了项目结构,以便您了解框架如何通过构建文件插入所需的设置。
第二章,定义动作,解释了我们可以如何定义具有默认解析器和结果的特定应用程序动作,以及具有自定义解析器和结果的动作。
第三章,构建路由,展示了路由在 Play 应用程序中的重要性。除此之外,我们还检查了 Play 提供的各种默认方法,这些方法可以简化路由过程。
第四章,探索视图,解释了如何使用 Twirl 和 Play 提供的各种其他辅助方法创建视图。在本章中,您还将学习如何在 Play 应用程序中支持多种语言,使用内置的 i18n API。
第五章,数据处理,展示了如何使我们在使用 Play 框架构建的应用程序中持久化应用程序数据的不同方法。此外,您还可以了解如何使用 Play 缓存 API 以及它是如何工作的。
第六章,响应式数据流,讨论了迭代器(Iteratee)、枚举器(Enumerator)和枚举者(Enumeratee)的概念,以及它们如何在 Play 框架中实现并用于内部使用。
第七章,玩转全局变量,揭示了通过全局插件为 Play 应用程序提供的功能。我们还讨论了请求-响应生命周期的钩子,使用这些钩子我们可以拦截请求和响应,并在必要时修改它们。
第八章,WebSockets 和 Actors,简要介绍了 Actor 模型以及在应用程序中使用 Akka Actors 的用法。我们还使用不同的方法,在 Play 应用程序中定义了一个具有各种约束和要求的 WebSocket 连接。
第九章, 测试,展示了如何使用 Specs2 和 ScalaTest 测试 Play 应用程序。我们探讨了可用于简化 Play 应用程序测试的不同辅助方法。
第十章, 调试和日志记录,介绍了如何在 IDE 中配置 Play 应用程序的调试。在本章中,您将学习如何在 Scala 控制台中启动 Play 应用程序。本章还强调了 Play Framework 提供的日志 API 以及自定义日志格式的自定义方法。
第十一章, Web 服务和认证,解释了 WS(WebService)插件及其暴露的 API。我们还使用 OpenID 和 OAuth 1.0a 从服务提供商访问用户数据。
第十二章, Play 在生产环境中的应用,解释了如何在生产环境中部署 Play 应用程序。在部署应用程序时,我们还会检查默认可用的不同打包选项(RPM、Debian、ZIP、Windows 等)。
第十三章, 编写 Play 插件,解释了所有插件及其声明、定义和最佳实践。
您需要为本书准备的
在开始阅读本书之前,请确保您已安装所有必要的软件。本书的先决条件如下:
-
Java:
www.oracle.com/technetwork/java/javase/downloads/jdk7-downloads-1880260.html
-
SBT 或 Activator:
typesafe.com/community/core-tools/activator-and-sbt
-
MariaDB:
downloads.mariadb.org/
-
MongoDB:
www.mongodb.org/downloads
-
Cassandra(可选):
cassandra.apache.org/download/
本书面向的对象
本书旨在帮助那些热衷于掌握 Play Framework 内部工作原理的开发者,以便有效地构建和部署与 Web 相关的应用程序。假设您对核心应用程序开发技术有基本的了解。
规范
在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"更新索引模板,以便每个<li>
元素都有一个按钮,点击该按钮将向服务器发送删除请求。"
代码块设置如下:
def runningT(block: => T): T = {
synchronized {
try {
Play.start(app)
block
} finally {
Play.stop()
}
}
}
当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:
class WebSocketChannel(out: ActorRef)
extends Actor with ActorLogging {
val backend = Akka.system.actorOf(DBActor.props)
def receive: Actor.Receive = {
case jsRequest: JsValue =>
backend ! convertJsonToMsg(jsRequest)
case x:DBResponse =>
out ! x.toJson
}
}
任何命令行输入或输出都应如下编写:
> run
[info] Compiling 1 Scala source to /AkkaActorDemo/target/scala-2.10/classes...
[info] Running com.demo.Main
?od u od woH ,olleH
ekops ew ecnis gnoL neeB
Sorry, didn't quite understand that I can only process a String.
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下显示:“当您点击提交时,表单不会提交,并且不会使用globalErrors
显示错误。”
注意
警告或重要注意事项以如下框中的形式出现。
小贴士
小贴士和技巧如下所示。
读者反馈
我们欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。
要向我们发送一般反馈,只需通过电子邮件发送到<feedback@packtpub.com>
,并在邮件主题中提及书籍的标题。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 书籍的骄傲拥有者,我们有多个方面可以帮助您充分利用您的购买。
下载示例代码
您可以从您的账户下载示例代码文件,地址为www.packtpub.com
,适用于您购买的所有 Packt 出版书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
,并注册以将文件直接发送给您。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中找到错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进此书的后续版本。如果您发现任何错误清单,请通过访问www.packtpub.com/submit-errata
,选择您的书籍,点击错误提交表单链接,并输入您的错误详细信息来报告它们。一旦您的错误清单得到验证,您的提交将被接受,错误清单将被上传到我们的网站或添加到该标题错误清单部分。
要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support
,并在搜索字段中输入书籍名称。所需信息将出现在错误清单部分。
盗版
互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。
请通过链接将疑似盗版材料发送至 <copyright@packtpub.com>
与我们联系。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面提供的帮助。
问题
如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com>
与我们联系,我们将尽力解决问题。
第一章. Play 入门
自从 1991 年 8 月首次出现以来,万维网已经取得了飞跃性的增长。它已经从行模式浏览器和静态网站发展到图形浏览器和高度交互式网站,如搜索引擎、在线百货商店、社交网络、游戏等。
复杂的网站或应用程序由一个或多个数据库和大量代码支持。在大多数情况下,此类 Web 应用程序使用框架来简化开发过程。框架提供了一个骨架结构,处理了大多数重复或常见功能。Ruby on Rails、Django、Grails 和 Play 是此类框架的几个例子。
Play 框架是由 Guillaume Bort 在 Zenexity(现在为 Zengularity)工作时开发的。它的第一个完整版本是在 2009 年 10 月发布的 1.0 版。2011 年,Sadek Drobi 加入了 Guillaume Bort,共同开发 Play 2.0,该版本被 Typesafe Stack 2.0 采用。Play 2.0 于 2012 年 3 月 13 日发布。
在本章中,我们将介绍以下主题:
-
选择 Play 的原因
-
创建一个示例 Play 应用程序
-
创建一个 TaskTracker 应用程序
探索 Play 的世界
Play 的安装无需麻烦。如果你有 Java JDK 6 或更高版本,要使 Play 运行,你只需安装Typesafe Activator或Simple Build Tool(SBT)即可。
Play 是完全 RESTful 的!表示性状态转移(REST)是一种架构风格,它依赖于无状态、客户端-服务器和缓存启用通信协议。它是一种轻量级的替代机制,如远程过程调用(RPC)和 Web 服务(包括 SOAP、WSDL 等)。在这里,无状态意味着客户端状态数据不会存储在服务器上,每个请求都应该包含服务器成功处理所需的所有数据。服务器不依赖于以前的数据来处理当前请求。客户端存储他们的会话状态,服务器可以无状态地服务更多的客户端。Play 构建系统使用Simple Build Tool(SBT),这是一个用于 Scala 和 Java 的构建工具。它还包含一个插件,允许原生编译 C 和 C++。SBT 使用增量重新编译来减少编译时间,并且可以在触发执行模式下运行,这意味着如果用户指定,所需任务将在用户在任何源文件中保存更改时运行。这个特性特别被 Play 框架利用,因此开发者不需要在开发阶段的每次更改后重新部署。这意味着如果 Play 应用程序在本地机器上从源代码运行,并且你编辑了它的代码,你只需在浏览器中重新加载应用程序即可查看更新后的应用程序。
它提供了一个默认的测试框架以及辅助器和应用程序存根,以简化应用程序的单元和功能测试。Specs2是 Play 中使用的默认测试框架。
由于 Play 内置了基于 Scala 的模板引擎,因此可以在模板中使用 Scala 对象(String
、List
、Map
、Int
、用户定义的对象等)。在 2.0 版本之前,Play 依赖于 Groovy 作为模板引擎,这是不可能的。
它使用 JBoss Netty 作为默认的 Web 服务器,但任何 Play 2 应用程序都可以被打包成 WAR 文件,并在需要时部署在 Servlet 2.5、3.0 和 3.1 容器上。有一个名为 play2-war-plugin 的插件(可以在 github.com/play2war/play2-war-plugin/
找到),可以用于为任何给定的 Play2 应用程序生成 WAR 文件。
Play 支持 模型-视图-控制器(MVC)模式。根据 MVC 模式,应用程序的组件可以分为三类:
-
模型:这代表应用程序数据或活动
-
视图:这是应用程序中用户可以看到的部分
-
控制器:这是负责处理来自终端用户输入的部分
该模式还定义了这些组件应该如何相互交互。让我们以在线商店作为我们的应用程序。在这种情况下,产品、品牌、用户、购物车等可以由每个模型表示。用户可以查看产品的应用程序页面定义在视图中(HTML 页面)。当用户将产品添加到购物车时,事务由控制器处理。视图不知道模型,模型也不知道视图。控制器向模型和视图发送命令。以下图显示了模型、视图和控制器之间的交互:
Play 还预包装了一个易于使用的 Hibernate 层,并且通过添加对各个模块的依赖,可以直接提供 OpenID、Ehcache 和 Web 服务集成。
在本章的后续部分,我们将使用 Play 创建一个简单的应用程序。这主要是针对早期使用 Play 的开发者。
一个示例 Play 应用程序
创建新的 Play 应用程序有两种方式:激活器,和不使用激活器。使用激活器创建 Play 项目更简单,因为最简约的应用程序至少需要六个文件。
Typesafe Activator 是一个可以用来使用 Typesafe 堆栈创建应用程序的工具。它依赖于使用预定义的模板来创建新项目。设置 Activator 的说明可以在 typesafe.com/get-started
找到。
使用激活器构建 Play 应用程序
让我们使用激活器和简单的模板来构建一个新的 Play 应用程序:
$ activator new pathtoNewApp/sampleApp just-play-scala
然后,使用 run
命令运行项目:
sampleApp $ sbt run
默认情况下,它通过 http://localhost:9000
启动应用程序,使其可访问。
注意
run
命令以开发模式启动项目。在这种模式下,应用程序的源代码会监视变化,如果有任何变化,代码将被重新编译。然后我们可以修改模型、视图或控制器,并通过重新加载浏览器来看到它们在应用程序中的反映。
看一下项目结构。它将类似于这里所示的结构:
如果我们不能使用 Activator,我们可能不得不创建所有这些文件。现在,让我们逐个深入探讨这些文件,看看它们各自的作用。
构建定义
让我们从项目的关键部分开始——其构建定义,在我们的案例中,是build.sbt
文件。.sbt
扩展名来源于用于 Play 应用程序的构建工具。我们将为不熟悉 SBT 的人介绍这个概念。构建定义本质上是一个键值对的列表,类似于带有:=
符号作为赋值操作符的赋值语句。
注意
SBT 版本低于 0.13.7 期望在构建定义中两个不同语句之间的分隔符为换行符。
构建文件的包含内容如下:
name := "sampleApp"""
version := "1.0.0"
lazy val root = project.in(file(".")).enablePlugins(PlayScala)
在前面的构建定义中,指定了项目的name
、version
和root
的值。另一种指定值的方法是更新现有的值。我们可以使用+=
符号为单个项目追加值,使用++=
为序列追加值。例如:
resolvers += Resolver.sonatypeRepo("snapshots")
scalacOptions ++= Seq("-feature", "-language:reflectiveCalls")
resolvers
是依赖项可以从中获取的 URL 列表,而scalacOptions
是传递给 Scala 编译器的参数列表。
或者,SBT 项目也可以使用.scala
构建文件。我们的应用程序的结构将是:
SimpleApp
的.scala
构建定义如下:
import sbt._
import Keys._
import play.Play.autoImport._
import PlayKeys._
object ApplicationBuild extends Build {
val appName = "SimpleApp"
val appVersion = "1.0.0"
val appDependencies = Seq(
// Add your project dependencies here
)
val main = Project(appName, file(".")).enablePlugins(play.PlayScala).settings(
version := appVersion,
libraryDependencies ++= appDependencies
)
}
当我们需要为应用程序/插件定义自定义任务/设置时,.scala
构建定义非常有用,因为它使用 Scala 代码。.sbt
定义通常比其对应的.scala
定义更小、更简单,因此更受欢迎。
如果没有通过启用 PlayScala 插件导入的 Play 设置,SBT 将无法知道我们的项目是一个 Play 应用程序,并且会根据 Play 应用程序的语义进行定义。
那么,这个声明是否足够让 SBT 正确运行 Play 应用程序?
不,还有其他的事情!SBT 允许我们通过插件扩展构建定义。基于 Play 的项目使用 Play SBT 插件,并且 SBT 就是从这个插件中获取所需的设置。为了使 SBT 下载我们的项目将使用的所有插件,它们应该被明确添加。这是通过在projectRoot/project
目录中的plugins.sbt
中添加它们来完成的。
让我们来看看plugins.sbt
文件。文件内容如下:
resolvers += "Typesafe repository" at "http://repo.typesafe.com/typesafe/releases/"
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.3.8")
传递给addSbtPlugin
的参数是插件的 Ivy 模块 ID。当插件不在 Maven 或 Typesafe 仓库上托管时,解析器很有帮助。
build.properties
文件用于指定 SBT 版本,以避免使用两个或更多不同版本的 SBT 编译的相同构建定义之间的不兼容性问题。
这涵盖了 Play 应用程序的所有与构建相关的文件。
源代码
现在,让我们看看我们项目的源代码。大部分源代码都在app
文件夹中。通常,模型的代码在app/models
或app/com/projectName/models
中,控制器的源代码在app/controllers
或app/com/projectName/controllers
中,其中com.projectName
是包名。视图的代码应该在app/views
或app/views
的子文件夹中。
views/main.scala.html
文件是我们运行应用程序时能看到的那一页。如果这个文件缺失,您可以添加它。如果您想知道为什么文件命名为main.scala.html
而不是main.html
,这是因为它是一个 Twirl 模板;它便于使用 Scala 代码与 HTML 结合来定义视图。我们将在第四章探索视图中深入探讨这一点。
现在,更新main.scala.html
的内容如下:
@(title: String)(content: Html)
<!DOCTYPE html>
<html>
<head>
<title>@title</title>
</head>
<body>
@content
</body>
</html>
我们可以从 Scala 代码中提供标题和内容来显示这个视图。一个视图可以通过控制器绑定到特定的请求。所以,让我们更新我们的控制器SampleAppController
的代码,如下所示:
package controllers
import play.api.mvc._
import play.api.templates.Html
object SampleAppController extends Controller {
def index = Action {
val content = Html("<div>This is the content for the sample app<div>")
Ok(views.html.main("Home")(content))
}
}
提示
下载示例代码
您可以从www.packtpub.com
的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
Action
和Ok
是由play.mvc.api
包提供的方法。第二章定义动作详细介绍了它们。
保存更改并运行应用程序后,我们将看到截图所示的http://localhost:9000
上托管的页面:
请求处理过程
让我们看看请求是如何处理的!
应用程序将支持的所有请求必须在conf/routes
文件中定义。每个路由定义有三个部分。第一部分是请求方法。它可以是GET
、POST
、PUT
和DELETE
中的任何一个。第二部分是路径,第三部分是返回响应的方法。当在conf/routes
文件中定义请求时,它在conf/routes
文件中映射的方法被调用。
例如,路由文件中的一个条目可能是:
GET / controllers.SampleAppController.index
这意味着对于/
路径上的 GET 请求,我们已经将响应映射为从SampleController.index()
方法返回的那个。
一个示例请求如下:
curl 'http://localhost:9000/'
继续添加更多页面到应用程序中,以便更加熟悉,也许是一个常见问题解答、联系我们或关于我们。
前面代码中解释的 Play 应用程序的请求-响应周期在此处表示:
public
目录基本上用于提供资源,如样式表、JavaScript 和图像,这些资源与 Play 无关。为了使这些文件可访问,默认情况下也将public
的路径添加到路由中:
GET /assets/*file controllers.Assets.at(path="/public", file)
我们将在第三章构建路由中详细了解路由。
文件conf/application.conf
用于设置应用程序级别的配置属性。
target
目录被 SBT 用于编译、构建或其他过程中生成的文件。
创建 TaskTracker 应用程序
让我们创建一个简单的TaskTracker应用程序,它允许我们添加待办任务并删除它们。我们将继续修改上一节中构建的SampleApp
。在这个应用程序中,我们不会使用数据库来存储任务。在 Play 中使用Anorm或其他模块持久化模型是可能的;这将在第五章处理数据中更详细地讨论。
我们需要一个带有输入框以输入任务的视图。将另一个模板文件index.scala.html
添加到视图中,使用上一节生成的模板作为样板:
@main("Task Tracker") {
<h2>Task Tracker</h2>
<div>
<form>
<input type="text" name="taskName" placeholder="Add a new Task" required>
<input type="submit" value="Add">
</form>
</div>
}
为了使用模板,我们可以从我们的 Scala 代码中调用其生成的方法,或者通过其名称在其他模板中引用它。当我们想要对所有模板应用更改时,使用主模板会很有帮助。例如,如果我们想为应用程序添加一个样式表,只需在主模板中添加这个样式表即可确保它被添加到所有依赖的视图中。
要在加载时查看此模板的内容,请更新index
方法为:
package controllers
import play.api.mvc._
object TaskController extends Controller {
def index = Action {
Ok(views.html.index())
}
}
注意,我们还将所有SampleAppController
的实例替换为TaskController
。
运行应用程序并在浏览器中查看;页面将类似于这个图:
现在,为了处理功能,让我们添加一个名为Task
的模型,我们将使用它来表示应用程序中的任务。由于我们还想删除功能,我们需要使用唯一的 ID 来标识每个任务,这意味着我们的模型应该有两个属性:一个 ID 和一个名称。Task
模型将是:
package models
case class Task(id: Int, name: String)
object Task {
private var taskList: List[Task] = List()
def all: List[Task] = {
taskList
}
def add(taskName: String) = {
val newId: Int = taskList.last.id + 1
taskList = taskList ++ List(Task(newId, taskName))
}
def delete(taskId: Int) = {
taskList = taskList.filterNot(task => task.id == taskId)
}
}
在这个模型中,我们使用一个名为taskList
的私有变量来跟踪会话中的任务。
在add
方法中,每当添加一个新任务时,我们将其追加到这个列表中。而不是保持另一个变量来计数 ID,我选择递增列表中最后一个元素的 ID。
在delete
方法中,我们简单地过滤掉具有给定 ID 的任务,而all
方法返回这个会话的列表。
现在,我们需要在我们的控制器中调用这些方法,然后将它们绑定到一个请求路由。现在,以这种方式更新控制器:
import models.Task
import play.api.mvc._
object TaskController extends Controller {
def index = Action {
Redirect(routes.TaskController.tasks)
}
def tasks = Action {
Ok(views.html.index(Task.all))
}
def newTask = Action(parse.urlFormEncoded) {
implicit request =>
Task.add(request.body.get("taskName").get.head)
Redirect(routes.TaskController.index)
}
def deleteTask(id: Int) = Action {
Task.delete(id)
Ok
}
}
在前面的代码中,routes
指的是可以用来访问在conf/routes
中定义的应用程序路由的辅助工具。现在尝试运行应用程序!
它将抛出一个编译错误,指出values tasks
不是controllers.ReverseTaskController
的成员。这是因为我们还没有更新路由。
添加一个新任务
现在,让我们绑定获取任务和添加新任务的行动:
GET / controllers.TaskController.index
# Tasks
GET /tasks controllers.TaskController.tasks
POST /tasks controllers.TaskController.newTask
我们将完成我们应用程序的视图,以便它可以促进以下功能:
接受并渲染 List[Task]
@(tasks: List[Task])
@main("Task Tracker") {
<h2>Task Tracker</h2>
<div>
<form action="@routes.TaskController.newTask()" method="post">
<input type="text" name="taskName" placeholder="Add a new Task" required>
<input type="submit" value="Add">
</form>
</div>
<div>
<ul>
@tasks.map { task =>
<li>
@task.name
</li>
}
</ul>
</div>
}
我们现在在视图中添加了一个表单,它接受一个名为taskName
的文本输入,并将此数据提交给TaskController.newTask
方法。
注意
注意,我们现在为这个模板添加了一个tasks
参数,并在视图中显示它。Scala 元素和预定义模板在视图中以@
twirl 符号开头。
现在,当运行应用程序时,我们将能够添加任务以及查看现有的任务,如下所示:
删除任务
我们的应用程序中剩下的唯一功能是删除任务。更新索引模板,以便每个<li>
元素都有一个按钮,点击该按钮将向服务器发送删除请求:
<li>
@task.name <button onclick="deleteTask ( @task.id) ;">Remove</button>
</li>
然后,我们需要更新路由文件以映射删除操作:
DELETE /tasks/:id controllers.TaskController.deleteTask (id: Int).
我们还需要在我们的视图中定义deleteTask
。为此,我们可以简单地添加一个脚本:
<script>
function deleteTask ( id ) {
var req = new XMLHttpRequest ( ) ;
req.open ( "delete", "/tasks/" + id ) ;
req.onload = function ( e ) {
if ( req.status = 200 ) {
document.location.reload ( true ) ;
}
} ;
req.send ( ) ;
}
</script>
注意
理想情况下,我们不应该在窗口的全局命名空间中定义 JavaScript 方法。在这个例子中已经这样做,以保持简单,但不建议用于任何实时应用程序。
现在,当我们运行应用程序时,我们可以添加任务以及删除它们,如下所示:
我将美化应用程序的任务留给你。在公共目录中添加一个样式表,并在主模板中声明它。例如,如果taskTracker.css
文件位于public/stylesheets
,则main.scala.html
文件中的链接将是:
<link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/taskTracker.css")">
摘要
本章介绍了 Play 框架的基本概念。在本章中,我们学习了如何使用 Play 框架构建简单的应用程序。我们了解了其项目结构,以了解框架如何通过构建文件插入所需的设置。我们还讨论了此类应用程序的各个部分:模型、路由、视图、控制器等。
在下一章中,我们将详细介绍操作。
第二章。定义行动
如果你正在阅读这篇文章,那么你可能已经度过了第一章,或者跳过了它。无论如何,我假设你知道一个简单 Play 应用程序的结构。在 Play 中,控制器生成 Action 值,为此,它内部使用几个对象和方法。在本章中,我们将了解幕后发生了什么,以及我们如何在构建应用程序时利用这些行动。
在本章中,我们将涵盖以下主题:
-
定义行动
-
请求体解析器
-
行动组合和故障排除
一个虚拟的艺术家模型
在以下章节中,我们将引用一个artist
模型。它是一个简单的class
,有一个伴随的object
,如下定义:
case class Artist(name: String, country: String)
object Artist {
val availableArtist = Seq(Artist("Wolfgang Amadeus Mozart", "Austria"),
Artist("Ludwig van Beethoven", "Germany"),
Artist("Johann Sebastian Bach", "Germany"),
Artist("Frédéric François Chopin", "Poland"),
Artist("Joseph Haydn", "Austria"),
Artist("Antonio Lucio Vivaldi", "Italy"),
Artist("Franz Peter Schubert", "Austria"),
Artist("Franz Liszt", "Austria"),
Artist("Giuseppe Fortunino Francesco Verdi", "Austria"))
def fetch: Seq[Artist] = {
availableArtist
}
def fetchByName(name: String): Seq[Artist] = {
availableArtist.filter(a => a.name.contains(name))
}
def fetchByCountry(country: String): Seq[Artist] = {
availableArtist.filter(a => a.country == country)
}
def fetchByNameOrCountry(name: String, country: String): Seq[Artist] = {
availableArtist.filter(a => a.name.contains(name) || a.country == country)
}
def fetchByNameAndCountry(name: String, country: String): Seq[Artist] = {
availableArtist.filter(a => a.name.contains(name) && a.country == country)
}
}
艺术家模型有一个方法来获取所有这些艺术家,以及一些基于不同参数过滤艺术家的方法。
注意
在实际应用程序中,模型与数据库交互,但为了使事情简单,我们已经将数据硬编码为Seq[Artist]
。
我们还有一个home.scala.html
视图,它以表格的形式显示艺术家信息:
@(artists: Seq[Artist])
<!DOCTYPE html>
<html>
<head>
<title>Action App</title>
</head>
<body>
<table>
<thead>
<tr>
<th>Name</th>
<th>Country</th>
</tr>
</thead>
<tbody>
@artists.map { artist =>
<tr>
<td>@artist.name</td>
<td>@artist.country</td>
</tr>
}
</tbody>
</table>
</body>
</html>
这是一个 twirl 模板,它需要一个Seq[Artist]
。它与我们在上一章中构建的 TaskTracker 应用程序的视图类似。
行动
Play 中的Action定义了服务器应该如何响应用户请求。定义 Action 的方法映射到routes
文件中的请求。例如,让我们定义一个 Action,它将显示所有艺术家的信息作为响应:
def listArtist = Action {
Ok(views.html.home(Artist.fetch))
}
现在,为了使用这个 Action,我们应该将其映射到routes
文件中的请求。
GET /api/artist controllers.Application.listArtist
在这个例子中,我们获取所有艺术家,并将它们与视图一起发送,作为对请求的响应。
注意
在route
文件中使用的api
术语只是一个 URL 前缀,不是强制的。
运行应用程序,并从浏览器访问http://localhost:9000/api/artist
。可以看到一个包含可用艺术家的表格。
Action 接受一个请求并产生一个结果。它是EssentialAction
特质的实现。它被定义为:
trait EssentialAction extends (RequestHeader => Iteratee[Array[Byte], Result]) with Handler {
def apply() = this
}
object EssentialAction {
def apply(f: RequestHeader => Iteratee[Array[Byte], Result]): EssentialAction = new EssentialAction {
def apply(rh: RequestHeader) = f(rh)
}
}
Iteratee是从函数式语言中借用的一个概念。它用于以增量方式处理数据块。我们将在第六章反应式数据流中更深入地探讨它。
apply
方法接受一个函数,该函数将请求转换成结果。RequestHeader
和其他数据块代表请求。简而言之,apply
方法接受一个请求并返回一个结果。
让我们看看定义行动的一些方法。
带参数的 Action
我们可能会遇到需要定义一个从请求路径中获取值的 Action 的情况。在这种情况下,我们需要添加方法签名所需的参数,并在路由文件中传递它们。这个例子将是获取按所选名称检索艺术家的方法。在控制器中添加以下内容:
def fetchArtistByName(name:String) = Action {
Ok(views.html.home(Artist.fetchByName(name)))
}
在routes
文件中的映射将是:
GET /api/artist/:name controllers.Application.fetchArtistByName(name)
注意
如果没有明确指定,请注意,路径中参数的类型默认设置为String
。可以在方法调用中指定类型。因此,定义的路由等同于:
GET /api/artist/:name controllers.Application.fetchArtistByName(name:String)
类似地,如果需要,我们可以添加更多参数。
现在,让我们考虑一个搜索查询的使用案例。我们希望操作能够接受查询参数,例如名称和国家。操作定义如下:
def search(name: String, country: String) = Action {
val result = Artist.fetchByNameOrCountry(name, country)
if(result.isEmpty){
NoContent
}
else {
Ok(views.html.home(result))
}
}
如果没有艺术家符合标准,则响应为空,并显示状态码 204(无内容)。如果不满足,则响应状态为200 = (Ok)
,并将结果作为响应体显示。
路由文件中对应此操作的条目将是以下内容:
GET /api/search/artist controllers.Application.search(name:String,country:String)
我们在路径中不使用任何参数,但应该包含与routes
文件中方法的参数名称对应的查询参数。
这将生成一个有效的 URL:http://localhost:9000/api/search/artist?name=Franz&country=Austria
如果我们决定将country
设置为可选参数怎么办?
让我们修改路由以适应这一变化:
GET /api/search/artist controllers.Application.search(name:String?="",country:String?="")
这允许我们通过名称进行查询,因此,现在这两个 URL 将看起来像这样:http://localhost:9000/api/search/artist?name=Franz
和 http://localhost:9000/api/search/artist?name=Franz&country=Austria
都已支持。
在这里,我们通过在路由定义中为country
参数设置默认值来使其成为可选参数。或者,我们也可以定义一个接受Option
类型参数的操作:
def search2(name: Option[String], country: String) = Action {
val result = name match{
case Some(n) => Artist.fetchByNameOrCountry(n, country)
case None => Artist.fetchByCountry(country)
}
if(result.isEmpty){
NoContent
}
else {
Ok(views.html.home(result))
}
}
然后,路由将如下所示:
GET /api/search2/artist controllers.Application.search2(name:Option[String],country:String)
我们现在可以带或不带国家名称进行请求:
http://localhost:9000/api/search2/artist?country=Austria
http://localhost:9000/api/search2/artist?name=Franz&country=Austria
在本节中显示的示例中,我们不需要使用请求来生成我们的结果,但在某些情况下,我们会使用请求来生成相关结果。然而,为了做到这一点,理解请求内容的格式至关重要。我们将在下一节中看到这是如何完成的。
请求体解析器
考虑到任何应用程序中最常见的 POST 请求——用于登录的请求。如果请求体包含用户的凭据,比如 JSON 或 XML 格式,这将是足够的吗?请求处理器能否直接提取这些数据并处理它们?不,因为请求中的数据必须被应用程序代码理解,它必须被转换为兼容的类型。例如,发送到 Scala 应用程序的 XML 必须转换为 Scala XML。
有几个库,例如 Jackson、XStream 等,可以用来完成这个任务,但我们不需要它们,因为 Play 内部支持这个功能。Play 提供了请求体解析器,可以将请求体转换为常用内容类型的等效 Scala 对象。此外,我们还可以扩展现有解析器或定义新的解析器。
每个 Action 都有一个解析器。我如何知道这一点?嗯,我们用来定义应用应该如何响应的 Action 对象,仅仅是 Action 特质的扩展,并且定义如下:
trait Action[A] extends EssentialAction {
//Type of the request body.
type BODY_CONTENT = A
//Body parser associated with this action.
def parser: BodyParser[A]
//Invokes this action
def apply(request: Request[A]): Future[Result]
def apply(rh: RequestHeader): Iteratee[Array[Byte], Result] = parser(rh).mapM {
case Left(r) =>
Future.successful(r)
case Right(a) =>
val request = Request(rh, a)
Play.maybeApplication.map { app =>
play.utils.Threads.withContextClassLoader(app.classloader) {
apply(request)
}
}.getOrElse {
apply(request)
}
}(executionContext)
//The execution context to run this action in
def executionContext: ExecutionContext = play.api.libs.concurrent.Execution.defaultContext
//Returns itself, for better support in the routes file.
override def apply(): Action[A] = this
override def toString = {
"Action(parser="+ parser + ")"
}
}
apply
方法转换解析器返回的值。解析器返回的值可以是结果或请求体(表示为Either[Result,A]
)。
因此,转换被定义为两种可能的结果。如果我们进行模式匹配,我们得到Left(r)
,这是一个结果类型,以及Right(a)
,这是请求体。
mapM
方法与map
方法功能相似,唯一的区别是它是异步执行的。
然而,即使没有解析器,也可以定义 Action 吗?是的,也可以不是。
让我们看看一个示例 Action:一个 POST 请求,这是订阅更新的必需请求。这个请求接受用户的电子邮件 ID 作为查询参数,这意味着我们需要访问请求体以完成此用户的订阅。首先,我们将检查在不指定解析器的情况下请求体看起来像什么。在控制器中创建一个名为subscribe
的 Action,如下所示:
def subscribe = Action {
request =>
Ok("received " + request.body)
}
现在,在路由文件中为这个添加一个条目:
POST /subscribe controllers.AppController.subscribe
然后,运行应用程序。使用 REST 客户端或 Curl(您更习惯哪个)向http://localhost:9000/subscribe
发送带有userId@gmail.com
电子邮件 ID 的 POST 请求。
例如:
curl 'http://localhost:9000/subscribe' -H 'Content-Type: text/plain;charset=UTF-8' --data-binary 'userId@gmail.com'
这个请求的响应将是以下内容:
received AnyContentAsText(userId@gmail.com)
你注意到我们的subscribe
方法理解了内容是文本吗?请求体被转换成了AnyContentAsText(userId@gmail.com)
。我们的方法是如何确定这一点的?这不是映射到特定 Action 的解析器的职责吗?
当为 Action 未指定解析器时,BodyParsers.parse.anyContent
方法返回的解析器被设置为该 Action 的解析器。这是由ActionBuilder
处理的,我们将在本章后面看到。以下代码片段显示了在没有提供解析器时生成 Action 的一种方法:
final def apply(block: R[AnyContent] => Result): Action[AnyContent] = apply(BodyParsers.parse.anyContent)(block)
现在,让我们来检查一下BodyParsers.parse.anyContent
方法的作用:
def anyContent: BodyParser[ AnyContent] = BodyParser("anyContent") { request =>
import play.api.libs.iteratee.Execution.Implicits.trampoline
request.contentType.map(_.toLowerCase(Locale.ENGLISH)) match {
case _ if request.method == HttpVerbs.GET || request.method == HttpVerbs.HEAD => {
Play.logger.trace("Parsing AnyContent as empty")
empty(request).map(_.right.map(_ => AnyContentAsEmpty))
}
case Some("text/plain") => {
Play.logger.trace("Parsing AnyContent as text")
text(request).map(_.right.map(s => AnyContentAsText(s)))
}
case Some("text/xml") | Some("application/xml") | Some(ApplicationXmlMatcher()) => {
Play.logger.trace("Parsing AnyContent as xml")
xml(request).map(_.right.map(x => AnyContentAsXml(x)))
}
case Some("text/json") | Some("application/json") => {
Play.logger.trace("Parsing AnyContent as json")
json(request).map(_.right.map(j => AnyContentAsJson(j)))
}
case Some("application/x-www-form-urlencoded") => {
Play.logger.trace("Parsing AnyContent as urlFormEncoded")
urlFormEncoded(request).map(_.right.map(d => AnyContentAsFormUrlEncoded(d)))
}
case Some("multipart/form-data") => {
Play.logger.trace("Parsing AnyContent as multipartFormData")
multipartFormData(request).map(_.right.map(m => AnyContentAsMultipartFormData(m)))
}
case _ => {
Play.logger.trace("Parsing AnyContent as raw")
raw(request).map(_.right.map(r => AnyContentAsRaw(r)))
}
}
}
首先,它检查请求类型是否支持在请求中发送数据。如果不支持,它返回AnyContentAsEmpty
(你可以通过在路由文件中将请求类型改为 GET 并发送 GET 请求来检查这一点),否则它将请求的内容类型 Header 与支持的类型进行比较。如果找到匹配项,它将数据转换成相应的类型并返回,否则它将解析为字节并返回play.api.mvc.RawBuffer
。
注意
AnyContentAsEmpty
、AnyContentAsText
、AnyContentAsXml
、AnyContentAsJson
、AnyContentAsFormUrlEncoded
、AnyContentAsMultipartFormData
和AnyContentAsRaw
都扩展了AnyContent
特质。
因此,当为支持的某一种内容类型定义了 Action 或者当它是 GET/HEAD 请求时,我们不需要提及解析器。
让我们看看如何在我们的 Action 中访问请求体。我们现在可以更新我们的subscrib
e
方法:
def subscribe = Action {
request =>
val reqBody: AnyContent = request.body
val textContent: Option[String] = reqBody.asText
textContent.map {
emailId =>
Ok("added " + emailId + " to subscriber's list")
}.getOrElse {
BadRequest("improper request body")
}
}
为了访问请求体中的数据,我们需要使用asText
方法将其从AnyContent
转换为Option[String]
。如果我们在 Action 定义中添加解析器,这将变得更加简洁:
def subscribe = Action(parse.text) {
request =>
Ok("added " + request.body + " to subscriber's list")
}
urlFormEncoded
文本 XML 解析器返回标准的 Scala 对象,而其他解析器返回 Play 对象。
我们可以假设订阅请求采用以下格式的 JSON:
{"emailId": "userId@gmail.com", " interval": "month"}
现在,我们需要修改我们的subscribe
方法为def subscribe = Action(parse.json) {
,如下所示:
request =>
val reqData: JsValue = request.body
val emailId = (reqData \ "emailId").as[String]
val interval = (reqData \ "interval").as[String]
Ok(s"added $emailId to subscriber's list and will send updates every $interval")
}
对于以下请求:
curl 'http://localhost:9000/subscribe' -H 'Content-Type: text/json' --data-binary '{"emailId": "userId@gmail.com", "interval": "month"}'
我们得到以下响应:
将userId@gmail.com
添加到订阅者列表,并将每月发送更新
parse.json
将请求体转换为play.api.libs.json.JsValue
。\
运算符用于访问特定键的值。同样,还有一个\\
运算符,它可以用于键的值,尽管它可能不是当前节点的直接子节点。Play-Json 有几个简化 JSON 格式数据处理的方法,例如修改结构或将其转换为 Scala 模型等。Play-Json 也可以作为一个独立的库使用,以便在非 Play 项目中使用。其文档可在www.playframework.com/documentation/2.3.x/ScalaJson
找到。
现在,让我们看看如何编写一个添加新用户的 Action,它接受内容类型为 multipart 的请求:
import java.io.File
def createProfile = Action(parse.multipartFormData) {
request =>
val formData = request.body.asFormUrlEncoded
val email: String = formData.get("email").get(0)
val name: String = formData.get("name").get(0)
val userId: Long = User(email, name).save
request.body.file("displayPic").map {
picture =>
val path = "/socialize/user/"
if (!picture.filename.isEmpty) {
picture.ref.moveTo(new File(path + userId + ".jpeg"))
}
Ok("successfully added user")
}.getOrElse {
BadRequest("failed to add user")
}
}
请求有三个字段:email
、name
和displayPic
。从请求数据中,我们获取电子邮件、姓名并添加新用户。User.
save
方法在用户表中添加条目,如果存在具有相同电子邮件 ID 的用户,则抛出错误。这就是为什么文件中的操作只在添加用户后执行。displayPic
是可选的;因此,在保存图像之前,会检查其长度是否大于零。
备注
在文件相关操作之前完成数据事务会更好,因为它们可能会失败,而对于不正确的请求,可能不需要文件相关操作。以下表格显示了支持的内容类型、解析器和它们的默认转换。
内容类型 | 解析器 | 解析为 Scala 类型 |
---|---|---|
text/plain |
text |
String |
application/json 或text/json |
json |
play.api.libs.json.JsValue |
application/xml , text/xml , or application/XXX+xml |
xml |
NodeSeq |
application/form-url-encoded |
urlFormEncoded |
Map[String, Seq[String]] |
multipart/form-data |
multipartFormData |
play.api.mvc.MultipartFormData[TemporaryFile] |
other |
raw |
Play.api.mvc.RawBuffer |
扩展解析器
让我们扩展 JSON 解析器,以便我们得到一个订阅模型。我们将假设Subscription
模型如下定义:
case class Subscription(emailId: String, interval: String)
现在,让我们编写一个解析器,将请求体转换为订阅对象。以下代码应在控制器中编写:
val parseAsSubscription = parse.using {
request =>
parse.json.map {
body =>
val emailId:String = (body \ "emailId").as[String]
val fromDate:Long = (body \ "fromDate").as[Long]
Subscription(emailId, fromDate)
}
}
implicit val subWrites = Json.writes[Subscription]
def getSub = Action(parseAsSubscription) {
request =>
val subscription: Subscription = request.body
Ok(Json.toJson(subscription))
}
此外,还有宽容的解析器。宽容的意思是,格式中的错误不会被忽略。这仅仅意味着它忽略了请求中的内容类型头,并基于指定的类型进行解析。例如,让我们更新subscribe
方法:
def subscribe = Action(parse.tolerantJson) {
request =>
val reqData: JsValue = request.body
val emailId = (reqData \ "email").as[String]
val interval = (reqData \ "interval").as[String]
Ok(s"added $emailId to subscriber's list and will send updates every $interval")
}
现在,一个内容类型为文本的请求和一个内容类型为 text/JSON 或任何其他类型的请求将给出相同的结果。Play 支持的所有基本解析器都有宽容的解析器。
探索结果
在 Play 中,对请求的响应是一个结果。结果有两个组成部分:响应头和响应体。让我们看看这个简单的例子:
def plainResult = Action {
Result(
header = ResponseHeader(200, Map(CONTENT_TYPE -> "text/plain")),
body = Enumerator("This is the response from plainResult method".getBytes())
)
}
注意,我们使用了枚举来表示响应体。枚举是一种向迭代器提供数据的方式。我们将在第六章“反应式数据流”中详细讨论这些内容。
除了这个之外,结果还有额外的功能,这些功能为我们提供了更好的手段来处理响应头、会话、cookie 等。
结果可以发送 JSON、XML 和图像作为响应,除了字符串内容。生成结果的一个更简单的方法是使用结果助手。结果助手用于大多数 HTTP 响应状态。作为一个例子,让我们看看 Play 内置的TODO
动作是如何实现的:
val TODO = Action {
NotImplementedplay.api.templates.Html)
}
在这个片段中,NotImplemented
是一个助手,它返回一个状态为 501 的结果,而views.html.defaultpages.todo()
返回默认页面,即todo.scala.html
。
作为例子,我们将考虑发送用户个人头像的 Action。现在的 Action 将是这样的:
def getUserImage(userId: Long) = Action {
val path: String = s"/socialize/user/$userId.jpeg"
val img = new File(path)
if (img.exists()) {
Ok.sendFile(
content = img,
inline = true
)
}
else
NoContent
}
在这里,我们尝试使用预定义的getUserImagePath
方法加载用户的个人头像。如果图像文件存在并附加到响应中,我们返回一个状态码为204
的响应。
我们还看到了如何使用结果助手发送页面内容,无论是静态的还是动态的,使用视图:
def listArtist = Action {
Ok(views.html.home(Artist.fetch))
}
我们还可以使用Status
类来生成结果,如下所示:
def save = Action(parse.text) {
request =>
Status(200)("Got: " + request.body)
}
此表显示了结果助手及其对应的状态码:
结果助手 | 状态码常量 | 状态码 |
---|---|---|
– | CONTINUE |
100 |
– | SWITCHING_PROTOCOLS |
101 |
Ok |
OK |
200 |
Created |
CREATED |
201 |
Accepted |
ACCEPTED |
202 |
NonAuthoritativeInformation |
NON_AUTHORITATIVE_INFORMATION |
203 |
NoContent |
NO_CONTENT |
204 |
ResetContent |
RESET_CONTENT |
205 |
PartialContent |
PARTIAL_CONTENT |
206 |
MultiStatus |
MULTI_STATUS |
207 |
– | MULTIPLE_CHOICES |
300 |
MovedPermanently |
MOVED_PERMANENTLY |
301 |
Found |
FOUND |
302 |
SeeOther |
SEE_OTHER |
303 |
NotModified |
NOT_MODIFIED |
304 |
– | USE_PROXY |
305 |
TemporaryRedirect |
TEMPORARY_REDIRECT |
307 |
BadRequest |
BAD_REQUEST |
400 |
Unauthorized |
UNAUTHORIZED |
401 |
– | PAYMENT_REQUIRED |
402 |
Forbidden |
FORBIDDEN |
403 |
NotFound |
NOT_FOUND |
404 |
MethodNotAllowed |
METHOD_NOT_ALLOWED |
405 |
NotAcceptable |
NOT_ACCEPTABLE |
406 |
– | PROXY_AUTHENTICATION_REQUIRED |
407 |
RequestTimeout |
REQUEST_TIMEOUT |
408 |
Conflict |
CONFLICT |
409 |
Gone |
GONE |
410 |
– | LENGTH_REQUIRED |
411 |
PreconditionFailed |
PRECONDITION_FAILED |
412 |
EntityTooLarge |
REQUEST_ENTITY_TOO_LARGE |
413 |
UriTooLong |
REQUEST_URI_TOO_LONG |
414 |
UnsupportedMediaType |
UNSUPPORTED_MEDIA_TYPE |
415 |
– | REQUESTED_RANGE_NOT_SATISFIABLE |
416 |
ExpectationFailed |
EXPECTATION_FAILED |
417 |
UnprocessableEntity |
UNPROCESSABLE_ENTITY |
422 |
Locked |
LOCKED |
423 |
FailedDependency |
FAILED_DEPENDENCY |
424 |
TooManyRequest |
TOO_MANY_REQUEST |
429 |
InternalServerError |
INTERNAL_SERVER_ERROR |
500 |
NotImplemented |
NOT_IMPLEMENTED |
501 |
BadGateway |
BAD_GATEWAY |
502 |
ServiceUnavailable |
SERVICE_UNAVAILABLE |
503 |
GatewayTimeout |
GATEWAY_TIMEOUT |
504 |
HttpVersionNotSupported |
HTTP_VERSION_NOT_SUPPORTED |
505 |
InsufficientStorage |
INSUFFICIENT_STORAGE |
507 |
异步操作
假设我们正在美食广场,并在一个亭子点餐,我们得到了一个凭证和账单。稍后,当订单准备好时,亭子闪烁凭证号码,当我们注意到它时,我们取走订单。
这类似于具有异步响应周期的请求,其中亭子充当服务器,订单类似于请求,凭证作为承诺,当订单准备好时得到解决。
大多数操作最好异步处理。这也通常是首选,因为它不会在操作完成之前阻塞服务器资源。
Play Action 是一个辅助对象,它扩展了 ActionBuilder
特性。ActionBuilder
特性的 apply
方法实现了我们之前看到的 Action
特性。让我们看看 ActionBuilder
特性中的相关代码:
trait ActionBuilder[+R[_]] extends ActionFunction[Request, R] {
self =>
final def applyA(block: R[A] =>
Result): Action[A] = async(bodyParser) { req: R[A] =>
Future.successful(block(req))
}
final def asyncA(block: R[A] => Future[Result]): Action[A] = composeAction(new Action[A] {
def parser = composeParser(bodyParser)
def apply(request: Request[A]) = try {
invokeBlock(request, block)
} catch {
// NotImplementedError is not caught by NonFatal, wrap it
case e: NotImplementedError => throw new RuntimeException(e)
// LinkageError is similarly harmless in Play Framework, since automatic reloading could easily trigger it
case e: LinkageError => throw new RuntimeException(e)
}
override def executionContext = ActionBuilder.this.executionContext
})
...
}
注意,apply
方法本身在内部调用 async
方法。async
方法期望我们定义 Action,这会导致 Future[Result]
,从而帮助我们编写非阻塞代码。
我们将使用相同的方法来定义一个异步 Action。假设我们需要从远程客户端获取请求的文件,整合/分析数据,然后发送结果。由于我们不知道文件的大小以及与远程客户端的网络连接状态,最好异步处理 Action。Action 将以这种方式定义:
def getReport(fileName:String ) = Action.async {
Future {
val file:File = new File(fileName)
if (file.exists()) {
val info = file.lastModified()
Ok(s"lastModified on ${new Date(info)}")
}
else
NoContent
}
}
在获取文件后,如果它是空的,我们发送一个状态码为 204 的响应,否则我们继续处理并发送处理后的数据作为结果的一部分。
我们可能会遇到一个实例,就像我们在前面的例子中看到的那样,我们不想等待远程客户端获取文件超过 10 秒。在这种情况下,我们需要以这种方式修改操作定义:
def getReport(fileName: String) = Action.async {
val mayBeFile = Future {
new File(fileName)
}
val timeout = play.api.libs.concurrent.Promise.timeout("Past max time", 10, TimeUnit.SECONDS)
Future.firstCompletedOf(Seq(mayBeFile, timeout)).map {
case f: File =>
if (f.exists()) {
val info = f.lastModified()
Ok(s"lastModified on ${new Date(info)}")
}
else
NoContent
case t: String => InternalServerError(t)
}
}
因此,如果远程客户端在 10 秒内没有响应请求的文件,我们将收到一个状态码为 500 的响应,内容是我们为超时设置的消息,“超过最大时间”。
内容协商
根据 HTTP 协议:
内容协商是在有多种表示形式可供选择时,选择给定响应的最佳表示的过程。
它可以是服务器驱动的,也可以是代理驱动的,或者两者的组合,这被称为透明协商。Play 提供了对服务器驱动协商的支持。这由渲染特性处理,并由控制器特性扩展。控制器特性是 Play 应用程序中控制器对象扩展的地方。
让我们看看 Rendering
特性:
trait Rendering {
object render {
//Tries to render the most acceptable result according to the request's Accept header value.
def apply(f: PartialFunction[MediaRange, Result])(implicit request: RequestHeader): Result = {
def _render(ms: Seq[MediaRange]): Result = ms match {
case Nil => NotAcceptable
case Seq(m, ms @ _*) =>
f.applyOrElse(m, (m: MediaRange) => _render(ms))
}
// "If no Accept header field is present, then it is assumed that the client accepts all media types."
val result =
if (request.acceptedTypes.isEmpty) _render(Seq(new MediaRange("*", "*", Nil, None, Nil)))
else _render(request.acceptedTypes)
result.withHeaders(VARY -> ACCEPT)
}
/**Tries to render the most acceptable result according to the request's Accept header value.
* This function can be used if you want to do asynchronous processing in your render function.
*/
def async(f: PartialFunction[MediaRange, Future[Result]])(implicit request: RequestHeader): Future[Result] = {
def _render(ms: Seq[MediaRange]): Future[Result] = ms match {
case Nil => Future.successful(NotAcceptable)
case Seq(m, ms @ _*) =>
f.applyOrElse(m, (m: MediaRange) => _render(ms))
}
// "If no Accept header field is present, then it is assumed that the client accepts all media types."
val result =
if (request.acceptedTypes.isEmpty) _render(Seq(new MediaRange("*", "*", Nil, None, Nil)))
else _render(request.acceptedTypes)
result.map(_.withHeaders(VARY -> ACCEPT))
}
}
}
在 apply
方法中定义的 _render
方法会在请求的接受头中调用部分 f
函数。如果没有为任何接受头定义 f
,则转发状态码为 406 的响应。如果定义了,则返回 f
对第一个定义了 f
的接受头的第一个结果。
由于控制器扩展了渲染特性,我们可以在我们的操作定义中使用渲染对象。例如,我们可能有一个操作,在从具有 XML 格式的文件中读取配置后,根据请求的接受头以 JSON 和 XML 格式获取配置。让我们看看这是如何实现的:
def getConfig = Action {
implicit request =>
val xmlResponse: Node = <metadata>
<company>TinySensors</company>
<batch>md2907</batch>
</metadata>
val jsonResponse = Json.obj("metadata" -> Json.arr(
Json.obj("company" -> "TinySensors"),
Json.obj("batch" -> "md2907"))
)
render {
case Accepts.Xml() => Ok(xmlResponse)
case Accepts.Json() => Ok(jsonResponse)
}
}
在这个片段中,Accepts.Xml()
和 Accepts.Json()
是 Play 的辅助方法,用于检查请求是否接受 application/xml
和 application/json
类型的响应。目前有四个预定义的接受类型,如下表所示:
请求接受辅助 | 接受头值 |
---|---|
XML | application/xml |
JSON | application/json |
HTML | text/html |
JavaScript | text/javascript |
这由 RequestExtractors
特性和 AcceptExtractors
特性促进。RequestExtractors
也由控制器特性扩展。让我们看看这里的提取器特性:
trait RequestExtractors extends AcceptExtractors {
//Convenient extractor allowing to apply two extractors.
object & {
def unapply(request: RequestHeader): Option[(RequestHeader, RequestHeader)] = Some((request, request))
}
}
//Define a set of extractors allowing to pattern match on the Accept HTTP header of a request
trait AcceptExtractors {
//Common extractors to check if a request accepts JSON, Html, etc.
object Accepts {
import play.api.http.MimeTypes
val Json = Accepting(MimeTypes.JSON)
val Html = Accepting(MimeTypes.HTML)
val Xml = Accepting(MimeTypes.XML)
val JavaScript = Accepting(MimeTypes.JAVASCRIPT)
}
}
//Convenient class to generate extractors checking if a given mime type matches the Accept header of a request.
case class Accepting(val mimeType: String) {
def unapply(request: RequestHeader): Boolean = request.accepts(mimeType)
def unapply(mediaRange: play.api.http.MediaRange): Boolean = mediaRange.accepts(mimeType)
}
从这段代码中,我们只需要定义一个自定义接受值,这是我们期望请求的接受头将具有的值。例如,为了定义 image/png
的辅助程序,我们使用以下代码:
val AcceptsPNG = Accepting("image/png")
我们还注意到 RequestExtractors
有一个 &
对象,我们可以在希望向多个接受类型发送相同响应时使用它。因此,在前面代码中显示的 getConfig
方法中,如果为 application/json
和 text/javascript
发送相同的响应,我们将对其进行如下修改:
def fooBar = Action {
implicit request =>
val xmlResponse: Node = <metadata>
<company>TinySensors</company>
<batch>md2907</batch>
</metadata>
val jsonResponse = Json.obj("metadata" -> Json.arr(
Json.obj("company" -> "TinySensors"),
Json.obj("batch" -> "md2907"))
)
render {
case Accepts.Xml() => Ok(xmlResponse)
case Accepts.Json() & Accepts.JavaScript() => Ok(jsonResponse)
}
}
当定义异步操作时,render
对象可以被类似地使用。
过滤器
在大多数应用程序中,我们需要对所有请求执行相同的操作。我们可能在定义了所有应用程序所需的动作之后,在稍后阶段需要向所有响应中添加一些字段。
那么,在这种情况下,我们是否需要更新所有的动作?
不。这就是过滤器 API 来拯救我们的地方。我们不需要修改定义我们的动作的方式来解决该问题。我们只需要定义一个过滤器并使用它。
让我们看看我们如何定义我们的过滤器:
import org.joda.time.DateTime
import org.joda.time.format.DateTimeFormat
import play.api.mvc._
import play.api.http.HeaderNames._
import play.api.libs.concurrent.Execution.Implicits.defaultContext
object HeadersFilter {
val noCache = Filter {
(nextFilter, rh) =>
nextFilter(rh) map {
case result: Result => addNoCacheHeaders(result)
}
}
private def addNoCacheHeaders(result: Result): Result = {
result.withHeaders(PRAGMA -> "no-cache",
CACHE_CONTROL -> "no-cache, no-store, must-revalidate, max-age=0",
EXPIRES -> serverTime)
}
private def serverTime = {
val now = new DateTime()
val dateFormat = DateTimeFormat.forPattern(
"EEE, dd MMM yyyy HH:mm:ss z")
dateFormat.print(now)
}
}
HeadersFilter.noCache
过滤器将所有需要禁用浏览器缓存的头信息添加到响应中。PRAGMA
、CACHE_CONTROL
和 EXPIRES
是由 play.api.http.HeaderNames
提供的常量。
现在,为了使用这个过滤器,我们需要更新应用程序的全局设置。
任何基于 Play 的应用程序的全局设置都可以使用一个全局对象进行配置。这是一个名为 Global
的对象,位于应用程序目录中。我们将在 第七章 玩转全局设置 中了解更多关于全局设置的信息。
定义过滤器应该如何使用有两种方式。这些是:
-
为全局对象扩展
WithFilters
类而不是GlobalSettings
。 -
在全局对象中手动调用过滤器。
使用
WithFilters
的一个例子是:object Global extends WithFilters(HeadersFilter.noCache) { // ... }
现在,让我们看看如何手动完成这个操作:
object Global extends GlobalSettings { override def doFilter(action: EssentialAction): EssentialAction = HeadersFilter.noCache(action) }
在 Play 中,过滤器定义的方式类似于动作——有一个过滤器特质,它扩展了
EssentialFilter
,还有一个辅助过滤器对象。辅助过滤器定义如下:object Filter { def apply(filter: (RequestHeader => Future[Result], RequestHeader) => Future[Result]): Filter = new Filter { def apply(f: RequestHeader => Future[Result])(rh: RequestHeader): Future[Result] = filter(f, rh) } }
在这个代码片段中,
apply
方法调用一个新的过滤器,即过滤器特质。可以为一个单一的应用程序应用多个过滤器。如果使用
WithFilters
,它们将按照指定的顺序应用。如果手动设置,我们可以使用WithFilters
类的apply
方法的内部使用的过滤器对象。Filters
对象定义如下:object Filters { def apply(h: EssentialAction, filters: EssentialFilter*) = h match { case a: EssentialAction => FilterChain(a, filters.toList) case h => h } }
FilterChain
是另一个辅助对象,用于从EssentialAction
和多个EssentialFilters
的组合中组合EssentialAction
:object FilterChain { def applyA: EssentialAction = new EssentialAction { def apply(rh: RequestHeader): Iteratee[Array[Byte], Result] = { val chain = filters.reverse.foldLeft(action) { (a, i) => i(a) } chain(rh) } } }
-
当需要对所有路由进行无差别操作时,推荐使用过滤器。Play 提供了一个过滤器模块,其中包括
GzipFilter
、SecurityHeadersFilter
和CSRFFilter
。
动作组合
定义一个请求的动作仅仅是使用定义如下所示的动作辅助对象的行为:
object Action extends ActionBuilder[Request] {
def invokeBlockA => Future[Result]) = block(request)
}
在动作块中编写的代码将用于 invokeBlock
方法。该方法是从 ActionBuilder
继承的。这是一个提供生成动作的辅助方法的特质。我们定义动作的所有不同方式,如异步、同步、指定或不指定解析器等,都在 ActionBuilder
中声明。
我们还可以通过扩展 ActionBuilder
并定义一个自定义的 invoke
块来定义我们自己的动作。
动作组合的需要
让我们通过一个案例研究来探讨。如今,许多应用程序都会跟踪请求,例如请求发起的机器的 IP 地址、接收时间,甚至整个请求本身。在几乎每个为这种应用程序定义的动作中添加相同的代码将是一种罪行。
现在,假设我们需要在每次遇到特定模块(例如管理员用户)时使用persistReq
方法持久化请求,那么在这种情况下,我们可以定义一个仅在此模块内使用的自定义动作。让我们看看我们如何定义一个在处理之前持久化请求的自定义动作:
import play.api.mvc._
import scala.concurrent.Future
object TrackAction extends ActionBuilder[Request] {
override protected def invokeBlockA => Future[Result]) = {
persistReq(request)
block(request)
}
private def persistReqA = {
...
}
}
在我们的应用程序中,我们可以像使用默认动作一样使用它:
def viewAdminProfile(id: Long) = TrackAction {
request =>
...
}
def updateAdminProfile(id: Long) = TrackAction(parse.json) {
request =>
...
}
定义自定义动作的另一种方式是通过扩展动作特性。因此,我们也可以如下定义TrackAction
:
case class TrackActionA extends Action[A] {
def apply(request: Request[A]): Future[Result] = {
persistReq(request)
action(request)
}
private def persistReq(request: Request[A]) = {
…
}
lazy val parser = action.parser
}
它的使用方式类似于以下:
def viewAdminProfile(id: Long) = TrackAction {
Action {request =>
…
}
}
def updateAdminProfile(id: Long) = TrackAction {
Action(parse.json) { request =>
…
}
}
注意,我们需要再次在动作对象内部包装动作定义。我们可以通过定义ActionBuilder
来移除每次包装动作对象时的额外开销,它使用composeAction
方法:
object TrackingAction extends ActionBuilder[Request] {
def invokeBlockA => Future[Result]) = {
block(request)
}
override def composeActionA = new TrackAction(action)
}
现在,使用方式如下:
def viewAdminProfile(id: Long) = TrackingAction {
request =>
...
}
def updateAdminProfile(id: Long) = TrackingAction(parse.json) {
request =>
...
}
区分动作组合和过滤器
动作组合是一个扩展EssentialAction
并返回结果的动作。当我们需要在少数几个路由或动作上执行操作时,它更为合适。动作组合比过滤器更强大,更擅长处理特定问题,例如身份验证。
它提供了读取、修改甚至阻止请求的支持。还有定义自定义请求类型动作的条款。
自定义请求
首先,让我们看看如何定义自定义请求。我们也可以使用WrappedRequest
类来定义自定义请求。这被定义为如下:
class WrappedRequestA extends Request[A] {
def id = request.id
def tags = request.tags
def body = request.body
def headers = request.headers
def queryString = request.queryString
def path = request.path
def uri = request.uri
def method = request.method
def version = request.version
def remoteAddress = request.remoteAddress
def secure = request.secure
}
假设我们希望将接收请求的时间与每个请求一起传递,我们可以这样定义:
class TimedRequestA extends WrappedRequestA
现在,让我们看看我们如何操作传入的请求并将它们转换为TimedRequest
:
def timedActionA = Action.async(action.parser) {
request =>
val time = new DateTime()
val newRequest = new AppRequest(time, request)
action(newRequest)
}
因此,timedAction
动作可以以这种方式在控制器中使用:
def getUserList = timedAction {
Action {
request =>
val users= User.getAll
Ok(Json.toJson(users))
}
}
现在,假设我们希望阻止来自某些浏览器的所有请求;可以这样做:
def timedActionA = Action.async(action.parser) {
request =>
val time = new DateTime()
val newRequest = new AppRequestA
request.headers.get(USER_AGENT).collect {
case agent if isCompatibleBrowser(agent) =>
action(newRequest)
}.getOrElse{
Future.successful(Ok(views.html.main()))
}
}
这里,isCompatibleBrowser
方法检查浏览器是否受支持。
我们还可以操作响应;让我们在响应头中添加处理请求所需的时间:
def timedActionA = Action.async(action.parser) {
request =>
val time = new DateTime()
val newRequest = new AppRequest(time, request)
action(newRequest).map(_.withHeaders("processTime" -> new DateTime().minus(time.getMillis).getMillis.toString()))
}
现在,让我们看看我们如何定义一个用于自定义请求的动作。你可能想知道为什么我们需要自定义请求。以我们的应用程序为例,它为用户提供电子邮件、聊天、阻止、上传、分享等功能。在这种情况下,我们可以将这些功能与用户对象绑定,以便在内部请求中包含用户对象。
需要用户对象的原因
我们的 REST API 只发送userId
,这是一个数字。对于所有这些操作,我们需要用户的emailId
、userName
和可选的个人信息图片。让我们以下述方式定义UserRequest
:
class UserRequestA extends WrappedRequestA
现在,让我们定义一个使用此请求的动作:
def UserAction(userId: Long) = new ActionBuilder[UserRequest] {
def invokeBlockA => Future[Result]) = {
User.findById(userId).map { user:User =>
block(new UserRequest(user, request))
} getOrElse {
Future.successful(Redirect(views.html.login))
}
}
}
因此,在我们的 Action 中,我们找到与给定userId
对应的用户,否则重定向到登录页面。
在这里,我们可以看到如何使用UserAction
:
def initiateChat(userId:Long,chatWith:Long) = UserRequest{
request=>
val status:Boolean = ChatClient.initiate(request.user,chatWith)
if(status){
Ok
}else{
Unauthorized
}
}
聊天客户端通过userId.chatWith
方法向用户发送消息,表明一个用户(其配置文件为request.user
)想要聊天。如果另一个用户同意,则返回true
,否则返回false
。
故障排除
你可能会遇到以下需要故障排除的场景:
-
编译时遇到错误:在这里找不到任何 HTTP 请求头。
即使你已经使用
RequestHeader
定义了 Action,你仍然会得到这个错误。Play 中处理请求的大多数方法都期望一个隐式的
RequestHeader
。遵循这一惯例是为了保持代码简单。例如,让我们看看这里的控制器特质:trait Controller extends Results with BodyParsers with HttpProtocol with Status with HeaderNames with ContentTypes with RequestExtractors with Rendering { //Provides an empty `Action` implementation: the result is a standard 'Not implemented yet' result page. val TODO = Action { NotImplementedplay.api.templates.Html) } //Retrieves the session implicitly from the request. implicit def session(implicit request: RequestHeader) = request.session //Retrieve the flash scope implicitly from the request. implicit def flash(implicit request: RequestHeader) = request.flash implicit def lang(implicit request: RequestHeader) = { play.api.Play.maybeApplication.map { implicit app => val maybeLangFromCookie = request.cookies.get(Play.langCookieName).flatMap(c => Lang.get(c.value)) maybeLangFromCookie.getOrElse(play.api.i18n.Lang.preferred(request.acceptLanguages)) }.getOrElse(request.acceptLanguages.headOption.getOrElse(play.api.i18n.Lang.defaultLang)) } }
注意,
session
、flash
和lang
方法接受一个隐式参数,例如请求,它是RequestHeader
。在这些情况下,我们需要在我们的 Action 定义中标记请求头为隐式。通常,在 Play 应用程序中标记所有请求头为隐式更安全。因此,为了修复这个错误,我们需要修改我们的Action
定义如下:def foo = Action { implicit request => … }
-
我的 GET 请求的请求体没有被解析。你可能想知道为什么。GET 请求不应该有请求体。尽管 HTTP 规范对此并不明确,但通常情况下,浏览器不会转发请求体。Play 请求体解析器在解析之前会检查请求是否允许有请求体,也就是说,请求不是 GET 请求。
在你的 GET 和 DELETE 请求中避免请求体更好。如果你需要向这些请求添加请求体,也许你应该重新设计你的应用程序的 REST API。
-
你无法使用 Play 过滤器:
GzipFilter
、SecurityHeadersFilter
或CSRFFilter
。你得到一个错误:对象filters
不是包play
的成员,在行import play.filters
中。Filters 是一个独立的模块,需要显式包含。你应该在
build.sbt
文件中将它添加为libraryDependencies += filters
,然后重新加载项目。 -
使用 Future 时遇到编译错误:如果你找不到隐式的
ExecutionContext
,要么为自己要求一个,要么导入ExecutionContext.Implicits.global
。然而,为什么要这样做呢?未来需要
ExecutionContext
,它定义了线程池,线程将用于操作。因此,如果没有为 Future 提供ExecutionContext
,你可能会遇到编译错误。有关更多信息,请参阅Scala 文档中的 Futures部分docs.scala-lang.org/overviews/core/futures.html
。 -
使用 JSON 解析器时遇到运行时错误:
JsResultException
:JsResultException(errors:List((,List(ValidationError(error.expected.jsstring,WrappedArray())))))]
这通常发生在从 JSON 中提取的字段不在请求体中时。这可能是由于存在拼写错误,例如,应该是
emailId
,而你可能发送的是电子邮件。你可以使用asOpt
方法代替as
。例如:val emailId = (body\"emailId"). asOpt[String]
然后,如果你或任何字段缺失,你可以抛出一个带有友好信息的错误。或者,你可以使用
getOrElse
方法传递默认值。
摘要
在本章中,我们看到了如何定义和扩展控制器的主要组件。我们看到了如何定义一个具有默认解析器和结果的特定应用程序的动作,以及具有自定义解析器和结果的动作。此外,我们还看到了如何使用过滤器和 ActionComposition
来管理特定应用程序的关注点。在这个过程中,我们看到了如何定义一个自定义请求。
第三章。构建路由
在本章中,我们将介绍以下主题:
-
定义应用程序支持的服务
-
接收到的请求流程
-
配置路由
-
处理资产
Play 路由简介
所有支持的路由都指定在单个文件中:routes
(默认)。这使得确定哪个是理想的变得更容易。
如果编译过程中出现错误,routes
文件将编译失败。
然而,routes
文件不是一个 Scala 对象。那么编译器是如何知道如何处理 routes
文件的?为了找出答案,让我们执行以下步骤:
-
让我们创建一个显示 Hello, World! 页面的项目。现在,将主页
index.scala.html
定义如下:<!DOCTYPE html> <html> <head> <title>Home</title> </head> <body> <h1>Hello, World!</h1> </body> </html>
-
我们将在我们的控制器中以这种方式使用它:
package controllers import play.api.mvc._ object AppController extends Controller { def index = Action { Ok(views.html.index()) } }
-
我们只需要在
routes
文件中添加一个条目就可以查看我们的页面:# Home page GET / controllers.AppController.index
-
现在编译项目。你会注意到在
HelloWorld/target/scala-2.10/src_managed/main
目录中现在有一个routes_routing.scala
文件。文件的内容将类似于以下代码片段:import play.core._ import play.core.Router._ import play.core.j._ import play.api.mvc._ import Router.queryString object Routes extends Router.Routes { private var _prefix = "/" def setPrefix(prefix: String) { _prefix = prefix List[(String,Routes)]().foreach { case (p, router) => router.setPrefix(prefix + (if(prefix.endsWith("/")) "" else "/") + p) } } def prefix = _prefix lazy val defaultPrefix = { if(Routes.prefix.endsWith("/")) "" else "/" } // @LINE:5 private[this] lazy val controllers_AppController_index0 = Route("GET", PathPattern(List(StaticPart(Routes.prefix)))) def documentation = List(("""GET""", prefix,"""controllers.AppController.index""")).foldLeft(List.empty[(String,String,String)]) { (s,e) => e.asInstanceOf[Any] match { case r @ (_,_,_) => s :+ r.asInstanceOf[(String,String,String)] case l => s ++ l.asInstanceOf[List[(String,String,String)]] }} def routes:PartialFunction[RequestHeader,Handler] = { // @LINE:5 case controllers_AppController_index0(params) => { call { invokeHandler(controllers.AppController.index, HandlerDef(this, "controllers.AppController", "index", Nil,"GET", """ Routes This file defines all application routes (Higher priority routes first) ~~~~ Home page""", Routes.prefix + """""")) } } } }
因此,Play 从 routes
文件生成 Scala 代码。使用路由文件创建了一个 routes
部分函数。call
方法接受一个返回处理器的函数,并定义传递给它的参数。它被定义为处理 0 到 21 个参数。
invokeHandler
方法定义如下:
def invokeHandlerT(implicit d: HandlerInvoker[T]): Handler = {
d.call(call, handler) match {
case javaAction: play.core.j.JavaAction => new play.core.j.JavaAction with RequestTaggingHandler {
def invocation = javaAction.invocation
val annotations = javaAction.annotations
val parser = javaAction.annotations.parser
def tagRequest(rh: RequestHeader) = doTagRequest(rh, handler)
}
case action: EssentialAction => new EssentialAction with RequestTaggingHandler {
def apply(rh: RequestHeader) = action(rh)
def tagRequest(rh: RequestHeader) = doTagRequest(rh, handler)
}
case ws @ WebSocket(f) => {
WebSocketws.FRAMES_TYPE))(ws.frameFormatter)
}
case handler => handler
}
d.call
(调用和处理)的结果与预定义的 play.core.j.JavaAction
、EssentialAction
和 WebSocket
类型(所有这些都扩展了处理器特质)相匹配,并返回其结果。
HandlerDef
是一个类,其定义如下:
case class HandlerDef(ref: AnyRef, routerPackage: String, controller: String, method: String, parameterTypes: Seq[Class[_]], verb: String, comments: String, path: String)
自动生成 routes_routing.scala
让我们看看 routes_routing.scala
文件是如何生成的。
Play 利用 Simple Build Tool(SBT)提供的功能添加一个源生成任务。源生成任务应在 sourceManaged
的子目录中生成源,并返回生成的文件序列。
SBT 文档可以在 www.scala-sbt.org/0.13.2/docs/Howto/generatefiles.html
找到。
使用情况可以在 PlaySettings.scala
中看到,如下所示:
sourceGenerators in Compile <+= (state, confDirectory, sourceManaged in Compile, routesImport, generateReverseRouter, generateRefReverseRouter, namespaceReverseRouter) map {
(s, cd, sm, ri, grr, grrr, nrr) => RouteFiles(s, Seq(cd), sm, ri, grr, grrr, nrr)
},
RouteFiles
定义在 PlaySourceGenerators
特质中,该特质处理路由和视图的 Scala 代码生成。是的,甚至视图也被转换成 Scala 代码。例如,HelloWorld
项目在 HelloWorld/target/scala-2.10/src_managed/main/views/html
中有一个 index.template.scala
文件。
RouteFiles
的定义调用了 RoutesCompiler.compile
方法,然后返回源将生成的文件路径。compile
方法使用 RouteFileParser
解析文件,然后使用 generateRouter
方法生成 Scala 代码。
反向路由
Play 提供了一种使用 Scala 方法进行 HTTP 调用的功能。对于每个定义的路由,routes_ReverseRouting.scala
文件中都会生成一个等效的 Scala 方法。这在从 Scala 代码(例如视图)内部进行请求时非常方便。
@(tasks: List[Task], taskForm: Form[String])
@import helper._
@main("Task Tracker") {
<h2>Task Tracker</h2>
<div>
@form(routes.TaskController.newTask) {
@taskForm.globalError.map { error =>
<p class="error">
@error.message
</p>
}
<form>
<input type="text" name="taskName" placeholder="Add a new Task" required>
<input type="submit" value="Add">
</form>
}
</div>
<div>
<ul>
@tasks.map { task =>
<li>
@form(routes.TaskController.deleteTask(task.id)) {
@task.name <input type="submit" value="Remove">
}
</li>
}
</ul>
</div>
}
routes_reverseRouting.scala
文件的内容可能类似于以下内容:
import Routes.{prefix => _prefix, defaultPrefix => _defaultPrefix}
import play.core._
import play.core.Router._
import play.core.j._
import play.api.mvc._
import Router.queryString
// @LINE:5
package controllers {
// @LINE:5
class ReverseAppController {
// @LINE:5
def index(): Call = {
Call("GET", _prefix)
}
}
}
// @LINE:5
package controllers.javascript {
// @LINE:5
class ReverseAppController {
// @LINE:5
def index : JavascriptReverseRoute = JavascriptReverseRoute(
"controllers.AppController.index",
"""
function() {
return _wA({method:"GET", url:"""" + _prefix + """"})
}
"""
)
}
}
// @LINE:5
package controllers.ref {
// @LINE:5
class ReverseAppController {
// @LINE:5
def index(): play.api.mvc.HandlerRef[_] = new play.api.mvc.HandlerRef(
controllers.AppController.index(), HandlerDef(this, "controllers.AppController", "index", Seq(), "GET", """ Routes
This file defines all application routes (Higher priority routes first)
~~~~
Home page""", _prefix + """""")
)
}
}
反向路由返回一个调用。调用描述了一个 HTTP 请求,可以用来创建链接或填充和重定向数据。它定义如下:
case class Call(method: String, url: String) extends play.mvc.Call {
//Transform this call to an absolute URL.
def absoluteURL(secure: Boolean = false)(implicit request: RequestHeader) = {
"http" + (if (secure) "s" else "") + "://" + request.host + this.url
}
// Transform this call to an WebSocket URL.
def webSocketURL(secure: Boolean = false)(implicit request: RequestHeader) = {
"ws" + (if (secure) "s" else "") + "://" + request.host + this.url
}
override def toString = url
}
JavaScript 反向路由
在routes_reverseRouting.scala
中,还有一个返回JavascriptReverseRoute
的方法。当我们希望发送请求时,可以在 JavaScript 代码中使用它。然而,在此之前,我们需要定义一个 JavaScript 路由器。我们可以通过定义一个动作并为它添加一个路由来实现,如下例所示:
def javascriptRoutes = Action { implicit request =>
Ok(
Routes.javascriptRouter("jsRouter")(
routes.javascript.index
)
).as("text/javascript")
}
然后,我们可以以这种方式将其包含在路由文件中:
GET /javascriptRoutes controllers.AppController.javascriptRoutes
接下来,我们可以在视图中如下引用它:
<script type="text/javascript" src="img/@routes.AppController.javascriptRoutes"></script>
完成此操作后,在我们的 JavaScript 脚本中可以使用路由器向服务器发送请求,如下所示:
jsRouter.controllers.AppController.index.ajax({
success: function(data) {
console.log("redirect successful");
} ,
error:function(e){
console.log("something terrible happened" + e);
}
});
资产
任何 Web 应用程序都需要样式表或其他资源,如图像、脚本等。在非 Play 应用程序中,我们会通过确定文件的相对位置来引用这些资源。例如,假设我们的应用程序有一个webapp
文件夹,其中包含index.html
,我们需要添加一个位于webapp/styles
的homePage.css
样式表。现在,index.html
中的引用可能类似于以下内容:
<link rel="stylesheet" href="styles/homePage.css" />
这样的相对路径可能会变得非常混乱,有时难以管理。在 Play 应用程序中,资源放置在 public 目录中,可以通过请求访问。建议您将 public 目录分为三个子目录,用于图像、CSS 样式表和 JavaScript 文件,以保持一致性,如下图所示:
此外,Play 默认提供资产控制器来支持请求,可以访问资源(资产)。在大多数 Play 应用程序中,路由文件中也提供了一个资产路由,如下所示:
GET /assets/*file controllers.Assets.at(path="/public", file)
此路由提供了对资源(如样式表、脚本等)的访问。文件是路径/public
之后的剩余部分,这是访问所必需的。例如,要获取homePage.css
样式表,我们需要向/assets/stylesheets/homePage.css
发送 GET 请求。以/assets/
开头的是文件路径。
在视图中,我们需要使用routes
辅助工具。因此,如果我们想在某个视图中添加样式表,我们可以如下引用它:
<link rel="stylesheet" href="@routes.Assets.at("stylesheets/homePage.css")" />
类似地,我们将如下引用 JavaScript 脚本:
<script src="img/@routes.Assets.at("javascripts/slider.js")" type="text/javascript"></script>
还可以指定图像、样式表或脚本的单独路径,以便请求路径更短,如下所示:
GET /styles/*file controllers.Assets.at(path="/public/styles", file)
GET /images/*file controllers.Assets.at(path="/public/images", file)
动作at
定义如下:
def at(path: String, file: String, aggressiveCaching: Boolean = false): Action[AnyContent] = Action.async {
implicit request =>
import Implicits.trampoline
val pendingResult: Future[Result] = for {
Some(name) <- Future.successful(resourceNameAt(path, file))
(assetInfo, gzipRequested) <- assetInfoForRequest(request, name)
} yield {
val stream = assetInfo.url(gzipRequested).openStream()
Try(stream.available -> Enumerator.fromStream(stream)(Implicits.defaultExecutionContext)).map {
case (length, resourceData) =>
maybeNotModified(request, assetInfo, aggressiveCaching).getOrElse {
cacheableResult(
assetInfo,
aggressiveCaching,
result(file, length, assetInfo.mimeType, resourceData, gzipRequested, assetInfo.gzipUrl.isDefined)
)
}
}.getOrElse(NotFound)
}
pendingResult.recover {
case e: InvalidUriEncodingException =>
Logger.debug(s"Invalid URI encoding for $file at $path", e)
BadRequest
case e: Throwable =>
Logger.debug(s"Unforseen error for $file at $path", e)
NotFound
}
}
注意
如果文件有可用的 gzip 版本,资产控制器将提供该版本。gzip 版本是指通过使用 gzip 压缩文件而获得的文件版本。它将 .gz
扩展名添加到文件名中。
除了资源之外,AssetController
还添加了 etag
头部。
etag
缩写用于实体标签。这是请求资源的唯一标识符,通常是资源或其最后修改时间戳的哈希。
客户端库
大多数应用程序中的视图依赖于第三方库。在 Play 中,我们可以使用 webJars 和 npm 定义位于这些库中的依赖项。
Play 从 WebJar 依赖以及 npm
包中提取资产到公共资产目录内的 lib
目录。我们可以在定义具有对那里现有文件依赖的资产时引用这些资产。例如,如果我们的视图依赖于 d3.js
,那么我们使用以下方式:
<script src="img/@routes.Assets.at("lib/d3/d3.v3.min.js")" charset="utf-8"></script>
注意
WebJars 是用于网络应用程序客户端开发的库的 JAR 文件。
npm
是 node packaged modules 的缩写。它是 Node.js 的包管理器。它允许开发人员通过命令行安装已注册的模块。
要使用 WebJar,我们需要像在其他模块中一样定义我们项目对该模块的依赖,如下所示:
libraryDependencies+="org.webjars" % "d3js" % "3.4.6-1"
要包含 npm 包,我们需要将 package.json
文件放置在项目根目录中。package.json
文件将类似于以下内容:
{
"name": "myApp",
"version": "1.0.0",
"dependencies": {
},
"devDependencies": {
"grunt": "~0.4.1",
"grunt-contrib-concat": "~0.1.3",
"grunt-contrib-cssmin": "~0.5.0",
"grunt-contrib-clean": "~0.4.0",
"grunt-contrib-less": "~0.7.0"
},
"engines": {
"node": ">=0.8.0"
}
}
配置路由定义
Play 支持静态和动态请求路径。如果请求路径无法与定义的任何路由匹配,则在运行时抛出 Action not found
错误,该错误使用 devNotFound.scala.html
默认模板渲染。
动态路径
动态路径是可以用于多个请求的路径,它们可能或可能不会产生相似响应。例如,默认的资产路径是一个用于提供资源的路径:
GET /assets/*file controllers.Assets.at(path="/public", file)
*
符号表示 /assets/
之后直到找到空格的所有内容都是 file
变量的值。
当我们需要添加一个或多个变量时,让我们看看另一种使路径动态化的方法。例如,要通过 userId
获取用户详情,我们使用以下代码:
GET /api/user/:userId controllers.UserController.getUser(userId)
默认情况下,路径中出现的所有变量都是 String
类型。如果需要转换,则应明确指定类型。因此,如果 getUser
方法需要一个长参数,我们只需以这种方式指定它:
GET /api/user/:userId controllers.UserController.getUser(userId:Long)
使用 userId
的 :
前缀意味着 userId
变量正好是一个 URI 部分。资产路径使用 任何后缀指示符 作为相对文件路径,这是访问任何文件所必需的。
路径不以变量结尾并不是必需的;例如,/api/user/:userId/album
可以作为一个有效的路径来获取用户存储的所有专辑。
同一个路径中也可以使用多个变量。假设我们想要获取一个特定的专辑,我们可以使用 /api/user/:userId/album/:albumId
。
注意
我们可以在路径中指定的变量最大数量是 21,因为这是在 routes_routing.scala
中使用的 call
方法定义可以处理的最大的数量。此外,请求路径变得复杂,最终包含太多的变量。通常,将此类参数的数量保持在五个以下是一个好的做法。
Play 还支持使用正则表达式来匹配变量。例如,假设我们想要限制一个字符串变量只包含字母,例如地区代码;在这种情况下,我们的路由可以定义为如下:
GET /api/region/$regionId<[a-zA-Z]{2}>/user controllers.UserController.getUserByRegion(regionId)
注意,当我们为路由中的变量指定正则表达式时,它是以 $
符号开头,而不是在定义路由时使用的 :
符号。
前面的路由定义通过正则表达式限制了请求。例如:
-
/api/region/IN/user
是一个有效的路径 -
/api/region/CABT/user
和/api/region/99/user
是无效的
注意
路由的优先级顺序由其在 routes
文件中的位置定义。路由器返回给定路径的第一个匹配的路由。如果相同的请求类型和路由映射到两个不同的动作,编译器不会抛出错误或警告。一些 IDE 在出现重复的路由定义时会给出提示,但确保这种情况不发生完全是开发者的责任。
此表总结了定义动态路径的不同方法:
Sr.no. | Purpose | Special characters | Example usage(s) |
---|---|---|---|
1 | URI 路径分隔符是变量的一部分 | * |
/assets/*file |
2 | 单个或多个变量 | : |
/api/user/:userId /api/user/:userId/album /api/user/:userId/album/:albumId |
3 | 变量的正则表达式模式 | ` | Sr.no. |
--- | --- | --- | --- |
1 | URI 路径分隔符是变量的一部分 | * |
/assets/*file |
2 | 单个或多个变量 | : |
/api/user/:userId /api/user/:userId/album /api/user/:userId/album/:albumId |
| /api/region/$regionId<[a-zA-Z]{2}>/user |
静态路径
静态请求路径是固定和不变的。它们不支持请求路径中的参数。此类请求所需的所有数据都应该通过请求参数或请求体发送。例如,用于登录或注销的动作如下所示:
GET /login controllers.Application.login
那么 Play 是否也会搜索特定的字符来识别路径的类型?
是的,特殊字符被 RoutesFileParser
用于识别路径是静态的还是动态的。路径被定义为如下:
def singleComponentPathPart: Parser[DynamicPart] = (":" ~> identifier) ^^ {
case name => DynamicPart(name, """[^/]+""", encode = true)
}
def multipleComponentsPathPart: Parser[DynamicPart] = ("*" ~> identifier) ^^ {
case name => DynamicPart(name, """.+""", encode = false)
}
def regexComponentPathPart: Parser[DynamicPart] = "$" ~> identifier ~ ("<" ~> (not(">") ~> """[^\s]""".r +) <~ ">" ^^ { case c => c.mkString }) ^^ {
case name ~ regex => DynamicPart(name, regex, encode = false)
}
def staticPathPart: Parser[StaticPart] = (not(":") ~> not("*") ~> not("$") ~> """[^\s]""".r +) ^^ {
case chars => StaticPart(chars.mkString)
}
在识别路径的方法中,~>
, not
, 和 ^^
方法来自 scala.util.parsing.combinator.{Parser, RegexParsers}
。DynamicPart
和 StaticPart
是为了捕获 URL 的部分而定义的,这样就可以更简单地传递值到相应的动作。它们被定义为如下:
trait PathPart
case class DynamicPart(name: String, constraint: String, encode: Boolean) extends PathPart with Positional {
override def toString = """DynamicPart("""" + name + "\", \"\"\"" + constraint + "\"\"\"," + encode + ")" //"
}
case class StaticPart(value: String) extends PathPart {
override def toString = """StaticPart("""" + value + """")"""
}
case class PathPattern(parts: Seq[PathPart]) {
def has(key: String): Boolean = parts.exists {
case DynamicPart(name, _, _) if name == key => true
case _ => false
}
override def toString = parts.map {
case DynamicPart(name, constraint, encode) => "$" + name + "<" + constraint + ">"
case StaticPart(path) => path
}.mkString
}
配置请求参数
许多应用程序在 RESTful HTTP GET 请求中使用了额外的参数来获取所需的信息。Play 也支持配置这些请求参数。
假设我们有一个按用户名搜索用户的需求,我们可以这样定义:
GET /api/search/user controllers.UserController.search(name)
因此,我们不需要在操作中从请求中获取参数。我们可以让 Play 处理从请求中获取参数并将其传递给操作。
当请求参数是可选的时,我们该怎么办?例如,如果我们允许通过用户名搜索用户,其中lastName
是可选的,会发生什么。
我们可以将Option
指定为该请求参数的类型。因此,路由定义将与以下类似:
GET /api/search/user controllers.UserController.search(firstName:String, lastName:Option[String])
此外,我们还可以指定请求参数的默认值(如果有的话)。假设我们还有一个用于搜索请求的限制参数。在这种情况下,如果我们希望将默认值设置为10
,则路由定义如下:
GET /api/search/user controllers.UserController.search(firstName:String, lastName:Option[String], limit:Int ?= 10)
故障排除
该应用程序按预期工作,但当代码添加到一个或多个基本包中时,反向路由不起作用。
路由已编译,因此当你对控制器进行更改时,项目应该重新编译。在这种情况下,运行clean
命令然后编译项目。最好查看生成的路由文件是否反映了所做的更改。如果没有,删除目标目录并编译项目。
摘要
在本章中,我们看到了路由在 Play 应用程序中扮演的基本角色。此外,我们还看到了 Play 提供的各种默认方法,以简化路由过程,例如资产、反向路由等。
在下一章中,我们将看到如何在 Play 应用程序中定义视图,并揭示其工作原理。此外,从模板机制,以及构建和使用表单以及国际化等内部细节都将被详细阐述。
第四章 探索视图
视图是应用程序的一个基本组成部分,或者在交互最小的情况下,它们是展示应用程序功能的方式。它们有增加或完全阻止最终用户数量的能力。增强用户体验的视图总是比那些像迷宫一样复杂、用户难以完成简单任务的视图更受欢迎。它们是应用程序成功与否的决定性因素。
在本章中,我们将涵盖以下主题:
-
使用 Twirl 构建视图
-
生成表单
-
国际化
-
模板内部(涵盖 Twirl 的工作基本原理)
深入 Scala 模板
Twirl模板由参数和内容组成。以下图显示了名为login.scala.html
的登录页面模板的组件:
注意
参数必须首先声明,因为它们被用作生成模板对象的apply
方法的参数。例如,对于前面代码中显示的main.scala.html
模板,apply
方法将是:
def apply/*1.2*/(title: String)(content:play.api.twirl.Html):play.api.templates.HtmlFormat.Appendable = {...}
模板内容可以是 HTML,也可以是 Scala 代码。
例如,让我们看看一些与 Play 捆绑在一起的defaultpages
(通过views.html.defaultpages
对象访问)中的defaultpages
。对于此操作,默认视图未实现;todo.scala.html
没有模板参数,其内容是纯 HTML。它定义如下:
<!DOCTYPE html>
<html>
<head>
<title>TODO</title>
<link rel="shortcut icon" href="data:image/png;base64,iVBORw..">
<style>
...
</style>
</head>
<body>
<h1>TODO</h1>
<p id="detail">
Action not implemented yet.
</p>
</body>
</html>
类似地,未授权的默认视图unauthorized.scala.html
也是一个静态页面。
现在,让我们检查开发模式下找不到操作视图devNotFound.scala.html
的定义:
@(request:play.api.mvc.RequestHeader, router:Option[play.core.Router.Routes])
<!DOCTYPE html>
<html>
<head>
<title>Action not found</title>
<link rel="shortcut icon" href="data:image/png;base64,iVBORw..">
</head>
<body>
<h1>Action not found</h1>
<p id="detail">
For request '@request'
</p>
@router match {
case Some(routes) => {
<h2>
These routes have been tried, in this order:
</h2>
<div>
@routes.documentation.zipWithIndex.map { r =>
<pre><span class="line">@(r._2 + 1)</span><span class="route"><span class="verb">@r._1._1</span><span class="path">@r._1._2</span><span class="call">@r._1._3</span></span></pre>
}
</div>
}
case None => {
<h2>
No router defined.
</h2>
}
}
</body>
</html>
在模板片段中,已排除样式组件,以专注于使用的 Scala 代码。
如果定义了路由文件,则它将以预格式化的块列出所有可用路由。可以调用为模板参数类型定义的方法,即使在模板内部也可以。例如,如果books: Seq[String]
是参数之一,我们可以在模板内部调用@books.length
或@books.map{...}
等。
此外,Twirl 模板可以用于另一个模板中。这允许我们有可重用的视图块。例如,假设我们有一个主模板,它被所有其他视图使用,应用程序的主题(包括页眉、页脚、基本布局等)可以通过调整主模板来更新。考虑以下定义的模板main.scala.html
:
@(title: String)(content: play.twirl.api.Html)
<!DOCTYPE html>
<html>
<head>
<title>@title</title>
</head>
<body>
<header>brand name</header>
@content
<footer>Copyright 2013</footer>
</body>
</html>
重复使用此模板将像以下这样简单:
@main("locate us"){
<div>
company address
</div>
}
另一个例子是将小部件定义为模板。这些小部件模板可以在应用程序的多个视图中使用。同样,我们也可以在我们的模板中定义代码块。
构建视图
让我们构建一个视图,这在今天的 Web 应用中很常见。一个用户被要求选择他们想要登录的账户的视图,例如 Google、Facebook 等,提供了一个提供者列表,默认情况下,第一个提供者应该被选中。
考虑到在支持的第三方认证列表中,otherAuth
作为模板参数传递。otherAuth
的类型是Seq[ThirdPartyAuth]
,其中ThirdyPartyAuth
是一个案例类,用于表示任何第三方认证 API。
因此,这是以下完成的:
<div>
<p>
Please select the account you wish to use
@for(auth <- otherAuth) {
<input type="radio" name="account" value="@auth.id"> @auth.name
<br/>
}
</p>
</div>
在这个片段中,我们使用了for
循环遍历所有受支持的第三方认证。在模板中,我们可以使用两个 Scala 函数,for
和if
,以及模板内部定义的和基于模板参数类型定义的函数。
现在剩下的唯一重要部分是设置默认值。我们可以通过使用 Twirl 提供的defining
方法之一来实现这一点。让我们创建一个变量来检查提供者是否是第一个。然后我们可以为两种可能性提供不同的标记。如果我们修改我们的代码以适应这一点,我们将得到以下代码:
<div>
<p>
Please select the account you wish to use
@for(auth <- otherAuth) {
@defining(auth.id == otherAuth.head.id) { isChecked =>
@if(isChecked) {
<input type="radio" name="account" value="@auth.id" checked="checked"> @auth.name
} else {
<input type="radio" name="account" value="@auth.id"> @auth.name
}
}
<br/>
}
</p>
</div>
生成表单
在需要从用户那里获取输入的情况下,表单很重要,例如在注册、登录、搜索等情况。
Play 提供了生成表单的辅助工具和将表单数据转换为 Scala 对象的包装类。
现在,我们将使用 Play 提供的表单助手构建一个用户注册表单:
@helper.form(action = routes.Application.newUser) {
<label>Email Id
<input type="email" name="email" tabindex="1" required="required">
</label>
<label>Password
<input type="password" name="password" tabindex="2" required="required">
</label>
<input type="submit" value="Register" type="button">
}
在这里,@helper.form
是 Play 提供的一个模板,定义如下:
@(action: play.api.mvc.Call, args: (Symbol,String)*)(body: => Html)
<form action="@action.url" method="@action.method" @toHtmlArgs(args.toMap)>
@body
</form>
我们还可以为form
元素提供其他参数,作为Symbol
和String
的元组。Symbol
组件将成为参数,其对应的String
组件将以以下方式设置为它的值:
@helper.form(action = routes.Application.newUser, 'enctype -> "multipart/form-data")
生成的 HTML 现在如下所示:
<form action="/register" method="POST" enctype="multipart/form-data">...</form>
这是因为定义了以下toHtmlArgs
辅助方法:
def toHtmlArgs(args: Map[Symbol, Any]) = play.twirl.api.Html(args.map({
case (s, None) => s.name
case (s, v) => s.name + "=\"" + play.twirl.api.HtmlFormat.escape(v.toString).body + "\""
}).mkString(" "))
现在,当我们尝试注册用户时,动作内的请求体将是:
AnyContentAsFormUrlEncoded(Map(email -> ArrayBuffer(testUser@app.com), password -> ArrayBuffer(password)))
如果指定了enctype
参数,并且请求被解析为multipartformdata
,则请求体将如下所示:
MultipartFormData(Map(password -> List(password), email -> List(testUser@app.com)),List(),List(),List())
我们不需要定义自定义方法来接受一个映射,以便它产生相应的模型,我们可以使用play.api.data.Form
表单数据助手对象。
表单对象有助于以下:
-
将表单数据映射到用户定义的模型(如案例类)或元组
-
验证输入的数据是否满足所需的约束。这可以针对所有字段集体进行,也可以针对每个字段独立进行,或者两者都进行。
-
填写默认值。
我们可能需要将表单数据转换为凭据;在这种情况下,类定义如下:
case class Credentials(loginId: String, password: String)
我们可以将注册视图更新为使用表单对象,如下所示:
@import models.Credentials
@(registerForm: Form[Credentials])(implicit flash: Flash)
@main("Register") {
<div id="signup" class="form">
@helper.form(action = routes.Application.newUser, 'enctype -> "multipart/form-data") {
<hr/>
<div>
<label>Email Id
<input type="email" name="loginId" tabindex="1" required="required">
</label>
<label>Password
<input type="password" name="password" tabindex="2" required="required">
</label>
</div>
<input type="submit" value="Register">
<hr/>
Existing User?<a href="@routes.Application.login()">Login</a>
<hr/>
}
</div>
}
现在我们定义一个表单,它从具有 loginId
和 password
字段的表单创建一个凭据对象:
val signupForm = Form(
mapping(
"loginId" -> email,
"password" -> nonEmptyText
)(Credentials.apply)(Credentials.unapply)
现在我们定义以下操作:
def register = Action {
implicit request =>
Ok(views.html.register(signupForm)).withNewSession
}
def newUser = Action(parse.multipartFormData) {
implicit request =>
signupForm.bindFromRequest().fold(
formWithErrors => BadRequest(views.html.register(formWithErrors)),
credentials => Ok
)
}
register
和 newUser
方法分别映射到 GET /register
和 POST /register
。我们将表单传递到视图中,以便在表单验证出错时,它们会与表单字段一起在视图中显示。我们将在下一节中详细看到这一点。
让我们看看它是如何工作的。当我们填写表单并提交时,调用将转到 newUser
动作。signupForm
是一个表单,定义如下:
case class FormT { … }
我们使用了在其伴随对象中定义的构造函数:
def applyT: Form[T] = Form(mapping, Map.empty, Nil, None)
mapping
方法可以接受最多 18 个参数。也可以使用 tuple
方法来定义表单,这会依次调用 mapping
方法:
def tupleA1, A2, a2: (String, Mapping[A2])): Mapping[(A1, A2)] = mapping(a1, a2)((a1: A1, a2: A2) => (a1, a2))((t: (A1, A2)) => Some(t))
使用这个方法,而不是为 signupForm
映射,你将得到以下代码:
val signupForm = Form(
tuple(
"loginId" -> email,
"password" -> nonEmptyText
)
)
注意
在使用映射和元组定义表单时,我们使用的 email
和 nonEmptyText
术语是预定义约束,并且也在 Form
对象中定义。下一节将详细讨论它们。
当定义只有一个字段的表单时,我们可以使用 single
方法,因为元组没有为单个字段定义,如下所示:
def singleA1): Mapping[(A1)] = mapping(a1)((a1: A1) => (a1))((t: (A1)) => Some(t))
在我们的操作中调用的方法是 signupForm.bindRequestFrom
。bindRequestFrom
方法接受一个隐式请求,并用请求中的表单数据填充表单。
一旦我们填写了表单,我们需要检查它是否有任何错误。这就是 fold
方法派上用场的地方,如下定义:
def foldR: R = value match {
case Some(v) if errors.isEmpty => success(v)
case _ => hasErrors(this)
}
变量 errors
和 value
来自表单构造函数。错误类型是 Seq[FormError]
,而值的类型是 Option[T]
。
然后,如果表单有错误,我们将 fold
的结果映射到 BadRequest(formWithErrors)
;如果没有错误,我们可以继续处理通过表单提交的数据。
在数据上添加约束
限制用户输入的表单数据,通常需要遵循一个或多个规则。例如,检查姓名字段数据是否不包含数字,年龄是否小于 18 岁,如果使用过期卡完成交易等情况。Play 提供了默认约束,可用于验证字段数据。使用这些约束,我们可以轻松定义表单,并在某些方面限制字段数据,如下所示:
mapping(
"userName" -> nonEmptyText,
"emailId" -> email,
"password" -> nonEmptyText(minLength=8,maxLength=15)
)
默认约束可以大致分为两类:定义简单 Mapping[T]
的那些,以及消耗 Mapping[T]
并产生 Mapping[KT]
的那些,如下所示:
mapping(
"userName" -> nonEmptyText,
"interests" -> list(nonEmptyText)
)
在此示例中,Mapping[String]
转换为 Mapping[List[String]]
。
还有两种不属于上述任一类的约束。它们是 ignored
和 checked
。
当我们需要将该字段的用户数据进行映射时,可以使用ignored
约束。例如,登录时间或登出时间等字段应由应用程序填写,而不是用户。我们可以这样使用mapping
:
mapping(
"loginId" -> email,
"password" -> nonEmptyText,
"loginTime" -> ignored(System.currentTimeMillis())
)
当我们需要确保用户已选中特定的复选框时,可以使用checked
约束。例如,在signupForm
中接受组织的条款和条件等,我们可以这样定义:
mapping(
"loginId" -> email,
"password" -> nonEmptyText,
"agree" -> checked("agreeTerms")
)
第一类约束列在此表中:
约束 | 结果 | 额外属性及其默认值(如有) |
---|---|---|
text |
Mapping[String] |
minLength : 0, maxLength : Int.MaxValue |
nonEmptyText |
Mapping[String] |
minLength : 0, maxLength : Int.MaxValue |
number |
Mapping[Int] |
min : Int.MinValue , max : Int.MaxValue , strict : false |
longNumber |
Mapping[Long] |
min : Long.MinValue , max : Long.MaxValue , strict: false |
bigDecimal |
Mapping[BigDecimal] |
precision, scale |
date |
Mapping[java.util.Date] |
pattern, timeZone : java.util.TimeZone.getDefault |
sqlDate |
Mapping[java.sql.Date] |
pattern, timeZone : java.util.TimeZone.getDefault |
jodaDate |
Mapping[org.joda.time.DateTime] |
pattern, timeZone : org.joda.time.DateTimeZone.getDefault |
jodaLocalDate |
Mapping[org.joda.time.LocalDate] |
pattern |
email |
Mapping[String] |
|
boolean |
Mapping[Boolean] |
此表列出了第二类中包含的约束:
约束 | 结果 | 必需的参数及其类型 |
---|---|---|
optional |
Mapping[Option[A]] |
mapping : Mapping[A] |
default |
Mapping[A] |
mapping : Mapping[A] , value: A |
list |
Mapping[List[A]] |
mapping : Mapping[A] |
seq |
Mapping[Seq[A]] |
mapping : Mapping[A] |
set |
Mapping[Seq[A]] |
mapping : Mapping[A] |
除了这些字段约束外,我们还可以使用verifying
方法在字段上定义临时的和/或自定义约束。
可能会出现应用程序允许用户选择他们的userName
的情况,该用户名只能由数字和字母组成。为了确保此规则不被违反,我们可以定义一个临时的约束:
mapping(
"userName" -> nonEmptyText(minLength=5) verifying pattern("""[A-Za-z0-9]*""".r, error = "only digits and alphabet are allowed in userName"
)
或者,我们可以使用Constraint
案例类定义一个自定义约束:
val validUserName = """[A-Za-z0-9]*""".r
val userNameCheckConstraint: Constraint[String] = Constraint("contraints.userName")({
text =>
val error = text match {
case validUserName() => Nil
case _ => Seq(ValidationError("only digits and alphabet are allowed in userName"))
}
if (error.isEmpty) Valid else Invalid(error)
})
val userNameCheck: Mapping[String] = nonEmptyText(minLength = 5).verifying(passwordCheckConstraint)
我们可以在表单定义中使用此功能:
mapping(
"userName" -> userNameCheck
)
注意,nonEmpty
, minLength
, maxLength
, min
, max
, pattern
, 和 email
是预定义的约束。它们定义在play.api.data.validation
特质中。在定义自定义约束时,可以使用可用的约束作为参考。
处理错误
当表单提交中一个或多个约束被违反时,会发生什么?bindFromRequest
方法创建一个包含错误的表单,我们之前称之为formWithErrors
。
对于每个违反的约束,都会保存一个错误。错误由FormError
表示,其定义如下:
case class FormError(key: String, messages: Seq[String], args: Seq[Any] = Nil)
key
是违反约束的字段名称,message
是对应的错误信息,args
是消息中使用的任何参数。在多个字段中定义约束的情况下,key
是一个空字符串,此类错误被称为globalErrors
。
可以通过定义的errors
方法访问特定字段的表单错误:
def errors(key: String): Seq[FormError] = errors.filter(_.key == key)
例如:
registerForm.errors("userName")
或者,为了只访问第一个错误,我们可以使用error
方法。它定义如下:
def error(key: String): Option[FormError] = errors.find(_.key == key)
那么,我们如何访问globalErrors
(即由多个字段一起定义的约束错误)?
我们可以使用表单的globalErrors
方法,它定义如下:
def globalErrors: Seq[FormError] = errors.filter(_.key.isEmpty)
如果我们只想获取第一个globalError
方法,我们可以使用globalError
方法。它定义如下:
def globalError: Option[FormError] = globalErrors.headOption
当我们使用表单字段助手时,字段特定的错误会被映射到字段,并在存在时显示。然而,如果我们不使用表单助手,我们需要显示错误,如下所示:
<label>Password
<input type="password" name="password" tabindex="2" required="required">
</label>
@registerForm.errors("password").map{ er => <p>@er.message</p>}
需要显式地将globalErrors
方法添加到视图中,如下所示:
@registerForm.globalErrors.map{ er => <p>@er.message</p>}
表单字段助手
在上一个示例中,我们使用了form
字段的 HTML 代码,但也可以使用 Play 提供的form
字段助手来完成这项工作。我们可以更新我们的view,@import models.Credentials
,如下所示:
@(registerForm: Form[Credentials])(implicit flash: Flash)
@main("Register") {
@helper.form(action = routes.Application.newUser, 'enctype -> "multipart/form-data") {
@registerForm.globalErrors.map { error =>
<p class="error">
@error.message
</p>
}
@helper.inputText(registerForm("loginId"), 'tabindex -> "1", '_label -> "Email ID",
'type -> "email", 'required -> "required", '_help -> "A valid email Id")
@helper.inputPassword(registerForm("password"), 'tabindex -> "2",
'required -> "required", '_help -> "preferable min.length=8")
<input type="submit" value="Register">
<hr/>
Existing User?<a href="@routes.Application.login()">Login</a>
}
}
让我们看看它是如何工作的。助手inputText
是一个如下定义的视图:
@(field: play.api.data.Field, args: (Symbol,Any)*)(implicit handler: FieldConstructor, lang: play.api.i18n.Lang)
@inputType = @{ args.toMap.get('type).map(_.toString).getOrElse("text") }
@input(field, args.filter(_._1 != 'type):_*) { (id, name, value, htmlArgs) =>
<input type="@inputType" id="@id" name="@name" value="@value" @toHtmlArgs(htmlArgs)/>
}
它使用内部输入助手,这同样也是一个视图,可以定义如下:
@(field: play.api.data.Field, args: (Symbol, Any)* )(inputDef: (String, String, Option[String], Map[Symbol,Any]) => Html)(implicit handler: FieldConstructor, lang: play.api.i18n.Lang)
@id = @{ args.toMap.get('id).map(_.toString).getOrElse(field.id) }
@handler(
FieldElements(
id,
field,
inputDef(id, field.name, field.value, args.filter(arg => !arg._1.name.startsWith("_") && arg._1 != 'id).toMap),
args.toMap,
lang
)
)
两个form
字段助手都使用隐式的FieldConstructor
。这个字段构造器负责渲染 HTML。默认情况下,defaultFieldConstructor
被转发。它定义如下:
@(elements: FieldElements)
<dl class="@elements.args.get('_class) @if(elements.hasErrors) {error}" id="@elements.args.get('_id).getOrElse(elements.id + "_field")">
@if(elements.hasName) {
<dt>@elements.name(elements.lang)</dt>
} else {
<dt><label for="@elements.id">@elements.label(elements.lang)</label></dt>
}
<dd>@elements.input</dd>
@elements.errors(elements.lang).map { error =>
<dd class="error">@error</dd>
}
@elements.infos(elements.lang).map { info =>
<dd class="info">@info</dd>
}
</dl>
因此,如果我们想更改form
字段的布局,我们可以定义一个自定义的FieldConstructor
并将其传递给form
字段助手,如下所示:
@input(contactForm("name"), '_label -> "Name", '_class -> "form-group", '_size -> "100") { (id, name, value, htmlArgs) =>
<input class="form-control" type="text" id="@id" name="@name" value="@value" @toHtmlArgs(htmlArgs)/>
}
本节试图解释表单助手是如何工作的;更多示例,请参阅 Play 框架文档www.playframework.com/documentation/2.3.x/ScalaForms
。
国际化
由于互联网的广泛覆盖,现在可以与来自不同地区的人们进行沟通和互动。一个只使用一种特定语言与用户沟通的应用程序,通过仅使用该语言来限制其用户基础。国际化本地化可以通过消除由于仅使用特定语言而产生的障碍,来满足来自各个地区的用户群体。
现在,让我们构建一个简单的视图,它允许我们提出一个问题。views/index.scala.html
视图文件将类似于以下内容:
@(enquiryForm: Form[(String, Option[String], String)])
@import helper._
@main("Enquiry") {
<div>
<h2>Have a question? Ask Us</h2>
@form(routes.AppController.enquire) {
@enquiryForm.globalError.map { error =>
<p>
@error.message
</p>
}
<label for="emailId">Your email address
<input type="email" id="emailId" name="emailId" required>
</label>
<label for="userName">Your name
<input type="text" class="form-control" id="userName" name="userName">
</label>
<label for="question">Your question
<textarea rows="4" id="question" name="question"></textarea>
</label>
<br/>
<button type="submit">Ask</button>
}
</div>
}
这里,AppController
是一个控制器,定义如下:
package controllers
import play.api.mvc._
import play.api.data.Form
import play.api.data.Forms._
object AppController extends Controller {
val enquiryForm = Form(
tuple(
"emailId" -> email,
"userName" -> optional(text),
"question" -> nonEmptyText)
)
def index = Action {
implicit request =>
Redirect(routes.AppController.askUs)
}
def askUs = Action {
implicit request =>
Ok(views.html.index(enquiryForm))
}
def enquire = Action {
implicit request =>
enquiryForm.bindFromRequest.fold(
errors => BadRequest(views.html.index(errors)),
query => {
println(query.toString)
Redirect(routes.AppController.askUs)
}
)
}
}
主模板views/main.scala.html
定义如下:
@(title: String)(content: Html)
<!DOCTYPE html>
<html>
<head>
<title>@title</title>
</head>
<body>
@content
</body>
</html>
应用程序的路线如下定义:
# Home page
GET / controllers.AppController.index
# Other
GET /ask controllers.AppController.askUs
POST /enquire controllers.AppController.enquire
现在我们启动应用程序时,借助一点样式(CSS 样式),我们的视图看起来类似于以下这样:
支持多语言视图
我们可能希望我们的应用程序同时提供英语和法语。因此,为不同的语言提供不同的视图是一个坏主意。这意味着每次添加对一种语言的支持时,我们都需要在我们的应用程序中以这种特定语言定义所有视图。使用 Play 的i18n支持,支持另一种语言可以简单到只需添加一个包含翻译的文件。
首先,我们需要在conf/application.conf
中指定应用程序支持的语言。请注意,这是默认conf/application.conf
中的注释代码,表示以下内容:
# The application languages
# ~~~~~
# application.langs="en"
应指定语言的方式是其 ISO 639-2 代码,可选地后跟 ISO 3166-1 alpha-2 国家代码。您还可以包括法语,如下所示:
application.langs="en,fr"
在 Play 中,用于在特定语言中渲染内容的翻译被称为消息。对于每种语言,我们需要提供一个conf/messages.lang-code
文件。如果我们希望有通用内容,我们应该在conf/messages
中定义它;这对于名称、品牌等非常有用。
让我们创建一个名为conf/messages.en
的英语messages
文件:
enquiry.title = Enquiry
enquiry.askUs=Have A Question? Ask Us!
enquiry.user.email=Your email address
enquiry.user.name=Your name
enquiry.question=Your question
enquiry.submit=Ask
现在我们需要更新我们的视图以使用这些消息,形式为@(enquiryForm: Form[(String, Option[String], String)])(implicit lang: Lang)
:
@import helper._
@main(Messages("enquiry.title")) {
<div>
<h2>@Messages("enquiry.askUs")</h2>
@form(routes.AppController.enquire) {
@enquiryForm.globalError.map { error =>
<p>
@error.message
</p>
}
<label for="emailId">@Messages("enquiry.user.email")
<input type="email" id="emailId" name="emailId" required>
</label>
<label for="userName">@Messages("enquiry.user.name")
<input type="text" class="form-control" id="userName" name="userName">
</label>
<label for="question">@Messages("enquiry.question")
<textarea rows="4" id="question" name="question"></textarea>
</label>
<br/>
<button type="submit">@Messages("enquiry.submit")</button>
}
</div>
}
现在,让我们添加法语messages
文件,conf/messages.fr
:
enquiry.title = Demande de renseignements
enquiry.askUs = Vous avez une question? Demandez-nous!
enquiry.user.email = Votre adresse e-mail
enquiry.user.name = Votre nom
enquiry.question = Votre question
enquiry.submit = Demandez
修改您的浏览器设置,以便将法语(fr)设置为首选语言并运行应用程序。您应该能够看到法语查询视图:
我们还可以在导入play.api.i18n
之后在 Scala 代码中使用这些消息:
val title = Messages("enquiry.title")
理解国际化
当我们在代码中使用Messages
(单词)时,它调用play.api.i18n.Messages
对象的apply
方法。apply
方法定义如下:
def apply(key: String, args: Any*)(implicit lang: Lang): String = {
Play.maybeApplication.flatMap { app =>
app.plugin[MessagesPlugin].map(_.api.translate(key, args)).getOrElse(throw new Exception("this plugin was not registered or disabled"))
}.getOrElse(noMatch(key, args))
}
Play 有一个内部插件,称为MessagesPlugin
,定义如下:
class MessagesPlugin(app: Application) extends Plugin {
import scala.collection.JavaConverters._
import scalax.file._
import scalax.io.JavaConverters._
private def loadMessages(file: String): Map[String, String] = {
app.classloader.getResources(file).asScala.toList.reverse.map { messageFile =>
new Messages.MessagesParser(messageFile.asInput, messageFile.toString).parse.map { message =>
message.key -> message.pattern
}.toMap
}.foldLeft(Map.empty[String, String]) { _ ++ _ }
}
private lazy val messages = {
MessagesApi {
Lang.availables(app).map(_.code).map { lang =>
(lang, loadMessages("messages." + lang))
}.toMap + ("default" -> loadMessages("messages"))
}
}
//The underlying internationalization API.
def api = messages
//Loads all configuration and message files defined in the classpath.
override def onStart() {
messages
}
}
此插件负责加载所有消息并生成一个MessagesApi
对象,该对象随后用于获取消息的值。因此,当我们提到一个消息时,它就是从这个MessagesApi
实例中获取的。MessagesApi
的定义如下:
case class MessagesApi(messages: Map[String, Map[String, String]]) {
import java.text._
//Translates a message.
def translate(key: String, args: Seq[Any])(implicit lang: Lang): Option[String] = {
val langsToTry: List[Lang] =
List(lang, Lang(lang.language, ""), Lang("default", ""), Lang("default.play", ""))
val pattern: Option[String] =
langsToTry.foldLeft[Option[String]](None)((res, lang) =>
res.orElse(messages.get(lang.code).flatMap(_.get(key))))
pattern.map(pattern =>
new MessageFormat(pattern, lang.toLocale).format(args.map(_.asInstanceOf[java.lang.Object]).toArray))
}
//Check if a message key is defined.
def isDefinedAt(key: String)(implicit lang: Lang): Boolean = {
val langsToTry: List[Lang] = List(lang, Lang(lang.language, ""), Lang("default", ""), Lang("default.play", ""))
langsToTry.foldLeftBoolean({ (acc, lang) =>
acc || messages.get(lang.code).map(_.isDefinedAt(key)).getOrElse(false)
})
}
}
注意
隐式的lang
参数是获取接受语言消息的关键。
Play 中的 Scala 模板
Play 支持在视图中使用 Scala 代码,并提供了一些辅助方法来简化定义视图的过程。
我们已经创建了不同的视图。让我们看看它们是如何实际渲染的。考虑我们在第一章中看到的任务跟踪器应用程序的视图,Play 入门。
@(tasks: List[Task], taskForm: Form[String])
@import helper._
@main("Task Tracker") {
<h2>Task Tracker</h2>
<div>
@form(routes.TaskController.newTask) {
@taskForm.globalError.map { error =>
<p class="error">
@error.message
</p>
}
<form>
<input type="text" name="taskName" placeholder="Add a new Task" required>
<input type="submit" value="Add">
</form>
}
</div>
<div>
<ul>
@tasks.map { task =>
<li>
@form(routes.TaskController.deleteTask(task.id)) {
@task.name <input type="submit" value="Remove">
}
</li>
}
</ul>
</div>
}
视图包含 Scala 代码和 HTML,那么它是如何正确渲染的呢?
在浏览器中打开任务跟踪器视图,而不运行 Play 应用程序。浏览器将页面渲染如下:
现在看看当你运行 Play 应用程序时,它如何以不同的方式渲染!
当 Play 应用程序编译时,会生成与路由相关的文件(routes_reverseRouting.scala
和routes_routing.scala
,controllers/routes.java
)和 Scala 视图。路由相关的文件是通过路由编译器生成的,而 Scala 视图是通过模板编译器生成的。Play 的 Scala 模板引擎已被提取出来,以方便在独立于 Play 的项目中使用。Play 的 Scala 模板引擎现在作为 Twirl 提供。根据github.com/spray/twirl
,选择 Twirl 作为名称的原因是:
作为“Play 框架 Scala 模板引擎”这个相对繁琐的名称的替代,我们寻找一个更短且带有“冲击力”的名称,并喜欢将 Twirl 作为对模板语言“魔法”字符@的参考,该字符有时也被称为“twirl”。
理解 Twirl 的工作原理
Play 的插件通过依赖SbtTwirl来定义;我们可以在插件定义中看到这一点:
object Play
extends AutoPlugin
with PlayExceptions
with PlayReloader
with PlayCommands
with PlayRun
with play.PlaySettings
with PlayPositionMapper
with PlaySourceGenerators {
override def requires = SbtTwirl && SbtJsTask && SbtWebDriver
val autoImport = play.PlayImport
override def projectSettings =
packageArchetype.java_server ++
defaultSettings ++
intellijCommandSettings ++
Seq(testListeners += testListener) ++
Seq(
scalacOptions ++= Seq("-deprecation", "-unchecked", "-encoding", "utf8"),
javacOptions in Compile ++= Seq("-encoding", "utf8", "-g")
)
}
此外,还有一些使用TwirlKeys在defaultSettings
中定义的 SBT 键。TwirlKeys 公开了一些键,可以用来根据我们的要求自定义 Twirl。使用 TwirlKeys 公开的键包括:
-
twirlVersion
: 这是用于 twirl-api 依赖的 Twirl 版本(SettingKey[String]
)。 -
templateFormats
: 这定义了 Twirl 模板格式(SettingKey[Map[String, String]]
)。默认可用的格式有html
、txt
、xml
和js
。 -
templateImports
: 这包括用于 Twirl 模板的额外导入(SettingKey[Seq[String]]
)。默认值是一个空序列。 -
useOldParser
: 这使用原始的 Play 模板解析器(SettingKey[Boolean]
);默认值为 false。 -
sourceEncoding
: 这包括模板文件和生成的 Scala 文件的源编码(TaskKey[String]
)。如果 Scala 编译器选项中没有指定编码,则使用 UTF-8 编码。 -
compileTemplates
: 这将 Twirl 模板编译成 Scala 源文件(TaskKey[Seq[File]]
)。
要理解这个任务,让我们看看 Twirl 插件中如何定义twirlSettings
:
def twirlSettings: Seq[Setting[_]] = Seq(
includeFilter in compileTemplates := "*.scala.*",
excludeFilter in compileTemplates := HiddenFileFilter,
sourceDirectories in compileTemplates := Seq(sourceDirectory.value / "twirl"),
sources in compileTemplates <<= Defaults.collectFiles(
sourceDirectories in compileTemplates,
includeFilter in compileTemplates,
excludeFilter in compileTemplates
),
watchSources in Defaults.ConfigGlobal <++= sources in compileTemplates,
target in compileTemplates := crossTarget.value / "twirl" / Defaults.nameForSrc(configuration.value.name),
compileTemplates := compileTemplatesTask.value,
sourceGenerators <+= compileTemplates,
managedSourceDirectories <+= target in compileTemplates
)
compileTemplates
设置从compileTemplatesTask.value
获取其值。compileTemplatesTask
反过来返回TemplateCompiler.compile
方法的结果,如下所示:
def compileTemplatesTask = Def.task {
TemplateCompiler.compile(
(sourceDirectories in compileTemplates).value,
(target in compileTemplates).value,
templateFormats.value,
templateImports.value,
(includeFilter in compileTemplates).value,
(excludeFilter in compileTemplates).value,
Codec(sourceEncoding.value),
useOldParser.value,
streams.value.log
)
}
...
}
TemplateCompiler.compile
的定义如下:
def compile(
sourceDirectories: Seq[File],
targetDirectory: File,
templateFormats: Map[String, String],
templateImports: Seq[String],
includeFilter: FileFilter,
excludeFilter: FileFilter,
codec: Codec,
useOldParser: Boolean,
log: Logger) = {
try {
syncGenerated(targetDirectory, codec)
val templates = collectTemplates(sourceDirectories, templateFormats, includeFilter, excludeFilter)
for ((template, sourceDirectory, extension, format) <- templates) {
val imports = formatImports(templateImports, extension)
TwirlCompiler.compile(template, sourceDirectory, targetDirectory, format, imports, codec, inclusiveDot = false, useOldParser = useOldParser)
}
generatedFiles(targetDirectory).map(_.getAbsoluteFile)
} catch handleError(log, codec)
}
如果项目内不存在,compile
方法将在项目中创建 target/scala-scalaVersion/src_managed
目录。如果已存在,则通过 cleanUp
方法删除所有匹配 "*.template.scala"
模式的文件。之后,collectTemplates
方法通过搜索名称匹配 "*.scala.*"
模式且以受支持扩展名结尾的文件来获取 Seq[(File, String, TemplateType)]
。
然后,collectTemplates
的结果中的每个对象都作为 TwirlCompiler.compile
的参数传递。
TwirlCompiler.compile
负责解析和生成 Scala 模板,其定义如下:
def compile(source: File, sourceDirectory: File, generatedDirectory: File,
formatterType: String, additionalImports: String = "", logRecompilation: (File, File) => Unit = (_, _) => ()) = {
val resultType = formatterType + ".Appendable"
val (templateName, generatedSource) = generatedFile(source, sourceDirectory, generatedDirectory)
if (generatedSource.needRecompilation(additionalImports)) {
logRecompilation(source, generatedSource.file)
val generated = parseAndGenerateCode(templateName, Path(source).byteArray, source.getAbsolutePath, resultType, formatterType, additionalImports)
Path(generatedSource.file).write(generated.toString)
Some(generatedSource.file)
} else {
None
}
}
parseAndGenerateCode
方法获取解析器并解析文件。生成的解析 Template
(内部对象)随后传递给 generateFinalCode
方法。generateFinalCode
方法负责生成代码。内部,它使用 generateCode
方法,其定义如下:
def generateCode(packageName: String, name: String, root: Template, resultType: String, formatterType: String, additionalImports: String) = {
val extra = TemplateAsFunctionCompiler.getFunctionMapping(
root.params.str,
resultType)
val generated = {
Nil :+ """
package """ :+ packageName :+ """
import twirl.api._
import TemplateMagic._
""" :+ additionalImports :+ """
/*""" :+ root.comment.map(_.msg).getOrElse("") :+ """*/
object """ :+ name :+ """ extends BaseScalaTemplate[""" :+ resultType :+ """,Format[""" :+ resultType :+ """]](""" :+ formatterType :+ """) with """ :+ extra._3 :+ """ {
/*""" :+ root.comment.map(_.msg).getOrElse("") :+ """*/
def apply""" :+ Source(root.params.str, root.params.pos) :+ """:""" :+ resultType :+ """ = {
_display_ {""" :+ templateCode(root, resultType) :+ """}
}
""" :+ extra._1 :+ """
""" :+ extra._2 :+ """
def ref: this.type = this
}"""
}
generated
}
parseAndGenerateCode
的结果写入其对应的文件。
让我们看看我们将要使用我们生成的文件的地方!
考虑到定义在第一章中的视图,Play 入门;生成的 Scala 模板类似于以下内容:
package views.html
import play.templates._
import play.templates.TemplateMagic._
import play.api.templates._
import play.api.templates.PlayMagic._
import models._
import controllers._
import play.api.i18n._
import play.api.mvc._
import play.api.data._
import views.html._
/**/
object index extends BaseScalaTemplate[play.api.templates.HtmlFormat.Appendable,Format[play.api.templates.HtmlFormat.Appendable]](play.api.templates.HtmlFormat) with play.api.templates.Template2[List[Task],Form[String],play.api.templates.HtmlFormat.Appendable] {
/**/
def apply/*1.2*/(tasks: List[Task], taskForm: Form[String]):play.api.templates.HtmlFormat.Appendable = {
_display_ {import helper._
SeqAny,format.raw/*4.1*/("""
"""),_display_(SeqAny/*5.22*/ {_display_(SeqAny,_display_(SeqAny/*10.41*/ {_display_(SeqAny,_display_(SeqAny,_display_(SeqAny),format.raw/*14.31*/("""
</p>
""")))})),format.raw/*16.10*/("""
<form>
<input type="text" name="taskName" placeholder="Add a new Task" required>
<input type="submit" value="Add">
</form>
""")))})),format.raw/*22.6*/("""
</div>
<div>
<ul>
"""),_display_(SeqAny,_display_(SeqAny)/*28.65*/ {_display_(SeqAny,_display_(SeqAny),format.raw/*29.31*/(""" <input type="submit" value="Remove">
""")))})),format.raw/*30.18*/("""
</li>
""")))})),format.raw/*32.10*/("""
</ul>
</div>
""")))})))}
}
def render(tasks:List[Task],taskForm:Form[String]): play.api.templates.HtmlFormat.Appendable = apply(tasks,taskForm)
def f:((List[Task],Form[String]) => play.api.templates.HtmlFormat.Appendable) = (tasks,taskForm) => apply(tasks,taskForm)
def ref: this.type = this
}
/*
-- GENERATED --
DATE: Timestamp
SOURCE: /TaskTracker/app/views/index.scala.html
HASH: ff7c2a525ebc63755f098d4ef80a8c0147eb7778
MATRIX: 573->1|726->44|754->63|790->65|818->85|857->87|936->131|980->166|1020->168|1067->179|1084->187|1109->203|1158->214|1242->262|1256->267|1286->275|1345->302|1546->472|1626->516|1640->521|1653->525|1701->535|1772->570|1828->617|1868->619|1926->641|1939->645|1966->650|2053->705|2113->733
LINES: 19->1|23->1|25->4|26->5|26->5|26->5|31->10|31->10|31->10|33->12|33->12|33->12|33->12|35->14|35->14|35->14|37->16|43->22|47->26|47->26|47->26|47->26|49->28|49->28|49->28|50->29|50->29|50->29|51->30|53->32
-- GENERATED --
*/
因此,当我们以 views.html.index(Task.all, taskForm)
的方式在控制器中引用此视图时,我们正在调用生成的模板对象 index 的 apply
方法。
故障排除
在使用 Play 视图时,我们可能会遇到以下一些问题:
-
当你点击 提交 时,表单没有提交,并且没有使用
globalErrors
显示错误。可能存在一种情况,某个特定字段缺失或字段名称有误。它不会在
globalErrors
中显示,但如果尝试显示单个字段的错误,error.required
将会显示缺失的字段。 -
我们是否需要为应用程序的视图使用 Twirl 模板?
不,Play 不会强迫开发者为视图使用 Twirl 模板。他们可以自由地以他们认为简单或舒适的方式设计视图。例如,这可以通过使用 Handlebars、Google Closure 模板等方式完成。
-
这是否以任何方式影响了应用程序的性能?
不,除非你的视图定义没有性能缺陷,否则将其插入 Play 应用程序不会影响性能。有一些项目使用 Play 服务器作为它们的原生 Android 和 iOS 应用程序。
-
Play 是否支持其他模板库?
不,但有一些 Play 插件可以帮助使用其他可用的模板机制或库。由于它们是由个人或其他组织开发的,所以在使用之前请检查它们的许可协议。
-
尽管应用程序的语言配置已经更新,并添加了各种语言的消息,但视图只以英语渲染。在运行时没有抛出错误,但仍然没有达到预期的效果。
为了让 Play 从请求中确定使用的语言,要求请求必须是隐式的。确保应用程序中定义的所有操作都使用隐式请求。
另一种可能性是 Accept-Language 头可能缺失。这可以通过更新浏览器设置来添加。
-
当访问一个在语言资源中没有映射的消息时,会发生编译错误吗?
不,如果访问了一个未定义的消息,将会发生编译错误。如果需要,您可以实现这个机制,或者如果可用并且满足您的要求,可以使用开源插件中的某些功能。
摘要
在本章中,我们看到了如何使用 Twirl 和 Play 提供的各种辅助方法来创建视图。我们构建了不同类型的视图:可重用的模板或小部件和表单。我们还看到了如何使用内置的 i18n API 在我们的 Play 应用程序中支持多种语言。
在下一章中,我们将介绍如何在 Play 中处理数据事务,并深入了解如何有效地设计您的模型。
第五章. 与数据工作
MVC 方法讨论模型、视图和控制器。我们在前面的章节中详细介绍了视图和控制器,而相当程度上忽略了模型。模型是 MVC 的重要组成部分;对模型所做的更改将反映在使用它们的视图和控制器中。
没有数据事务的 Web 应用是不完整的。本章是关于设计模型和在 Play 中处理数据库事务。
在本章中,我们将涵盖以下主题:
-
模型
-
JDBC
-
Anorm
-
Slick
-
ReactiveMongo
-
一个缓存 API
介绍模型
模型是一个领域对象,它映射到数据库实体。例如,一个社交网络应用有用户。用户可以注册、更新个人资料、添加朋友、发布链接等。在这里,用户是一个领域对象,每个用户在数据库中都将有相应的条目。因此,我们可以以下这种方式定义一个用户模型:
case class User(id: Long,
loginId: String,
name: Option[String],
dob: Option[Long])
object User { def register (loginId: String,...) = {…}
...
}
之前,我们定义了一个没有使用数据库的模型:
case class Task(id: Int, name: String)
object Task {
private var taskList: List[Task] = List()
def all: List[Task] = {
taskList
}
def add(taskName: String) = {
val lastId: Int = if (!taskList.isEmpty) taskList.last.id else 0
taskList = taskList ++ List(Task(lastId + 1, taskName))
}
def delete(taskId: Int) = {
taskList = taskList.filterNot(task => task.id == taskId)
}
}
任务列表示例有一个Task
模型,但它没有绑定到数据库,以保持事情简单。在本章结束时,我们将能够使用数据库来支持它。
JDBC
在使用关系型数据库的应用中,使用Java 数据库连接(JDBC)访问数据库是很常见的。Play 提供了一个插件来管理 JDBC 连接池。该插件内部使用 BoneCP (jolbox.com/
),一个快速的Java 数据库连接池(JDBC pool)库。
注意
要使用该插件,应在构建文件中添加一个依赖项:
val appDependencies = Seq(jdbc)
插件支持 H2、SQLite、PostgreSQL、MySQL 和 SQL。Play 附带了一个 H2 数据库驱动程序,但为了使用其他数据库,我们应该添加相应的驱动程序的依赖项:
val appDependencies = Seq( jdbc,
"mysql" % "mysql-connector-java" % "5.1.18",...)
插件公开以下方法:
-
getConnection
:它接受数据库的名称,以及使用此连接执行任何语句时是否应该自动提交。如果没有提供名称,它将获取默认名称的数据库连接。 -
withConnection
:它接受一个应该使用 JDBC 连接执行的代码块。一旦代码块执行完毕,连接将被释放。或者,它接受数据库的名称。 -
withTransaction
:它接受一个应该使用 JDBC 事务执行的代码块。一旦代码块执行完毕,连接及其创建的所有语句都将被释放。
插件如何知道数据库的详细信息?数据库的详细信息可以在conf/application.
conf
中设置:
db.default.driver=com.mysql.jdbc.Driver
db.default.url="jdbc:mysql://localhost:3306/app"
db.default.user="changeme"
db.default.password="changeme"
第一部分,db
,是一组属性,这些属性由 DBPlugin 使用。第二部分是数据库的名称,例如示例中的default
,最后一部分是属性的名称。
对于 MySQL 和 PostgreSQL,我们可以在 URL 中包含用户名和密码:
db.default.url="mysql://user:password@localhost:3306/app"
db.default.url="postgres://user:password@localhost:5432/app"
对于额外的 JDBC 配置,请参阅 www.playframework.com/documentation/2.3.x/SettingsJDBC
。
现在我们已经启用并配置了 JDBC 插件,我们可以连接到类似 SQL 的数据库并执行查询:
def fetchDBUser = Action {
var result = "DB User:"
val conn = DB.getConnection()
try{
val rs = conn.createStatement().executeQuery("SELECT USER()")
while (rs.next()) {
result += rs.getString(1)
}
} finally {
conn.close()
}
Ok(result)
}
或者,我们可以使用 DB.withConnection
辅助函数,它管理 DB 连接:
def fetchDBUser = Action {
var result = "DB User:"
DB.withConnection { conn =>
val rs = conn.createStatement().executeQuery("SELECT USER()")
while (rs.next()) {
result += rs.getString(1)
}
}
Ok(result)
}
Anorm
Anorm 是 Play 中一个支持使用纯 SQL 与数据库交互的模块。
Anorm 提供了查询 SQL 数据库并将结果解析为 Scala 对象的方法,包括内置和自定义的。
Anorm 的目标,如 Play 网站上所述(www.playframework.com/documentation/2.3.x/ScalaAnorm
)是:
使用 JDBC 是一件痛苦的事情,但我们提供了一个更好的 API
我们同意直接使用 JDBC API 是繁琐的,尤其是在 Java 中。你必须处理检查异常,并且反复迭代 ResultSet 以将原始数据集转换为你的数据结构。
我们提供了一个更简单的 JDBC API;使用 Scala,你不需要担心异常,并且使用函数式语言转换数据非常容易。实际上,Play Scala SQL 访问层的目的是提供几个 API,以有效地将 JDBC 数据转换为其他 Scala 结构。
你不需要另一个 DSL 来访问关系数据库
SQL 已经是访问关系数据库的最佳 DSL,我们不需要发明新的东西。此外,SQL 语法和功能可能因数据库供应商而异。
如果你尝试使用另一个专有 SQL DSL 抽象这个点,你将不得不处理针对每个供应商的几个方言(如 Hibernate 的),并且限制自己不使用特定数据库的有趣功能。
Play 有时会为你提供预填充的 SQL 语句,但我们的想法不是隐藏我们底层使用 SQL 的事实。Play 只是为了在简单查询中节省输入大量字符,你总是可以回退到普通的 SQL。
生成 SQL 的类型安全 DSL 是一个错误
有些人认为类型安全的 DSL 更好,因为所有查询都由编译器检查。不幸的是,编译器根据你通常通过将数据结构映射到数据库模式而编写的元模型定义来检查你的查询。
没有保证这个元模型是正确的。即使编译器说你的代码和查询类型正确,它仍然可能在运行时因为实际数据库定义的不匹配而悲惨地失败。
掌握你的 SQL 代码
对象关系映射在简单情况下工作得很好,但当你必须处理复杂的模式或现有数据库时,你将花费大部分时间与你的 ORM 作战,以使其生成你想要的 SQL 查询。
自己编写 SQL 查询对于简单的 'Hello World' 应用程序来说可能是繁琐的,但对于任何实际应用,您最终将通过完全控制您的 SQL 代码来节省时间和简化代码。
注意
当使用 Anorm 开发应用程序时,应明确指定其依赖项,因为它在 Play 中是一个独立的模块(从 Play 2.1 开始):
val appDependencies = Seq(
jdbc,
anorm
)
让我们在 MySQL 中想象我们的用户模型。表可以定义如下:
CREATE TABLE `user` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`login_id` varchar(45) NOT NULL,
`password` varchar(50) NOT NULL,
`name` varchar(45) DEFAULT NULL,
`dob` bigint(20) DEFAULT NULL,
`is_active` tinyint(1) NOT NULL DEFAULT '1',
PRIMARY KEY (`id`),
UNIQUE KEY `login_id_UNIQUE` (`login_id`),
UNIQUE KEY `id_UNIQUE` (`id`)
) ENGINE=InnoDB
现在我们来看看我们将在这个表中进行的不同查询。查询如下所示:
-
Insert
:此查询包括添加新用户 -
Update
:此查询包括更新配置文件、密码等 -
Select
:此查询包括根据特定标准检索一个或多个用户的详细信息
假设当用户请求从我们的应用程序中删除其账户时,我们不会从数据库中删除用户,而是将用户的状态标记为不活跃。因此,我们不会使用任何删除查询。
使用 Anorm,我们可以自动生成 userId
如下:
DB.withConnection {
implicit connection =>
val userId = SQL"""INSERT INTO user(login_id,password,name,
dob) VALUES($loginId,$password,$name,$dob)""".executeInsert()
userId
}
在这里,loginId
、password
、name
和 dob
是在运行时替换查询的变量。Anorm 只构建 java.sql.PreparedStatements
,这可以防止 SQL 注入。
SQL 方法返回 SimpleSql
类型的对象,并定义如下:
implicit class SqlStringInterpolation(val sc: StringContext) extends AnyVal {
def SQL(args: ParameterValue*) = prepare(args)
private def prepare(params: Seq[ParameterValue]) = {
// Generates the string query with "%s" for each parameter placeholder
val sql = sc.parts.mkString("%s")
val (ns, ps): (List[String], Map[String, ParameterValue]) =
namedParams(params)
SimpleSql(SqlQuery(sql, ns), ps,
defaultParser = RowParser(row => Success(row)))
}
}
SimpleSql
用于表示查询的中间格式。其构造函数如下:
case class SimpleSqlT extends Sql { … }
executeInsert
方法使用 SimpleSql
对象的 getFilledStatement
方法获取 PreparedStatement
。然后执行 PreparedStatement
的 getGeneratedKeys()
方法。
getGeneratedKeys
方法产生一个自动生成的键,它是调用它的语句执行的结果。如果没有创建键,它返回一个空对象。
现在我们使用 Anorm 更新用户的密码:
DB.withConnection {
implicit connection =>
SQL"""UPDATE user SET password=$password WHERE id = $userId""".executeUpdate()
}
executeUpdate
方法的工作方式与 executeInsert
类似。区别在于它调用 PreparedStatement
的 executeUpdate
方法,而不是 getGeneratedKeys
。
executeUpdate
方法返回受影响的行数,对于 数据操纵语言(DML)语句。如果 SQL 语句是其他类型,例如 数据定义语言(DDL),则返回 0
。
现在我们尝试获取所有注册用户的详细信息。如果我们希望结果行被解析为用户对象,我们应该定义一个解析器。用户的解析器如下所示:
def userRow:RowParser[User] = {
getLong ~
getString ~
get[Option[String]]("name") map {
case id ~ login_id ~ name => User(id, login_id, name)
}
}
在大多数查询中,我们不需要密码和出生日期,因此我们可以从用户 RowParser
默认中排除它们。
使用此解析器的查询可以表示如下:
DB.withConnection {
implicit connection =>
val query = "SELECT id,login_id,name FROM user"
SQL(query).as(userRow.*)
}
.*
符号表示结果应有一行或多行,类似于正则表达式中的常见解释。同样,当预期结果由零行或多行组成时,可以使用 .+
符号。
提示
如果您使用的是不支持字符串插值的较旧版本的 Scala,查询将按如下方式编写:
DB.withConnection {
implicit connection =>
val insertQuery = """INSERT INTO user(login_id,password,name,
|dob) VALUES({loginId},{password},{name},{dob})""".stripMargin
val userId = SQL(insertQuery).on(
'loginId -> loginId,
'password -> password,
'name -> name,
'dob -> dob).executeInsert()
userId
}
on
方法通过传递给它的参数映射更新查询。它是在以下方式中为SimpleSql
定义的:
def on(args: NamedParameter*): SimpleSql[T] =
copy(params = this.params ++ args.map(_.tupled))
请参阅 Play 文档(www.playframework.com/documentation/2.3.x/ScalaAnorm
)和 Anorm API 文档(www.playframework.com/documentation/2.3.x/api/scala/index.html#anorm.package
)以获取更多用例和详细信息。
Slick
根据 Slick 网站(slick.typesafe.com/doc/2.1.0/introduction.html#what-is-slick
):
Slick 是 Typesafe 为 Scala 提供的现代数据库查询和访问库。它允许您以使用 Scala 集合的方式处理存储数据,同时同时让您完全控制数据库访问发生的时间和传输的数据。您还可以直接使用 SQL。
当使用 Scala 而不是原始 SQL 进行查询时,您将受益于编译时安全性和组合性。Slick 可以使用其可扩展的查询编译器为不同的后端数据库生成查询,包括您自己的数据库。
我们可以通过 play-slick 插件在我们的 Play 应用程序中使用 Slick。该插件为 Slick 在 Play 应用程序中的使用提供了额外的功能。根据github.com/playframework/
,play-slick 包括以下三个特性:
-
一个包装器 DB 对象,它使用 Play 配置文件中定义的数据源,并从连接池中提取它们。它存在是为了能够以与 Anorm JDBC 连接相同的方式使用 Slick 会话。有一些智能缓存和负载均衡,可以使您的数据库连接性能更佳。
-
一个 DDL 插件,它读取 Slick 表并在重新加载时自动创建模式更新。这在演示和入门时特别有用。
-
一个包装器,用于与 Slick 一起使用 play Enumeratees
要使用它,我们需要在构建文件中添加以下库依赖项:
"com.typesafe.play" %% "play-slick" % "0.8.1"
Let's see how we can define user operations using Slick.
首先,我们需要在 Scala 中定义模式。这可以通过将所需的表映射到 case 类来完成。对于我们的用户表,模式可以定义为:
case class SlickUser(id: Long, loginId: String, name: String)
class SlickUserTable(tag: Tag) extends TableSlickUser {
def id = columnLong
def loginId = columnString
def name = columnString
def dob = columnLong
def password = columnString
def * = (id, loginId, name) <>(SlickUser.tupled, SlickUser.unapply)
}
Table
是 Slick 的一个特质,其列通过column
方法指定。以下类型适用于列:
-
数值类型:包括
Byte
、Short
、Int
、Long
、BigDecimal
、Float
和Double
-
日期类型:包括
java.sql.Date
、java.sql.Time
和java.sql.Timestamp
-
UUID 类型:包括
java.util.UUID
-
LOB 类型:包括
java.sql.Blob
、java.sql.Clob
和Array[Byte]
-
其他类型:包括
Boolean
、String
和Unit
column
方法接受列约束,如PrimaryKey
、Default
、AutoInc
、NotNull
和Nullable
。
对于每个表,*
方法是强制性的,类似于RowParser
。
现在我们可以使用这个定义一个TableQuery
Slick,并使用它来查询数据库。有简单的方法可以执行等效的 DB 操作。我们可以使用 play-slick 包装器和 Slick API 在 Anorm 对象中定义这些方法:
object SlickUserHelper {
val users = TableQuery[SlickUserTable]
def add(loginId: String,
password: String,
name: String = "anonymous",
dateOfBirth: DateTime): Long = {
play.api.db.slick.DB.withSession { implicit session =>
users.map(p => (p.loginId, p.name, p.dob, p.password))
.returning(users.map(_.id))
.insert((loginId, name, dateOfBirth.getMillis, password))
}
}
def updatePassword(userId: Long,
password: String) = {
play.api.db.slick.DB.withSession { implicit session =>
users.filter(_.id === userId)
.map(u => u.password)
.update(password)
}
}
def getAll: Seq[SlickUser] = {
play.api.db.slick.DB.withSession { implicit session =>
users.run
}
}
}
run
方法等同于调用SELECT *
。
更多详情请参阅 Slick(slick.typesafe.com/doc/2.1.0/
)和 play-slick 文档(github.com/playframework/play-slick
)。
ReactiveMongo
由于非结构化数据、写入可扩展性等原因,现在许多应用程序使用 NoSQL 数据库。MongoDB 就是其中之一。根据其网站(docs.mongodb.org/manual/core/introduction/
):
MongoDB 是一个提供高性能、高可用性和自动扩展的开源文档数据库。
MongoDB 的关键特性包括:
高性能
高可用性(自动故障转移、数据冗余)
自动扩展(水平扩展)
ReactiveMongo 是 MongoDB 的 Scala 驱动程序,支持非阻塞和异步 I/O 操作。有一个名为 Play-ReactiveMongo 的 Play Framework 插件。它不是一个 Play 插件,但它由 ReactiveMongo 团队支持和维护。
注意
这一节需要具备 MongoDB 的相关知识,请参阅www.mongodb.org/
。
要使用它,我们需要做以下操作:
-
在构建文件中将它作为依赖项包含:
libraryDependencies ++= Seq( "org.reactivemongo" %% "play2-reactivemongo" % "0.10.5.0.akka23" )
-
在
conf/play.plugins
中包含插件:1100:play.modules.reactivemongo.ReactiveMongoPlugin
-
在
conf/application.conf
中添加 MongoDB 服务器详情:mongodb.servers = ["localhost:27017"] mongodb.db = "your_db_name" mongodb.credentials.username = "user" mongodb.credentials.password = "pwd"
或者,使用以下方法:
mongodb.uri = "mongodb://user:password@localhost:27017/your_db_name"
让我们通过一个示例应用程序来看看这个插件的用法。在我们的应用程序中,我们可能会遇到允许用户以热传感器、烟雾探测器等形式监控其设备活动的情况。
在将设备与安装了我们的应用程序的设备一起使用之前,该设备应注册到这个应用程序中。每个设备都有一个ownerId
、deviceId
、其配置和产品信息。因此,让我们假设在注册时,我们以以下格式获得一个 JSON:
{
"deviceId" : "aghd",
"ownerId" : "someUser@someMail.com"
"config" : { "sensitivity" : 4, …},
"info" : {"brand" : "superBrand","os" : "xyz","version" : "2.4", …}
}
一旦设备注册,所有者可以更新配置或同意更新产品的软件。软件更新由设备公司处理,我们只需要在我们的应用程序中更新详细信息。
对数据库的查询将是:
-
Insert
:此查询包括注册设备 -
Update
:此查询包括更新设备配置或信息 -
Delete
:此查询发生在设备注销时 -
Select
:此查询发生在所有者希望查看设备详情时使用 Reactive Mongo,设备注册将是:
def registerDevice(deviceId: String, ownerId: String, deviceDetails: JsObject): Future[LastError] = { var newDevice = Json.obj("deviceId" -> deviceId, "ownerId" -> ownerId.trim) val config = (deviceDetails \ "configuration").asOpt[JsObject] val metadata = (deviceDetails \ "metadata").asOpt[JsObject] if (!config.isDefined) newDevice = newDevice ++ Json.obj("configuration" -> Json.parse("{}")) if (!metadata.isDefined) newDevice = newDevice ++ Json.obj("metadata" -> Json.parse("{}")) collection.insertJsValue }
在这个片段中,我们已从可用的设备详细信息中构建了一个 JSON 对象,并将其插入到devices
中。在这里,集合定义如下:
def db = ReactiveMongoPlugin.db
def collection = db.collection("devices")
插入命令接受数据和其类型:
The db operations for fetching a device or removing it are simple,def fetchDevice(deviceId: String): Future[Option[JsObject]] = {
val findDevice = Json.obj("deviceId" -> deviceId)
collection.find(findDevice).one[JsObject]
}
def removeDeviceById(deviceId: String): Future[LastError] = {
val removeDoc = Json.obj("deviceId" -> deviceId)
collection.removeJsValue
}
这就留下了更新查询。更新是针对配置或信息的单个属性触发的,也就是说,请求只有一个字段,其新值如下:
{ "sensitivity": 4.5}
现在,更新此内容的查询将是:
def updateConfiguration(deviceId: String,
ownerId: String,
updatedField: JsObject) = {
val property = updatedField.keys.head
val propertyValue = updatedField.values.head
val toUpdate = Json.obj(s"configuration.$property" -> propertyValue)
val setData = Json.obj("$set" -> toUpdate)
val documentToUpdate = Json.obj("deviceId" -> deviceId, "ownerId" -> ownerId)
collection.updateJsValue, JsValue
}
当我们希望更新 MongoDB 中给定文档的字段时,我们应该将更新后的数据添加到查询中的$set
字段。例如,等效的 MongoDB 查询如下所示:
db.devices.update(
{ deviceId: "aghd" ,"ownerId" : "someUser@someMail.com"},
{ $set: { "configuration.sensitivity": 4.5 } }
)
缓存 API
在 Web 应用程序中,缓存是将动态生成的内容(无论是数据对象、页面还是页面的一部分)在首次请求时存储在内存中的过程。如果后续请求相同的数据,则可以稍后重用,从而减少响应时间并提高用户体验。这些项目可以存储在 Web 服务器或其他软件中,例如代理服务器或浏览器。
Play 有一个最小的缓存 API,它使用 EHCache。如其在网站(ehcache.org/
)上所述:
Ehcache是一个开源、基于标准的缓存,用于提升性能、减轻数据库负担并简化可伸缩性。由于它稳健、经过验证且功能全面,因此它是使用最广泛的基于 Java 的缓存。Ehcache 可从进程内、一个或多个节点扩展到混合进程内/进程外配置,缓存大小可达数 TB。
它为表示层以及特定于应用程序的对象提供缓存。它易于使用、维护和扩展。
注意
要在 Play 应用程序中使用默认的缓存 API,我们应该如下声明它作为依赖项:
libraryDependencies ++= Seq(cache)
使用默认的缓存 API 类似于使用可变的Map[String, Any]
:
Cache.set("userSession", session)
val maybeSession: Option[UserSession] = Cache.getAsUserSession
Cache.remove("userSession")
此 API 通过EHCachePlugin
提供。该插件负责在启动应用程序时创建一个具有可用配置的 EHCache CacheManager 实例,并在应用程序停止时关闭它。我们将在第十三章中详细讨论 Play 插件,编写 Play 插件。基本上,EHCachePlugin
处理使用 EHCache 在应用程序中所需的所有样板代码,而EhCacheImpl
提供执行这些操作的方法,例如get
、set
和remove
。它定义如下:
class EhCacheImpl(private val cache: Ehcache) extends CacheAPI {
def set(key: String, value: Any, expiration: Int) {
val element = new Element(key, value)
if (expiration == 0) element.setEternal(true)
element.setTimeToLive(expiration)
cache.put(element)
}
def get(key: String): Option[Any] = {
Option(cache.get(key)).map(_.getObjectValue)
}
def remove(key: String) {
cache.remove(key)
}
}
注意
默认情况下,插件在conf
目录中查找ehcache.xml
文件,如果该文件不存在,则加载ehcache-default.xml
框架提供的默认配置。
还可以在启动应用程序时使用ehcache.configResource
参数指定ehcache
配置的位置。
缓存 API 还简化了处理应用程序客户端和服务器端请求结果的缓存。添加EXPIRES
和etag
头可以用来操作客户端缓存,而在服务器端,结果被缓存,因此对应的操作不会在每次调用时计算。
例如,我们可以缓存用于获取不活跃用户详细信息的请求的结果:
def getInactiveUsers = Cached("inactiveUsers") {
Action {
val users = User.getAllInactive
Ok(Json.toJson(users))
}
}
然而,如果我们想每小时更新一次,我们只需明确指定持续时间即可:
def getInactiveUsers = Cached("inactiveUsers").default(3600) {
Action {
val users = User.getAllInactive
Ok(Json.toJson(users))
}
}
所有这些都被Cached
案例类及其伴随对象处理。案例类定义如下:
case class Cached(key: RequestHeader => String, caching: PartialFunction[ResponseHeader, Duration]) { … }
伴随对象提供了生成缓存实例的常用方法,例如根据其状态缓存操作等。
缓存调用中的apply
方法调用定义如下:
def build(action: EssentialAction)(implicit app: Application) = EssentialAction { request =>
val resultKey = key(request)
val etagKey = s"$resultKey-etag"
// Has the client a version of the resource as fresh as the last one we served?
val notModified = for {
requestEtag <- request.headers.get(IF_NONE_MATCH)
etag <- Cache.getAsString
if requestEtag == "*" || etag == requestEtag
} yield Done[Array[Byte], Result](NotModified)
notModified.orElse {
// Otherwise try to serve the resource from the cache, if it has not yet expired
Cache.getAsResult.map(Done[Array[Byte], Result](_))
}.getOrElse {
// The resource was not in the cache, we have to run the underlying action
val iterateeResult = action(request)
// Add cache information to the response, so clients can cache its content
iterateeResult.map(handleResult(_, etagKey, resultKey, app))
}
}
它只是简单地检查结果是否已被修改。如果没有被修改,它尝试从Cache
中获取结果。如果结果不在缓存中,它将从操作中获取它,并使用handleResult
方法将其添加到Cache
中。handleResult
方法定义如下:
private def handleResult(result: Result, etagKey: String, resultKey: String, app: Application): Result = {
cachingWithEternity.andThen { duration =>
// Format expiration date according to http standard
val expirationDate = http.dateFormat.print(System.currentTimeMillis() + duration.toMillis)
// Generate a fresh ETAG for it
val etag = expirationDate // Use the expiration date as ETAG
val resultWithHeaders = result.withHeaders(ETAG -> etag, EXPIRES -> expirationDate)
// Cache the new ETAG of the resource
Cache.set(etagKey, etag, duration)(app)
// Cache the new Result of the resource
Cache.set(resultKey, resultWithHeaders, duration)(app)
resultWithHeaders
}.applyOrElse(result.header, (_: ResponseHeader) => result)
}
如果指定了持续时间,它将返回该值,否则它将返回默认的一年持续时间。
handleResult
方法简单地接受结果,添加etag
、过期头信息,然后将带有给定键的结果添加到Cache
中。
故障排除
以下部分涵盖了某些常见场景:
-
即使查询产生了预期的行为,Anorm 也会在
SqlMappingError
运行时抛出错误(当你期望一行时却有多行),这是一个使用“on duplicate key update”的插入查询。这可能发生在使用
executeInsert
执行此类查询时。当我们需要返回自动生成的键时,应使用executeInsert
方法。如果我们通过重复键更新某些字段,这意味着我们实际上不需要键。我们可以使用executeUpdate
来添加一个检查,看是否有一行已被更新。例如,我们可能想更新跟踪用户愿望清单的表:DB.withConnection { implicit connection => { val updatedRows = SQL"""INSERT INTO wish_list (user_id, product_id, liked_at) VALUES ($userId,$productId,$likedAt) ON DUPLICATE KEY UPDATE liked_at=$likedAt, is_deleted=false """.executeUpdate() updatedRows == 1 } }
-
我们能否为单个应用程序使用多个数据库?
是的,可以使用不同类型的数据库,包括相同类型和不同类型。如果一个应用程序需要这样做,我们可以使用两个或更多不同的关系型数据库或 NoSQL 数据库,或者两者的组合。例如,应用程序可能将其用户数据存储在 SQL 数据库中(因为我们已经知道用户数据的格式),而将用户设备的信息存储在 MongoDB 中(因为设备来自不同的供应商,它们的数据格式可能会变化)。
-
当查询有错误的语法时,Anorm 不会抛出编译错误。是否有配置可以启用此功能?
它的开发目的是在代码中使用 SQL 查询而无需任何麻烦。开发者预计会将正确的查询传递给 Anorm 方法。为了确保在运行时不会发生此类错误,开发者可以在本地执行查询并在成功后将其用于代码中。或者,还有一些第三方插件提供了类型安全的 DSL,如果它们满足要求,可以替代 Anorm 使用,例如 play-slick 或 scalikejdbc-play-support (
github.com/scalikejdbc/scalikejdbc-play-support
)。 -
是否可以使用其他的缓存机制?
是的,可以扩展对任何其他缓存的支持,例如 OSCache、SwarmCache、MemCached 等等,或者通过编写类似于 EHCachePlugin 的插件来实现自定义缓存。一些流行的缓存机制已经由个人和/或其他组织开发了 Play 插件。例如,play2-memcached (
github.com/mumoshu/play2-memcached
) 和 Redis 插件 (github.com/typesafehub/play-plugins/tree/master/redis
)。
摘要
在本章中,我们看到了在用 Play 框架构建的应用程序中持久化应用程序数据的不同方法。在这个过程中,我们看到了两种截然不同的方法:一种使用关系型数据库,另一种使用 NoSQL 数据库。为了在关系型数据库中持久化,我们研究了 Anorm 模块和 JDBC 插件的工作方式。为了将 NoSQL 数据库(MongoDB)用于我们应用程序的后端,我们使用了 Play 的 ReactiveMongo 插件。除此之外,我们还看到了如何使用 Play 缓存 API 以及它是如何工作的。
在下一章中,我们将学习如何在 Play 中处理数据流。
第六章:反应式数据流
在特定情况下,我们的应用程序可能需要处理大量文件上传。这可以通过将这些全部放入内存中、创建一个临时文件或直接在流上操作来实现。在这三种方法中,最后一种对我们来说效果最好,因为它消除了 I/O 流限制(如阻塞、内存和线程),并且消除了缓冲的需要(即在所需的速率上对输入进行操作)。
处理大量文件上传属于不可避免的操作集合,这些操作可能会对资源造成很大压力。属于同一类别的其他任务包括处理实时数据以进行监控、分析、批量数据传输以及处理大型数据集。在本章中,我们将讨论用于处理此类情况的 Iteratee 方法。本章涵盖了使用以下主题的简要说明来处理数据流的基础:
-
Iteratees
-
枚举器
-
Enumeratees
本章在某些时候可能看起来很紧张,但这里讨论的主题将对以下章节有所帮助。
数据流处理基础
假设我们将一个移动设备(如平板电脑、手机、MP3 播放器等)连接到其充电器并插入。这可能导致以下后果:
-
设备的电池开始充电,并持续充电,直到发生其他选项之一
-
设备的电池完全充电,并且设备为了继续运行而消耗的最小电力
-
由于设备故障,设备的电池无法充电
在这里,电源是源,设备是汇,而充电器是使能量从源传输到汇的通道。设备执行的处理或任务是为其电池充电。
好吧,这涵盖了 Iteratee 方法的大部分内容,没有使用任何通常的术语。简单来说,电源代表数据源,充电器充当 Enumerator,而设备充当 Iteratee。
哎呀,我们错过了 Enumeratee!假设常规电源的能量与设备不兼容;在这种情况下,充电器通常有一个内部组件执行这种转换。例如,将交流电(A.C.)转换为直流电(D.C.)。在这种情况下,充电器可以被认为是 Enumerator 和 Enumeratee 的组合。从电源收集能量的组件类似于 Enumerator,而转换能量的另一个组件类似于 Enumeratee。
Iteratee、Enumerator 和 Enumeratee 的概念起源于 Haskell 库 Iteratee I/O,该库由 Oleg Kiselyov 开发,旨在克服懒 I/O 所面临的问题。
如 Oleg 在其okmij.org/ftp/Streams.html
上的话所说:
Enumerator 是数据源的一个封装,一个流生产者——它将 iteratee 应用于流。Enumerator 接收一个 iteratee 并将其应用于正在产生的流数据,直到源耗尽或 iteratee 表示它已经足够。在处理完缓冲区和其他源排空资源后,enumerator 返回 iteratee 的最终值。因此,Enumerator 是一个 iteratee 转换器。
Iteratees 是流消费者,一个 Iteratee 可以处于以下状态之一:
-
完成或完成:Iteratee 已完成处理
-
继续:当前元素已被处理,但 Iteratee 还未完成,可以接受下一个元素
-
错误:Iteratee 遇到了错误
Enumeratee 既是消费者又是生产者,它逐步解码外部流并产生解码数据的嵌套流。
虽然枚举器知道如何获取下一个元素,但它对 Iteratee 将要对该元素执行的处理一无所知,反之亦然。
不同的库根据这些定义以不同的方式实现 Iteratee、Enumerator 和 Enumeratee。在接下来的章节中,我们将看到它们在 Play Framework 中的实现方式以及如何在我们的应用程序中使用它们。让我们从 Iteratee 开始,因为 Enumerator 需要一个 Iteratee。
Iteratees
Iteratee 被定义为 trait,Iteratee[E, +A]
,其中 E 是输入类型,A 是结果类型。Iteratee 的状态由 Step
的一个实例表示,它被定义为如下:
sealed trait Step[E, +A] {
def it: Iteratee[E, A] = this match {
case Step.Done(a, e) => Done(a, e)
case Step.Cont(k) => Cont(k)
case Step.Error(msg, e) => Error(msg, e)
}
}
object Step {
//done state of an iteratee
case class Done+A, E extends Step[E, A]
//continuing state of an iteratee.
case class ContE, +A extends Step[E, A]
//error state of an iteratee
case class ErrorE extends Step[E, Nothing]
}
这里使用的输入代表数据流中的一个元素,它可以是一个空元素、一个元素或文件结束指示符。因此,Input
被定义为如下:
sealed trait Input[+E] {
def mapU): Input[U] = this match {
case Input.El(e) => Input.El(f(e))
case Input.Empty => Input.Empty
case Input.EOF => Input.EOF
}
}
object Input {
//An input element
case class El+E extends Input[E]
// An empty input
case object Empty extends Input[Nothing]
// An end of file input
case object EOF extends Input[Nothing]
}
Iteratee 是一个不可变的数据类型,处理输入的每个结果都是一个具有新状态的新的 Iteratee。
处理 Iteratee 的可能状态时,为每个状态都有一个预定义的辅助对象。它们是:
-
Cont
-
完成
-
错误
让我们看看 readLine
方法的定义,它利用了这些对象:
def readLine(line: List[Array[Byte]] = Nil): Iteratee[Array[Byte], String] = Cont {
case Input.El(data) => {
val s = data.takeWhile(_ != '\n')
if (s.length == data.length) {
readLine(s :: line)
} else {
Done(new String(Array.concat((s :: line).reverse: _*), "UTF-8").trim(), elOrEmpty(data.drop(s.length + 1)))
}
}
case Input.EOF => {
Error("EOF found while reading line", Input.Empty)
}
case Input.Empty => readLine(line)
}
readLine
方法负责读取一行并返回一个 Iteratee。只要还有更多字节要读取,就会递归调用 readLine
方法。在完成处理后,返回一个具有完成状态(Done)的 Iteratee,否则返回一个具有连续状态(Cont)的 Iteratee。如果方法遇到 EOF,则返回一个具有错误状态(Error)的 Iteratee。
此外,Play Framework 还公开了一个配套的 Iteratee 对象,它提供了处理 Iteratee 的辅助方法。通过 Iteratee 对象公开的 API 在 www.playframework.com/documentation/2.3.x/api/scala/index.html#play.api.libs.iteratee.Iteratee$
中进行了文档说明。
Iteratee 对象也在框架内部使用,以提供一些关键特性。例如,考虑请求体解析器。BodyParser
对象的apply
方法定义如下:
def applyT(f: RequestHeader => Iteratee[Array[Byte], Either[Result, T]]): BodyParser[T] = new BodyParser[T] {
def apply(rh: RequestHeader) = f(rh)
override def toString = "BodyParser(" + debugName + ")"
}
因此,为了定义BodyParser[T]
,我们需要定义一个接受RequestHeader
并返回一个输入为Array[Byte]
、结果为Either[Result,T]
的Iteratee
的方法。
让我们看看一些现有的实现,以了解它是如何工作的。
RawBuffer
解析器定义如下:
def raw(memoryThreshold: Int): BodyParser[RawBuffer] = BodyParser("raw, memoryThreshold=" + memoryThreshold) { request =>
import play.core.Execution.Implicits.internalContext
val buffer = RawBuffer(memoryThreshold)
Iteratee.foreach[Array[Byte]](bytes => buffer.push(bytes)).map { _ =>
buffer.close()
Right(buffer)
}
}
RawBuffer
解析器使用Iteratee.forEach
方法并将接收到的输入推送到缓冲区。
文件解析器定义如下:
def file(to: File): BodyParser[File] = BodyParser("file, to=" + to) { request =>
import play.core.Execution.Implicits.internalContext
Iteratee.fold[Array[Byte], FileOutputStream](new FileOutputStream(to)) {
(os, data) =>
os.write(data)
os
}.map { os =>
os.close()
Right(to)
}
}
文件解析器使用Iteratee.fold
方法创建接收数据的FileOutputStream
。
现在,让我们看看 Enumerator 的实现以及这两部分是如何结合在一起的。
Enumerator
与 Iteratee 类似,Enumerator也是通过特性和同名的对象定义的:
trait Enumerator[E] {
parent =>
def applyA: Future[Iteratee[E, A]]
...
}
object Enumerator{
def applyE: Enumerator[E] = in.length match {
case 0 => Enumerator.empty
case 1 => new Enumerator[E] {
def applyA: Future[Iteratee[E, A]] = i.pureFoldNoEC {
case Step.Cont(k) => k(Input.El(in.head))
case _ => i
}
}
case _ => new Enumerator[E] {
def applyA: Future[Iteratee[E, A]] = enumerateSeq(in, i)
}
}
...
}
注意到特性和其伴生对象的apply
方法不同。特质的apply
方法接受Iteratee[E, A]
并返回Future[Iteratee[E, A]]
,而伴生对象的apply
方法接受类型为E
的序列并返回Enumerator[E]
。
现在,让我们使用伴生对象的apply
方法定义一个简单的数据流;首先,获取给定(Seq[String])
行中的字符计数:
val line: String = "What we need is not the will to believe, but the wish to find out."
val words: Seq[String] = line.split(" ")
val src: Enumerator[String] = Enumerator(words: _*)
val sink: Iteratee[String, Int] = Iteratee.foldString, Int((x, y) => x + y.length)
val flow: Future[Iteratee[String, Int]] = src(sink)
val result: Future[Int] = flow.flatMap(_.run)
变量result
具有Future[Int]
类型。我们现在可以处理它以获取实际计数。
在前面的代码片段中,我们通过以下步骤获取结果:
-
使用伴生对象的
apply
方法构建 Enumerator:val src: Enumerator[String] = Enumerator(words: _*)
-
通过将枚举器绑定到 Iteratee 获取
Future[Iteratee[String, Int]]
:val flow: Future[Iteratee[String, Int]] = src(sink)
-
展平
Future[Iteratee[String,Int]]
并处理它:val result: Future[Int] = flow.flatMap(_.run)
-
从
Future[Int]
获取结果:
幸运的是,Play 提供了一个快捷方法,通过合并步骤 2 和 3,这样我们就不必每次都重复相同的过程。该方法由|>>>
符号表示。使用快捷方法,我们的代码简化为如下:
val src: Enumerator[String] = Enumerator(words: _*)
val sink: Iteratee[String, Int] = Iteratee.foldString, Int((x, y) => x + y.length)
val result: Future[Int] = src |>>> sink
当我们可以直接使用数据类型的方法时,为什么还要使用这个方法?在这种情况下,我们是否使用String
的length
方法来获取相同的值(通过忽略空白字符)?
在这个例子中,我们获取数据作为一个单独的String
,但这不会是唯一的情况。我们需要处理连续数据的方法,例如文件上传,或从各种网络站点获取数据,等等。
例如,假设我们的应用程序从连接到它的所有设备(如摄像头、温度计等)以固定间隔接收心跳。我们可以使用Enumerator.generateM
方法模拟数据流:
val dataStream: Enumerator[String] = Enumerator.generateM {
Promise.timeout(Some("alive"), 100 millis)
}
在前面的代码片段中,每 100 毫秒产生一次 "alive"
字符串。传递给 generateM
方法的函数在 Iteratee 绑定到 Enumerator 时处于 Cont
状态时被调用。此方法用于内部构建枚举器,并在我们想要分析预期数据流的处理时很有用。
可以从文件、InputStream
或 OutputStream
创建 Enumerator。Enumerators 可以连接或交错。Enumerator API 在 www.playframework.com/documentation/2.3.x/api/scala/index.html#play.api.libs.iteratee.Enumerator$
中进行了文档说明。
使用 Concurrent 对象
Concurrent
对象是一个辅助工具,它提供了使用 Iteratees、枚举器和 Enumeratees 并发的实用工具。它的重要方法有两个:
-
单播:当需要向单个 iterate 发送数据时很有用。
-
广播:它便于将相同的数据并发发送到多个 Iteratee。
单播
例如,上一节中的字符计数示例可以如下实现:
val unicastSrc = Concurrent.unicastString
)
val unicastResult: Future[Int] = unicastSrc |>>> sink
unicast
方法接受 onStart
、onError
和 onComplete
处理器。在前面的代码片段中,我们提供了强制性的 onStart
方法。unicast 的签名如下:
def unicastE ⇒ Unit,
onComplete: ⇒ Unit = (),
onError: (String, Input[E]) ⇒ Unit = (_: String, _: Input[E]) => ())(implicit ec: ExecutionContext): Enumerator[E] {…}
因此,为了添加错误日志,我们可以定义 onError
处理器如下:
val unicastSrc2 = Concurrent.unicastString,
onError = { (msg, str) => Logger.error(s"encountered $msg for $str")}
)
现在,让我们看看广播是如何工作的。
广播
broadcast[E]
方法创建一个枚举器和通道,并返回一个 (Enumerator[E], Channel[E])
元组。因此获得的枚举器和通道可以用来向多个 Iteratee 广播数据:
val (broadcastSrc: Enumerator[String], channel: Concurrent.Channel[String]) = Concurrent.broadcast[String]
private val vowels: Seq[Char] = Seq('a', 'e', 'i', 'o', 'u')
def getVowels(str: String): String = {
val result = str.filter(c => vowels.contains(c))
result
}
def getConsonants(str: String): String = {
val result = str.filterNot(c => vowels.contains(c))
result
}
val vowelCount: Iteratee[String, Int] = Iteratee.foldString, Int((x, y) => x + getVowels(y).length)
val consonantCount: Iteratee[String, Int] = Iteratee.foldString, Int((x, y) => x + getConsonants(y).length)
val vowelInfo: Future[Int] = broadcastSrc |>>> vowelCount
val consonantInfo: Future[Int] = broadcastSrc |>>> consonantCount
words.foreach(w => channel.push(w))
channel.end()
vowelInfo onSuccess { case count => println(s"vowels:$count")}
consonantInfo onSuccess { case count => println(s"consonants:$count")}
Enumeratees
Enumeratee 也使用具有相同 Enumeratee
名称的特性和伴随对象来定义。
它的定义如下:
trait Enumeratee[From, To] {
...
def applyOnA: Iteratee[From, Iteratee[To, A]]
def applyA: Iteratee[From, Iteratee[To, A]] = applyOnA
...
}
Enumeratee 将其作为输入给出的 Iteratee 进行转换,并返回一个新的 Iteratee。让我们看看通过实现 applyOn
方法定义 Enumeratee 的方法。Enumeratee 的 flatten
方法接受 Future[Enumeratee]
并返回另一个 Enumeratee,其定义如下:
def flattenFrom, To = new Enumeratee[From, To] {
def applyOnA: Iteratee[From, Iteratee[To, A]] =
Iteratee.flatten(futureOfEnumeratee.map(_.applyOnA)(dec))
}
在前面的代码片段中,applyOn
方法被调用在传递了其未来的 Enumeratee 上,而 dec
是 defaultExecutionContext
。
使用伴随对象定义 Enumeratee 要简单得多。伴随对象有许多处理 Enumeratees 的方法,例如 map、transform、collect、take、filter 等。API 在 www.playframework.com/documentation/2.3.x/api/scala/index.html#play.api.libs.iteratee.Enumeratee$
中进行了文档说明。
让我们通过解决一个问题来定义一个Enumeratee
。我们在上一节中使用的示例,用于查找元音和辅音的数量,如果句子中的元音被大写,则不会正确工作,即当line
变量定义为以下内容时,src |>>> vowelCount
的结果将是错误的:
val line: String = "What we need is not the will to believe, but the wish to find out.".toUpperCase
为了解决这个问题,让我们将数据流中所有字符的大小写更改为小写。我们可以使用Enumeratee
来更新提供给Iteratee
的输入。
现在,让我们定义一个Enumeratee
,它返回给定的小写字符串:
val toSmallCase: Enumeratee[String, String] = Enumeratee.map[String] {
s => s.toLowerCase
}
有两种方法可以将Enumeratee
添加到数据流中。它可以绑定到以下内容:
-
枚举器
-
Iteratee
将一个Enumeratee
绑定到一个枚举器上
可以通过枚举器的through
方法将Enumeratee
绑定到枚举器上,该方法返回一个新的枚举器,并使用给定的Enumeratee
进行组合。
更新示例以包括Enumeratee
,我们得到以下内容:
val line: String = "What we need is not the will to believe, but the wish to find out.".toUpperCase
val words: Seq[String] = line.split(" ")
val src: Enumerator[String] = Enumerator(words: _*)
private val vowels: Seq[Char] = Seq('a', 'e', 'i', 'o', 'u')
def getVowels(str: String): String = {
val result = str.filter(c => vowels.contains(c))
result
}
src.through(toSmallCase) |>>> vowelCount
through
方法是对&>
方法的别名,它是为枚举器定义的,因此最后的语句也可以重写为以下内容:
src &> toSmallCase |>>> vowelCount
将一个Enumeratee
绑定到一个Iteratee
上
现在,让我们通过将Enumeratee
绑定到Iteratee
上来实现相同的流程。这可以通过使用Enumeratee
的transform
方法来完成。transform
方法将给定的Iteratee
转换,并产生一个新的Iteratee
。根据这一点修改流程,我们得到以下内容:
src |>>> toSmallCase.transform(vowelCount)
Enumeratee
的transform
方法有一个&>>
符号别名。使用这个别名,我们可以将流程重写如下:
src |>>> toSmallCase &>> vowelCount
除了Enumeratee
可以绑定到枚举器或Iteratee
之外,如果其中一个的输出类型与另一个的输入类型相同,不同的Enumeratee
也可以组合。例如,假设我们有一个filterVowel
Enumeratee
,它过滤掉元音,如下面的代码所示:
val filterVowel: Enumeratee[String, String] = Enumeratee.map[String] {
str => str.filter(c => vowels.contains(c))
}
toSmallCase
和filterVowel
的组合是可能的,因为toSmallCase
的输出类型是String
,而filterVowel
的输入类型也是String
。为此,我们使用Enumeratee
的compose
方法:
toSmallCase.compose(filterVowel)
现在,让我们使用以下方式重写流程:
src |>>> toSmallCase.compose(filterVowel) &>> sink
在这里,sink
被定义为以下内容:
val sink: Iteratee[String, Int] = Iteratee.foldString, Int((x, y) => x + y.length)
与transform
和compose
方法一样,这也具有><>
符号别名。让我们使用所有符号而不是方法名来定义以下流程:
src |>>> toSmallCase ><> filterVowel &>> sink
我们可以添加另一个Enumeratee
,它计算String
的长度并使用Iteratee
,它简单地求和长度:
val toInt: Enumeratee[String, Int] = Enumeratee.map[String] {
str => str.length
}
val sum: Iteratee[Int, Int] = Iteratee.foldInt, Int((x, y) => x + y)
src |>>> toSmallCase ><> filterVowel ><> toInt &>> sum
在前面的代码片段中,我们必须使用一个接受Int
类型数据的不同迭代器,因为我们的toInt
Enumeratee
将String
输入转换为Int
。
本章到此结束。定义几个数据流以熟悉 API。从简单的数据流开始,例如提取给定段落中的所有数字或单词,然后逐步复杂化。
摘要
在本章中,我们讨论了迭代器(Iteratees)、枚举器(Enumerators)和枚举者(Enumeratees)的概念。我们还看到了它们如何在 Play 框架中实现并被内部使用。本章还通过一个简单的示例向您展示了如何使用 Play 框架公开的 API 定义数据流。
在下一章中,我们将通过一个全局插件来探索 Play 应用程序提供的功能。
第七章。玩转全局变量
有时网络应用程序需要生存期超出请求-响应生命周期的应用程序范围内的对象,例如数据库连接、应用程序配置、共享对象和横切关注点(如身份验证、错误处理等)。考虑以下情况:
-
确保应用程序使用的数据库已定义且可访问。
-
当应用程序接收意外的大量流量时,通过电子邮件或任何其他服务进行通知。
-
记录应用程序服务的不同请求。这些日志可以稍后用于分析用户行为。
-
通过时间限制网络应用程序上的某些功能。例如,一些食品订购应用程序仅在上午 11 点到晚上 8 点之间接收订单,而任何其他时间对构建订单的请求都将被阻止,并显示关于时间的信息。
-
通常,当用户发送电子邮件且收件人的电子邮件 ID 错误或未使用时,发送者只有在 12 到 24 小时后才会被通知电子邮件发送失败。在此期间,会尝试再次发送电子邮件。
允许用户在支付因各种原因被拒绝时,使用相同的或不同的支付选项重试的应用程序。
在 Play 框架应用程序中,按照惯例,所有这些各种关注点都可以通过 GlobalSettings 来管理。
在本章中,我们将讨论以下主题:
-
GlobalSettings
-
应用程序生命周期
-
请求-响应生命周期
GlobalSettings
每个 Play 应用程序都有一个全局对象,可以用来定义应用程序范围内的对象。它还可以用来自定义应用程序的生命周期和请求-响应生命周期。
应用程序的全局对象可以通过扩展 GlobalSettings
特质来定义。默认情况下,对象的名称预期为 Global
,并且假定它位于 app
目录中。这可以通过更新 conf/application.conf
属性中的 application.global
来更改。例如,如果我们希望使用 app/com/org
命名空间中的 AppSettings
文件:
application.global=app.com.org.AppSettings
GlobalSettings
特质具有可以用来中断应用程序生命周期和请求-响应生命周期的方法。我们将在以下章节中根据需要查看其方法。
现在,让我们看看它是如何工作的。
通过 Play 框架开发的应用程序由 Application
特质的实例表示,因为其创建和构建将由框架本身处理。
Application
特质由 DefaultApplication
和 FakeApplication
扩展。FakeApplication
是一个用于测试 Play 应用程序的帮助程序,我们将在第九章测试中看到更多关于它的内容。DefaultApplication
定义如下:
class DefaultApplication(
override val path: File,
override val classloader: ClassLoader,
override val sources: Option[SourceMapper],
override val mode: Mode.Mode) extends Application with WithDefaultConfiguration with WithDefaultGlobal with WithDefaultPlugins
WithDefaultConfiguration
和WithDefaultPlugins
特质分别用于初始化应用程序的配置和插件对象。WithDefaultGlobal
特质负责为应用程序设置正确的全局对象。它定义如下:
trait WithDefaultGlobal {
self: Application with WithDefaultConfiguration =>
private lazy val globalClass = initialConfiguration.getString("application.global").getOrElse(initialConfiguration.getString("global").map { g =>
Play.logger.warn("`global` key is deprecated, please change `global` key to `application.global`")
g
}.getOrElse("Global"))
lazy private val javaGlobal: Option[play.GlobalSettings] = try {
Option(self.classloader.loadClass(globalClass).newInstance().asInstanceOf[play.GlobalSettings])
} catch {
case e: InstantiationException => None
case e: ClassNotFoundException => None
}
lazy private val scalaGlobal: GlobalSettings = try {
self.classloader.loadClass(globalClass + "$").getDeclaredField("MODULE$").get(null).asInstanceOf[GlobalSettings]
} catch {
case e: ClassNotFoundException if !initialConfiguration.getString("application.global").isDefined => DefaultGlobal
case e if initialConfiguration.getString("application.global").isDefined => {
throw initialConfiguration.reportError("application.global", s"Cannot initialize the custom Global object ($globalClass) (perhaps it's a wrong reference?)", Some(e))
}
}
private lazy val globalInstance: GlobalSettings = Threads.withContextClassLoader(self.classloader) {
try {
javaGlobal.map(new j.JavaGlobalSettingsAdapter(_)).getOrElse(scalaGlobal)
} catch {
case e: PlayException => throw e
case e: ThreadDeath => throw e
case e: VirtualMachineError => throw e
case e: Throwable => throw new PlayException(
"Cannot init the Global object",
e.getMessage,
e
)
}
}
def global: GlobalSettings = {
globalInstance
}
}
globalInstance
对象是用于此应用程序的global
对象。它设置为javaGlobal
或scalaGlobal
,具体取决于应用程序。如果应用程序没有为应用程序配置自定义的 Global 对象,则应用程序的global
设置为DefaultGlobal
。它定义如下:
object DefaultGlobal extends GlobalSettings
应用程序的生命周期
应用程序的生命周期有两个状态:运行和停止。这些是应用程序状态发生变化的时间。有时,我们需要在状态变化之前或之后立即执行某些操作。
Play 应用程序使用 Netty 服务器。为此,使用具有相同名称的类。它定义如下:
class NettyServer(appProvider: ApplicationProvider, port: Option[Int], sslPort: Option[Int] = None, address: String = "0.0.0.0", val mode: Mode.Mode = Mode.Prod) extends Server with ServerWithStop { … }
此类负责将应用程序绑定或引导到服务器。
ApplicationProvider
特质定义如下:
trait ApplicationProvider {
def path: File
def get: Try[Application]
def handleWebCommand(requestHeader: play.api.mvc.RequestHeader): Option[Result] = None
}
ApplicationProvider
的实现必须创建并初始化一个应用程序。目前,有三种不同的ApplicationProvider
实现。它们如下:
-
StaticApplication
:这个用于生产模式(代码更改不会影响已运行的应用程序的模式)。 -
ReloadableApplication
:这个用于开发模式(这是一个启用了连续编译的模式,以便开发者可以在保存时看到应用程序更改的影响,如果应用程序正在运行的话)。 -
TestApplication
:这个用于测试模式(通过测试启动一个模拟应用程序的模式)。
StaticApplication
和ReloadableApplication
都初始化一个DefaultApplication
。StaticApplication
用于生产模式,定义如下:
class StaticApplication(applicationPath: File) extends ApplicationProvider {
val application = new DefaultApplication(applicationPath, this.getClass.getClassLoader, None, Mode.Prod)
Play.start(application)
def get = Success(application)
def path = applicationPath
}
ReloadableApplication
用于开发模式,但由于类定义很大,让我们看看使用DefaultApplication
的相关代码行:
class ReloadableApplication(buildLink: BuildLink, buildDocHandler: BuildDocHandler) extends ApplicationProvider {
...
// First, stop the old application if it exists
Play.stop()
val newApplication = new DefaultApplication(reloadable.path, projectClassloader, Some(new SourceMapper {
def sourceOf(className: String, line: Option[Int]) = {
Option(buildLink.findSource(className, line.map(_.asInstanceOf[java.lang.Integer]).orNull)).flatMap {
case Array(file: java.io.File, null) => Some((file, None))
case Array(file: java.io.File, line: java.lang.Integer) => Some((file, Some(line)))
case _ => None
}
}
}), Mode.Dev) with DevSettings {
import scala.collection.JavaConverters._
lazy val devSettings: Map[String, String] = buildLink.settings.asScala.toMap
}
Play.start(newApplication)
...
}
对于StaticApplication
,应用程序只创建和启动一次,而ReloadableApplication
则会停止现有应用程序,创建并启动一个新的应用程序。ReloadableApplication
用于开发模式,以便开发者可以做出更改并看到它们的效果,而无需每次都手动重新加载应用程序。
ApplicationProvider
和NettyServer
的使用与此类似:
val appProvider = new ReloadableApplication(buildLink, buildDocHandler)
val server = new NettyServer(appProvider, httpPort, httpsPort, mode = Mode.Dev)
在下一节中,我们将讨论 GlobalSettings 中可用的方法,这些方法使我们能够挂钩到应用程序的生命周期。
干预应用程序的生命周期
考虑到我们的应用程序有以下规格:
-
在开始应用程序之前,我们需要确保
/opt/dev/appName
目录存在并且可以被应用程序访问。我们应用程序中的一个名为ResourceHandler.initialize
的方法来完成这个任务。 -
使用
DBHandler.createSchema
方法在启动时创建所需的模式。此方法不会删除已存在的模式。这确保了在重新启动应用程序时应用程序的数据不会丢失,并且模式仅在应用程序首次启动时生成。 -
当使用
Mailer.sendLogs
方法停止应用程序时,创建电子邮件应用程序日志。此方法将应用程序日志作为附件通过电子邮件发送到配置文件中设置为adminEmail
的emailId
。这用于追踪应用程序关闭的原因。
Play 提供了允许我们挂钩到应用程序的生命周期并完成此类任务的方法。GlobalSettings
特性具有辅助执行这些任务的方法。如果需要,这些方法可以通过 Global
对象进行覆盖。
为了满足前面描述的应用程序规范,在 Play 应用程序中我们只需要定义一个 Global
对象,如下所示:
object Global extends GlobalSettings {
override def beforeStart(app: Application): Unit = {
ResourceHandler.initialize
}
override def onStart(app: Application):Unit={
DBHandler.createSchema
}
override def onStop(app: Application): Unit = {
Mailer.sendLogs
}
}
ResourceHandler.initialize
、DBHandler.createSchema
和 Mailer.sendLogs
方法是针对我们的应用程序特定的,由我们定义,而不是由 Play 提供。
现在我们知道了如何挂钩到应用程序的生命周期,让我们仔细看看它是如何工作的。
深入挖掘应用程序的生命周期,我们可以看到所有 ApplicationProvider
的实现都使用 Play.start
方法来初始化一个应用程序。Play.start
方法定义如下:
def start(app: Application) {
// First stop previous app if exists
stop()
_currentApp = app
// Ensure routes are eagerly loaded, so that the reverse routers are correctly
// initialized before plugins are started.
app.routes
Threads.withContextClassLoader(classloader(app)) {
app.plugins.foreach(_.onStart())
}
app.mode match {
case Mode.Test =>
case mode => logger.info("Application started (" + mode + ")")
}
}
此方法确保在将应用程序设置为 _currentApp
之后立即调用每个插件的 onStart
方法。GlobalPlugin
默认添加到所有 Play 应用程序中,并定义为:
class GlobalPlugin(app: Application) extends Plugin {
// Call before start now
app.global.beforeStart(app)
// Called when the application starts.
override def onStart() {
app.global.onStart(app)
}
//Called when the application stops.
override def onStop() {
app.global.onStop(app)
}
}
在前面的代码片段中,app.global
指的是为应用程序定义的 GlobalSettings
。因此,GlobalPlugin
确保调用应用程序 GlobalSettings
的适当方法。
在插件初始化时调用 beforeStart
方法。
现在,我们只需要弄清楚 onStop
是如何被调用的。一旦应用程序停止,ApplicationProvider
就没有控制权了,因此使用 Java 运行时关闭钩子来确保应用程序停止后执行某些任务。以下是 NettyServer.createServer
方法中的相关代码:
Runtime.getRuntime.addShutdownHook(new Thread {
override def run {
server.stop()
}
})
在这里,运行时是 java.lang.Runtime
(有关相同内容的 Java 文档可在 docs.oracle.com/javase/7/docs/api/java/lang/Runtime.html
查找),而 server
是 NettyServer
的一个实例。NettyServer
的 stop
方法定义如下:
override def stop() {
try {
Play.stop()
} catch {
case NonFatal(e) => Play.logger.error("Error while stopping the application", e)
}
try {
super.stop()
} catch {
case NonFatal(e) => Play.logger.error("Error while stopping logger", e)
}
mode match {
case Mode.Test =>
case _ => Play.logger.info("Stopping server...")
}
// First, close all opened sockets
allChannels.close().awaitUninterruptibly()
// Release the HTTP server
HTTP.foreach(_._1.releaseExternalResources())
// Release the HTTPS server if needed
HTTPS.foreach(_._1.releaseExternalResources())
mode match {
case Mode.Dev =>
Invoker.lazySystem.close()
Execution.lazyContext.close()
case _ => ()
}
}
在这里,使用 Invoker.lazySystem.close()
调用来关闭 Play 应用程序内部使用的 ActorSystem。Execution.lazyContext.close()
调用是为了关闭 Play 的内部 ExecutionContext
。
Play.stop
方法定义如下:
def stop() {
Option(_currentApp).map { app =>
Threads.withContextClassLoader(classloader(app)) {
app.plugins.reverse.foreach { p =>
try {
p.onStop()
} catch { case NonFatal(e) => logger.warn("Error stopping plugin", e) }
}
}
}
_currentApp = null
}
此方法以相反的顺序调用所有已注册插件的 onStop
方法,因此 GlobalPlugin 的 onStop
方法被调用,并最终调用为应用程序定义的 GlobalSetting
的 onStop
方法。在这个过程中遇到的任何错误都被记录为警告,因为应用程序即将停止。
我们现在可以在应用程序的生命周期中添加任何任务,例如在启动前创建数据库模式,初始化全局对象,或者在停止时清理临时数据。
我们已经涵盖了应用程序的生命周期,现在让我们看看请求-响应生命周期。
请求-响应生命周期
Play 框架默认使用 Netty,因此请求由 NettyServer 接收。
Netty 允许执行各种操作,包括通过处理器进行自定义编码。我们可以定义一个处理器,将请求转换为所需的响应,并在启动应用程序时将其提供给 Netty。为了将 Play 应用程序与 Netty 集成,使用 PlayDefaultUpstreamHandler
。
注意
关于 Netty 中使用的请求的更多信息,请参阅 Netty 文档netty.io/wiki/user-guide-for-4.x.html
和 Netty ChannelPipeline 文档netty.io/4.0/api/io/netty/channel/ChannelPipeline.html
。
PlayDefaultUpstreamHandler
扩展了 org.jboss.netty.channel.SimpleChannelUpstreamHandler
以处理 HTTP 和 WebSocket 请求。它在以下方式启动应用程序到 Netty 时使用:
val defaultUpStreamHandler = new PlayDefaultUpstreamHandler(this, allChannels)
SimpleChannelUpStreamHandler
的 messageReceived
方法负责对收到的请求采取行动。PlayDefaultUpstreamHandler
覆盖了此方法,以便将请求发送到我们的应用程序。此方法太长(包括注释和空白行约为 260 行),所以我们在这里只查看相关块。
首先,为接收到的消息创建一个 Play RequestHeader
,并找到其对应的行为:
val (requestHeader, handler: Either[Future[Result], (Handler, Application)]) = Exception.allCatch[RequestHeader].either {
val rh = tryToCreateRequest
// Force parsing of uri
rh.path
rh
}.fold(
e => {
//Exception Handling
...
},
rh => server.getHandlerFor(rh) match {
case directResult @ Left(_) => (rh, directResult)
case Right((taggedRequestHeader, handler, application)) => (taggedRequestHeader, Right((handler, application)))
}
)
在前面的代码片段中,tryToCreateRequest
方法生成了 RequestHeader
,并且在这个过程中遇到的任何异常都被处理了。然后通过 server.getHandlerFor(rh)
获取 RequestHeader rh
的动作。在这里,一个 server
是服务器特质的实例,而 getHandlerFor
方法利用了应用程序的 global
对象及其 onRequestReceived
方法:
try {
applicationProvider.get.map { application =>
application.global.onRequestReceived(request) match {
case (requestHeader, handler) => (requestHeader, handler, application)
}
}
} catch {
//Exception Handling
...
}
在 PlayDefaultUpstreamHandler
的 messageReceived
方法中,从 server.getHandlerFor
获取的动作最终被调用,从而产生响应。
PlayDefaultUpStreamHandler
与应用程序的大部分交互都是通过其全局对象进行的。在下一节中,我们将看到与请求-响应生命周期相关的 GlobalSettings 中的可用方法。
玩弄请求-响应生命周期
GlobalSettings
特性包含与应用程序生命周期不同阶段以及其请求-响应生命周期相关的各种方法。使用请求相关的钩子,我们可以在接收到请求、找不到请求的操作等情况下定义业务逻辑。
以下是与请求相关的各种方法:
-
onRouteRequest
:此方法使用路由器来识别给定RequestHeader
的操作 -
onRequestReceived
:这会产生RequestHeader
和其操作。内部,它调用onRouteRequest
方法 -
doFilter
:这向应用程序添加了一个过滤器 -
onError
:这是一个处理处理过程中异常的方法 -
onHandlerNotFound
:当找不到 RequestHeader 对应的操作时使用 -
onBadRequest
:当请求体不正确时内部使用 -
onRequestCompletion
:用于在请求成功处理之后执行操作
操作请求及其响应
在某些应用程序中,强制过滤、修改、重定向请求及其响应是必要的。考虑以下示例:
-
任何服务的请求都必须包含包含会话详情和用户身份的头,除非是登录、注册和忘记密码等实例
-
所有以
admin
开头的路径请求都必须受到用户角色的限制 -
如果可能,将请求重定向到区域站点(例如 Google)
-
向请求或响应添加额外的字段
可以使用 onRequestReceived
、onRouteRequest
、doFilter
和 onRequestCompletion
方法来拦截请求或其响应,并根据要求对其进行操作。
让我们看看 onRequestReceived
方法:
def onRequestReceived(request: RequestHeader): (RequestHeader, Handler) = {
val notFoundHandler = Action.async(BodyParsers.parse.empty)(this.onHandlerNotFound)
val (routedRequest, handler) = onRouteRequest(request) map {
case handler: RequestTaggingHandler => (handler.tagRequest(request), handler)
case otherHandler => (request, otherHandler)
} getOrElse {
// We automatically permit HEAD requests against any GETs without the need to
// add an explicit mapping in Routes
val missingHandler: Handler = request.method match {
case HttpVerbs.HEAD =>
new HeadAction(onRouteRequest(request.copy(method = HttpVerbs.GET)).getOrElse(notFoundHandler))
case _ =>
notFoundHandler
}
(request, missingHandler)
}
(routedRequest, doFilter(rh => handler)(routedRequest))
}
它使用 onRouteRequest
和 doFilter
方法获取给定 RequestHeader
对应的处理程序。如果没有找到处理程序,则发送 onHandlerNotFound
的结果。
由于 onRequestReceived
方法在请求处理方式中起着关键作用,有时可能更简单的是覆盖 onRouteRequest
方法。
onRouteRequest
方法定义如下:
def onRouteRequest(request: RequestHeader): Option[Handler] = Play.maybeApplication.flatMap(_.routes.flatMap {
router =>
router.handlerFor(request)
})
在这里,路由器是应用程序的 router
对象。默认情况下,它是编译时从 conf/routes
生成的对象。路由器扩展了 Router.Routes
特性,并且 handlerFor
方法定义在这个特性中。
让我们尝试实现一个解决方案,以阻止对 login
、forgotPassword
和 register
之外的服务进行请求,如果请求头没有会话和用户详情。我们可以通过覆盖 onRouteRequest
来做到这一点:
override def onRouteRequest(requestHeader: RequestHeader) = {
val path = requestHeader.path
val pathConditions = path.equals("/") ||
path.startsWith("/register") ||
path.startsWith("/login") ||
path.startsWith("/forgot")
if (!pathConditions) {
val tokenId = requestHeader.headers.get("Auth-Token")
val userId = requestHeader.headers.get("Auth-User")
if (tokenId.isDefined && userId.isDefined) {
val isValidSession = SessionDetails.validateSession(SessionDetails(userId.get.toLong, tokenId.get))
if (isValidSession) {
super.onRouteRequest(request)
}
else Some(controllers.SessionController.invalidSession)
}
else {
Some(controllers.SessionController.invalidSession)
}
}
else {
super.onRouteRequest(request)
}
}
首先,我们检查请求的路径是否有受限访问。如果有,我们检查必要的头是否可用且有效。只有在这种情况下,才会返回相应的 Handler
,否则返回无效会话的 Handler
。如果需要根据用户的角色来控制访问,可以遵循类似的方法。
我们还可以使用 onRouteRequest
方法为旧版已弃用的服务提供兼容性。例如,如果旧版应用程序有一个 GET /user/:userId
服务,现在已被修改为 /api/user/:userId
,并且有其他应用程序依赖于这个应用程序,那么我们的应用程序应该支持这两个路径的请求。然而,路由文件只列出了新路径和服务,这意味着在尝试访问应用程序支持的路径之前,我们应该处理这些请求:
override def onRouteRequest(requestHeader: RequestHeader) = {
val path = requestHeader.path
val actualPath = getSupportedPath(path)
val customRequestHeader = requestHeader.copy(path = actualPath)
super.onRouteRequest(customRequestHeader)
}
getSupportedPath
是一个自定义方法,它为给定的旧路径提供一个新路径。我们创建一个新的 RequestHeader
并带有更新后的字段,然后将这个新 RequestHeader
传递给后续的方法,而不是使用原始的 RequestHeader
。
同样,我们也可以添加/修改 RequestHeader
的头部或任何其他字段。
doFilter
方法可以用来添加过滤器,类似于在第二章 定义动作 中展示的:
object Global extends GlobalSettings {
override def doFilter(action: EssentialAction): EssentialAction = HeadersFilter.noCache(action)
}
或者,我们可以扩展 WithFilters
类而不是 GlobalSettings
:
object Global extends WithFilters(new CSRFFilter()) with GlobalSettings
WithFilters
类扩展了 GlobalSettings
并用构造函数中传入的 Filter
覆盖了 doFilter
方法。它定义如下:
class WithFilters(filters: EssentialFilter*) extends GlobalSettings {
override def doFilter(a: EssentialAction): EssentialAction = {
Filters(super.doFilter(a), filters: _*)
}
}
onRequestCompletion
方法可以在请求被处理后执行特定任务。例如,假设应用程序需要从特定的 GET 请求(如搜索)中持久化数据。这有助于理解和分析用户在我们的应用程序中寻找什么。在获取数据之前从请求中持久化信息可以显著增加响应时间并损害用户体验。因此,在发送响应之后进行此操作会更好:
override def onRequestCompletion(requestHeader: RequestHeader) {
if(requestHeader.path.startsWith("/search")){
//code to persist request parameters, time, etc
}}
处理错误和异常
一个应用程序如果不能处理错误和异常是无法存在的。根据业务逻辑,它们被处理的方式可能因应用程序而异。Play 提供了一些标准实现,这些实现可以在应用程序的全局对象中被覆盖。当发生异常时,会调用 onError
方法,定义如下:
def onError(request: RequestHeader, ex: Throwable): Future[Result] = {
def devError = views.html.defaultpages.devError(Option(System.getProperty("play.editor"))) _
def prodError = views.html.defaultpages.error.f
try {
Future.successful(InternalServerError(Play.maybeApplication.map {
case app if app.mode == Mode.Prod => prodError
case app => devError
}.getOrElse(devError) {
ex match {
case e: UsefulException => e
case NonFatal(e) => UnexpectedException(unexpected = Some(e))
}
}))
} catch {
case NonFatal(e) => {
Logger.error("Error while rendering default error page", e)
Future.successful(InternalServerError)
}
}
}
UsefulException
是一个抽象类,它扩展了 RuntimeException
。它由 PlayException
辅助类扩展。onError
的默认实现(在之前的代码片段中)简单地检查应用程序是否处于生产模式或开发模式,并发送相应的视图作为 Result
。这种方法会导致 defaultpages.error
或 defaultpages.devError
视图。
假设我们想要发送一个状态为 500 的响应,并包含异常。我们可以通过覆盖 onError
方法轻松实现:
override def onError(request: RequestHeader, ex: Throwable) = {
log.error(ex)
InternalServerError(ex.getMessage)
}
当用户发送一个在 conf/routes
中未定义路径的请求时,会调用 onHandlerNotFound
方法。它定义如下:
def onHandlerNotFound(request: RequestHeader): Future[Result] = {
Future.successful(NotFound(Play.maybeApplication.map {
case app if app.mode != Mode.Prod => views.html.defaultpages.devNotFound.f
case app => views.html.defaultpages.notFound.f
}.getOrElse(views.html.defaultpages.devNotFound.f)(request, Play.maybeApplication.flatMap(_.routes))))
}
它会根据应用启动的模式发送一个视图作为响应。在开发模式下,视图包含一个错误消息,告诉我们为该路由定义了一个动作,以及带有请求类型的支持路径列表。如果需要,我们可以覆盖这个行为。
在以下情况下会调用onBadRequest
方法:
-
请求被发送,并且其对应动作具有不同的内容类型
-
请求中缺少一些参数,并且在解析时请求抛出异常
它被定义为如下:
def onBadRequest(request: RequestHeader,
error: String): Future[Result] = {
Future.successful(BadRequest(views.html.defaultpages.badRequest(request, error)))
}
此方法也会发送一个视图作为响应,但在大多数应用中,我们希望发送带有错误消息的BadRequest
而不是视图。这可以通过覆盖默认实现来实现,如下所示:
import play.api.mvc.{Result, RequestHeader,Results}
override def onBadRequest(request: RequestHeader,
error: String): Future[Result] = {
Future{
Results.BadRequest(error)
}
}
摘要
在本章中,我们看到了通过全局插件提供给 Play 应用的特性。通过扩展GlobalSettings
,我们可以挂钩到应用的生命周期,并在不同阶段执行各种任务。除了用于应用生命周期的钩子外,我们还讨论了用于请求-响应生命周期的钩子,通过这些钩子我们可以拦截请求和响应,并在必要时修改它们。
第八章. WebSocket 和 Actor
在本章中,我们将涵盖以下主题:
-
WebSocket 简介
-
Actor 模型和 Akka Actor
-
Play 中的 WebSocket:使用 Iteratee 和 Actor
-
FrameFormatters
WebSocket 简介
想象一下:
一个电影爱好者正在尝试在线购买电影票。他或她已经选定了座位,输入了付款详情,并提交了。他或她收到一个错误消息,说他们试图预订的票已经售罄。
考虑一个应用程序,它提供了有关股市的详细信息,并允许购买/出售股票。当有人输入付款详情并提交这些详情时,他们会收到一个错误消息,说购买已被拒绝,因为股票价格已经改变。
在最初需要通过 HTTP 获取实时数据的应用程序中,开发者意识到他们需要在客户端和服务器端之间实现双向通信。这通常是通过以下方法之一实现的:
-
轮询:客户端以固定和规律的时间间隔发送请求。服务器在短时间内(不到 1 秒或如此)对每个请求做出响应。
-
长轮询:当发送请求时,服务器在指定时间间隔内状态没有变化时不会响应结果。在从服务器收到响应后,才会触发请求。因此,客户端会根据需要重复请求,直到收到前一个请求的响应。
-
流式传输:向服务器发送请求会导致一个打开的响应,该响应会持续更新并无限期保持打开状态。
虽然这些方法有效,但使用它们导致了一些问题:
-
这导致了每个客户端 TCP 连接数量的增加
-
在客户端将响应映射到其对应请求时,HTTP 头部开销很大
2011 年,一个使用单个 TCP 连接进行双向通信的协议 WebSocket(RFC6455)被互联网工程任务组(IETF)标准化。到 2012 年 9 月 20 日,万维网联盟(W3C)提出了 WebSocket API 的规范。
与 HTTP 不同,WebSocket 中没有请求-响应周期。一旦连接,客户端和服务器就可以互相发送消息。通信可以是服务器到客户端,也可以是客户端到服务器,即双向全双工通信。
根据 WebSocket API:
-
可以通过调用构造函数来建立 WebSocket 连接,例如
WebSocket(url, protocols)
-
可以通过
send(data)
方法通过连接将数据发送到服务器 -
调用
close()
将导致关闭连接 -
以下事件处理程序可以在客户端定义:
-
onopen
-
onmessage
-
onerror
-
onclose
-
下面是一个使用 JavaScript 的示例:
var webSocket = new WebSocket('ws://localhost:9000');
webSocket.onopen = function () {
webSocket.send("hello");
};
webSocket.onmessage = function (event) {
console.log(event.data);
};
webSocket.onclose = function () {
alert("oops!! Disconnected")
}
Play 中的 WebSocket
WebSockets 不能使用 Action 定义,因为它们应该是双向的。Play 提供了一个辅助工具来帮助处理 WebSockets,该工具的文档位于 www.playframework.com/documentation/2.3.x/api/scala/index.html#play.api.mvc.WebSocket$
。
注意
使用辅助工具定义的 WebSockets 使用 Play 服务器底层的 TCP 端口。
WebSockets 可以在 Play 应用程序中以类似于 Actions 的方式定义。从 Play 2.3 开始,WebSocket 辅助工具找到了使用 Actor 定义 WebSocket 交互的方法。然而,在我们了解更多关于辅助工具提供的方法之前,让我们稍微偏离一下,先熟悉一下 Actor Model 和 Akka Actors。
Actor Model
编程中的并发可以通过使用线程来实现,这可能会包括丢失更新或死锁的风险。通过利用异步通信,Actor 模型促进了并发。
根据 Actor 模型,actor 是计算的基本单元。它不能独立存在,也就是说,它总是特定 actor 系统的一部分。如果一个 actor 知道另一个 actor 的地址,它可以向其 actor 系统中的一个或多个 actor 发送消息。它也可以向自己发送消息。由于通信是异步的,因此无法保证消息发送或接收的顺序。
当 actor 收到一条消息时,它可以执行以下操作:
-
将其转发给另一个已知地址的 actor
-
创建更多 actors
-
指定它将为下一条消息采取的操作
注意
Actor Model 首次在 1973 年 8 月由 Carl Hewitt、Peter Bishop 和 Richard Steiger 在国际人工智能联合会议(IJCAI'73)论文 A Universal Modular ACTOR Formalism for Artificial Intelligence 中描述,该论文是会议的一部分。
介绍 Akka Actors
Akka 是 Typesafe Reactive Platform 的一部分,与 Play 框架类似。根据他们的网站:
Akka 是一个用于在 JVM 上构建高度并发、分布式和容错的事件驱动应用的工具包和运行时。
Akka 实现了 Actor Model 的一种版本,通常称为 Akka Actors,并且对 Java 和 Scala 都可用。根据 Akka 文档,Actors 给你:
-
简单且高级的并发和并行抽象
-
异步、非阻塞且高性能的事件驱动编程模型
-
非常轻量级的事件驱动进程(每 GB 堆内存中有数百万个 actors)
Akka Actors 可以作为一个库使用,可以通过将它们添加到项目的依赖项中来在项目中使用:
libraryDependencies ++= Seq(
"com.typesafe.akka" %% "akka-actor" % "2.3.4"
)
注意
在 Play 项目中,不需要在 Akka 中显式添加依赖项,因为 Play 内部使用 Akka。
我们可以定义一个 actor,通过扩展 Actor trait 并在receive
方法中定义行为来实现。让我们构建一个 actor,它反转接收到的任何字符串消息:
class Reverser extends Actor {
def receive = {
case s:String => println( s.reverse)
case _ => println("Sorry, didn't quite understand that. I can only process a String.")
}
}
object Reverser {
def props = Props(classOf[Reverser])
}
要使用 actor,我们首先需要初始化ActorSystem
:
val system = ActorSystem("demoSystem")
现在我们可以通过使用actorOf
方法来获取 actor 的引用:
val demoActor = system.actorOf(Reverser.props, name = "demoActor")
这个引用可以用来发送消息:
demoActor ! "Hello, How do u do?"
demoActor ! "Been Long since we spoke"
demoActor ! 12345
现在,让我们运行应用程序并看看 actor 做了什么:
> run
[info] Compiling 1 Scala source to /AkkaActorDemo/target/scala-2.10/classes...
[info] Running com.demo.Main
?od u od woH ,olleH
ekops ew ecnis gnoL neeB
Sorry, didn't quite understand that I can only process a String.
假设我们想要定义一个接受minLength
和MaxLength
作为参数的 Actor,我们需要修改Reverser
类及其伴随类如下:
class ReverserWithLimit(min:Int,max:Int) extends Actor {
def receive = {
case s:String if (s.length> min & s.length<max)=> println( s.reverse)
case _ => println(s"Sorry, didn't quite understand that. I can only process a String of length $min-$max.") }
}
object ReverserWithLimit {
def props(min:Int,max:Int) = Props(classOf[Reverser],min,max)
}
更多关于 Akka actor 的详细信息,请参阅akka.io/docs/
。
使用 Iteratee 的 WebSocket
让我们定义一个 WebSocket 连接,它接受字符串并通过Iteratee发送字符串的逆序:
def websocketBroadcast = WebSocket.using[String] {
request =>
val (out, channel) = Concurrent.broadcast[String]
val in = Iteratee.foreach[String] {
word => channel.push(word.reverse)
}
(in, out)
}
WebSocket.using
方法使用一个 Iteratee(输入通道)及其对应的枚举器(输出通道)创建一个特定类型的 WebSocket。在前面的代码片段中,我们返回一个包含 Iteratee 输入和枚举器输出的元组。
Concurrent
对象也是一个辅助工具,它提供了使用 Iteratee、枚举器和枚举器的并发实用工具。broadcast[E]
方法创建一个枚举器和通道,并返回一个(Enumerator[E], Channel[E])
元组。因此获得的枚举器和通道可以用来向多个 Iteratee 广播数据。
在此之后,我们需要将其绑定到路由文件中的路径,这与我们对 Action 所做的操作类似:
GET /ws controllers.Application.websocketBroadcast
现在,使用一个浏览器插件,例如 Chrome 的简单 WebSocket 客户端(请参阅chrome.google.com/webstore/detail/simple-websocket-client/pfdhoblngboilpfeibdedpjgfnlcodoo
),我们可以在应用程序运行时通过 WebSocket 发送消息,如图所示:
由于我们的应用程序中没有使用多个 Iteratee,我们可以使用Concurrent.unicast
。这将需要我们稍微修改我们的代码:
def websocketUnicast = WebSocket.using[String] {
request =>
var channel: Concurrent.Channel[String] = null
val out = Concurrent.unicast[String] {
ch =>
channel = ch
}
val in = Iteratee.foreach[String] {
word => channel.push(word.reverse)
}
(in, out)
}
注意,与broadcast
方法不同,unicast
方法不返回一个枚举器和通道的元组,而是只提供一个枚举器。我们必须声明一个通道变量并将其初始化为 null,以便在 Iteratee 中可以访问它。当调用unicast
方法时,它被设置为unicast
方法内部生成的通道。
注意
unicast
方法还允许我们定义onComplete
和onError
方法,但它们并不了解 Iteratee,也就是说,我们无法在这些方法中引用 Iteratee。
这个例子过于简单,并没有突出定义和使用 Iteratee 所涉及的复杂性。让我们尝试一个更具挑战性的用例。现在,我们可能需要构建一个允许用户通过 WebSocket 连接到他们的数据库并加载/查看数据的 Web 应用程序。在这种情况下,前端发送 JSON 消息。
现在 WebSocket 可以接收以下任何消息:
-
连接请求:这是一个显示连接到数据库所需信息的消息(例如主机、端口、用户 ID 和密码)
-
查询字符串:这是要在数据库中执行的查询
-
断开连接请求:这是一个关闭与数据库连接的消息
在此之后,消息被翻译并发送到DBActor,它发送回状态消息或带有行数据的查询结果,然后通过 WebSocket 将其翻译为 JSON 并发送回。
从 DBActor 收到的响应可以是以下之一:
-
成功连接
-
连接失败
-
查询结果
-
无效查询
-
已断开连接
def dbWebsocket = WebSocket.using[JsValue] { request => WebSocketChannel.init }
我们可以以下方式定义一个 WebSocket 处理器来处理此场景:
在这里,WebSocketChannel
是一个 actor,它与 DBActor 及其伴随对象通信,并定义如下:
object WebSocketChannel {
def props(channel: Concurrent.Channel[JsValue]): Props =
Props(classOf[WebSocketChannel], channel)
def init: (Iteratee[JsValue, _], Enumerator[JsValue]) = {
var actor: ActorRef = null
val out = Concurrent.unicast[JsValue] {
channel =>
actor = Akka.system.actorOf(WebSocketChannel.props(channel))
}
val in = Iteratee.foreach[JsValue] {
jsReq => actor ! jsReq
}
(in, out)
}
}
WebSocketChannel
定义如下:
class WebSocketChannel(wsChannel: Concurrent.Channel[JsValue])
extends Actor with ActorLogging {
val backend = Akka.system.actorOf(Props(classOf[DBActor]))
def receive: Actor.Receive = {
case jsRequest: JsValue =>
backend ! convertJson(jsRequest)
case x: DBResponse =>
wsChannel.push(x.toJson)
}
}
在前面的代码中,convertJson
将JsValue
转换为 DBActor 可以理解的格式。
在下一节中,我们将使用 Play 2.3.x 版本中可用的新 WebSocket 方法实现相同的应用程序。
使用 Actors 而不使用 Iterates 的 WebSocket
Play WebSocket API 允许使用 Actors 来定义行为。让我们构建一个 WebSocket 应用程序,一旦连接,就回复给定字符串的反转。我们可以通过稍微修改我们的 Reverser Actor,使其具有一个参数作为可以/必须发送消息的 Actor 的引用来实现,如下所示:
class Reverser(outChannel: ActorRef) extends Actor {
def receive = {
case s: String => outChannel ! s.reverse
}
}
object Reverser {
def props(outChannel: ActorRef) = Props(classOf[Reverser], outChannel)
}
websocket
可以在控制器中如下定义:
def websocket = WebSocket.acceptWithActor[String, String] {
request => out =>
Reverser.props(out)
}
最后,我们在路由文件中添加一个条目:
GET /wsActor controllers.Application.websocket
现在当应用程序运行时,我们可以通过浏览器插件通过 WebSocket 发送消息。
现在,让我们尝试使用这种方法实现dbWebSocket
:
def dbCommunicator = WebSocket.acceptWithActor[JsValue, JsValue] {
request => out =>
WebSocketChannel.props(out)
}
这里,WebSocketChannel
定义如下:
class WebSocketChannel(out: ActorRef)
extends Actor with ActorLogging {
val backend = Akka.system.actorOf(DBActor.props)
def receive: Actor.Receive = {
case jsRequest: JsValue =>
backend ! convertJsonToMsg(jsRequest)
case x:DBResponse =>
out ! x.toJson
}
}
object WebSocketChannel {
def props(out: ActorRef): Props =
Props(classOf[WebSocketChannel], out)
}
convertJsonToMsg
方法负责将 JSON 转换为 DBActor 可以接受的格式。
关闭 WebSocket
当 WebSocket 关闭时,Play 会自动停止与其绑定的 actor。这种绑定以两种方式工作:当底层 actor 被杀死时,WebSocket 连接会关闭。如果需要在连接关闭后释放任何资源,我们可以通过覆盖 actor 的postStop
方法来实现。在我们的例子中,我们在WebSocketChannel
中初始化了一个 DBActor。我们需要确保在 WebSocket 关闭时将其杀死,因为每个 WebSocket 连接都会导致 DBActor 的初始化。我们可以通过发送一个毒药丸来实现,如下所示:
override def postStop() = {
backend ! PoisonPill
}
使用 FrameFormatter
假设一个传入的 JSON 在每次请求中都有相同的字段,而不是每次都解析它;我们可以以这种方式定义一个等效的类:
case class WebsocketRequest(reqType:String, message:String)
现在,我们可以定义我们的 WebSocket,以自动将 JSON 消息转换为WebSocketRequest
。这是通过指定acceptWithActor
方法的数据类型来实现的:
def websocketFormatted = WebSocket.acceptWithActor[WebsocketRequest, JsValue]{
request => out =>
SomeActor.props(out)
}
然而,为了按预期工作,我们需要两个隐式值。第一个是将传入的帧转换为WebsocketRequest
,这需要一个JsValue
到WebSocketRequest
格式化器的转换:
implicit val requestFormat = Json.format[WebsocketRequest]
implicit val requestFrameFormatter = FrameFormatter.jsonFrame[WebsocketRequest]
类似地,我们也可以指定输出消息的类型:
FrameFormatter
是一个辅助工具,可以将org.jboss.netty.handler.codec.http.websocketx.WebSocketFrame
转换为play.core.server.websocket.Frames
。
注意
WebSocket 方法不会像 Action 解析器那样自动验证接收到的数据的格式。如果需要,我们将需要额外进行此操作。
故障排除
-
在
GlobalSettings
中,WebSockets
中断Actions
的等效操作是什么?如果我们想拒绝缺少某些头部的 WebSocket 连接怎么办?以下代码片段没有按预期工作:override def onRouteRequest(request: RequestHeader): Option[Handler] = { if(request.path.startsWith("/ws")){ Option(controllers.Default.error) } else super.onRouteRequest(request) }
从全局对象中断 WebSocket 的方式与中断 Action 的方式不同。然而,还有其他方法可以实现这一点:通过使用
tryAccept
和tryAcceptWithActor
方法。WebSocket 定义可以用以下代码替换:def wsWithHeader = WebSocket.tryAccept[String] { rh => Future.successful(rh.headers.get("token") match { case Some(x) => var channel: Concurrent.Channel[String] = null val out = Concurrent.unicast[String] { ch => channel = ch } val in = Iteratee.foreach[String] { word => channel.push(word.reverse) } Right(in, out) case _ => Left(Forbidden) }) }
当使用 Actor 时,使用
tryAcceptWithActor
方法定义 WebSocket:def wsheaders = WebSocket.tryAcceptWithActor[String, String] { request => Future.successful(request.headers.get("token") match { case Some(x) => Right(out => Reverser.props(out)) case _ => Left(Forbidden) }) }
在前面的例子中,我们只是检查是否存在令牌头部,但这可以更新为任何其他标准。
-
Play 支持 wss 吗?
截至 2.3.x 版本,没有内置对 wss 的支持。然而,可以使用代理,如 Nginx 或 HAProxy 作为安全的 WebSocket(wss)端点,并将请求转发到具有不安全 WebSocket 端点的内部 Play 应用程序。
摘要
在本章中,我们学到了一些东西。本章简要介绍了 Actor 模型以及在应用程序中使用 Akka Actor 的方法。此外,我们使用两种不同的方法在 Play 应用程序中定义了具有各种约束和要求的 WebSocket 连接:第一种方法是我们使用 Iteratees 和 Enumerators,第二种方法是我们使用 Akka Actors。
在下一章中,我们将看到我们可以使用Specs2和ScalaTest测试 Play 应用程序的不同方法。
第九章。测试
测试是交叉检查应用程序/流程实现的过程。它将缺点暴露出来。当你升级/降级一个或多个依赖项时,它可能非常有用。根据不同的编程实践,测试可以划分为各种类别,但在这章中,我们只将讨论两种类型的测试:
-
单元测试:这些是检查特定代码部分功能的测试
-
功能测试:这些是检查特定操作的测试,通常编写来验证与用例或场景相关的代码是否正常工作
在以下章节中,我们将看到我们可以使用 Specs2 和 ScalaTest 测试 Play 应用的不同方式。
注意
使用 Specs2 和 ScalaTest 任意一个库编写的测试是相似的。主要区别在于关键字、语法和风格。由于不同的开发者可能有不同的偏好,在本章中,使用这两个库定义测试,以方便起见。大多数使用 Specs2 编写的测试以 'Spec'
结尾,而使用 ScalaTest 编写的测试以 'Test'
结尾。
编写测试的设置
Play 随 Specs2
打包,因为这是内部用于测试的库。它默认提供对使用 Specs2 测试应用程序的支持,即不需要额外的库依赖项。
在早期使用 ScalaTest
是困难的,但现在,Play 也提供了使用 ScalaTest 的辅助方法。尽管它来自传递依赖,但我们需要添加一个库依赖项来使用这些辅助方法:
val appDependencies = Seq(
"org.scalatestplus" %% "play" % "1.1.0" % "test"
)
注意
org.scalatestplus.play
的 1.1.0 版本与 Play 2.3.x 兼容。当与 Play 的其他版本一起工作时,最好在 www.scalatest.org/plus/play/versions
检查兼容性。
单元测试
单元测试可以像任何 Scala 项目一样编写。例如,假设我们有一个实用方法 isNumberInRange
,它接受一个字符串并检查它是否在范围 [0,3600] 内。它被定义为以下内容:
def isNumberInRange(x:String):Boolean = {
val mayBeNumber = Try{x.toDouble}
mayBeNumber match{
case Success(n) => if(n>=0 && n<=3600) true else false
case Failure(e) => false
}
}
让我们使用 Specs2
编写一个单元测试来检查这个函数:
class UtilSpec extends Specification {
"range method" should {
"fail for Character String" in {
Util.isNumberInRange("xyz") should beFalse
}
"fail for Java null" in {
Util.isNumberInRange(null) should beFalse
}
"fail for Negative numbers" in {
Util.isNumberInRange("-2") should beFalse
}
"pass for valid number" in {
Util.isNumberInRange("1247") should beTrue
}
"pass for 0" in {
Util.isNumberInRange("0") should beTrue
}
"pass for 3600" in {
Util.isNumberInRange("3600") should beTrue
}
}
}
这些场景也可以使用 ScalaTest
通过轻微修改来编写:
class UtilTest extends FlatSpec with Matchers {
"Character String" should "not be in range" in {
Util.isNumberInRange("xyz") should be(false)
}
"Java null" should "not be in range" in {
Util.isNumberInRange(null) should be(false)
}
"Negative numbers" should "not be in range" in {
Util.isNumberInRange("-2") should be(false)
}
"valid number" should "be in range" in {
Util.isNumberInRange("1247") should be(true)
}
"0" should "be in range" in {
Util.isNumberInRange("0") should be(true)
}
"3600" should "be in range" in {
Util.isNumberInRange("3600") should be(true)
}
}
需要依赖外部依赖和数据服务层的单元测试应该使用 模拟 来定义。模拟是模拟实际行为的过程。Mockito、ScalaMock、EasyMock 和 jMock 是一些便于模拟的库。
拆解 PlaySpecification
使用 Specs2 编写的测试也可以按照以下方式编写:
class UtilSpec extends PlaySpecification {...}
PlaySpecification
是一个特质,它提供了使用 Specs2 测试 Play 应用程序所需的辅助方法。它被定义为:
trait PlaySpecification extends Specification
with NoTimeConversions
with PlayRunners
with HeaderNames
with Status
with HttpProtocol
with DefaultAwaitTimeout
with ResultExtractors
with Writeables
with RouteInvokers
with FutureAwaits {
}
让我们扫描这些特质暴露的 API,以了解其重要性:
-
Specification
和NoTimeConversions
是 Specs2 的特质。NoTimeConversions
可以用来禁用时间转换。 -
PlayRunners
提供了在运行中的应用程序或服务器中执行代码块的帮助方法,可以指定或不指定浏览器。 -
HeaderNames
和Status
分别定义了所有标准 HTTP 头和 HTTP 状态码的常量,以及它们的相关名称,如下所示:HeaderNames.ACCEPT_CHARSET = "Accept-Charset" Status.FORBIDDEN = 403
-
HttpProtocol
定义了与 HTTP 协议相关的常量:object HttpProtocol extends HttpProtocol trait HttpProtocol { // Versions val HTTP_1_0 = "HTTP/1.0" val HTTP_1_1 = "HTTP/1.1" // Other HTTP protocol values val CHUNKED = "chunked" }
-
ResultExtractors
提供了从 HTTP 响应中提取数据的帮助方法,这些方法的数据类型为Future[Result]
。这些方法如下:-
charset(of: Future[Result])(implicit timeout: Timeout): Option[String]
-
contentAsBytes(of: Future[Result])(implicit timeout: Timeout): Array[Byte]
-
contentAsJson(of: Future[Result])(implicit timeout: Timeout): JsValue
-
contentAsString(of: Future[Result])(implicit timeout: Timeout): String
-
contentType(of: Future[Result])(implicit timeout: Timeout): Option[String]
-
cookies(of: Future[Result])(implicit timeout: Timeout): Cookies
-
flash(of: Future[Result])(implicit timeout: Timeout): Flash
-
header(header: String, of: Future[Result])(implicit timeout: Timeout): Option[String]
-
headers(of: Future[Result])(implicit timeout: Timeout): Map[String, String]
-
redirectLocation(of: Future[Result])(implicit timeout: Timeout): Option[String]
-
session(of: Future[Result])(implicit timeout: Timeout): Session
-
status(of: Future[Result])(implicit timeout: Timeout): Int
-
这些方法调用中的
implicit Timeout
由DefaultAwaitTimeout
特质提供,默认超时设置为 20 秒。这可以通过在场景作用域内提供隐式超时来覆盖。 -
-
RouteInvokers
提供了使用Router
调用给定请求的相应Action
的方法。这些方法如下:-
routeT(implicit w: Writeable[T]): Option[Future[Result]]
-
routeT(implicit w: Writeable[T]): Option[Future[Result]]
-
routeT(implicit w: Writeable[T]): Option[Future[Result]]
-
routeT(implicit w: Writeable[T]): Option[Future[Result]]
-
callT(implicit w: Writeable[T]): Future[Result]
-
callT(implicit w: Writeable[T]): Future[Result]
这些方法调用中的
implicit Writable
由Writeables
特质提供。call
方法是从EssentialActionCaller
继承的。 -
-
FutureAwaits
特质提供了在指定或不指定等待时间的情况下等待请求的方法。
注意
虽然支持 Play 应用的 ScalaTest 库有一个 PlaySpec
抽象类,但没有 PlaySpecification
的等效物。取而代之的是,有一个帮助对象,定义如下:
object Helpers extends PlayRunners
with HeaderNames
with Status
with HttpProtocol
with DefaultAwaitTimeout
with ResultExtractors
with Writeables
with EssentialActionCaller
with RouteInvokers
with FutureAwaits
PlaySpec
定义如下:
abstract class PlaySpec extends WordSpec with MustMatchers with OptionValues with WsScalaTestClient
因此,导入 play.api.test.Helpers
也足以使用仅有的帮助方法。
对于以下部分,关于使用 Specs2 的测试,我们将扩展 PlaySpecification,而对于 ScalaTest,我们假设已经导入了 play.api.test.Helpers
,并且测试扩展到 PlaySpec
。
单元测试控制器
我们可能有一个简单的项目,其中包含 User
模型和 UserRepo
,定义如下:
case class User(id: Option[Long], loginId: String, name: Option[String],
contactNo: Option[String], dob: Option[Long], address: Option[String])
object User{
implicit val userWrites = Json.writes[User]
}
trait UserRepo {
def authenticate(loginId: String, password: String): Boolean
def create(u: User, host: String, password: String): Option[Long]
def update(u: User): Boolean
def findByLogin(loginId: String): Option[User]
def delete(userId: Long): Boolean
def find(userId: Long): Option[User]
def getAll: Seq[User]
def updateStatus(userId: Long, isActive: Boolean): Int
def updatePassword(userId: Long, password: String): Int
}
在这个项目中,我们需要测试 UserController
的 getUser
方法——这是一个定义用来访问用户详情的控制器,这些详情由用户模型处理,其中 UserController
定义如下:
object UserController extends Controller {
/* GET a specific user's details */
def getUser(userId: Long) = Action {
val u = AnormUserRepo.find(userId)
if (u.isEmpty) {
NoContent
}
else {
Ok(Json.toJson(u))
}
}
....
}
AnormUserRepo
是 UserRepo
的一个实现,它使用 Anorm 进行数据库事务。UserController
中的方法在路由文件中映射如下:
GET /api/user/:userId controllers.UserController.getUser(userId:Long)
由于测试库尚未完全支持模拟 Scala 对象,因此有几种不同的方法可以单元测试控制器。这些方法如下:
-
在特质中定义控制器的所有方法,然后这个特质可以被一个对象扩展,同时特质的功能被测试
-
将控制器定义为类,并使用依赖注入连接其他所需服务
这两种方法都需要我们修改我们的应用程序代码。我们可以选择最适合我们编码实践的一种。让我们看看这些更改是什么,以及如何在以下部分中编写相应的测试。
使用特质定义控制器
在这种方法中,我们定义了控制器中的所有方法在一个特质中,并通过扩展这个特质来定义控制器。例如,UserController
应该定义为如下所示:
trait BaseUserController extends Controller {
this: Controller =>
val userRepo:UserRepo
/* GET a specific user's details */
def getUser(userId: Long) = Action {
val u = userRepo.find(userId)
if (u.isEmpty) {
NoContent
} else {
Ok(Json.toJson(u))
}
}
}
object UserController extends BaseUserController{
val userRepo = AnormUserRepo
}
现在,我们可以使用 Specs2 编写 BaseUserController
特质的测试——UserControllerSpec
,如下所示:
class UserControllerSpec extends Specification with Mockito {
"UserController#getUser" should {
"be valid" in {
val userRepository = mock[UserRepo]
val defaultUser = User(Some(1), "loginId", Some("name"), Some("contact_no"), Some(20L), Some("address"))
userRepository.find(1) returns Option(defaultUser)
class TestController extends Controller with BaseUserController{
val userRepo = userRepository
}
val controller = new TestController
val result: Future[Result] = controller.getUser(1L).apply(FakeRequest())
val userJson: JsValue = contentAsJson(result)
userJson should be equalTo(Json.toJson(defaultUser))
}
}
}
FakeRequest
是一个在测试中生成伪造 HTTP 请求的辅助工具。
在这里,我们模拟 UserRepo
并使用这个模拟生成 TestController
的新实例。ScalaTest 通过其 MockitoSugar
特质提供了与 Mockito 的集成,因此模拟代码将有一些小的变化。
使用 ScalaTest,UserControllerTest
测试将如下所示:
class UserControllerTest extends PlaySpec with Results with MockitoSugar {
"UserController#getUser" should {
"be valid" in {
val userRepository = mock[UserRepo]
val defaultUser = User(Some(1), "loginId", Some("name"), Some("contact_no"), Some(20L), Some("address"))
when(userRepository.find(1)) thenReturn Option(defaultUser)
class TestController extends Controller with BaseUserController{
val userRepo = userRepository
}
val controller = new TestController
val result: Future[Result] = controller.getUser(1L).apply(FakeRequest())
val userJson: JsValue = contentAsJson(result)
userJson mustBe Json.toJson(defaultUser)
}
}
}
使用依赖注入
我们可以使我们的控制器依赖于特定的服务,并且所有这些都可以通过使用依赖注入库通过全局对象的 getControllerInstance
方法进行配置。
在这个例子中,我们通过将其添加为项目依赖项来使用 Guice:
val appDependencies = Seq(
...
"com.google.inject" % "guice" % "3.0",
"javax.inject" % "javax.inject" % "1"
)
现在,让我们更新 Global
对象中的 getControllerInstance
方法:
object Global extends GlobalSettings {
val injector = Guice.createInjector(new AbstractModule {
protected def configure() {
bind(classOf[UserRepo]).to(classOf[AnormUserRepo])
}
})
override def getControllerInstanceA: A = injector.getInstance(controllerClass)
}
我们现在将 UserController
定义为一个单例,它扩展了 play.api.mvc.Controller
并使用 UserRepo
,该库是通过依赖注入实现的:
@Singleton
class UserController @Inject()(userRepo: UserRepo) extends Controller {
implicit val userWrites = Json.writes[User]
/* GET a specific user's details */
def getUser(userId: Long) = Action {
val u = userRepo.find(userId)
if (u.isEmpty) {
NoContent
}
else {
Ok(Json.toJson(u))
}
}
}
我们还需要修改路由文件:
GET /api/user/:userId @controllers.UserController.getUser(userId:Long)
方法调用开头处的 @
符号表示应该使用全局对象的 getControllerInstance
方法。
注意
如果我们不添加方法名称的 @
后缀,它将搜索具有 UserController
名称的对象,并在编译期间抛出错误:
object UserController is not a member of package controllers
[error] Note: class UserController exists, but it has no companion object.
[error] GET /api/user/:userId controllers.UserController.getUser(userId:Long)
最后,我们可以使用 Specs2 编写单元测试,如下所示:
class UserControllerSpec extends Specification with Mockito {
"UserController#getUser" should {
"be valid" in {
val userRepository = mock[AnormUserRepo]
val defaultUser = User(Some(1), "loginId", Some("name"), Some("contact_no"), Some(20L), Some("address"))
userRepository.find(1) returns Option(defaultUser)
val controller = new UserController(userRepository)
val result: Future[Result] = controller.getUser(1L).apply(FakeRequest())
val userJson: JsValue = contentAsJson(result)
userJson should be equalTo(Json.toJson(defaultUser))
}
}
}
在这里,我们模拟 AnormUserRepo
并使用这个模拟生成 UserController
的新实例。
使用 ScalaTest 的相同测试如下:
class UserControllerTest extends PlaySpec with Results with MockitoSugar {
"UserController#getUser" should {
"be valid" in {
val userRepository = mock[AnormUserRepo]
val defaultUser = User(Some(1), "loginId", Some("name"), Some("contact_no"), Some(20L), Some("address"))
when(userRepository.find(1)) thenReturn Option(defaultUser)
val controller = new UserController(userRepository)
val result: Future[Result] = controller.getUser(1L).apply(FakeRequest())
val userJson: JsValue = contentAsJson(result)
userJson mustBe Json.toJson(defaultUser)
}
}
}
以下表格总结了这两种方法的关键区别,以便更容易决定哪一种最适合您的需求:
使用特质进行控制器 | 使用依赖注入 |
---|---|
需要在特质中定义和声明所有要由控制器支持的的方法。 | 需要将控制器定义为单例类,并为全局对象的getControllerInstance 方法提供实现。 |
不需要额外的库。 | 需要使用依赖注入库,并提供在不同应用模式中插入不同类的灵活性。 |
需要定义一个额外的类来扩展测试的控制器特质。 | 不需要定义任何额外的类来测试控制器,因为可以从单例中实例化一个新的实例。 |
更多关于依赖注入的示例,请参阅www.playframework.com/documentation/2.3.x/ScalaDependencyInjection
。
功能测试
让我们看看 Play 的一些测试用例,看看如何使用辅助方法。例如,考虑定义如下DevErrorPageSpec
测试:
object DevErrorPageSpec extends PlaySpecification{
"devError.scala.html" should {
val testExceptionSource = new play.api.PlayException.ExceptionSource("test", "making sure the link shows up") {
...
}
….
"show prod error page in prod mode" in {
val fakeApplication = new FakeApplication() {
override val mode = play.api.Mode.Prod
}
running(fakeApplication) {
val result = DefaultGlobal.onError(FakeRequest(), testExceptionSource)
Helpers.contentAsString(result) must contain("Oops, an error occurred")
}
}
}
}
此测试以生产模式启动FakeApplication
,并检查当FakeRequest
遇到异常时的响应。
FakeApplication
扩展了应用程序,并定义如下:
case class FakeApplication(config: Map[String, Any] = Map(),
path: File = new File("."),
sources: Option[SourceMapper] = None,
mode: Mode.Mode = Mode.Test,
global: GlobalSettings = DefaultGlobal,
plugins: Seq[Plugin] = Nil) extends Application {
val classloader = Thread.currentThread.getContextClassLoader
lazy val configuration = Configuration.from(config)
}
正在运行的方法是 PlayRunners 的一部分,在给定应用程序的上下文中执行代码块。它定义如下:
def runningT(block: => T): T = {
synchronized {
try {
Play.start(app)
block
} finally {
Play.stop()
}
}
}
PlayRunners 有更多关于如何运行的定义,如下所示:
-
runningT(block: => T): T
:这可以用来在一个运行的服务器中执行一段代码块。 -
runningT(block: TestBrowser => T): T
:这可以用来在一个运行的服务器中执行一段代码块,并使用测试浏览器。 -
runningT, WEBDRIVER <: WebDriver(block: TestBrowser => T): T
:这也可以用来在一个运行的服务器中使用 Selenium WebDriver 执行带有测试浏览器的代码块。此方法内部使用之前的方法。
代替直接使用running
方法,我们可以定义测试使用包装类,这些类利用了running
。对于 Specs2 和 ScalaTest 有不同的辅助类。
使用 Specs2
首先,让我们看看使用 Specs2 时可用的一些。它们如下所示:
-
WithApplication
:它用于在运行的应用程序上下文中执行测试。例如,考虑我们想要为CountController
编写功能测试的情况,该控制器负责按视角分组获取不同数据的计数。我们可以编写如下测试:class CountControllerSpec extends PlaySpecification with BeforeExample { override def before: Any = { TestHelper.clearDB } """Counter query""" should { """fetch count of visits grouped by browser names""" in new WithApplication { TestHelper.postSampleData val queryString = """applicationId=39&perspective=browser&from=1389949200000&till=1399145400000""".stripMargin val request = FakeRequest(GET, "/query/count?" + queryString) val response = route(request) val result = response.get status(result) must equalTo(OK) contentAsJson(result) must equalTo(TestHelper.browserCount) } }
在这里,假设
TestHelper
是一个专门为简化测试用例代码(将常见过程作为方法提取)而定义的辅助对象。如果我们需要指定
FakeApplication
,我们可以通过将其作为参数传递给WithApplication
构造函数来实现:val app = FakeApplication() """fetch count of visits grouped by browser names""" in new WithApplication(app) {
当我们想要为测试更改默认应用程序配置、GlobalSettings 等,这会很有用。
-
WithServer
: 它用于在新的TestServer
上执行运行中的应用程序上下文中的测试。当我们需要在特定端口上启动新的FakeApplication
时,这非常有用。在稍微修改之前的示例后:"""fetch count of visits grouped by browser names""" in new WithServer(app = app, port = testPort) { { ... }
-
WithBrowser
: 它通过在浏览器中执行某些操作来测试应用程序的功能。例如,考虑一个模拟应用程序,其中按钮点击时页面标题会改变。我们可以这样测试它:class AppSpec extends PlaySpecification { val app: FakeApplication = FakeApplication( withRoutes = TestRoute ) "run in firefox" in new WithBrowser(webDriver = WebDriverFactory(FIREFOX), app = app) { browser.goTo("/testing") browser.$("#title").getTexts().get(0) must equalTo("Test Page") browser.$("b").click() browser.$("#title").getTexts().get(0) must equalTo("testing") }}
我们假设
TestRoute
是一个部分函数,它映射到一些路由,然后可以在测试中使用。
使用 ScalaTest
现在,让我们看看 ScalaTestPlus-Play,这个库提供了用于通过 ScalaTest 进行测试的辅助方法,它有什么可以提供的。在本节中,我们将根据适用性展示 ScalatestPlus-Play
的示例。ScalaTest 的辅助方法如下:
-
OneAppPerSuite
: 在套件中运行任何测试之前,它使用Play.start
启动FakeApplication
,然后在测试完成后停止它。应用程序通过变量app
公开,如果需要可以重写。从ExampleSpec.scala
:class ExampleSpec extends PlaySpec with OneAppPerSuite { // Override app if you need a FakeApplication with other than non-default parameters. implicit override lazy val app: FakeApplication = FakeApplication(additionalConfiguration = Map("ehcacheplugin" -> "disabled")) "The OneAppPerSuite trait" must { "provide a FakeApplication" in { app.configuration.getString("ehcacheplugin") mustBe Some("disabled") } "make the FakeApplication available implicitly" in { def getConfig(key: String)(implicit app: Application) = app.configuration.getString(key) getConfig("ehcacheplugin") mustBe Some("disabled") } "start the FakeApplication" in { Play.maybeApplication mustBe Some(app) } } }
如果我们希望为所有或多个套件使用相同的应用程序,我们可以定义一个嵌套套件。对于此类示例,我们可以参考库中的
NestedExampleSpec.scala
。 -
OneAppPerTest
: 它为套件中定义的每个测试启动一个新的FakeApplication
。应用程序通过newAppForTest
方法公开,如果需要可以重写。例如,考虑OneAppTest
测试,其中每个测试都使用通过newAppForTest
获取的不同FakeApplication
:class DiffAppTest extends UnitSpec with OneAppPerTest { private val colors = Seq("red", "blue", "yellow") private var colorCode = 0 override def newAppForTest(testData: TestData): FakeApplication = { val currentCode = colorCode colorCode+=1 FakeApplication(additionalConfiguration = Map("foo" -> "bar", "ehcacheplugin" -> "disabled", "color" -> colors(currentCode) )) } def getConfig(key: String)(implicit app: Application) = app.configuration.getString(key) "The OneAppPerTest trait" must { "provide a FakeApplication" in { app.configuration.getString("color") mustBe Some("red") } "make another FakeApplication available implicitly" in { getConfig("color") mustBe Some("blue") } "make the third FakeApplication available implicitly" in { getConfig("color") mustBe Some("yellow") } } }
-
OneServerPerSuite
: 它为套件启动一个新的FakeApplication
和一个新的TestServer
。应用程序通过变量app
公开,如果需要可以重写。服务器端口由变量port
设置,如果需要可以更改/修改。这已在OneServerPerSuite
的示例(ExampleSpec2.scala
)中演示:class ExampleSpec extends PlaySpec with OneServerPerSuite { // Override app if you need a FakeApplication with other than non-default parameters. implicit override lazy val app: FakeApplication = FakeApplication(additionalConfiguration = Map("ehcacheplugin" -> "disabled")) "The OneServerPerSuite trait" must { "provide a FakeApplication" in { app.configuration.getString("ehcacheplugin") mustBe Some("disabled") } "make the FakeApplication available implicitly" in { def getConfig(key: String)(implicit app: Application) = app.configuration.getString(key) getConfig("ehcacheplugin") mustBe Some("disabled") } "start the FakeApplication" in { Play.maybeApplication mustBe Some(app) } "provide the port number" in { port mustBe Helpers.testServerPort } "provide an actual running server" in { import java.net._ val url = new URL("http://localhost:" + port + "/boum") val con = url.openConnection().asInstanceOf[HttpURLConnection] try con.getResponseCode mustBe 404 finally con.disconnect() } } }
当我们需要多个套件使用相同的 FakeApplication 和 TestServer 时,我们可以定义类似于
NestedExampleSpec2.scala
的嵌套套件测试。 -
OneServerPerTest
: 它为套件中定义的每个测试启动一个新的FakeApplication
和TestServer
。应用程序通过newAppForTest
方法公开,如果需要可以重写。例如,考虑DiffServerTest
测试,其中每个测试都使用通过newAppForTest
获取的不同FakeApplication
,并且TestServer
端口被重写:class DiffServerTest extends PlaySpec with OneServerPerTest { private val colors = Seq("red", "blue", "yellow") private var code = 0 override def newAppForTest(testData: TestData): FakeApplication = { val currentCode = code code += 1 FakeApplication(additionalConfiguration = Map("foo" -> "bar", "ehcacheplugin" -> "disabled", "color" -> colors(currentCode) )) } override lazy val port = 1234 def getConfig(key: String)(implicit app: Application) = app.configuration.getString(key) "The OneServerPerTest trait" must { "provide a FakeApplication" in { app.configuration.getString("color") mustBe Some("red") } "make another FakeApplication available implicitly" in { getConfig("color") mustBe Some("blue") } "start server at specified port" in { port mustBe 1234 } } }
-
OneBrowserPerSuite
: 它为每个测试套件提供一个新的 Selenium WebDriver 实例。例如,假设我们希望通过在 Firefox 中打开应用程序来测试按钮的点击,测试可以像ExampleSpec3.scala
一样编写:@FirefoxBrowser class ExampleSpec extends PlaySpec with OneServerPerSuite with OneBrowserPerSuite with FirefoxFactory { // Override app if you need a FakeApplication with other than non-default parameters. implicit override lazy val app: FakeApplication = FakeApplication( additionalConfiguration = Map("ehcacheplugin" -> "disabled"), withRoutes = TestRoute ) "The OneBrowserPerSuite trait" must { "provide a FakeApplication" in { app.configuration.getString("ehcacheplugin") mustBe Some("disabled") } "make the FakeApplication available implicitly" in { def getConfig(key: String)(implicit app: Application) = app.configuration.getString(key) getConfig("ehcacheplugin") mustBe Some("disabled") } "provide a web driver" in { go to ("http://localhost:" + port + "/testing") pageTitle mustBe "Test Page" click on find(name("b")).value eventually { pageTitle mustBe "scalatest" } } } }
我们假设
TestRoute
是一个部分函数,它映射到一些路由,然后可以在测试中使用。同样的特质可以用来在多个浏览器中测试应用程序,如
MultiBrowserExampleSpec.scala
中所示。要执行所有浏览器的测试,我们应该使用AllBrowsersPerSuite
,如下所示:class AllBrowsersPerSuiteTest extends PlaySpec with OneServerPerSuite with AllBrowsersPerSuite { // Override newAppForTest if you need a FakeApplication with other than non-default parameters. override lazy val app: FakeApplication = FakeApplication( withRoutes = TestRoute ) // Place tests you want run in different browsers in the `sharedTests` method: def sharedTests(browser: BrowserInfo) = { "navigate to testing "+browser.name in { go to ("http://localhost:" + port + "/testing") pageTitle mustBe "Test Page" click on find(name("b")).value eventually { pageTitle mustBe "testing" } } "navigate to hello in a new window"+browser.name in { go to ("http://localhost:" + port + "/hello") pageTitle mustBe "Hello" click on find(name("b")).value eventually { pageTitle mustBe "helloUser" } } } // Place tests you want run just once outside the `sharedTests` method // in the constructor, the usual place for tests in a `PlaySpec` "The test" must { "start the FakeApplication" in { Play.maybeApplication mustBe Some(app) } }
OneBrowserPerSuite
特质也可以与嵌套测试一起使用。请参阅NestedExampleSpec3.scala
。 -
OneBrowserPerTest
: 它为套件中的每个测试启动一个新的浏览器会话。这可以通过运行ExampleSpec4.scala
测试来注意到。它与ExampleSpec3.scala
类似,但OneServerPerSuite
和OneBrowserPerSuite
分别被替换为OneServerPerTest
和OneBrowserPerTest
,如下所示:@FirefoxBrowser class ExampleSpec extends PlaySpec with OneServerPerTest with OneBrowserPerTest with FirefoxFactory { ... }
我们还用
newAppForTest
重写方法替换了重写的app
变量。尝试编写一个使用AllBrowsersPerTest
特质的测试。
提示
当在定义自定义演员的应用程序上同时运行多个功能测试时,可能会遇到 InvalidActorNameException。我们可以通过定义一个嵌套测试来避免这种情况,其中多个测试使用相同的 FakeApplication
。
摘要
在本章中,我们看到了如何使用 Specs2 或 ScalaTest 测试 Play 应用程序。我们还遇到了简化 Play 应用程序测试的不同辅助方法。在单元测试部分,我们讨论了在设计模型和控制器时可以采取的不同方法,这些方法基于首选的测试过程,使用具有定义方法的特质或依赖注入。我们还讨论了在具有测试服务器和浏览器使用 Selenium WebDriver 的上下文中对 Play 应用程序的功能测试。
在下一章中,我们将讨论 Play 应用程序的调试和日志记录。
第十章:调试和日志记录
调试和日志记录是开发者可以使用以识别 bug 或应用程序意外行为的根本原因的工具。
调试的目的是找到我们代码中的缺陷或痛点,这是导致问题的原因。日志记录为我们提供了关于应用程序状态及其处理各个阶段的信息。在本章中,我们将涵盖以下主题:
-
调试 Play 应用程序
-
配置日志记录
-
在 Scala 控制台中实验
调试 Play 应用程序
可以使用Java 平台调试架构(JPDA)传输来调试应用程序。根据 Oracle 文档(参考docs.oracle.com/javase/7/docs/technotes/guides/jpda/conninv.html
):
JPDA 传输是调试器与被调试的虚拟机(以下简称目标 VM)之间通信的方法。通信是面向连接的 - 一方作为服务器,监听连接。另一方作为客户端并连接到服务器。JPDA 允许调试器应用程序或目标 VM 充当服务器。
我们可以使用以下任意一个命令以调试模式启动控制台:
-
通过使用
play
:play debug
-
通过使用
activator
:activator -jvm-debug <port>
-
通过使用
sbt
:sbt -jvm-debug <port>
所有这些命令只是用于通过调用选项启动目标 VM 的调试模式的包装器:
-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=n,address=<port>
注意
play
命令使用JPDA_PORT
或环境变量9999
作为端口号。在将JPDA_PORT
设置为所需的端口号后,目标虚拟机将监听该端口。
配置 IDE 进行调试
一旦我们以调试模式启动控制台,我们就可以在应用程序运行时连接我们的 IDE 并对其进行调试。如果你熟悉如何进行此操作,你可以跳过本节。
配置 IDE 的过程将与所有 IDE 中使用的过程类似。让我们通过以下步骤看看如何在IntelliJ Idea中完成它:
-
从运行菜单中选择编辑配置…,将弹出一个对话框。它将类似于以下截图:
-
点击+,将显示一个类似于以下截图的菜单:
-
选择远程并更新名称和端口字段:
-
然后,点击 IDE 右上角现在可见的绿色虫子图标,我们就可以开始调试应用程序了:
在 Scala 控制台中实验
当你在 Scala 项目中工作时,Scala 控制台非常方便。同样的控制台也适用于我们的 Play 应用程序的控制台。我们只需要在我们的应用程序控制台中执行console
命令即可获得 Scala 控制台:
[app]$ console
[info] Compiling 3 Scala sources to /home/app/target/scala-2.10/classes...
[info] Starting scala interpreter...
[info]
Welcome to Scala version 2.10.4 (Java HotSpot(TM) 64-Bit Server VM, Java 1.7.0_60).
Type in expressions to have them evaluated.
Type :help for more information.
scala>
然而,我们只能从模型或工具中调用方法。如果这些包内的类或对象使用Play.application.configuration
或尝试从数据库或其他 Play 工具获取数据,我们将无法实例化它们。这是因为大多数 Play 组件都需要访问当前运行的 Play 应用程序的实例。导入play.api.Play.current
使得这成为可能,但并不完全;我们仍然需要一个正在运行的应用程序,这将标记为当前应用程序。
让我们在 Scala 控制台创建一个应用程序并启动它,然后导入play.api.Play.current
:
scala> :pas
// Entering paste mode (ctrl-D to finish)
import play.api.Play
val application = new DefaultApplication(new java.io.File("."), this.getClass.getClassLoader, None, Mode.Dev)
Play.start(application)
import play.api.Play.current
一旦我们退出粘贴模式,代码将被解释,应用程序将被启动。我们可以从以下输出中看到这一点:
// Exiting paste mode, now interpreting.
SLF4J: Class path contains multiple SLF4J bindings.
SLF4J: Found binding in [jar:file:/home/.ivy2/cache/ch.qos.logback/logback-classic/jars/logback-classic-1.1.1.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: Found binding in [jar:file:/home/.ivy2/cache/org.slf4j/slf4j-log4j12/jars/slf4j-log4j12-1.7.2.jar!/org/slf4j/impl/StaticLoggerBinder.class]
SLF4J: See http://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J: Actual binding is of type [ch.qos.logback.classic.util.ContextSelectorStaticBinder]
[info] play - Application started (Dev)
import play.api.Play
application: play.api.DefaultApplication = play.api.DefaultApplication@29600952
import play.api.Play.current
scala>
现在,我们可以查看配置,查看或修改数据,等等。例如,让我们尝试获取应用程序的配置:
scala> Play.application.configuration
res7: play.api.Configuration = Configuration(Config(SimpleConfigObject({"akka":{"actor":{"creation-timeout":"20s","debug":{"autoreceive":"off","event-stream":"off","fsm":"off","lifecycle":"off","receive":"off","router-misconfiguration":"off","unhandled":"off"},"default-dispatcher":{"attempt-teamwork":"on","default-executor":{"fallback":"fork-join-executor"},"executor":"default-executor","fork-join-executor":{"parallelism-factor":3,"parallelism-max":64,"parallelism-min":8},"mailbox-requirement":"","shutdown-timeout":"1s","thread-pool-executor":{"allow-core-timeout":"on","core-pool-size-factor":3,"core-pool-size-max":64,"core-pool-size-min":8,"keep-alive-time":"60s","max-pool-size-factor":3,"max-pool-size-max":64,"max-pool-size-min":8,"task-queue-size":-1,"task-queue-type":"linked"},"thro...
很好,不是吗?然而,如果我们想要为不同的输入调用动作并检查结果,这还不够。在这种情况下,我们不应该使用console
命令,而应该使用test:console
命令:
[app] $ test:console
[info] Starting scala interpreter...
[info]
Welcome to Scala version 2.10.4 (Java HotSpot(TM) 64-Bit Server VM, Java 1.7.0_60).
Type in expressions to have them evaluated.
Type :help for more information.
scala> :pas
// Entering paste mode (ctrl-D to finish)
import play.api.test.Helpers._
import play.api.test._
import play.api.Play
val application = FakeApplication()
Play.start(application)
import play.api.Play.current
// Exiting paste mode, now interpreting.
…
现在,从这个 Scala 控制台,我们可以查看配置、修改数据,以及调用一个动作:
scala> Play.application.configuration
res0: play.api.Configuration = Configuration(Config(SimpleConfigObject({"akka":{"actor":{"creation-timeout":"20s","debug":{"autoreceive":"off","event-stream":"off","fsm":"off","lifecycle":"off","receive":"off","router-misconfiguration":"off","unhandled":"off"},"default-dispatcher":{"attempt-teamwork":"on","default-executor":{"fallback":"fork-join-executor"},"executor":"default-executor","fork-join-executor":{"parallelism-factor":3,"parallelism-max":64,"parallelism-min":8},"mailbox-requirement":"","shutdown-timeout":"1s","thread-pool-executor":{"allow-core-timeout":"on","core-pool-size-factor":3,"core-pool-size-max":64,"core-pool-size-min":8,"keep-alive-time":"60s","max-pool-size-factor":3,"max-pool-size-max":64,"max-pool-size-min":8,"task-queue-size":-1,"task-queue-type":"linked"},"thro...
scala> controllers.Application.index("John").apply(FakeRequest())
res1: scala.concurrent.Future[play.api.mvc.Result] = scala.concurrent.impl.Promise$KeptPromise@6fbd57ac
scala> contentAsString(res1)
res2: String = Hello John
提示
使用test:console
而不是console
;当你决定检查一个动作时,你不需要切换。
日志记录
日志是记录应用程序中事件发生的时间和原因的行为。如果处理得当,日志非常有用;否则,它们只是噪音。通过审查日志输出,你有可能确定事件的原因。
日志不仅有助于处理应用程序错误,还可以保护应用程序免受误用和恶意攻击,以及了解业务的不同方面。
Play 的日志 API
Play 通过play.api.Logger
公开日志 API。让我们看看它的类和对象定义:
class Logger(val logger: Slf4jLogger) extends LoggerLike
object Logger extends LoggerLike {
...
val logger = LoggerFactory.getLogger("application")
def apply(name: String): Logger = new Logger(LoggerFactory.getLogger(name))
def applyT: Logger = new Logger(LoggerFactory.getLogger(clazz))
...
}
LoggerLike
特质只是Slf4jLogger
的一个包装。默认情况下,所有应用程序日志都映射到带有应用程序名称的Logger
,而与 Play 相关的日志则映射到带有 Play 名称的Logger
。
在导入play.api.Logger
之后,我们可以使用默认的日志记录器或以这种方式定义一个自定义的日志记录器:
-
通过使用默认的日志记录器:
import play.api.Logger object Task{ def delete(id:Long) = { logger.debug(s"deleting task with id $id") ... } }
-
通过使用具有其类名的日志记录器:
import play.api.Logger object Task{ private lazy val taskLogger = Logger(getClass) def delete(id:Long) = { taskLogger.debug(s"deleting task with id $id") ... } }
-
通过使用具有自定义名称的日志记录器:
import play.api.Logger object Task{ private lazy val taskLogger = Logger("application.model") def delete(id:Long) = { taskLogger.debug(s"deleting task with id $id") ... } }
注意
Logger
支持的方法在 API 中进行了文档说明,请参阅www.playframework.com/documentation/2.3.x/api/scala/index.html#play.api.Logger
。
Play 的日志配置
Play 框架使用Logback
作为日志引擎。默认配置如下:
<configuration>
<conversionRule conversionWord="coloredLevel" converterClass="play.api.Logger$ColoredLevel" />
<appender name="FILE" class="ch.qos.logback.core.FileAppender">
<file>${application.home}/logs/application.log</file>
<encoder>
<pattern>%date - [%level] - from %logger in %thread %n%message%n%xException%n</pattern>
</encoder>
</appender>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%coloredLevel %logger{15} - %message%n%xException{5}</pattern>
</encoder>
</appender>
<logger name="play" level="INFO" />
<logger name="application" level="DEBUG" />
<!-- Off these ones as they are annoying, and anyway we manage configuration ourself -->
<logger name="com.avaje.ebean.config.PropertyMapLoader" level="OFF" />
<logger name="com.avaje.ebeaninternal.server.core.XmlConfigLoader" level="OFF" />
<logger name="com.avaje.ebeaninternal.server.lib.BackgroundThread" level="OFF" />
<logger name="com.gargoylesoftware.htmlunit.javascript" level="OFF" />
<root level="ERROR">
<appender-ref ref="STDOUT" />
<appender-ref ref="FILE" />
</root>
</configuration>
此配置将日志写入projectHome/logs/application.log
。因此,会生成一个巨大的文件。我们可以通过提供自定义的logger.xml
来修改此配置。
自定义日志文件配置可以以两种方式设置:
-
通过将配置保存到
conf/application-logger.xml
或conf/logger.xml
。尽管当两者都存在时,使用任一文件名,如application-logger.xml
或logger.xml
都有效,但logger.xml
的设置不会被应用。 -
通过指定系统属性来指定文件。此方法比其他选项具有更高的优先级。
有三个属性:
-
logger.resource
: 此属性设置类路径内的文件 -
logger.file
: 此属性通过绝对路径设置文件 -
logger.url
: 此属性通过以下方式使用 URL 设置文件:[app]$ start -Dlogger.url=http://serverPath/conf/appName/logger.xml
配置日志的另一个重要方面是通过设置所需的日志级别。我们将在下一节中讨论这一点。
日志级别
日志级别可以在conf/application.conf
中设置。默认值如下:
# Root logger:
logger.root=ERROR
# Logger used by the framework:
logger.play=INFO
# Logger provided to your application:
logger.application=DEBUG
我们还可以这样设置属于特定包和第三方库的类的日志级别:
logger.com.apache.cassandra = DEBUG
支持的日志级别按严重程度递减的顺序如下:
-
ERROR
-
WARN
-
INFO
-
DEBUG
-
TRACE
如果我们希望关闭某些类或包的日志记录,可以将日志级别设置为OFF
。这将禁用特定记录器的日志记录。
注意
一些库对日志库有传递性依赖。在定义依赖时最好排除这些日志包。可以按照以下方式操作:
"orgName" % "packageName" % "version" excludeAll(
ExclusionRule(organization = "org.slf4j"),
ExclusionRule(organization = "ch.qos.logback"))
摘要
在本章中,我们讨论了如何在 IDE 中配置 Play 应用的调试。我们还介绍了如何在 Scala 控制台中启动 Play 应用。本章还涵盖了 Play 框架提供的日志 API 以及自定义日志格式。
许多 Web 应用利用第三方 API,要么是为了避免重写现有代码,要么是为了使用户更容易采用他们的应用。在下一章中,我们将探讨开发者如何在 Play 应用中使用现有的外部 API。
第十一章。网络服务和身份验证
互联网浩瀚且不断扩展。许多日常任务都可以以更简单的方式进行——账单支付、检查产品评论、预订电影票等。除此之外,大多数电子设备现在都可以连接到互联网,例如手机、手表、监控系统和安全系统。这些设备可以相互通信,而且它们不必都是同一品牌。应用程序可以利用用户特定的信息并提供更定制化的功能。最重要的是,我们可以通过验证来决定是否愿意与应用程序共享我们的信息。
在本章中,我们将介绍 Play 框架对以下内容的支持:
-
调用网络服务
-
OpenID 和 OAuth 身份验证
调用网络服务
假设我们需要在线预订机票。我们可以通过使用飞行品牌的网站(如汉莎航空、阿联酋航空等)或旅行预订网站(如 ClearTrip、MakeMyTrip 等)来完成这项任务。我们如何从两个或更多不同的网站完成相同的任务呢?
飞行品牌的网站提供了一些 API,这些 API 是旅行预订网站工作的。这些 API 可以是免费提供的,也可以通过合同收费,由提供者和其他第三方决定。这些 API 也被称为网络服务。
网络服务基本上是一种通过互联网调用的方法。只有提供者完全了解这些网站的内部运作。使用网络服务的人只知道其目的和可能的后果。
许多应用程序出于各种原因需要/更喜欢使用第三方 API 来完成常见任务,例如业务领域的通用规范、提供安全授权的更简单方式,或避免维护开销等。
Play 框架有一个专门满足此类需求的网络服务 API。可以通过将其作为依赖项包含来使用网络服务 API:
libraryDependencies ++= Seq(
ws
)
一个常见的用例是使用事务性电子邮件 API 服务(如 Mailgun、SendGrid 等)发送带有账户验证和/或重置密码链接的电子邮件。
假设我们的应用程序有这样的需求,并且我们有一个处理所有这些交易的Email
对象。我们需要一个发送电子邮件的方法,它实际上会调用电子邮件 API 服务,然后是其他内部调用发送的方法。使用 Play 网络服务 API,我们可以将Email
定义为:
object Email {
val logger = Logger(getClass)
private def send(emailIds: Seq[String], subject: String, content: String): Unit = {
var properties: Properties = new Properties()
try {
properties.load(new FileInputStream("/opt/appName/mail-config.properties"))
val url: String = properties.getProperty("url")
val apiKey: String = properties.getProperty("api")
val from: String = properties.getProperty("from")
val requestHolder: WSRequestHolder = WS.url(url).withAuth("api", apiKey, WSAuthScheme.BASIC)
val requestData = Map(
"from" -> Seq(from),
"to" -> emailIds,
"subject" -> Seq(subject),
"text" -> Seq(content))
val response: Future[WSResponse] = requestHolder.post(requestData)
response.map(
res => {
val responseMsg: String = res.json.toString()
if (res.status == 200) {
logger.info(responseMsg)
} else {
logger.error(responseMsg)
}
}
)
} catch {
case exp: IOException =>
logger.error("Failed to load email configuration properties.")
}
}
def sendVerification(userId: Long, emailId: String, host: String): Unit = {
val subject: String = "Email Verification"
val content: String =
s"""To verify your account on <appName>, please click on the link below
|
|http://$host/validate/user/$userId""".stripMargin
send(Seq(emailId), subject, content)
}
def recoverPassword(emailId: String, password: String): Unit = {
val subject: String = "Password Recovery"
val emailContent: String = s"Your password has been reset.The new password is $password"
send(Seq(emailId), subject, emailContent)
}
}
网络服务 API 通过WS
对象公开,该对象提供了作为 HTTP 客户端查询网络服务的方法。在前面的代码片段中,我们使用了网络服务 API 来发起一个 POST 请求。其他可用的方法来触发请求并获取响应或响应流包括:
-
get
或getStream
-
put
或putAndRetrieveStream
-
post
或postAndRetrieveStream
-
delete
-
head
这些调用中的任何结果都是 Future[WSResponse]
类型,因此我们可以安全地说,网络服务 API 是异步的。
它不仅限于 REST 服务。例如,假设我们使用 SOAP 服务来获取所有国家的货币:
def displayCurrency = Action.async {
val url: String = "http://www.webservicex.net/country.asmx"
val wsReq: String = """<?xml version="1.0" encoding="utf-8"?>
|<soap12:Envelope >
| <soap12:Body>
| <GetCurrencies />
| </soap12:Body>
|</soap12:Envelope>""".stripMargin
val response: Future[WSResponse] = WS.url(url).withHeaders("Content-Type" -> "application/soap+xml").post(wsReq)
response map {
data => Ok(data.xml)
}
}
可以使用 WS.url()
构建一个 HTTP 请求,它返回一个 WSRequestHolder
实例。WSRequestHolder
特性有添加头部、身份验证、请求参数、数据等方法。以下是一个常用方法的另一个示例:
WS.url("http://third-party.com/service?=serviceName")
.withAuth("api","apiKey", WSAuthScheme.BASIC)
.withQueryString("month" -> "12",
"year" -> "2014",
"code" -> "code")
.withHeaders(HeaderNames.ACCEPT -> MimeTypes.JSON)
.get
尽管在这个例子中我们使用了基本身份验证,但网络服务 API 支持大多数常用的身份验证方案,您可以在以下链接中找到:
-
简单且受保护的 GSSAPI 协商机制 (SPNEGO):
en.wikipedia.org/wiki/SPNEGO
-
NT LAN 管理器 (NTLM):
en.wikipedia.org/wiki/NT_LAN_Manager
通过 WS
对象可用的所有方法只是调用可用的 WSAPI
特性的实现中的相关方法。默认提供的网络服务 API 使用 Ning 的 AysncHttpClient(请参阅 github.com/AsyncHttpClient/async-http-client
)。如果我们想使用任何其他 HTTP 客户端,我们需要实现 WSAPI
特性并通过插件绑定它。当我们添加 ws
Play 库时,它将 play.api.libs.ws.ning.NingWSPlugin
添加到我们的应用程序中,该插件定义为:
class NingWSPlugin(app: Application) extends WSPlugin {
@volatile var loaded = false
override lazy val enabled = true
private val config = new DefaultWSConfigParser(app.configuration, app.classloader).parse()
private lazy val ningAPI = new NingWSAPI(app, config)
override def onStart() {
loaded = true
}
override def onStop() {
if (loaded) {
ningAPI.resetClient()
loaded = false
}
}
def api = ningAPI
}
注意
在 Play 应用中,使用 SSL 与 WS 需要对配置进行一些更改,并在 www.playframework.com/documentation/2.3.x/WsSSL
中有文档说明。
由于大量应用程序依赖于来自各种来源的用户数据,Play 提供了 OpenID 和 OAuth 的 API。我们将在以下章节中讨论这些内容。
OpenID
OpenID 是一种身份验证协议,其中 OpenID 提供商验证第三方应用程序中用户的身份。OpenID 提供商是任何向用户提供 OpenID 的服务/应用程序。Yahoo、AOL 等是这些服务/应用程序的几个例子。需要用户 OpenID 来完成交易的应用程序被称为 OpenID 消费者。
OpenID 消费者中的控制流程如下:
-
用户将被引导到支持的/选定的 OpenID 提供商的登录页面。
-
一旦用户完成登录,OpenID 提供商会告知用户 OpenID 消费者请求的用户相关数据。
-
如果用户同意共享信息,则会被重定向到消费者应用程序中请求的页面。信息被添加到请求 URL 中。这些信息被称为属性属性,相关文档位于
openid.net/specs/openid-attribute-properties-list-1_0-01.html
。
Play 提供了一个 API 来简化 OpenID 交易,相关文档位于 www.playframework.com/documentation/2.3.x/api/scala/index.html#play.api.libs.openid.OpenID$
。
以下两个关键方法如下:
-
redirectURL
:此参数用于验证用户、请求特定用户信息并将其重定向到回调页面 -
verifiedId
:此参数用于从已验证的 OpenID 回调请求中提取用户信息
让我们构建一个使用提供者 Yahoo 的 OpenID 的应用程序。我们可以定义控制器如下:
object Application extends Controller {
def index = Action.async {
implicit request =>
OpenID.verifiedId.map(info => Ok(views.html.main(info.attributes)))
.recover {
case t: Throwable =>
Redirect(routes.Application.login())
}
}
def login = Action.async {
implicit request =>
val openIdRequestURL: String = "https://me.yahoo.com"
OpenID.redirectURL(
openIdRequestURL,
routes.Application.index.absoluteURL(),
Seq("email" -> "http://schema.openid.net/contact/email",
"name" -> "http://openid.net/schema/namePerson/first"))
.map(url => Redirect(url))
.recover { case t: Throwable => Ok(t.getMessage) }
}
}
在前面的代码片段中,login
方法将用户重定向到 Yahoo 登录页面(参考 me.yahoo.com
)。一旦用户登录,系统会询问用户是否允许应用程序共享其个人资料。如果用户同意,则会重定向到 routes.Application.index.absoluteURL()
。
index
方法期望在登录成功时由 OpenID 提供商(在我们的例子中是 Yahoo)共享的数据。如果数据不可用,用户将被重定向到 login
方法。
OpenID.redirectURL
的第三个参数是一个元组序列,它指示应用程序所需的信息(所需属性)。每个元组的第二个元素是使用 OpenID 属性交换请求的属性属性的标签——它使得个人身份信息的传输成为可能。每个元组的第一个元素是 OpenID 提供者在回调请求的 queryString
中映射属性属性值的标签。
例如,http://openid.net/schema/namePerson/first
属性通过其名字表示属性属性。在登录成功后,此属性的值和消费者提供的标签会被添加到回调中的 queryString
。因此,openid.ext1.value.name=firstName
会被添加到登录回调中。
OAuth
根据 oauth.net/core/1.0/
,OAuth 的定义如下:
“OAuth 认证是用户在不与消费者共享其凭据的情况下授予其受保护资源访问权限的过程。OAuth 使用服务提供商生成的令牌,而不是在受保护资源请求中使用用户的凭据。此过程使用两种令牌类型:”
请求令牌:由消费者用于请求用户授权访问受保护资源。用户授权的请求令牌被交换为访问令牌,必须只使用一次,并且不得用于其他任何目的。建议请求令牌有有限的有效期。
访问令牌:由消费者代表用户访问受保护资源。访问令牌可以限制对某些受保护资源的访问,并且可能有有限的有效期。服务提供商应允许用户撤销访问令牌。仅访问令牌应用于访问受保护资源。
OAuth 认证分为三个步骤:
消费者获取一个未经授权的请求令牌。
用户授权请求令牌。
消费者将请求令牌交换为访问令牌。"
具体可以访问什么以及可以访问多少由服务提供商决定。
OAuth 有三个版本:1.0、1.0a 和 2.0。第一个版本(1.0)存在一些安全问题,并且服务提供商不再使用。
Play 提供了用于 1.0 和 1.0a 版本的 API,而不是用于 2.0,因为使用这个版本要简单得多。API 文档位于www.playframework.com/documentation/2.3.x/api/scala/index.html#play.api.libs.oauth.package
。
让我们构建一个应用程序,该程序利用 Twitter 账户通过 Play 的 OAuth API 进行登录。
初始时,我们需要使用 Twitter 账户在apps.twitter.com/
注册应用程序,以便我们有一个有效的消费者密钥和密钥组合。之后,我们可以定义如下动作:
val KEY: ConsumerKey = ConsumerKey("myAppKey", "myAppSecret")
val TWITTER: OAuth = OAuth(ServiceInfo(
"https://api.twitter.com/oauth/request_token",
"https://api.twitter.com/oauth/access_token",
"https://api.twitter.com/oauth/authorize", KEY),
true)
def authenticate = Action { request =>
TWITTER.retrieveRequestToken("http://localhost:9000/welcome") match {
case Right(t) => {
Redirect(TWITTER.redirectUrl(t.token)).withSession("token" -> t.token, "secret" -> t.secret)
}
case Left(e) => throw e
}
}
OAuth 是 Play 的一个辅助类,具有以下签名:
OAuth(info: ServiceInfo, use10a: Boolean = true)
参数确定 OpenID 的版本。如果设置为true
,则使用 OpenID 1.0,否则使用 1.0。
它提供了这三个方法:
-
redirectURL
: 这将获取用户应重定向到的 URL 字符串,以便通过提供者授权应用程序 -
retrieveRequestToken
: 这从提供者那里获取请求令牌 -
retrieveAccessToken
: 这将请求令牌交换为访问令牌
在前面的动作定义中,我们只使用提供者进行登录;除非我们用访问令牌交换授权的请求令牌,否则我们无法获取任何用户详情。要获取访问令牌,我们需要请求令牌和oauth_verifier
,这是服务提供商在授予请求令牌时提供的。
使用 Play OAuth API,在获取请求令牌后进行重定向会将oauth_verifier
添加到请求查询字符串中。因此,我们应该重定向到一个尝试获取访问令牌并随后存储它的动作,以便它对未来的请求易于访问。在这个例子中,它被存储在会话中:
def authenticate = Action { request =>
request.getQueryString("oauth_verifier").map { verifier =>
val tokenPair = sessionTokenPair(request).get
TWITTER.retrieveAccessToken(tokenPair, verifier) match {
case Right(t) => {
Redirect(routes.Application.welcome()).withSession("token" -> t.token, "secret" -> t.secret)
}
case Left(e) => throw e
}
}.getOrElse(
TWITTER.retrieveRequestToken("http://localhost:9000/twitterLogin") match {
case Right(rt) =>
Redirect(TWITTER.redirectUrl(rt.token)).withSession("token" -> rt.token, "secret" -> rt.secret)
case Left(e) => throw e
})
}
private def sessionTokenPair(implicit request: RequestHeader): Option[RequestToken] = {
for {
token <- request.session.get("token")
secret <- request.session.get("secret")
} yield {
RequestToken(token, secret)
}
}
def welcome = Action.async {
implicit request =>
sessionTokenPair match {
case Some(credentials) => {
WS.url("https://api.twitter.com/1.1/statuses/home_timeline.json")
.sign(OAuthCalculator(KEY, credentials))
.get()
.map(result => Ok(result.json))
}
case _ => Future.successful(Redirect(routes.Application.authenticate()).withNewSession)
}
}
在用户成功登录和授权后,我们通过 welcome 动作获取用户时间轴上的状态并将其作为 JSON 显示。
注意
Play 中没有内置对使用 OAuth 2.0、CAS、SAML 或任何其他协议进行身份验证的支持。然而,开发者可以选择使用符合他们要求的第三方插件或库。其中一些包括 Silhouette (silhouette.mohiva.com/v2.0
)、deadbolt-2 (github.com/schaloner/deadbolt-2
)、play-pac4j (github.com/pac4j/play-pac4j
))等等。
摘要
在本章中,我们学习了 WS(Web 服务)插件以及通过它暴露的 API。我们还看到了如何使用 OpenID 和 OAuth 1.0a(因为大多数服务提供商使用 1.0a 或 2.0)从服务提供商访问用户数据,借助 Play 中的 OpenID 和 OAuth API。
在下一章中,我们将了解 Play 提供的某些模块是如何工作的,以及我们如何使用它们来构建自定义模块。
第十二章:生产环境下的玩耍
由于受安全、负载/流量(预期处理)、网络问题等各种因素的影响,应用程序部署、配置等在生产环境中略有不同。在本章中,我们将了解如何使我们的 Play 应用程序在生产环境中运行。本章涵盖了以下主题:
-
部署应用程序
-
生产环境配置
-
启用 SSL
-
使用负载均衡器
部署 Play 应用程序
Play 框架提供了用于在生产环境中打包和部署 Play 应用程序的命令。
我们之前使用的run
命令以DEV
模式启动应用程序并监视代码的变化。当代码发生变化时,应用程序将被重新编译和重新加载。在开发过程中保持警觉是很有用的,但在生产环境中这会是一个不必要的开销。此外,PROD
模式下显示的默认错误页面与DEV
模式下显示的不同,即它们关于发生错误的详细信息较少(出于安全原因)。
让我们看看在生产环境中我们可以以不同方式部署应用程序。
使用 start 命令
要以PROD
模式启动应用程序,我们可以使用start
命令:
[PlayScala] $ start
[info] Wrote /PlayScala/target/scala-2.10/playscala_2.10-1.0.pom
(Starting server. Type Ctrl+D to exit logs, the server will remain in background)
Play server process ID is 24353
[info] play - Application started (Prod)
[info] play - Listening for HTTP on /0:0:0:0:0:0:0:0:9000
进程 ID 可以在以后用来停止应用程序。通过按Ctrl + D,我们不会丢失日志,因为默认情况下它们也被捕获在logs/application.log
中(即,当日志配置没有变化时)。
start
命令可以可选地接受应用程序部署的端口号:
[PlayScala] $ start 9123
[info] Wrote /PlayScala/target/scala-2.10/playscala_2.10-1.0.pom
(Starting server. Type Ctrl+D to exit logs, the server will remain in background)
Play server process ID is 12502
[info] play - Application started (Prod)
[info] play - Listening for HTTP on /0:0:0:0:0:0:0:0:9123
使用发行版
虽然start
命令足以部署应用程序,但在需要应用程序的可移植版本的情况下,可能不足以满足需求。在本节中,我们将了解如何构建我们应用程序的独立发行版。
Play 框架支持使用sbt-native-packager
插件(参考www.scala-sbt.org/sbt-native-packager/
)构建应用程序的发行版。该插件可以用来创建.msi
(Windows)、.deb
(Debian)、.rpm
(Red Hat 软件包管理器)和.zip
(通用)文件,以及我们应用程序的 Docker 镜像。该插件还支持在应用程序的构建文件中定义包的设置。其中一些设置是通用的,而其他的是特定于操作系统的。以下表格显示了通用设置:
设置 | 目的 | 默认值 |
---|---|---|
packageName |
创建的输出包的名称(不带扩展名) | 从混合大小写和空格转换为小写和短横线分隔的项目名称 |
packageDescription |
包的描述 | 项目名称 |
packageSummary |
Linux 软件包内容的摘要 | 项目名称 |
executableScriptName |
执行脚本的名称 | 将项目名称从混合大小写和空格转换为小写和短横线分隔的名称 |
maintainer |
本地软件包维护者的名称/电子邮件地址 |
现在,让我们看看如何为不同的操作系统构建软件包并使用它们。
通用发行版
通用发行版与所有/大多数操作系统兼容。生成的软件包位于 projectHome/target/universal
。我们可以使用以下任一命令根据需要创建软件包:
-
universal:packageBin
– 此命令创建打包应用的appname-appVersion.zip
文件 -
universal:packageZipTarball
– 此命令创建打包应用的appname-appVersion.tgz
文件 -
universal:packageOsxDmg
– 此命令创建打包应用的appname-appVersion.dmg
文件(该命令仅在 OS X 上有效)
注意
universal:packageZipTarball
命令需要 gzip
、xz
和 tar
命令行工具,而 universal:packageOsxDmg
命令则需要 OS X 或安装了 hdiutil
的系统。
要使用通过这些命令构建的软件包,提取文件并对于基于 Unix 的系统执行 bin/appname
,对于带有 Windows 的系统执行 bin/appname.bat
。
注意
在 Play 应用程序中,我们可以使用 dist
命令代替 universal:packageBin
。dist
命令会删除使用 universal:packageBin
命令打包应用时创建的不必要中间文件。
Debian 发行版
我们可以使用 debian:packageBin
命令创建可在基于 Debian 的系统上安装的发行版。.deb
文件位于 projectHome/target
。
注意
要构建 Debian 软件包,应在 build
文件中设置 Debian 设置中的 packageDescription
值。其他 Debian 软件包设置也可以在 build
文件中设置。
打包完成后,我们可以使用 dpkg-deb
命令安装应用:
projectHome$ sudo dpkg -i target/appname-appVersion.deb
安装完成后,我们可以通过执行以下命令来启动应用:
$ sudo appname
rpm 发行版
可以使用 rpm:packageBin
命令创建应用的 rpm
软件包。以下表格显示了 rpm
软件包的一些可用设置:
设置 | 目的 |
---|---|
rpmVendor |
本 rpm 软件包的供应商名称 |
rpmLicense |
rpm 软件包内代码的许可协议 |
rpmUrl |
应包含在 rpm 软件包中的 URL |
rpmDescription |
本 rpm 软件包的描述 |
rpmRelease |
本 rpm 软件包的特殊发布号 |
注意
在 rpm
中,rpmVendor
的值、rpm
中的 packageSummary
和 rpm
中的 packageDescription
必须在 build
文件中设置,才能成功创建应用 rpm
软件包,其中 rpm
是作用域,例如 rpm:= "SampleProject"
中的名称。
生成 rpm
软件包后,我们可以使用 yum
或等效工具安装它:
projectHome$ sudo yum install target/appname-appVersion.rpm
安装完成后,我们可以通过执行以下命令来启动应用:
$ sudo appname
Windows 发行版
可以使用 windows:packageBin
命令创建应用程序的 Windows 安装程序,appname-appVersion.msi
。文件位于 projectHome/target
。
生产环境配置
Play 框架理解应用程序在部署到生产之前可能需要更改配置。为了简化部署,部署应用程序的命令也接受应用程序级别的配置作为参数:
[PlayScala] $ start -Dapplication.secret=S3CR3T
[info] Wrote /PlayScala/target/scala-2.10/playscala_2.10-1.0.pom
(Starting server. Type Ctrl+D to exit logs, the server will remain in background)
Play server process ID is 14904
让我们按照以下方式更改应用程序的 HTTP 端口:
#setting http port to 1234
[PlayScala] $ start -Dhttp.port=1234
在某些项目中,生产环境和开发环境的配置被保存在两个独立的文件中。我们可以传递一个或多个配置,或者完全传递一个不同的文件。有三种明确指定配置文件的方式。可以通过以下选项之一实现:
-
config.resource
: 当文件位于类路径中(application/conf
中的文件)时使用此选项 -
config.file
: 当文件在本地文件系统中可用,但与应用程序资源捆绑在一起时使用此选项 -
config.url
: 当文件需要从 URL 加载时使用此选项
假设我们的应用程序在生产环境中使用conf/application-prod.conf
,我们可以如下指定该文件:
[PlayScala] $ start -Dconfig.resource=application-prod.conf
同样,我们也可以通过将 config
键替换为 logger
来修改日志配置:
[PlayScala] $ start -Dlogger.resource=logger-prod.xml
我们还可以通过传递设置作为参数来配置底层的 Netty 服务器,这不可能通过 application.conf
实现。以下表格列出了可以在一种或多种方式中配置的与服务器相关的某些设置。
与地址和端口相关的属性如下:
属性 | 目的 | 默认值 |
---|---|---|
http.address |
应用程序部署的地址 | 0.0.0.0 |
http.port |
应用程序可用的端口 | 9000 |
https.port |
应用程序可用的 sslPort 端口 |
与 HTTP 请求(HttpRequestDecoder
)相关的属性如下:
属性 | 目的 | 默认值 |
---|---|---|
http.netty.maxInitialLineLength |
初始行的最大长度(例如,GET / HTTP/1.0 ) |
4096 |
http.netty.maxHeaderSize |
所有头部合并后的最大长度 | 8192 |
http.netty.maxChunkSize |
主体或其每个分块的长度最大值。如果主体的长度超过此值,内容将被分成此大小或更小的块(最后一个块的情况)。如果请求发送分块数据,并且一个分块的长度超过此值,它将被分成更小的块。 | 8192 |
以下表格显示了与 TCP 套接字选项相关的属性:
属性 | 目的 | 默认值 |
---|---|---|
http.netty.option.backlog |
队列中传入连接的最大大小 | |
http.netty.option.reuseAddress |
重新使用地址 | |
http.netty.option.receiveBufferSize |
接收缓冲区所使用的套接字大小。 | |
http.netty.option.sendBufferSize |
发送缓冲区所使用的套接字大小。 | |
http.netty.option.child.keepAlive |
保持连接活跃。 | False |
http.netty.option.child.soLinger |
如果存在数据,则在关闭时保持等待。 | 负整数(禁用) |
http.netty.option.tcpNoDelay |
禁用 Nagle 算法。TCP/IP 使用一种称为 Nagle 算法的算法来合并短数据段并提高网络效率。 | False |
http.netty.option.trafficClass |
服务类型(ToS)八位字节,位于互联网协议(IP)头部中。 | 0 |
启用 SSL
为我们的应用程序启用 SSL 有两种方式。我们可以在启动时提供所需的配置来提供 HTTPS 应用程序,或者通过代理请求通过启用 SSL 的 Web 服务器。在本节中,我们将了解如何使用第一种选项,而后者将在下一节中介绍。
我们可以选择运行 HTTP 和 HTTPS 版本,或者仅使用http.port
和https.port
设置选择其中之一。默认情况下,HTTPS 是禁用的,我们可以通过指定以下https.port
来启用它:
#setting https port to 1234
[PlayScala] $ start -Dhttps.port=1234
#disabling http port and setting https port to 1234
[PlayScala] $ start -Dhttp.port=disabled -Dhttps.port=1234
如果我们没有提供它们,Play 将生成自签名证书,并以启用 SSL 的方式启动应用程序。然而,这些证书不适合实际应用,我们需要使用以下设置指定密钥库的详细信息:
属性 | 目的 | 默认值 |
---|---|---|
https.keyStore |
包含私钥和证书的密钥库的路径 | 此值是动态生成的 |
https.keyStoreType |
密钥库类型 | Java 密钥库(JKS) |
https.keyStorePassword |
密码 | 空密码 |
https.keyStoreAlgorithm |
密钥库算法 | 平台的默认算法 |
此外,我们还可以通过play.http.sslengineprovider
设置指定SSLEngine
。此操作的前提是自定义的SSLEngine
应该实现play.server.api.SSLEngineProvider
特质。
注意
当启用了 SSL 的 Play 应用程序在生产环境中运行时,建议使用 JDK 1.8,因为 Play 使用 JDK 1.8 的一些功能来简化它。如果使用 JDK 1.8 不可行,则应使用启用 SSL 的反向代理。有关更多详细信息,请参阅www.playframework.com/documentation/2.3.x/ConfiguringHttps
。
使用负载均衡器
处理大量流量的网站通常使用一种称为负载均衡的技术来提高应用程序的可用性和响应性。负载均衡器将传入流量分配到多个托管相同内容的服务器。负载分配由各种调度算法确定。
在本节中,我们将看到如何使用不同的 HTTP 服务器(假设它们运行在 IP 127.0.0.1
、127.0.0.2
和 127.0.0.3
的 9000
端口上)在我们的应用程序服务器前添加负载均衡器。
Apache HTTP
Apache HTTP 服务器提供了一个安全、高效且可扩展的服务器,支持 HTTP 服务。Apache HTTP 服务器可以通过其 mod_proxy
和 mod_proxy_balance
模块用作负载均衡器。
要使用 Apache HTTP 作为负载均衡器,服务器中必须存在 mod_proxy
和 mod_proxy_balancer
。要设置负载均衡器,我们只需更新 /etc/httpd/conf/httpd.conf
。
让我们逐步更新配置:
-
声明
VirtualHost
:<VirtualHost *:80> </VirtualHost>
-
禁用
VirtualHost
的正向代理,以便我们的服务器不能用于掩盖源服务器的客户端身份:ProxyRequests off
-
我们应该添加一个带有均衡标识符和
BalanceMembers
的代理,而不是文档根。如果我们想使用 轮询 策略,我们还需要将其设置为lbmethod
(负载均衡方法):<Proxy balancer://app> BalancerMember http://127.0.0.1:9000 BalancerMember http://127.0.0.2:9000 BalancerMember http://127.0.0.3:9000 ProxySet lbmethod=byrequests </Proxy>
-
现在,我们需要添加代理的访问权限,它应该对所有人均可访问:
Order Deny,Allow Deny from none Allow from all
-
最后,我们需要将代理映射到我们希望在服务器上加载应用程序的路径。这可以通过一行完成:
ProxyPass / balancer://app/
需要添加到 Apache HTTP 配置文件的配置如下:
<VirtualHost *:80>
ProxyPreserveHost On
ProxyRequests off
<Proxy balancer://app>
BalancerMember http://127.0.0.1:9000
BalancerMember http://127.0.0.2:9000
BalancerMember http://127.0.0.3:9000
Order Deny,Allow
Deny from none
Allow from all
ProxySet lbmethod=byrequests
</Proxy>
ProxyPass / balancer://app/
</VirtualHost>
要启用 SSL,我们需要在 VirtualHost
定义中添加以下代码:
SSLEngine on
SSLCertificateFile /path/to/domain.com.crt
SSLCertificateKeyFile /path/to/domain.com.key
注意
此配置已在 2014 年 7 月 31 日于 Apache/2.4.10 上进行过测试。
有关 Apache HTTP 的 mod_proxy
模块的更多信息,请参阅 httpd.apache.org/docs/2.2/mod/mod_proxy.html
。
nginx 服务器
nginx 服务器是一个高性能的 HTTP 服务器和反向代理,同时也是 IMAP/POP3 代理服务器。我们可以配置 nginx 使用两个模块——proxy
和 upstream
作为负载均衡器。这两个模块是 nginx 核心的一部分,默认情况下可用。
nginx 配置文件 nginx.conf
通常位于 /etc/nginx
。让我们逐步更新它,以使用 nginx 作为我们的应用程序的负载均衡器:
-
首先,我们需要为我们的应用程序服务器集群定义一个
upstream
模块。其语法如下:upstream <group> { <loadBalancingMethod>; server <server1>; server <server2>; }
默认的负载均衡方法是轮询。因此,当我们希望使用它时,无需明确指定。现在,对于我们的应用程序,
upstream
模块将如下所示:upstream app { server 127.0.0.1:9000; server 127.0.0.2:9000; server 127.0.0.3:9000; }
-
现在,我们所需做的就是代理所有请求。为此,我们必须更新
server
模块的location
模块:server { listen 80 default_server; server_name localhost; … location / { proxy_pass http://app; proxy_set_header Host $host; } }
nginx 服务器也支持代理 WebSocket。要启用 WebSocket 连接,我们需要向 location
模块添加两个头部。因此,如果我们的 Play 应用程序使用 WebSocket,我们可以将 location
模块定义为以下内容:
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
要启用 SSL,我们需要在服务器定义中添加以下设置:
ssl_certificate /path/to/domain.com.crt;
ssl_certificate_key path/to/domain.com.key;
注意
此配置已在 nginx/1.4.7 上进行过测试。
更多关于 nginx 负载均衡配置的详细信息,请参阅 nginx.org/en/docs/http/load_balancing.html#nginx_load_balancing_configuration
。
lighttpd
lighttpd
服务器是一个轻量级的网络服务器,设计和优化用于高性能环境。所有可能需要的实用工具都作为模块提供,可以根据我们的需求进行包含。我们可以使用 mod_proxy
模块将 lighttpd
设置为 Play 应用的前端服务器。为此,我们需要进行一些配置更改。具体如下:
-
更新
lighttpd.conf
文件(通常位于/etc/lighttpd/
),以加载额外的模块。 -
默认情况下,加载模块是禁用的。可以通过取消注释此行来启用:
include "modules.conf"
-
更新
modules.conf
(位于lighttpd.conf
相同的目录中),以加载mod_proxy
模块。 -
默认情况下,仅启用
mod_access
。将server.modules
更新为以下代码:server.modules = ( "mod_access", "mod_proxy" )
-
现在,通过取消注释此行来启用加载
mod_proxy
的设置:include "conf.d/proxy.conf"
-
更新
proxy.conf
文件(通常位于/etc/lighttpd/conf.d/
),包含服务器代理配置。q
模块只有三个设置:-
proxy.debug
:此设置启用/禁用日志级别 -
proxy.balance
:此设置是负载均衡算法(轮询、哈希和公平) -
proxy.server
:此设置是请求发送的地方定义
proxy.server
设置的预期格式如下:( <extension> => ( [ <name> => ] ( "host" => <string> , "port" => <integer> ), ( "host" => <string> , "port" => <integer> ) ), <extension> => ... )
以下是对此代码中术语的解释:
-
<extension>
:此术语是文件扩展名或前缀(如果以"/"
开头);空引号""
匹配所有请求 -
<name>
:此术语是mod_status
生成的统计信息中显示的可选名称 -
host
:此术语用于指定代理服务器的 IP 地址 -
port
:此术语用于设置对应主机的 TCP 端口(默认值为80
)
-
-
根据需要更新代理设置:
server.modules += ( "mod_proxy" ) proxy.balance = "round-robin" proxy.server = ( "" => ( "app" => ( "host" => "127.0.0.1", "port" => 9000 ), ( "host" => "127.0.0.2", "port" => 9000 ), ( "host" => "127.0.0.3", "port" => 9000 ) ) )
注意
此配置已在 2014 年 3 月 12 日在 lighttpd/1.4.35 上进行过测试。
关于
mod_proxy
的配置设置更多信息,请参阅redmine.lighttpd.net/projects/lighttpd/wiki/Docs_ModProxy
。
高可用性代理
高可用性 代理(HAProxy)为基于 TCP 和 HTTP 的应用程序提供高可用性、负载均衡和代理。我们可以通过更新 haproxy.cfg
配置文件(通常位于 /etc/haproxy/
)将 HAProxy 设置为负载均衡器。
让我们逐步进行所需的配置更改:
-
首先,我们需要定义后端集群。定义后端的语法如下:
backend <name> balance <load balance method> server <sname> <ip>:<port> server <sname> <ip>:<port>
-
因此,我们应用程序的后端将如下所示:
backend app balance roundrobin server app1 127.0.0.1:9000 server app1 127.0.0.2:9000 server app1 127.0.0.3:9000
-
现在,我们只需将请求指向后端集群。我们可以通过更新前端部分来完成此操作:
frontend main *:80 default_backend app
对于使用 WebSockets 的应用程序,不需要额外的配置。
注意
此配置已在 HAProxy 版本 1.5.9(2014/11/25)上进行了测试。
故障排除
这些是一些你可能遇到的一些边缘情况:
-
我们需要将我们的应用程序部署到 Tomcat。我们如何将应用程序打包为 WAR?
虽然 Play 默认不支持此功能,但我们可以使用
play2-war-plugin
模块(参考github.com/play2war/play2-war-plugin/
)来实现这一点。 -
有没有更简单的方法来部署应用程序到 PaaS?
在 Heroku、Clever Cloud、Cloud Foundry 和/或 AppFog 上部署 Play 应用程序的文档可在
www.playframework.com/documentation/2.3.x/DeployingCloud
查阅。
摘要
在本章中,我们看到了如何在生产环境中部署 Play 应用程序。在部署过程中,我们看到了默认可用的不同打包选项(例如 rpm
、deb
、zip
、windows
等)。我们还看到了不同的配置设置,例如 HTTP 端口、请求头最大大小等,这些我们可以在生产环境中启动应用程序时指定。我们还讨论了如何使用反向代理向应用程序发送请求。
在下一章中,我们将讨论 Play 插件的工作原理,以及如何构建定制的 Play 插件以满足不同的需求。
第十三章。编写 Play 插件
为了使我们的应用可管理,我们将它们分解为独立的模块。这些模块也可以提取为单独的项目/库。
Play 插件不过是另一个具有额外能力的模块——在启动前、启动时和/或停止 Play 应用时绑定任务。在本章中,我们将看到如何编写自定义插件。
在本章中,我们将涵盖以下主题:
-
插件定义
-
插件声明
-
通过插件公开服务
-
编写插件的技巧
插件定义
一个 Play 插件可以通过扩展play.api.plugin
来定义,其定义如下:
trait Plugin {
//Called when the application starts.
def onStart() {}
// Called when the application stops.
def onStop() {}
// Is the plugin enabled?
def enabled: Boolean = true
}
现在,我们可能处于需要在一个应用启动或停止时发送电子邮件的情况,以便管理员可以稍后使用这个时间间隔来监控应用性能并检查为什么它停止。我们可以定义一个插件为我们完成这项工作:
class NotifierPlugin(app:Application) extends Plugin{
private def notify(adminId:String,status:String):Unit = {
val time = new Date()
val msg = s"The app has been $status at $time"
//send email to admin with the msg
log.info(msg)
}
override def onStart() {
val emailId = app.configuration.getString("notify.admin.id").get
notify(emailId,"started")
}
override def onStop() {
val emailId = app.configuration.getString("notify.admin.id").get
notify(emailId,"stopped")
}
override def enabled: Boolean = true
}
我们也可以定义利用其他库的插件。我们可能需要构建一个在启动时建立到Cassandra
(一个 NoSQL 数据库)的连接池,并允许用户稍后使用此池的插件。为了构建此插件,我们将使用 Java 的cassandra-driver
。然后我们的插件将如下所示:
class CassandraPlugin(app: Application) extends Plugin {
private var _helper: Option[CassandraConnection] = None
def helper = _helper.getOrElse(throw new RuntimeException("CassandraPlugin error: CassandraHelper initialization failed"))
override def onStart() = {
val appConfig = app.configuration.getConfig("cassandraPlugin").get
val appName: String = appConfig.getString("appName").getOrElse("appWithCassandraPlugin")
val hosts: Array[java.lang.String] = appConfig.getString("host").getOrElse("localhost").split(",").map(_.trim)
val port: Int = appConfig.getInt("port").getOrElse(9042)
val cluster = Cluster.builder()
.addContactPoints(hosts: _*)
.withPort(port).build()
_helper = try {
val session = cluster.connect()
Some(CassandraConnection(hosts, port, cluster, session))
} catch {
case e: NoHostAvailableException =>
val msg =
s"""Failed to initialize CassandraPlugin.
|Please check if Cassandra is accessible at
| ${hosts.head}:$port or update configuration""".stripMargin
throw app.configuration.globalError(msg)
}
}
override def onStop() = {
helper.session.close()
helper.cluster.close()
}
override def enabled = true
}
在这里,CassandraConnection
的定义如下:
private[plugin] case class CassandraConnection(hosts: Array[java.lang.String],
port: Int,
cluster: Cluster,
session: Session)
cassandra-driver
节点被声明为库依赖项,并在需要的地方导入其类。
注意
在插件的build
定义中,对 Play 的依赖项应标记为提供,因为使用插件的程序已经对 Play 有依赖,如下所示:
libraryDependencies ++= Seq(
"com.datastax.cassandra" % "cassandra-driver-core" % "2.0.4",
"com.typesafe.play" %% "play" % "2.3.0" % "provided" )
插件声明
现在我们已经定义了一个插件,让我们看看 Play 框架是如何识别和启用它来应用于应用的。生产模式和开发模式(静态和可重载应用)的ApplicationProvider
都依赖于DefaultApplication
,其定义如下:
class DefaultApplication(
override val path: File,
override val classloader: ClassLoader,
override val sources: Option[SourceMapper],
override val mode: Mode.Mode) extends Application with WithDefaultConfiguration with WithDefaultGlobal with WithDefaultPlugins
trait WithDefaultPlugins
行负责将插件绑定到应用的生命周期。其定义如下:
trait WithDefaultPlugins {
self: Application =>
private[api] def pluginClasses: Seq[String] = {
import scala.collection.JavaConverters._
val PluginDeclaration = """([0-9_]+):(.*)""".r
val pluginFiles = self.classloader.getResources("play.plugins").asScala.toList ++ self.classloader.getResources("conf/play.plugins").asScala.toList
pluginFiles.distinct.map { plugins =>
PlayIO.readUrlAsString(plugins).split("\n").map(_.replaceAll("#.*$", "").trim).filterNot(_.isEmpty).map {
case PluginDeclaration(priority, className) => (priority.toInt, className)
}
}.flatten.sortBy(_._1).map(_._2)
}
...
}
因此,我们应该在名为play.plugins
的文件中声明我们的插件类。从一个或多个play.plugins
文件中获得的全部插件声明将被合并并排序。每个声明的插件都分配了一个优先级,用于排序。一旦排序,插件将按顺序在应用启动前加载。
应根据插件的依赖关系设置优先级。建议的优先级如下:
-
100
:当插件没有依赖项时,设置此优先级,例如消息插件(用于i18n
) -
200
:此优先级是为创建和管理数据库连接池的插件设置的 -
300-500
:此优先级是为依赖于数据库的插件设置的,例如 JPA、Ebean 和 evolutions
注意
10000
被有意保留作为全局插件,以便在所有其他插件加载之后加载。这允许开发者在使用全局对象时无需额外配置即可使用其他插件。
默认的play.plugins
文件只包含基本的插件声明:
1:play.core.system.MigrationHelper
100:play.api.i18n.DefaultMessagesPlugin
1000:play.api.libs.concurrent.AkkaPlugin
10000:play.api.GlobalPlugin
Play 模块中的一些更多插件声明如下:
200:play.api.db.BoneCPPlugin
500:play.api.db.evolutions.EvolutionsPlugin
600:play.api.cache.EhCachePlugin
700:play.api.libs.ws.ning.NingWSPlugin
注意
通常,Play 插件需要在应用程序的build
定义中指定为库依赖项。一些插件与play.plugins
文件捆绑在一起。然而,对于那些没有的,我们需要在我们的应用程序的conf/play.plugins
文件中设置优先级。
通过插件公开服务
一些插件需要为用户提供辅助方法以简化事务,而其他插件则只需在应用程序的生命周期中添加一些任务。例如,我们的NotifierPlugin
仅在启动和停止时发送电子邮件。然后,可以通过play.api.Application
的plugin
方法访问CassandraPlugin
的方法:
object CassandraHelper {
private val casPlugin = Play.application.plugin[CassandraPlugin].get
//complete DB transactions with the connection pool started through the plugin
def executeStmt(stmt:String) = {
casPlugin.session.execute(stmt)
}
}
或者,插件也可以提供一个辅助对象:
object Cassandra {
private val casPlugin = Play.application.plugin[CassandraPlugin].get
private val cassandraHelper = casPlugin.helper
/**
* gets the Cassandra hosts provided in the configuration
*/
def hosts: Array[java.lang.String] = cassandraHelper.hosts
/**
* gets the port number on which Cassandra is running from the configuration
*/
def port: Int = cassandraHelper.port
/**
* gets a reference of the started Cassandra cluster
* The cluster is built with the configured set of initial contact points
* and policies at startup
*/
def cluster: Cluster = cassandraHelper.cluster
/**
* gets a reference of the started Cassandra session
* A new session is created on the cluster at startup
*/
def session: Session = cassandraHelper.session
/**
* executes CQL statements available in given file.
* Empty lines or lines starting with `#` are ignored.
* Each statement can extend over multiple lines and must end with a semi-colon.
* @param fileName - name of the file
*/
def loadCQLFile(fileName: String): Unit = {
Util.loadScript(fileName, cassandraHelper.session)
}
}
可用模块的列表维护在www.playframework.com/documentation/2.3.x/Modules
。
编写插件的技巧
这里有一些编写插件的技巧:
-
在开始编写插件之前,检查你是否真的需要一个插件来解决你的问题。如果你的问题不需要干预应用程序的生命周期,那么编写一个库会更好。
-
在编写/更新插件的同时,构建一个使用该插件的示例 Play 应用程序。这将允许你仅通过在每次更改时本地发布插件来检查其功能的完整性。
-
如果插件公开了一些服务,尝试提供一个辅助对象。这有助于保持 API 的一致性,并简化开发者的体验。
例如,Play 提供的多数插件(如
akka
、jdbc
、ws
等)都通过提供辅助对象来使 API 可用。插件内部的更改不会影响通过这些对象公开的公共 API。 -
如果可能的话,尽量用足够的测试来支持插件。
-
记录 API 和/或特殊情况。这可能会在将来对使用插件的每个人都有帮助。
摘要
Play 插件为我们提供了在应用程序生命周期的特定阶段执行特定任务的灵活性。Play 有一些插件是大多数应用程序通常需要的,例如 Web 服务、认证等。我们讨论了 Play 插件的工作原理以及如何构建自定义插件以满足不同的需求。