Handling form submission

概述

表单的处理和提交是每个 web 应用的重要部分。Play 自带了一些功能,简化了简易表单的处理,并使处理复杂表单成为可能。

Play 的表单处理方式基于数据绑定的概念。当数据来自于一个 POST 请求时,Play 会查找格式化的值并将其绑定到 Form 对象上。Play 可以根据绑定的表单及数据构建一个样本类(case class),调用自定义的验证器,等等。

通常,表单会在 Controller 实例中直接使用。不过 Form 的定义并不需要和样本类(case class)或是模型一致匹配:他们纯粹都是为了处理输入,不同的 POST 使用不同的 Form 非常合理。

导入

为了使用表单,必须在类中先导入以下几个包:

1. import play.api.data._
2. import play.api.data.Forms._

表单基础

让我们先来看一下基本的表单处理:

  • 定义一个表单,
  • 定义表单中的约束,
  • 在一个 action 中验证该表单,
  • 在视图模板中显示表单,
  • 最后,在视图模板中处理表单的结果(或是错误)

最终的结果看起来是这样的:

Form Result

定义一个表单

首先,定义一个包含了表单中所有元素的样本类(case class)。这里我们想要获取一个用户的名字和年龄,因此我们创建了一个 UserData 对象:

1. case class UserData(name: String, age: Int)

现在我们有了一个样本类(case class),接着需要定义一个 Form 结构。定义表单的方法是将表单的数据绑定到样本类(case class)的实例中,定义如下:

1. val userForm = Form(
2. mapping(
3. "name" -> text,
4. "age" -> number
5. )(UserData.apply)(UserData.unapply)
6. )

表单对象定义了 mapping 方法。这个方法接收表单的名字和约束作为参数,并接收另外两个方法:一个 apply 方法和一个 unapply 方法。因为 UserData 是一个样本类(case class),我们可以将 apply 和 unapply 直接插入到 mapping 方法中。

注意:由于表单处理的实现问题,一个单一元组(tuple)或是 mapping 最多只能有18个元素。如果你的表单有超过18个元素,你需要将他们通过列表或是嵌套值分开来。

当你使用了 Map ,表单会创建一个带有绑定值的 UserData 实例:

1. val anyData = Map("name" -> "bob", "age" -> "21")
2. val userData = userForm.bind(anyData).get

但大多数的时候会在 Action 中使用表单,其中的数据由请求提供。Form 包含了 bindFormRequest,接收一个请求作为隐式(implicit)参数。如果你定义了一个隐式(implicit)请求,那么 bindFormRequest 就能够找到他。

1. val userData = userForm.bindFromRequest.get

注意:这里使用 get 其实是有问题的。如果表单没能绑定数据,那么 get 会抛出异常。在接下来的几章中我们会介绍一种更安全的方法来处理输入。

Play 并没有限制说只能用样本类(case class) 来映射你的表单。只要正确设置了 apply 和 unapply 方法,就能传入你想要的任何东西,比如元组可以使用 Forms.tuple 来映射,或是模型样本类(model case class)。但是,为表单指定一个样本类(case class)有以下这些好处:

  • 表单指定的样本类(case class)简单易用。样本类(case class)原本便被设计为存储数据的简易容器,即写即用,和表单功能天然匹配。
  • 表单指定的样本类(case class)功能强大。元组易于使用,但元组并不允许你自定义 apply 或是 unapply 方法,且只能通过元数(arity,如 _1, _2 等)来引用其中的数据。
  • 表单指定的样本类(case class)专为表单设计。重用模型样本类(model case class)确实很方便,但模型通常都含有一些额外的领域逻辑,甚至于一些数据持久化的细节,这些都会导致耦合过于紧密。另外,如果表单和模型并不是1:1严格映射的话,敏感数据必须被显示的忽略,以此来抵御篡改参数攻击。

定义表单的约束

text 约束认定空字符串依然有效。也就是说 name 可以为空且不会报错,这显然不是我们想要的。一个保证 name 有值的方法是使用 nonEmptyText 约束。

1. val userFormConstraints2 = Form(
2. mapping(
3. "name" -> nonEmptyText,
4. "age" -> number(min = 0, max = 100)
5. )(UserData.apply)(UserData.unapply)
6. )

