显示导航

使用命令对象处理表单数据

本指南将说明如何使用命令对象验证 Grails 应用程序中的输入数据

作者:Matthew Moss

Grails 版本 4.0.1

1 Grails 培训

Grails 培训 - 由创建和维护 Grails 框架的人员开发和交付!.

2 入门

在本指南中,你将使用 命令对象 处理带有自动类型转换、自定义验证和简单错误处理的表单提交。

2.1 你需要什么

若要完成本指南,你需要以下内容

  • 一些时间

  • 一个不错的文本编辑器或 IDE

  • 安装了 JDK 1.8 或更高版本,并相应地配置了 JAVA_HOME

2.2 如何完成本指南

要开始,请执行以下操作

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` 动作,使其如下所示

/grails-app/controllers/demo/PlayerController.groovy
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` 操作。

/grails-app/controllers/demo/PlayerController.groovy
@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 的属性完全匹配。

/grails-app/domain/demo/Player.groovy
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
    }
}

数据绑定将获取 namegameregion(如果设置)的表单提交的字符串值,并将这些属性设置在命令对象 player 中。winslosses 的表单提交的字符串值也会在自动类型转换为 int 之后设置到 player 中。将相同的名称用于域类属性和输入字段这一约定极大地简化了编码和应用程序的开发:视图字段使用域类对象的值预先填充,命令对象使用提交的表单数据填充,这对开发人员所需的工作非常少。

在绑定之后,将会进行数据验证。由于这是一个域类,所以将检查类 constraits 块中设置的所有约束。这给了我们添加简单错误检查的机会。在 PlayerController.groovy 的顶部附近,添加

import org.springframework.http.HttpStatus

然后更新 PlayerController 中的 save 操作。

/grails-app/controllers/demo/PlayerController.groovy
@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

/src/integration-test/groovy/demo/demo/PlayerControllerSpec.groovy
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 }
    }
}

现在您应该能够成功创建玩家并将其保存到数据库中。

本指南中针对 save 和其他操作提出的示例控制器代码故意保持简洁,以便专注于命令对象。您很可能希望在操作中增加其他功能。在构建自己的控制器时,一个良好的起点是使用 Grails generate-controller 脚本生成初始控制器

./grailsw generate-controller demo.Player

4.3 实现编辑和更新操作

下一步,我们添加更新现有玩家的能力。向 PlayerController 添加 editupdate 操作。您应该注意到,它们与 createsave 操作几乎相同。

    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 命令对象,我们限制了从表单提交中接受哪些字段。

创建一个单元测试来验证此行为

/src/test/groovy/demo/PlayerControllerSpec.groovy
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 并添加约束条件。

/grails-app/controllers/demo/PlayerController.groovy
class PlayerInfo {
    String name
    String game
    String region

    static constraints = {
        name blank: false
        game blank: false
        region nullable: true
    }
}

创建一个单元测试来验证在命令对象中定义的约束条件。

/src/test/groovy/demo/PlayerInfoSpec.groovy
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'
    }
}

再次修正更新操作,在尝试保存之前返回错误检查。

/grails-app/controllers/demo/PlayerController.groovy
@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 上启动应用程序。

6 是否需要 Grails 方面的帮助?

Object Computing, Inc.(OCI)赞助了本指南的编写。我们提供多种咨询和支持服务。

OCI 是 Grails 的归宿

认识团队