使用命令对象处理表单数据
本指南将说明如何使用命令对象验证 Grails 应用程序中的输入数据
作者:Matthew Moss
Grails 版本 4.0.1
1 Grails 培训
Grails 培训 - 由创建和维护 Grails 框架的人员开发和交付!.
2 入门
在本指南中,你将使用 命令对象 处理带有自动类型转换、自定义验证和简单错误处理的表单提交。
2.1 你需要什么
若要完成本指南,你需要以下内容
-
一些时间
-
一个不错的文本编辑器或 IDE
-
安装了 JDK 1.8 或更高版本,并相应地配置了
JAVA_HOME
2.2 如何完成本指南
要开始,请执行以下操作
-
下载并解压缩源代码
或
-
克隆 Git 存储库
git clone https://github.com/grails-guides/command-objects-and-forms.git
Grails 指南存储库包含两个文件夹
-
initial
初始项目。通常是一个简单的 Grails 应用程序,其中有一些附加代码可以让你事半功倍。 -
complete
一个完整示例。这是按照指南提供步骤并将其更改应用于initial
文件夹的结果。
若要完成该指南,请转到 initial
文件夹
-
cd
进入grails-guides/command-objects-and-forms/initial
并按照下一节中的说明进行操作。
如果你 cd 进入 grails-guides/command-objects-and-forms/complete ,则可以直接转到完成的示例 |
3 什么是命令对象?
正如Grails 文档所述,
命令对象是一个与数据绑定结合使用的类,通常用于验证可能不适合现有域类的的数据。仅当作为操作的参数使用时,类才会被视为命令对象。
虽然域类可以用作命令对象,但其他文件中定义的非域类(甚至与使用该类的控制器在同一文件中定义的类)也可以这样做。
4 编写应用程序
本指南将指导您完成创建包含使用命令对象的动作的控制器的过程。通过这些命令对象,您将学习如何以最少的样板代码安全、轻松地处理表单输入。
4.1 创建控制器
在我们可以使用命令对象创建任何动作之前,我们需要一个控制器。创建一个 `PlayerController` 类以使用现有的域对象 `Player`。
$ ./grailsw create-controller demo.Player
您第一次运行 `grails` 命令时,将从 Internet 下载应用程序依赖项。后续调用将快很多。 |
命令的输出看起来如下所示
| Created grails-app/controllers/demo/PlayerController.groovy | Created src/test/groovy/demo/PlayerControllerSpec.groovy
现在,您将在 `grails-app/controllers/demo` 目录中拥有一个新的、大部分为空的控制器,名为 `PlayerController.groovy`。更新 `index` 动作并添加 `show` 动作,使其如下所示
package demo
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import org.springframework.http.HttpStatus
@ReadOnly
class PlayerController {
def index() {
respond Player.list(params), model: [playerCount: Player.count()]
}
def show(Player player) {
respond player
}
}
已为您提供域类、视图和一些示例数据。此时,您应该能够运行应用程序并导航到 http://localhost:8080/player/ 以查看玩家列表,并单击玩家姓名以查看该玩家的信息。但是,其他任何动作(例如创建、保存、编辑、更新、删除)都无法工作,直到您编写这些动作。
随时,您都可以通过在命令行中键入 `./gradlew bootRun` 来启动应用程序;当您看到以下内容时,它正在运行并准备就绪 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 找不到页面
错误,因为 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
中。将相同的名称用于域类属性和输入字段这一约定极大地简化了编码和应用程序的开发:视图字段使用域类对象的值预先填充,命令对象使用提交的表单数据填充,这对开发人员所需的工作非常少。
在绑定之后,将会进行数据验证。由于这是一个域类,所以将检查类 constraits
块中设置的所有约束。这给了我们添加简单错误检查的机会。在 PlayerController.groovy
的顶部附近,添加
import org.springframework.http.HttpStatus
然后更新 PlayerController
中的 save
操作。
@Transactional
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 请求,并验证生成的相应行为。
我们使用 Micronaut HTTP 客户端。
确保你具有依赖 io.micronaut:micronaut-http-client
package demo
import grails.testing.spock.OnceBefore
import io.micronaut.http.HttpRequest
import io.micronaut.http.HttpStatus
import io.micronaut.http.MediaType
import io.micronaut.http.client.HttpClient
import io.micronaut.http.client.exceptions.HttpClientResponseException
import spock.lang.AutoCleanup
import spock.lang.Shared
import spock.lang.Specification
import grails.testing.mixin.integration.Integration
@SuppressWarnings(['LineLength', 'MethodName'])
@Integration
class PlayerControllerFuncSpec extends Specification {
@Shared
@AutoCleanup
private HttpClient client
@SuppressWarnings(['JUnitPublicNonTestMethod'])
@OnceBefore
void init() {
String baseUrl = "http://localhost:$serverPort" (1)
this.client = HttpClient.create(baseUrl.toURL())
}
@SuppressWarnings(['JUnitTestMethodWithoutAssert'])
void 'test save validation'() {
when:
Map<String, Object> json = [name : 'Bob Smith',
wins : 42,
losses : 'abc',]
client.toBlocking().exchange(HttpRequest.POST('/player/save', json)
.accept(MediaType.APPLICATION_JSON_TYPE), Map) (2)
then:
HttpClientResponseException e = thrown()
e.status == HttpStatus.UNPROCESSABLE_ENTITY (3)
e.response.body().errors.size() == 2
e.response.body().errors.find { it.field == 'losses' }.message == 'Property losses is type-mismatched'
e.response.body().errors.find { it.field == 'game' }.message ==
'Property [game] of class [class demo.Player] cannot be null'
}
}
1 | serverPort 属性自动注入。它包含 Grails 应用程序在功能测试期间运行的随机端口。 |
2 | 如果您的客户端接受 JSON,最好始终设置接受 Http 标头。您将收到 JSON 响应,而不是 HTML 页面。 |
3 | 由于命令对象验证失败,服务器返回 Http 状态 - 422 不可处理的实体 |
现在数据绑定、验证和错误检查已经完成,更新 save
操作以实际保存对象并响应表单提交。这将完成操作。
@Transactional
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 }
}
}
现在您应该能够成功创建玩家并将其保存到数据库中。
本指南中针对 ./grailsw generate-controller demo.Player |
4.3 实现编辑和更新操作
下一步,我们添加更新现有玩家的能力。向 PlayerController
添加 edit
和 update
操作。您应该注意到,它们与 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
中表单中的胜负字段删除。在运行应用程序时,转到玩家列表并单击其中一个玩家姓名。虽然您可以显示胜负数,但单击 **编辑** 按钮将显示一个不允许您更改胜负数的表单。
但这还不够:从表单中删除输入字段并不会阻止 更改这些字段。一个聪明的用户可以通过其他方式提交表单,从而更改这些值。
$ 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
命令后,Alexis Barnett 玩家将只有两场胜利和 194 场失败,而不是她应该拥有的 96 场胜利和 30 场失败。
解决此问题的一种方法是避免使用 Player
命令对象(以避免自动填充属性),而改为手动管理所有加载、绑定和验证。您可以自定义操作的行为并忽略修改胜负属性的任何尝试。
但命令对象并非必须为 domain 类:使用任何类!在 PlayerController 类下方(PlayerController.groovy 中)中定义以下类。请注意,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> {
@SuppressWarnings(['JUnitPublicNonTestMethod'])
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 中添加约束块。当你在 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 {
@SuppressWarnings(['JUnitPublicNonTestMethod'])
def "test PlayerInfo.region can be null"() {
expect:
new PlayerInfo(region: null).validate(['region'])
}
@SuppressWarnings(['JUnitPublicNonTestMethod'])
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'
}
@SuppressWarnings(['JUnitPublicNonTestMethod'])
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'
}
}
再次修正更新操作,在尝试保存之前返回错误检查。
@Transactional
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 会自动使其变得可验证,从而可以使用静态 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 上启动应用程序。