如果表单的输入没有满足该表单的约束条件,则会报错:

1. val boundForm = userFormConstraints2.bind(Map("bob" -> "", "age" -> "25"))
2. boundForm.hasErrors must beTrue

预置的约束定义在了表单对象中:

  • text:对应于 scala.String,可选参数为 minLength 和 maxLength。
  • nonEmptyText:对应于 scala.String,可选参数为 minLength 和 maxLength。
  • number:对应于 scala.Int,可选参数为 min, max 和 strict。
  • longNumber:对应于 scala.Long,可选参数为 min, max,和 strict。
  • bigDecimal:参数为 precision 和 scale。
  • date,sqlDate,jodaDate:对应于 java.util.Date, java.sql.Date 和 org.joda.time.DateTime,可选参数为 pattern 和 timeZone。
  • jodaLocalDate:对应于 org.joda.time.LocalDate,可选参数为 pattern。
  • email:对应于 scala.String,使用邮件正则表达式.
  • boolean:对应于 scala.Boolean。
  • checked:对应于 scala.Boolean。
  • optional:对应于 scala.Option。

定义特殊约束

你可以使用验证包在样本类(case class)中定义你自己的特殊约束。

1. val userFormConstraints = Form(
2. mapping(
3. "name" -> text.verifying(nonEmpty),
4. "age" -> number.verifying(min(0), max(100))
5. )(UserData.apply)(UserData.unapply)
6. )

你也可以直接在样本类(case class)中定义特殊约束:

1. def validate(name: String, age: Int) = {
2. name match {
3. case "bob" if age >= 18 =>
4. Some(UserData(name, age))
5. case "admin" =>
6. Some(UserData(name, age))
7. case _ =>
8. None
9. }
10. }

12. val userFormConstraintsAdHoc = Form(
13. mapping(
14. "name" -> text,
15. "age" -> number
16. )(UserData.apply)(UserData.unapply) verifying("Failed form constraints!", fields => fields match {
17. case userData => validate(userData.name, userData.age).isDefined
18. })
19. )

当然,你还能定义你自己的验证器。详情请见自定义验证器。

在 Action 中验证表单

现在已经有了约束,我们需要在 action 中验证表单,并处理表单错误。

我们使用 fold 方法来实现,它接收了两个函数:绑定失败时第一个会被调用,绑定成功则会调用第二个。

1. userForm.bindFromRequest.fold(
2. formWithErrors => {
3. // binding failure, you retrieve the form containing errors:
4. BadRequest(views.html.user(formWithErrors))
5. },
6. userData => {
7. /* binding success, you get the actual value. */
8. val newUser = models.User(userData.name, userData.age)
9. val id = models.User.create(newUser)
10. Redirect(routes.Application.home(id))
11. }
12. )

绑定失败时,我们用了 BadRequest 来渲染页面,并将错误作为参数传入该页。如果使用了视图 helper (在下面讨论),那么任何绑定于一个元素的错误都会被渲染在该元素边上。

绑定成功时,我们发出了一个 Redirect ,路由到 routes.Application.home,而不是渲染一个视图模板。这种模式称为 POST 后重定向,这是一种很好的防止重复提交的方式。

注意:当使用了 flashing 或是在其他方法中用到了 flash 域,使用 “POST 后重定向” 是必需的,因为新的 cookie 只能在重定向 HTTP 请求后获取。

在视图模板中显示表单

有了表单之后,我们就能在模板引擎中调用它。做法是在视图模板中将表单作为参数引入。对于 user.scala.html来说,该页的开头是这样的:

1. @(userForm: Form[UserData])

由于 user.scala.html 需要接收一个表单,在渲染 user.scala.html 时应先传入一个空的 userForm。

1. def index = Action {
2. Ok(views.html.user(userForm))
3. }

首先是要创建表单标签。这是一个简单的视图 helper, 创建了一个表单标签,并根据你所传入的逆向路由设置了 action 和 method 的标签参数。

1. @helper.form(action = routes.Application.userPost()) {
2. @helper.inputText(userForm("name"))
3. @helper.inputText(userForm("age"))
4. }

你可以在 views.html.helper 包中找到多个输入 helper。传入一个表单域,他们就能显示出相应的 HTML 输入,设置值和约束,并在表单绑定失败时报错。

