如何使用命令对象来处理表单数据
本指南将说明如何在 Groovy应用程序中使用命令对象验证输入数据
作者:马修莫斯
Groovy版本 3.3.1
1 Groovy培训
Groovy培训 - 由创建并维护 Groovy框架的人员开发和提供!
2 入门
在本指南中,您将使用命令对象处理表单提交,并进行自动类型转换、自定义验证和简单的错误处理。
2.1 您需要什么
要完成本指南,您需要以下内容
-
抽出一些时间
-
一个优秀的文本编辑器或集成开发环境
-
具有相应配置的JDK 1.7或更高版本以及JAVA_HOME
2.2 如何完成本指南
按照以下步骤入门
-
下载并解压缩源文件
or
-
克隆Git 代码库
git clone https://github.com/grails-guides/command-objects-and-forms.git
Groovy指南代码库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Groovy应用程序,增加了一些代码以便让您轻松上手。 -
complete
一个完整的案例。这是完成本指南所演示的步骤并将其更改应用到initial
文件夹的结果。
要完成本指南,请转到initial
文件夹
-
在
grails-guides/command-objects-and-forms/initial
中cd
然后按照下一部分中的说明进行操作。
如果你在 grails-guides/command-objects-and-forms/complete 中 cd ,可以直接转到已完成示例 |
3 什么是命令对象?
正如 Grails 文档 所述:
命令对象是一个与数据绑定结合使用的类,通常用于允许验证可能不适合现有域类的的数据。仅当一个类用作操作的参数时,它才被视为命令对象。
虽然域类可以用作命令对象,但在其他文件中定义的非域类(甚至是与使用该类的控制器在同一文件中定义的类)也可以用作命令对象。
4 编写应用程序
本指南将指导你完成创建具有使用命令对象的操作的控制器。借助这些命令对象,你将学会安全、轻松地处理表单输入,减少样板代码。
4.1 创建控制器
在我们使用命令对象创建任何操作之前,我们需要一个控制器。创建一个 PlayerController
类,以使用现有的域对象 Player
。
$ ./grailsw create-controller demo.Player
在你第一次运行 grails 命令时,应用程序依赖项将从互联网中下载。后续调用会快得多。 |
命令的输出看起来像
| Created grails-app/controllers/demo/PlayerController.groovy | Created src/test/groovy/demo/PlayerControllerSpec.groovy
你现在将获得一个新的、基本为空的控制器,名为 PlayerController.groovy
,它位于 grails-app/controllers/demo
目录中。更新 index
操作,并添加 show
操作,使其看起来像这样
package demo
import org.springframework.http.HttpStatus
class PlayerController {
def index() {
respond Player.list(params), model: [playerCount: Player.count()]
}
def show(Player player) {
respond player
}
}
为你提供了域类、视图和一些样本数据。此时,你应该可以运行应用程序并导航到 http://localhost:8080/player/ 来查看玩家列表,然后单击玩家姓名来查看个别玩家。但是,任何其他操作(如创建、保存、编辑、更新、删除)都无法正常运行,直到你编写这些操作。
在任何时候,你都可以通过在命令行中输入 Grails application running at http://localhost:8080 in environment: development 通过按下 Ctrl-C 来停止应用程序。 |
4.2 实现创建和保存操作
接下来,让我们添加创建新球员的能力。在 PlayerController
中,添加 create
操作。
@SuppressWarnings(['FactoryMethodName', 'GrailsMassAssignment'])
def create() {
respond new Player(params)
}
虽然此操作确实使用了 Player
类,但是 Player
的实例不是命令对象,因为它不是操作的参数。这应该是有道理的:因为操作的目的是创建新的玩家对象,因此不需要任何表单或输入处理。
在“球员列表”(http://localhost:8080/player/index)中,单击“新建球员”按钮查看创建新球员的表单(位于 http://localhost:8080/player/create)。如果你此时在表单中输入值并单击“创建”按钮,你将会收到一个 404 Page Not Found
错误,因为尚未定义 save
操作。现在,将这部分内容添加到 PlayerController
中。
def save(Player player) {
// ...
}
此处,player
是一个命令对象,因为它是操作的一个参数。在幕后,Grails 将识别这一点并重写你的操作以获取现有记录(如果需要)、将输入数据绑定到命令对象、执行依赖注入以及验证该对象。所有这些任务都可以手动完成,但是通过使用命令对象,所有这些行为都是自动的并使得你的代码不会充斥着大量样板代码。
可以这么讲,此处最好的特性是 数据绑定,而数据绑定是使用命令对象时获得的自动行为的一部分。当在创建页面上查看 HTML 源代码(通过编译的 create.gsp
生成)并查找 <form>
标记时
<form action="/player/save" method="post" >
<fieldset class="form">
<div class='fieldcontain required'>
<label for='name'>Name<span class='required-indicator'>*</span></label>
<input type="text" name="name" value="" required="" id="name" />
</div>
<div class='fieldcontain required'>
<label for='game'>Game<span class='required-indicator'>*</span></label>
<input type="text" name="game" value="" required="" id="game" />
</div>
<div class='fieldcontain'>
<label for='region'>Region</label>
<input type="text" name="region" value="" id="region" />
</div>
<div class='fieldcontain required'>
<label for='wins'>Wins<span class='required-indicator'>*</span></label>
<input type="number" name="wins" value="0" required="" min="0" id="wins" />
</div>
<div class='fieldcontain required'>
<label for='losses'>Losses<span class='required-indicator'>*</span></label>
<input type="number" name="losses" value="0" required="" min="0" id="losses" />
</div>
</fieldset>
<fieldset class="buttons">
<input type="submit" name="create" class="save" value="Create" id="create" />
</fieldset>
</form>
<input>
标记的名称与域类 Player
的属性完全匹配。
package demo
class Player {
String name
String game
String region
int wins = 0
int losses = 0
static constraints = {
name blank: false, unique: true
game blank: false
region nullable: true
wins min: 0
losses min: 0
}
}
数据绑定将采用 name
、game
和 region
的表单提交的字符串值(如果有设置),并将这些属性设置在你的命令对象 player
中。wins
和 losses
的表单提交的字符串值也将在自动类型转换至 int
之后设置在 player
中。使用与域类属性和输入字段相同的名称的约定大大简化了编码和应用程序开发:视图字段使用域类对象的预填充值,而命令对象使用已提交的表单数据填充,这样开发者需要做的心血就很小了。
在绑定之后执行数据验证;由于这是域类,因此将检查在该类的“约束”块中设置的任何约束。这使我们有机会添加简单的错误检查。在 PlayerController.groovy
顶部附近,添加
import org.springframework.http.HttpStatus
接下来更新 PlayerController
的 save
操作。
def save(Player player) {
if (player == null) {
render status: HttpStatus.NOT_FOUND
return
}
if (player.hasErrors()) {
respond player.errors, view: 'create'
return
}
}
现有的生成 gsp
文件包含 Javascript,可以尽最大可能完成客户端表单验证。要查看 Grails 服务器端验证和错误检查,请尝试使用 curl
。在命令行中(当 Grails 应用程序正在运行时),键入
$ curl --request POST --form "name=Bob Smith" --form "wins=42" --form "losses=abc" "http://localhost:8080/player/save.json"
你应该收到以下响应(此处以美观的方式显示)
{"errors": [
{"object": "demo.Player",
"field": "losses",
"rejected-value": "abc",
"message": "Property losses is type-mismatched"},
{"object": "demo.Player",
"field": "game",
"rejected-value": null,
"message": "Property [game] of class [class demo.Player] cannot be null"}
]}
我们将通过功能测试来验证这一点。
功能测试涉及对正在运行的应用程序发出 HTTP 请求并验证最终的行为。
我们使用 RestClient Builder Grails 插件。如果您使用 rest-api 配置文件创建一个 Grails 应用程序,则会为您添加此插件。
我们用 Web 配置文件创建了此应用,但为构建文件添加 testCompile 依赖关系很容易。将下一行添加到依赖项块。
dependencies {
testCompile "org.grails:grails-datastore-rest-client"
}
package demo
import spock.lang.Specification
import grails.plugins.rest.client.RestBuilder
import grails.testing.mixin.integration.Integration
@SuppressWarnings(['LineLength', 'MethodName'])
@Integration
class PlayerControllerFuncSpec extends Specification {
def 'test save validation'() {
given:
RestBuilder rest = new RestBuilder()
when:
def resp = rest.post("http://localhost:${serverPort}/player/save") { (1)
accept('application/json') (2)
contentType('application/json') (3)
json {
name = 'Bob Smith'
wins = 42
losses = 'abc'
}
}
then:
resp.status == 422 (4)
resp.json.errors.size() == 2
resp.json.errors.find { it.field == 'losses' }.message == 'Property losses is type-mismatched'
resp.json.errors.find { it.field == 'game' }.message == 'Property [game] of class [class demo.Player] cannot be null'
}
}
1 | serverPort 属性自动注入。它包含功能测试期间 Grails 应用运行时的随机端口。 |
2 | 如果您的客户端接受 JSON,通常最好设置接受 HTTP 标头 |
3 | 如果要发送 JSON 有效负载,应将 Content-Type HTTP 标头设置为 application/json |
4 | 由于命令对象验证失败,服务器返回 HTTP 状态 - 422 不可处理实体 |
在数据绑定、验证和错误检查完成之后,请更新 save
操作以实际保存对象并响应表单提交。这完成了操作。
def save(Player player) {
if (player == null) {
render status: HttpStatus.NOT_FOUND
return
}
if (player.hasErrors()) {
respond player.errors, view: 'create'
return
}
player.save flush: true
request.withFormat {
form multipartForm { redirect player }
'*' { respond player, status: HttpStatus.CREATED }
}
}
现在您应该能够成功创建新 player 并将其保存到数据库。
此处为此指南中的 ./grailsw generate-controller demo.Player |
4.3 执行编辑和更新操作
接下来,我们添加更新现有 player 的功能。将 edit
和 update
操作添加到 PlayerController
。您应该注意到,它们与 create
和 save
操作几乎相同。
def edit(Player player) {
respond player
}
def update(Player player) {
if (player == null) {
render status: HttpStatus.NOT_FOUND
return
}
if (player.hasErrors()) {
respond player.errors, view: 'edit'
return
}
player.save flush: true
request.withFormat {
form multipartForm { redirect player }
'*' { respond player, status: HttpStatus.OK }
}
}
edit
操作从 Player
命令对象开始;Grails 会自动获取现有的 Player
对象,该对象的属性加载为 edit.gsp
中表单的初始值。update
操作与 save
相同,除了错误重定向到 edit
而非 create
之外,成功的 HTTP 状态为 OK
而非 CREATED
。
假设玩家的胜负次数将在其他地方管理,则已从 edit.gsp
中表单中移除了胜负次数字段。运行应用时,请转到 player 列表,然后单击一个 player 姓名。虽然您可以显示胜负次数,但单击 **编辑** 按钮将显示不允许您更改胜负次数的表单。
但这还不够:从表单中移除输入字段不会阻止更改这些字段。聪明的用户可以通过其他方式提交表单来更改这些值。
$ curl --request GET "http://localhost:8080/player/show/1.json"
{"id":1, "game":"Pandemic", "losses":30, "name":"Alexis Barnett", "region":"EAST", "wins":96}
$ curl --request POST --form "id=1" --form "wins=2" --form "losses=194" "http://localhost:8080/player/update.json"
在第二个 curl
命令后,player Alexis Barnett 将只有两次胜利和 194 次失败,而不是应该有的 96 次胜利和 30 次失败。
修复此问题的一种方法是避免使用 Player
命令对象(以避免自动填充属性),而是手动管理所有加载、绑定和验证。您可以自定义操作的行为,并忽略任何修改胜场和败场属性的尝试。
但是,命令对象不需要是域类:使用任何类!在 PlayerController.groovy
中 PlayerController
的下面定义以下类。请注意,PlayerInfo
的属性名称与表单的 <input>
标记匹配;当提交表单时,表单值将绑定到名称匹配的命令对象的属性。
class PlayerInfo {
String name
String game
String region
}
将 update
操作更改为使用 PlayerInfo
作为命令对象。加载相应的 Player
实例,然后使用命令对象的属性进行更新和保存。
def update(PlayerInfo info) {
Player player = Player.get(params.id)
if (player == null) {
render status: HttpStatus.NOT_FOUND
return
}
player.properties = info.properties
player.save flush: true
if (player.hasErrors()) {
respond player.errors, view: 'edit'
return
}
request.withFormat {
form multipartForm { redirect player }
'*' { respond player, status: HttpStatus.OK }
}
}
因为 PlayerInfo
不包含 wins
或 losses
,所以即使提交了这些名称的值,也不会从表单提交中接受这些值。
$ curl --request GET "http://localhost:8080/player/show/4.json"
{"id":4, "name":"Catherine Newton", "game":"Scythe", "region":"WEST", "wins":66, "losses":40}
$ curl --request POST --form "id=4" --form "name=June Smith" --form "game=Chess" --form "region=NORTH" --form "wins=0" --form "losses=10" "http://localhost:8080/player/update.json"
$ curl --request GET "http://localhost:8080/player/show/4.json"
{"id":4, "name":"June Smith", "game":"Chess", "region":"NORTH", "wins":66, "losses":40}
请注意,name
、game
和 region
属性已全部更新,但是 wins
和 losses
属性没有更改,尽管我们为此付出了努力。通过使用 PlayerInfo
命令对象,我们限制了从表单提交中接受的字段。
创建一个单元测试来验证此行为
package demo
import spock.lang.Specification
import grails.testing.gorm.DomainUnitTest
import grails.testing.web.controllers.ControllerUnitTest
@SuppressWarnings(['MethodName', 'DuplicateListLiteral', 'DuplicateNumberLiteral', 'LineLength'])
class PlayerControllerSpec extends Specification implements ControllerUnitTest<PlayerController>, DomainUnitTest<Player> {
def "test update"() {
when:
def player = new Player(name: 'Sergio', game: 'XCOM: Enemy Unkown', region: 'Spain', wins: 3, losses: 2)
player.save()
params.id = player.id
params.name = 'Sergio del Amo'
params.game = 'XCOM 2'
params.region = 'USA'
params.wins = 4
controller.update() (1)
then: 'respond model has no errors'
!model.player.hasErrors()
and: 'player properties have been updated correctly'
model.player.name == 'Sergio del Amo'
model.player.game == 'XCOM 2'
model.player.region == 'USA'
and: 'non existing properties in the command object have not been modified'
model.player.wins == 3
model.player.losses == 2
}
}
1 | 调用 update 方法的无参数版本。将值置于 params 映射中。数据绑定会将这些值映射到命令对象。这模拟了在运行时发生的事情。 |
然而,当前的解决方案存在不足之处。因为现在 PlayerInfo
而不是 Player
是命令对象,所以只有在调用 player.save
之后才进行验证。最好在更早的时候进行验证,就像在 Player
是命令对象的时候那样。
为了解决此问题,向 PlayerInfo
添加一个 constraints
块。当您在 PlayerController.groovy
文件中定义命令对象类 PlayerInfo
时,Grails 将其识别为相关的命令对象类,并使该类能够被验证。更改 PlayerInfo
并添加约束。
class PlayerInfo {
String name
String game
String region
static constraints = {
name blank: false
game blank: false
region nullable: true
}
}
创建单元测试来验证在命令对象中定义的约束。
package demo
import spock.lang.Specification
@SuppressWarnings(['MethodName', 'DuplicateListLiteral'])
class PlayerInfoSpec extends Specification {
def "test PlayerInfo.region can be null"() {
expect:
new PlayerInfo(region: null).validate(['region'])
}
def "test PlayerInfo.name can be blank"() {
when:
def playerInfo = new PlayerInfo(name: '')
then:
!playerInfo.validate(['name'])
playerInfo.errors['name'].code == 'blank'
when:
playerInfo = new PlayerInfo(name: null)
then:
!playerInfo.validate(['name'])
playerInfo.errors['name'].code == 'nullable'
}
def "test PlayerInfo.game can be blank"() {
when:
def playerInfo = new PlayerInfo(game: '')
then:
!playerInfo.validate(['game'])
playerInfo.errors['game'].code == 'blank'
when:
playerInfo = new PlayerInfo(game: null)
then:
!playerInfo.validate(['game'])
playerInfo.errors['game'].code == 'nullable'
}
}
再次修改 update
操作,在尝试保存之前返回错误检查。
def update(PlayerInfo info) {
if (info.hasErrors()) {
respond info.errors, view: 'edit'
return
}
Player player = Player.get(params.id)
if (player == null) {
render status: HttpStatus.NOT_FOUND
return
}
player.properties = info.properties
player.save flush: true
request.withFormat {
form multipartForm { redirect player }
'*' { respond player, status: HttpStatus.OK }
}
}
命令对象类可以在其他地方定义(例如,在 src/
文件夹层次结构下)。它们不必与使用它们的控制器位于同一文件。这可能有助于维护关注点分离,或者仅仅使命令对象类可供多个控制器使用。
如果您的命令对象类在使用它的控制器中定义,则 Grails 会自动使它变为 Validateable
,允许使用静态 constraints
块。如果您在其他位置定义了类并且需要 constraints
,则需要添加 implements
子句如下所示
class PlayerInfo implements grails.validation.Validateable {
String name
String game
String region
static constraints = {
name blank: false
game blank: false
region nullable: true
}
}
5 运行应用程序
要运行应用程序,请使用 ./gradlew bootRun
命令,该命令将在端口 8080 上启动应用程序。