注意:你可以在模板中使用 @import heler._ 来避免在调用 helper 时前置 @helper。

Play 有多个输入 helper,其中最有用的是:

在 form helper 中,你可以通过指定额外的参数,将其添加到生成的 HTML中:

1. @helper.inputText(userForm("name"), 'id -> "name", 'size -> 30)

上面这个通用的 input helper 能让你编写你想要的 HTML 代码:

1. @helper.input(userForm("name")) { (id, name, value, args) =>
2. <input type="text" name="@name" id="@id" @toHtmlArgs(args)>
3. }

注意:所有额外的参数都会被添加到生成的 HTML 中,除非他们以 _ 字符开头。以 _ 开头的参数是域构造器参数。

对于复杂表单元素,你同样可以自定义视图 helper (在 views 页面中使用 scala 类)并自定义域构造器。

在视图模板中显示错误

表单中的错误类型为 Map[String, FormError],其中 FormError中有:

  • key:应和表单域相同。
  • message:一段错误提示信息或是对应于该信息的键。
  • args:错误提示信息中用到的一组参数。

绑定的表单实例中可以获得如下错误:

  • errors:返回所有错误,类型为 Seq[FormError]。
  • globalErrors:返回所有错误,类型为没有键的 Seq[FormError]。
  • error("name"):返回第一个绑定了该键的错误,类型为 Option[FormError]。
  • errors("name"):返回所有绑定了该键的错误,类型为 Option[FormError]。

使用表单 helper 可以自动渲染绑定于某表单域的错误,如 @helper.inputText 的错误显示如下:

1. <dl class="error" id="age_field">
2. <dt><label for="age">Age:</label></dt>
3. <dd><input type="text" name="age" id="age" value=""></dd>
4. <dd class="error">This field is required!</dd>
5. <dd class="error">Another error</dd>
6. <dd class="info">Required</dd>
7. <dd class="info">Another constraint</dd>
8. </dl>

没有绑定于任一键的全局错误(Global errors)不会有相应的 helper,且必须显示的定义在页面中:

1. @if(userForm.hasGlobalErrors) {
2. <ul>
3. @for(error <- userForm.globalErrors) {
4. <li>@error.message</li>
5. }
6. </ul>
7. }

使用元组做映射

你可以在表单域中使用元组,而非样本类(case class):

1. val userFormTuple = Form(
2. tuple(
3. "name" -> text,
4. "age" -> number
5. ) // tuples come with built-in apply/unapply
6. )

有时候使用元组会比定义样本类(case class)更方便,尤其在元组的元数(arity)很小时:

1. val anyData = Map("name" -> "bob", "age" -> "25")
2. val (name, age) = userFormTuple.bind(anyData).get

使用 single 做映射

元组只有在有多个值的时候才有用。如果只有一个表单域,可以用 Forms.single 来映射单一值,避免了使用样本类(case class)或是元组的额外开销:

1. val singleForm = Form(
2. single(
3. "email" -> email
4. )
5. )
1. val email = singleForm.bind(Map("email", "bob@example.com")).get

填值

有时候,你想要在表单中预置一些值,通常用于编辑数据:

1. val filledForm = userForm.fill(UserData("Bob", 18))

当配合视图 helper 使用时,该元素会填上预置的值:

1. @helper.inputText(filledForm("name")) @* will render value="Bob" *@

填值在 helper 需要一列值或是键值对时尤其有用,比如 select 和 inputRadioGroup helper 。使用 options 可以为 helper 填入列表,键值对和対值(pair)。

嵌套值

表单映射同样可以在已有的映射中使用 Forms.mapping 来实现嵌套值:

1. case class AddressData(street: String, city: String)

3. case class UserAddressData(name: String, address: AddressData)
1. val userFormNested: Form[UserAddressData] = Form(
2. mapping(
3. "name" -> text,
4. "address" -> mapping(
5. "street" -> text,
6. "city" -> text
7. )(AddressData.apply)(AddressData.unapply)
8. )(UserAddressData.apply)(UserAddressData.unapply)
9. )

注意:当你使用这种方法来嵌套数据时,浏览器传来的表单值必须命名为 address.street,address.city…

1. @helper.inputText(userFormNested("name"))
2. @helper.inputText(userFormNested("address.street"))
3. @helper.inputText(userFormNested("address.city"))

重复值

表单映射可以使用 Forms.list 或 Forms.seq 来定义重复值:

1. case class UserListData(name: String, emails: List[String])
1. val userFormRepeated = Form(
2. mapping(
3. "name" -> text,
4. "emails" -> list(email)
5. )(UserListData.apply)(UserListData.unapply)
6. )

当你这么使用重复数据时,浏览器传入的表单值必须被命名为 emails[0],emails[1],emails[2]…

使用 repeat helper 来生成和表单 emails 域相同数目的输入:

1. @helper.inputText(myForm("name"))
2. @helper.repeat(myForm("emails"), min = 1) { emailField =>
3. @helper.inputText(emailField)
4. }

min 参数允许你在表单数据为空时,定义最少显示多少个表单域。

可选值

使用 Forms.optional 来定义可选值:

1. case class UserOptionalData(name: String, email: Option[String])
1. val userFormOptional = Form(
2. mapping(
3. "name" -> text,
4. "email" -> optional(email)
5. )(UserOptionalData.apply)(UserOptionalData.unapply)
6. )

这样做会输出一个 Option[A],在没有找到任何表单值的情况下则返回 None。

默认值

你可以使用 Form#fill 来初始化值:

1. val filledForm = userForm.fill(User("Bob", 18))

你也可以通过 Forms.default 来定义默认值:

1. Form(
2. mapping(
3. "name" -> default(text, "Bob")
4. "age" -> default(number, 18)
5. )(User.apply)(User.unapply)
6. )

忽略值

如果你想定义某个表单域为一个静态值,使用 Forms.ignored:

1. val userFormStatic = Form(
2. mapping(
3. "id" -> ignored(23L),
4. "name" -> text,
5. "email" -> optional(email)
6. )(UserStaticData.apply)(UserStaticData.unapply)
7. )

合并起来

这个例子演示了使用模型和控制器来处理一个实体:

样本类 Contact:

1. case class Contact(firstname: String,
2. lastname: String,
3. company: Option[String],
4. informations: Seq[ContactInformation])

6. object Contact {
7. def save(contact: Contact): Int = 99
8. }

10. case class ContactInformation(label: String,
11. email: Option[String],
12. phones: List[String])

需要注意的是,Contact 里有一个包含了 ContactInformation 元素的 Seq 和一个含有 String 的 List。在这个例子中,我们组合了嵌套映射和重复映射(分别由 Forms.seq 和 Forms.list 定义)。

1. val contactForm: Form[Contact] = Form(

3. // Defines a mapping that will handle Contact values
4. mapping(
5. "firstname" -> nonEmptyText,
6. "lastname" -> nonEmptyText,
7. "company" -> optional(text),

9. // Defines a repeated mapping
10. "informations" -> seq(
11. mapping(
12. "label" -> nonEmptyText,
13. "email" -> optional(email),
14. "phones" -> list(
15. text verifying pattern("""[0-9.+]+""".r, error="A valid phone number is required")
16. )
17. )(ContactInformation.apply)(ContactInformation.unapply)
18. )
19. )(Contact.apply)(Contact.unapply)
20. )

这段代码演示了一条已存在的 Contact 如何通过填入数据在表单中显示出来:

1. def editContact = Action {
2. val existingContact = Contact(
3. "Fake", "Contact", Some("Fake company"), informations = List(
4. ContactInformation(
5. "Personal", Some("fakecontact@gmail.com"), List("01.23.45.67.89", "98.76.54.32.10")
6. ),
7. ContactInformation(
8. "Professional", Some("fakecontact@company.com"), List("01.23.45.67.89")
9. ),
10. ContactInformation(
11. "Previous", Some("fakecontact@oldcompany.com"), List()
12. )
13. )
14. )
15. Ok(views.html.contact.form(contactForm.fill(existingContact)))
16. }

最后是表单提交:

1. def saveContact = Action { implicit request =>
2. contactForm.bindFromRequest.fold(
3. formWithErrors => {
4. BadRequest(views.html.contact.form(formWithErrors))
5. },
6. contact => {
7. val contactId = Contact.save(contact)
8. Redirect(routes.Application.showContact(contactId)).flashing("success" -> "Contact saved!")
9. }
10. )
11. }