显示导航

使用 Grails 替换 Node/Express API

学习如何使用 Grails 替换 Node/Express 中的 RESTful API

作者:扎卡里·克莱因、塞尔吉奥·德尔·阿莫

Grails 版本 3.3.0

1 Grails 培训

Grails 培训 - 由创建并积极维护 Grails 框架的人员开发和提供的!。

2 引言

我们将使用 马克·沃尔克曼SETT 文章 逐步构建 Web 应用程序,2017 年 4 月份 SETT 问题中描述的 React + Node.js 应用程序。该文章提出了一个从头到尾开发现代 Web 应用程序的详细蓝图。本文中的示例项目包含一个 React 前端和一个 Node/express.js 后端,其后端由 PostgresSQL 数据库支持。该项目包括许多高级特性,如 Web 套接字和 HTTPS。

在本指南中,我们将展示如何使用 Grails 框架开发相同的 Web 应用程序。我们将进行的唯一明确技术更改是在后端使用 Grails 代替 Node/express.js,其他都将使用相同的技术栈,包括 React 和 PostgresSQL。然而,我们将了解到 Grails 如何简化和加快开发过程,并在需要时提供更精细的控制,从而提高开发人员的效率。

本文重点不在于学习网络应用程序的“内部运作”,而在于开发人员的生产效率。此外,对于 React 应用程序的实现细节,我们将把读者引导至原始 SETT 文章,因为我们将使用几乎完全相同的代码(如有更改,将重点突出并加以说明)。

本指南展示了如何将应用程序从技术堆栈转换,例如

technologystack node

转换为

technologystack grails

3 要求

要完成本指南,您将需要以下内容

  • 一些时间

  • 一个合适的文本编辑器或 IDE

  • JDK 1.8 或更高版本已安装且已正确配置了 `JAVA_HOME`

3.1 逐步执行

要开始,请执行以下操作

或者

Grails 指南存储库包含两个文件夹

  • initial 初始项目。通常是一个简单的 Grails 应用程序,其中包含一些附加代码,以便您快速入门。

  • complete 完成的示例。通过实施指南所示的步骤并对 initial 文件夹应用这些更改,可以得到此结果。

要完成指南,请转到 initial 文件夹

  • grails-guides/grails-vs-nodejs/initial 中执行 `cd`

并按照以下部分中的说明进行操作。

如果您 cdgrails-guides/grails-vs-nodejs/complete,可以立即转到完成的示例

4 编写应用程序

本文的完成的示例项目位于

4.1 React 个人资料

每个 Grails 项目都以一个 create-app 命令开始。在学习本指南时,您可以选择从其官方网站 安装 Grails,也可使用 sdkman(建议)进行安装。但是,无需在机器上安装框架即可创建 Grails 应用程序 - 相反,我们可以浏览至 http://start.grails.org 并使用 **Grails Application Forge** 创建应用程序。

选择最新版 Grails(截至撰写本文时为 3.3.0),并选择 react 个人资料。

通过使用 **应用程序个人资料**,Grails 允许您构建现代网络应用程序。有个人资料可帮助构建包含 JavaScript 前端的 REST API 或 Web 应用程序

启动客户端和服务器应用程序

下载应用程序后,将其展开到您选择的目录,在项目中执行 `cd` 并运行以下两个命令(在两个独立的终端会话中)

~ ./gradlew server:bootRun   //Windows users use "gradlew.bat"

//in a second terminal session
~ ./gradlew client:bootRun

gradlew 命令会启动 Gradle “wrapper”,其由 Gradle 构建工具 提供,后者自 Grails 3.0 开始用于所有 Grails 项目。wrapper 是一个特殊的脚本,它实际上在您运行命令之前下载并安装 Gradle 构建工具(如有必要)。然后 Gradle 将下载所有必需的依赖项(包括 Grails)并将其安装到您的项目中(同时也将它们缓存起来以供将来使用)。这就是为什么您无需在您的机器上安装 Grails 的原因:如果您的项目包含 Gradle wrapper,它将为您处理这一切。

您可以粗略地将 Gradle 视为 npm(“不”代表 Node Package Manager)的替代方案。它不会提供 npm 提供的 CLI,但在依赖项管理和构建处理方面起到了类似的作用。当运行 Gradle 命令(或“任务”)时,Gradle 将首先下载在项目 build.gradle 文件中列出的所有依赖项,类似于运行 npm install

那么这两个命令中的 serverclient 部分呢?由于我们使用了 react 配置文件,因此 Grails 实际上为我们创建了两个独立的“应用程序” - 后端 Grails 应用程序和 React 应用程序(后者又通过 create-react-app 生成)。Gradle 将这两个应用程序视作独立的子项目,并使用上述名称。这称为 多项目构建

当从项目根目录运行 Gradle“任务”时,在 ./gradlew [project_name]: 之后的任何内容都将匹配该子项目特定的任务。bootRun 任务在这两个项目中都已配置为启动相应的应用程序。

bootRun 从何而来?此 Gradle 任务继承自 Spring Boot 框架,Grails 正基于此构建。当然,默认情况下,create-react-app 项目没有此类任务。React 配置文件将 client:bootRun 任务提供为 npm/yarn start 脚本的包装。这让您可以使用高级 Gradle 功能,比如使用一个命令并行运行 serverclient。对于开发人员,运行 ../gradlew client:bootRun 等于在产品级 create-react-app 项目中运行 npm start(或 yarn start),实际上,如果您在机器上安装了 npm/ yarn,就可以以这种方式精确运行 client 应用程序。

一旦 gradlew 命令完成下载依赖项并启动各自的应用程序,您就应该能够浏览至 http://localhost:8080 查看 Grails 后端应用程序,并浏览至 http://localhost:3000 查看 React 应用程序。

在我们继续实现我们应用程序之前,花一点时间浏览我们现在拥有的应用程序。Grails 应用程序默认提供一些有用的元数据采用 JSON 格式,而 React 应用程序通过 REST 调用使用该数据并通过应用程序的导航菜单显示它。这不是一个非常有用的应用程序,但您会发现已经为您设置了许多样例程序。

4.2 数据源

现在,我们有了基本的应用程序结构,是时候建立我们的数据库了。我们正在尝试迁移一个React + Node/express 应用程序,它使用 PostgreSQL。然而,值得注意的是,Grails 已经为我们建立了一个基本的数据源,采用内存中 H2 数据库。每次运行应用程序时,都会销毁和重新创建该数据库,如果向领域类添加了新表/列,它甚至会在运行时更新(稍后会详细介绍)。对于许多应用程序来说,此默认数据库将非常适合应用程序开发的初始阶段,特别是如果您对数据模型进行了很多迭代更改的情况。然而,为了保持与被迁移的应用程序一致,我们将用我们想要的 PostgreSQL 数据库取代这个默认的 H2 数据源。

我们回顾一下在您的机器上安装 PostgresSQL 的步骤

要在 Windows 中安装 PostgreSQL,请参阅 https://postgresql.ac.cn/download/windows/

要在 macOS 中安装 PostgreSQL

按照 https://brew.sh.cn/ 上的说明安装 Homebrew。输入以下内容:brew install postgresql 要启动数据库服务器,请输入 pg_ctl -D /usr/local/var/postgres start

要稍后停止数据库服务器,请输入 pg_ctl -D /usr/local/var/postgres stop -m fast

— Web 应用程序
逐步执行 -- Mark Volkmann

一旦您安装了 Postgres(或者如果您已经安装了它),使用 createdb 为我们的应用程序创建一个新数据库

~ createdb ice_cream_db_2

如果您在上一篇文章(其中采取了上述安装步骤)中回忆起,此处的下一步将是为我们的应用程序创建所需的数据库表。但是,我们不会在本项目中使用任何 SQL。这是因为 Grails 提供了一个强大且对开发者友好的替代方案:GORM;适用于 JVM 的数据访问工具包。

4.3 GORM

GORM 可用于任何与 JDBC 兼容的数据库,其中包括 Postgres(以及其他 200 多个数据库)。要开始在我们新的 Grails 应用程序中使用 Postgres,我们需要完成 2 个步骤

步骤 1

在我们的 server 项目中安装 JDBC 驱动程序。编辑 server/build.gradle,然后找到名为 dependencies 的部分。添加以下代码行:runtime 'org.postgresql:postgresql:9.4.1212' 这将告诉 Gradle 从 Maven Central 存储库下载 org.postgresql.postgresql 库的 9.4.1212 版本,并将其安装在我们的应用程序中。

/server/build.gradle
runtime 'org.postgresql:postgresql:9.4.1212'
你可以把build.gradle当作 Node.js 项目中的package.json文件,它具有类似的目的。它指定了项目的存储库依赖项自定义任务(类似于 npm 脚本)。
步骤二

配置 GORM,使用我们的 PostgreSQL 数据库而不是默认的 H2 数据库。编辑server/grails-app/conf/application.yml,向下滚动到从datasource开始的部分,并用以下内容替换它

/server/grails-app/conf/application.yml
dataSource:
    dbCreate: create-drop
    driverClassName: org.postgresql.Driver
    dialect: org.hibernate.dialect.PostgreSQLDialect
    username: postgres
    password:
    url: jdbc:postgresql://localhost:5432/ice_cream_db_2

现在,我们的 Grails 应用已连接到我们的数据库,但是我们尚未创建任何表。使用 Grails 无需手动创建数据库架构(如果确实希望这样做,当然可以)。我们将在代码中使用领域类指定我们的领域模型。

根据惯例,Grails 会在grails-app/domain下加载任何 Groovy 类,作为领域类。这意味着,GORM 会将这些类映射到数据库中的表,并将这些类的属性映射到各个表中的列。GORM 会选择为我们创建这些表,我们已经在 application.yml 文件中使用dbCreate: update设置启用了此操作。

这意味着从原始文章设置数据库架构非常简单。

React + Node/express 使用了如下数据库架构

create table ice_creams (
  id serial primary key,
  flavor text
);

create table users (
  username text primary key,
  password text -- encrypted length
);

create table user_ice_creams (
  username text references users(username),
  ice_cream_id integer references ice_creams(id)
);

GORM 是 Grails 默认使用的数据库访问工具包。我们可以自定义生成数据库架构的外观。

根据惯例,Grails 中的领域类会使用明智的默认值指定它们映射到数据库的方式。你可以使用 ORM 映射 DSL 自定义这些默认值。

对于应用中所需的每张表,我们将在grails-app/domain目录下创建一个领域类。

运行以下命令

~ ./grailsw create-domain-class demo.IceCream
~ ./grailsw create-domain-class demo.User
~ ./grailsw create-domain-class demo.UserIceCream

这些命令将在grails-app/domain/demo下生成三个 Groovy 类。编辑这些文件,添加以下内容,以生成与上一个应用中相同的数据库架构

server/grails-app/domain/demo/User
package demo

class User implements Serializable {
    String username
    String password

    static mapping = {
        table 'users'
        password type: 'text'
        id name: 'username', generator: 'assigned'
        version false
    }
}
你可能已注意到,我们并未加密password列——别担心,我们稍后再做。
server/grails-app/domain/demo/IceCream
package demo

class IceCream implements Serializable {
    String flavor

    static mapping = {
        table 'ice_creams'
        flavor type: 'text'
        version false
    }
}
server/grails-app/domain/demo/UserIceCream
package demo

class UserIceCream implements Serializable {
    User user
    IceCream iceCream

    static mapping = {
        table 'user_ice_creams'
        id composite: ['user', 'iceCream']
        user column: 'username'
        iceCream column: 'ice_cream_id'
        version false
    }
}

前一个领域类代表了UserIceCream类的一个连接表。

包命名

在 Java 项目中比较普遍的是,我们为我们的领域类创建了一个“包”。包有助于将我们的类与我们以后可能使用的库或插件中的类区分开来。包也会反映在目录结构中:这两个文件将创建在grails-app/domain/demo/下面。

为什么我们使用句点而不是连字符作为分隔符,如在前一篇文章中显示的?Java 中通常认为在包名中使用破折号(连字符)违反惯例。参阅此链接了解有关 Java 命名惯例的详细信息

4.4 Grails 控制台

如果您现在启动您的应用,Grails 将连接到 Postgres 数据库,创建持久化您的域对象所需的表和列。当然,起初数据库中不会有任何数据。我们很快会解决此问题,但是现在,我们可以运行 Grails 命令,以便提供一个交互式控制台,可以在其中创建、更新、删除和查询我们的域对象。

运行以下命令

~ ./grailsw console

(如果您已在本地机器上安装 Grails,您可以直接运行 grails 命令,例如:grails console。但是,Grails 项目包含将为您安装正确版本的 Grails 的 grailsw “包装程序”命令。)

现在您应该看到 Grails 控制台。您可以从项目导入任何类(即您自己的代码和任何依赖项),并运行您希望的任何 Groovy 代码。

以下代码将演示如何使用 GORM 和 Groovy 语言从前一篇文章 “Web 应用” 执行数据库操作。

import demo.*

// Delete all rows from these tables:
// user and ice_cream via HQL updates (efficient for batch operations)
IceCream.executeUpdate("delete from IceCream")
User.executeUpdate("delete from User")

// Insert three new rows corresponding to three flavors.
def iceCreams = ['vanilla', 'chocolate', 'strawberry'].collect { flavor ->
    new IceCream(flavor: flavor).save(flush: true)
}

// Get an array of the ids of the new rows.
def ids = iceCreams*.id
println "Inserted records with ids ${ids.join(',')}"

// Delete the first row (vanilla)
def vanilla = IceCream.get(ids[0])
vanilla.delete()

// Change the flavor of the second row (chocolate) to "chocolate chip".
def chocolate = IceCream.findByFlavor('chocolate')
chocolate.flavor = 'chocolate chip'
chocolate.save(flush: true)

// Get all the rows in the table.
iceCreams = IceCream.list()

// Output their ids and flavors.
iceCreams.each { iceCream ->
    println "${iceCream.id}: ${iceCream.flavor}"
}

在 Grails 控制台中输入上述代码(使用前一条命令启动),然后点击“运行”按钮以执行脚本。如果您愿意,可以保存该脚本以便以后重复使用(注意,此 Groovy 脚本仅可在 Grails 控制台中运行,不能通过“普通”Groovy 控制台或 Groovy 编译器 (groovyc) 运行,这是因为我们的域类需要由 Greils 加载才能使用此代码)。

您可能认为此方法可用于向我们的数据库填充一些初始数据,而您是对的,我们在控制台中所做的任何插入/更新都持久保留到我们在 application.yml 文件中配置的数据库。但是,Grails 提供了一个 BootStrap.groovy 文件,非常适合此任务,如您在下一部分中所见。

4.5 种子数据

编辑文件 server/grails-app/init/demo/BootStrap.groovy 并添加以下代码

/server/grails-app/init/demo/BootStrap.groovy
package demo

import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@CompileStatic
class BootStrap {

    UserService userService
    UserRoleService userRoleService
    RoleService roleService
    IceCreamService iceCreamService

    def init = { servletContext ->
        log.info "Loading database..."

        if (!iceCreamService.count()) {

            List<Long> ids = []
            for (String flavor : ['vanilla', 'chocolate', 'strawberry']) {
                IceCream iceCream = iceCreamService.saveIcream(flavor)
                ids << iceCream.id
            }
            log.info "Inserted records with ids ${ids.join(',')}"
        }

        if (!roleService.count()) {
            Role role = roleService.saveRole( 'ROLE_USER')
            log.info "Inserted role..."

            User user = userService.createUser('sherlock', 'secret')
            log.info "Inserted user..."

            userRoleService.saveUserRole(user, role)
            log.info "Associated user with role..."
        }
    }
    def destroy = {
    }
}

如您所见,我们使用多个 Grails 服务(有关服务,请参阅下一部分)作为协作者。Grails 鼓励将您所有的业务逻辑保留在服务层中。

/server/grails-app/services/demo/IceCreamService.groovy
package demo

import grails.compiler.GrailsCompileStatic
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.springframework.validation.ObjectError

@Slf4j
@CompileStatic
@Transactional
class IceCreamService {
    IceCream saveIcream(String flavor, boolean flush = false) {
        IceCream iceCream = new IceCream(flavor: flavor)
        if ( !iceCream.save(flush: flush) ) {
            log.error 'Failure while saving icream {}', iceCream.errors.toString()
        }
        iceCream
    }
    @ReadOnly
    int count() {
        IceCream.count() as int
    }
/server/grails-app/services/demo/RoleService.groovy
package demo

import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@Transactional
@CompileStatic
class RoleService {
    Role saveRole(String authority, boolean flush = false) {
        Role r = new Role(authority: authority)
        if ( !r.save(flush: flush) ) {
            log.error 'Failure while saving role {}', r.errors.toString()
        }
        r
    }
    @ReadOnly
    int count() {
        Role.count() as int
    }
/server/grails-app/services/demo/UserService.groovy
package demo

import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.util.logging.Slf4j

@Slf4j
@Transactional
class UserService {
    User createUser(String username, String password, boolean flush = false) {
        User user = new User(username: username, password: password)
        if ( !user.save(flush: flush) ) {
            log.error 'Unable to save user {}', user.errors.toString()
        }
        user
    }
/server/grails-app/services/demo/UserRoleService.groovy
package demo

import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@Transactional
@CompileStatic
class UserRoleService {
    UserRole saveUserRole(User user, Role role, boolean flush = false) {
        UserRole ur = new UserRole(user: user, role: role)
        if ( !ur.save(flush: flush) ) {
            log.error 'Failure while saving user {}', ur.errors.toString()
        }
        ur
    }

4.6 验证

Grails 基于 Spring Boot,因此与 Spring 生态系统中许多其他项目兼容。这类项目中最热门的一个是 Spring Security。它为 Java Web 应用程序提供强大的认证和访问控制功能,支持从 LDAP 到 OAuth2 的多种认证方法。更棒的是,还有一组 Grails 插件,让 Spring Security 的设置变得非常简单。

再次编辑 server/build.gradle,并添加以下两行:server/build.gradle

/server/build.gradle
compile 'org.grails.plugins:spring-security-core:3.2.0.M1'
compile "org.grails.plugins:spring-security-rest:2.0.0.M2"

我们将 Spring Security Core 和 REST 插件配置放在了 application.yml

/server/grails-app/conf/application.yml
grails:
    plugin:
        springsecurity:
            userLookup:
                userDomainClassName: demo.User
                authorityJoinClassName: demo.UserRole
            authority:
                className: demo.Role
            rest:
                login:
                    endpointUrl: /login
            useSecurityEventListener: true
            controllerAnnotations:
                staticRules:
                    -
                        pattern: /stomp/**
                        access:
                            - permitAll
                    -
                        pattern: /signup/**
                        access:
                            - permitAll
            filterChain:
                chainMap:
                    - # Stateless chain
                        pattern: /**
                        filters: 'JOINED_FILTERS,-exceptionTranslationFilter,-authenticationProcessingFilter,-securityContextPersistenceFilter,-rememberMeAuthenticationFilter'

请查阅 Spring Security 文档Grails Spring Security 插件 文档,了解更多信息。

我们对 User 领域类进行了轻微修改。此外,我们还添加了其他两个领域类来映射角色以及角色和用户之间的关系。

/server/grails-app/domain/demo/User.groovy
package demo

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
@EqualsAndHashCode(includes='username')
class User implements Serializable {

    String username
    String password
    Date lastLogin = null (1)
    boolean enabled = true
    boolean accountExpired
    boolean accountLocked
    boolean passwordExpired

    Set<Role> getAuthorities() {
        (UserRole.findAllByUser(this) as List<UserRole>)*.role as Set<Role>
    }

    static constraints = {
        password nullable: false, blank: false, password: true
        username nullable: false, blank: false, unique: true
        lastLogin nullable: true
    }

    static mapping = {
        table 'users'
        password column: '`password`', type: 'text'
        id name: 'username', generator: 'assigned'
        version false
    }
}
1 我们将使用此属性来跟踪过期的会话
/server/grails-app/domain/demo/Role.groovy
package demo

import groovy.transform.EqualsAndHashCode
import groovy.transform.ToString
import grails.compiler.GrailsCompileStatic
import org.springframework.security.core.GrantedAuthority

@GrailsCompileStatic
@EqualsAndHashCode(includes='authority')
@ToString(includes='authority', includeNames=true, includePackage=false)
class Role implements Serializable, GrantedAuthority {

    private static final long serialVersionUID = 1

    String authority

    static constraints = {
        authority nullable: false, blank: false, unique: true
    }

    static mapping = {
        cache true
    }
}
/server/grails-app/domain/demo/UserRole.groovy
package demo

import grails.gorm.DetachedCriteria
import groovy.transform.ToString

import org.codehaus.groovy.util.HashCodeHelper
import grails.compiler.GrailsCompileStatic

@GrailsCompileStatic
@ToString(cache=true, includeNames=true, includePackage=false)
class UserRole implements Serializable {

    private static final long serialVersionUID = 1

    User user
    Role role

    @Override
    boolean equals(other) {
        if (other instanceof UserRole) {
            other.userId == user?.username && other.roleId == role?.id
        }
    }

    @Override
    int hashCode() {
        int hashCode = HashCodeHelper.initHash()
        if (user) {
            hashCode = HashCodeHelper.updateHash(hashCode, user.username)
        }
        if (role) {
            hashCode = HashCodeHelper.updateHash(hashCode, role.id)
        }
        hashCode
    }

    static UserRole get(String username, long roleId) {
        criteriaFor(username, roleId).get()
    }

    static boolean exists(String username, long roleId) {
        criteriaFor(username, roleId).count()
    }

    private static DetachedCriteria criteriaFor(String name, long roleId) {
        UserRole.where {
            user.username == name &&
                    role == Role.load(roleId)
        }
    }

    static UserRole create(User user, Role role, boolean flush = false) {
        def instance = new UserRole(user: user, role: role)
        instance.save(flush: flush)
        instance
    }

    static boolean remove(User u, Role r) {
        if (u != null && r != null) {
            UserRole.where { user == u && role == r }.deleteAll()
        }
    }

    static int removeAll(User u) {
        u == null ? 0 : UserRole.where { user == u }.deleteAll() as int
    }

    static int removeAll(Role r) {
        r == null ? 0 : UserRole.where { role == r }.deleteAll() as int
    }

    static constraints = {
        user nullable: false
        role nullable: false, validator: { Role r, UserRole ur ->
            if (ur.user?.id) {
                if (UserRole.exists(ur.user.username, r.id)) {
                    return ['userRole.exists']
                }
            }
        }
    }

    static mapping = {
        id composite: ['user', 'role']
        version false
    }
}

我们添加了一个“侦听器”,无论何时创建新的 User,它都将对 password 字段进行加密。

/server/src/main/groovy/demo/UserPasswordEncoderListener.groovy
package demo

import grails.plugin.springsecurity.SpringSecurityService
import org.grails.datastore.mapping.engine.event.AbstractPersistenceEvent
import org.grails.datastore.mapping.engine.event.EventType
import org.grails.datastore.mapping.engine.event.PreInsertEvent
import org.grails.datastore.mapping.engine.event.PreUpdateEvent
import org.hibernate.event.spi.PreDeleteEvent
import org.springframework.beans.factory.annotation.Autowired
import grails.events.annotation.gorm.Listener
import groovy.transform.CompileStatic

@CompileStatic
class UserPasswordEncoderListener {

    @Autowired
    SpringSecurityService springSecurityService

    @Listener(User)
    void onPreInsertEvent(PreInsertEvent event) {
        encodeUserPasswordForEvent(event)
    }

    @Listener(User)
    void onPreUpdateEvent(PreUpdateEvent event) {
        encodeUserPasswordForEvent(event)
    }

    private void encodeUserPasswordForEvent(AbstractPersistenceEvent event) {
        if (event.entityObject instanceof User) {
            User u = (event.entityObject as User)
            if (u.password && ((event instanceof  PreInsertEvent) || (event instanceof PreUpdateEvent && u.isDirty('password')))) {
                event.getEntityAccess().setProperty("password", encodePassword(u.password))
            }
        }
    }

    private String encodePassword(String password) {
        springSecurityService?.passwordEncoder ? springSecurityService.encodePassword(password) : password
    }
}

我们按 Bean 的形式在 resources.groovy 中注册此侦听器

/server/grails-app/conf/spring/resources.groovy
import demo.UserPasswordEncoderListener

// Place your Spring DSL code here
beans = {
    userPasswordEncoderListener(UserPasswordEncoderListener)
}

4.7 REST 服务

现在,我们已经配置并填充了数据库,是时候设置我们的服务层了。在这样做之前,了解 Grails 框架提供的两个主要数据处理“构件”非常重要。

  1. 控制器:Grails 是 MVC 框架,“C”代表控制器。在 MVC 应用程序中,控制器定义了 Web 应用程序的逻辑,并管理模型和视图之间的通信。控制器响应请求、与来自模型的数据交互,然后以视图(HTML 页面)或其他一些可消耗的格式(例如 JSON)响应。控制器是位于 grails-app/controllers 中的 Groovy 类

  2. 服务:很多时候,我们需要做的不仅仅是接受请求并将响应返回给数据。实际应用程序通常包含大量用于“业务逻辑”的代码。Grails 支持“服务”,它是完全访问模型但没有与请求绑定在一起的类。控制器以及应用程序的其他部分可以通过名称注入的方式调用服务(通过 Spring 的依赖注入提供),从而获取对其请求做出响应所需的数据。服务是位于 grails-app/services 中的 Groovy 类,并且可以通过名称注入到其他 Grails 构件中。

为了在应用程序中实现 RESTful API,我们使用控制器响应 API 请求 (POST、GET、PUT、DELETE),使用服务处理“业务逻辑”(在本例中非常简单)。让我们从控制器开始

React 应用程序使用多个端点。

4.8 注册端点

注册

为了注册进入应用程序,React 应用程序发送一个POST请求到/signup端点。其中包含包含用户名和密码的 JSON Payload。我们准备使用 Grails 映射此内容。

首先,我们在 URLMappings 中注册端点,方法是在映射中添加下一行

/server/grails-app/controllers/demo/UrlMappings.groovy
        post '/signup'(controller: 'signup')

接下来我们创建了一个控制器,该控制器借助于命令对象绑定 JSON Payload。

/server/grails-app/controllers/demo/SignupCommand.groovy
package demo

import grails.validation.Validateable

class SignupCommand implements Validateable {
    String username
    String password

    static constraints = {
        username nullable: false, blank: false
        password nullable: false, blank: false
    }
}
/server/grails-app/controllers/demo/SignupController.groovy
package demo

import grails.plugin.springsecurity.annotation.Secured
import grails.plugin.springsecurity.rest.token.AccessToken
import grails.plugin.springsecurity.rest.token.generation.TokenGenerator
import grails.plugin.springsecurity.rest.token.rendering.AccessTokenJsonRenderer
import grails.plugin.springsecurity.userdetails.GrailsUser
import groovy.transform.CompileStatic
import org.springframework.http.HttpStatus
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.userdetails.UserDetails

(1)
@CompileStatic
@Secured(['IS_AUTHENTICATED_ANONYMOUSLY'])
class SignupController {
    static responseFormats = ['json', 'xml']

    UserRoleService userRoleService
    UserService userService
    RoleService roleService
    TokenGenerator tokenGenerator
    AccessTokenJsonRenderer accessTokenJsonRenderer

    def signup(SignupCommand cmd) {
        (2)
        if ( userService.existsUserByUsername(cmd.username) ) {
            render status: HttpStatus.UNPROCESSABLE_ENTITY.value(), "duplicate key"
            return
        }

        Role roleUser = roleService.findByRoleName('ROLE_USER')
        if ( !roleUser ) {
            render status: HttpStatus.UNPROCESSABLE_ENTITY.value(), "default role: ROLE_USER does not exist"
            return
        }
        User user = userService.createUser(cmd.username, cmd.password)
        userRoleService.saveUserRole(user, roleUser)

        (3)
        UserDetails userDetails = new GrailsUser(user.username, user.password, user.enabled, !user.accountExpired,
                !user.passwordExpired, !user.accountLocked, user.authorities as Collection<GrantedAuthority>, user.id)
        AccessToken token = tokenGenerator.generateAccessToken(userDetails)
        render status: HttpStatus.OK.value(), accessTokenJsonRenderer.generateJson(token)
    }
}
1 @Secured注释指定了此控制器的访问控制权限 - 允许匿名访问
2 检查是否存在重复用户名
3 验证新创建的用户并生成验证令牌。此步骤省去了 React 应用程序在提交/signup请求后进行第二次登录请求的步骤

控制器使用多个服务作为协作者

/server/grails-app/services/demo/RoleService.groovy
package demo

import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@Transactional
@CompileStatic
class RoleService {
    Role findByRoleName(String roleName) {
        Role.where { authority == roleName }.get()
    }
}
/server/grails-app/services/demo/UserService.groovy
package demo

import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.util.logging.Slf4j

@Slf4j
@Transactional
class UserService {
    @ReadOnly
    boolean existsUserByUsername(String username) {
        findQueryByUsername(username).count()
    }
    DetachedCriteria<User> findQueryByUsername(String name) {
        User.where { username == name }
    }
    User createUser(String username, String password, boolean flush = false) {
        User user = new User(username: username, password: password)
        if ( !user.save(flush: flush) ) {
            log.error 'Unable to save user {}', user.errors.toString()
        }
        user
    }
}
/server/grails-app/services/demo/UserRoleService.groovy
package demo

import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j

@Slf4j
@Transactional
@CompileStatic
class UserRoleService {
    UserRole saveUserRole(User user, Role role, boolean flush = false) {
        UserRole ur = new UserRole(user: user, role: role)
        if ( !ur.save(flush: flush) ) {
            log.error 'Failure while saving user {}', ur.errors.toString()
        }
        ur
    }
}

4.9 用户偏好口味的端点

当登录应用程序或者刚注册时,应用程序向他展示了他最喜爱的口味列表。

为了获取这些口味,React 应用程序发送一个GET请求到/ice-cream/$username端点。我们准备使用 Grails 映射此内容。

首先,我们在 URLMappings 中注册端点,方法是在映射中添加下一行

/server/grails-app/controllers/demo/UrlMappings.groovy
        get "/ice-cream/$username"(controller: 'iceCream', action: 'index')

接下来,我们创建一个控制器操作,该操作获取已登录用户名最喜爱的口味列表

/server/grails-app/controllers/demo/IceCreamController.groovy
package demo
import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.annotation.Secured
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic

@CompileStatic
@Secured(['ROLE_USER']) (1)
class IceCreamController {
    static responseFormats = ['json']

    static allowedMethods = [index: 'GET', save: 'POST', delete: 'DELETE',]
    IceCreamService iceCreamService

    UserIceCreamService userIceCreamService

    SpringSecurityService springSecurityService

    def index(Integer max) {
        String username = loggedUsername()
        if ( !username ) {
            render status: 404
            return
        }
        params.max = Math.min(max ?: 10, 100) (2)

        List<IceCream> iceCreams = userIceCreamService.findAllIceCreamsByUsername(username)
        [iceCreams: iceCreams] (3)
    }
    @CompileDynamic
    protected String loggedUsername() {
        springSecurityService.principal?.username as String
    }
1 @Secured注释指定了此控制器的访问控制权限 - 需要验证和 ROLE_USER

控制器操作使用一个服务作为协作者

/server/grails-app/services/demo/UserIceCreamService.groovy
package demo
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.springframework.validation.ObjectError

@Slf4j
@Transactional
@CompileStatic
class UserIceCreamService {
    @ReadOnly
    List<IceCream> findAllIceCreamsByUsername(String loggedUsername) {
        UserIceCream.where {
            user.username == loggedUsername
        }.list()*.iceCream as List<IceCream>
    }
}

我们在 JSON 视图 的帮助下呈现 JSON

/server/grails-app/views/iceCream/index.gson
import demo.IceCream

model {
    Iterable<IceCream> iceCreams
}

json tmpl.icecream(iceCreams)
/server/grails-app/views/iceCream/_iceCream.gson
Unresolved directive in <stdin> - include::/home/runner/work/grails-vs-nodejs/grails-vs-nodejs/complete/server/grails-app/views/iceCream/_iceCream.gson[]

4.10 保存用户最爱的口味

用户可以从其个人资料中添加一个口味。

为此,React 应用程序向/ice-cream/$username端点发送一个POST请求。

我们准备使用 Grails 映射此内容。

首先,我们在 URLMappings 中注册端点,方法是在映射中添加下一行

/server/grails-app/controllers/demo/UrlMappings.groovy
        post "/ice-cream/$username"(controller: 'iceCream', action: 'save')

接下来,我们创建一个控制器操作,该操作向已登录用户名添加一个口味

/server/grails-app/controllers/demo/IceCreamController.groovy
package demo
import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.annotation.Secured
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic

@CompileStatic
@Secured(['ROLE_USER']) (1)
class IceCreamController {
    static responseFormats = ['json']

    static allowedMethods = [index: 'GET', save: 'POST', delete: 'DELETE',]
    IceCreamService iceCreamService

    UserIceCreamService userIceCreamService

    SpringSecurityService springSecurityService

    def save(String flavor) {
        String username = loggedUsername()
        if ( !username ) {
            render status: 404
            return
        }
        def id = iceCreamService.addIceCreamToUser(username, flavor)?.id
        render id ?: [status: 500]
    }
    @CompileDynamic
    protected String loggedUsername() {
        springSecurityService.principal?.username as String
    }
1 @Secured注释指定了此控制器的访问控制权限 - 需要验证和 ROLE_USER

控制器操作使用一个服务作为协作者

/server/grails-app/services/demo/IceCreamService.groovy
package demo

import grails.compiler.GrailsCompileStatic
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.springframework.validation.ObjectError
@Slf4j
@CompileStatic
@Transactional
class IceCreamService {
    /**
     * @return null if an error occurs while saving the ice cream or the association between icream and user
     */
    @GrailsCompileStatic
    IceCream addIceCreamToUser(String username, String iceCreamFlavor, boolean flush = false) {
        User user = userService.findByUsername(username)
        if ( !user ) {
            log.error 'User {} does not exist', username
            return null
        }
        IceCream iceCream = findQueryByFlavor(iceCreamFlavor).get() ?: new IceCream(flavor: iceCreamFlavor)

        if(!iceCream.save(flush: flush)) {
            iceCream.errors.allErrors.each { ObjectError error ->
                log.error(error.toString())
            }
            return null
        }
        UserIceCream userIceCream = userIceCreamService.create(user, iceCream, flush)
        if ( userIceCream.hasErrors() ) {
            return null
        }
        iceCream
    }
}

4.11 删除用户的口味

用户可以从其个人资料中移除一个口味。

为此,React 应用程序向/ice-cream/$username/$id端点发送一个DELETE请求。

我们准备使用 Grails 映射此内容。

首先,我们在 URLMappings 中注册端点,方法是在映射中添加下一行

/server/grails-app/controllers/demo/UrlMappings.groovy
        delete "/ice-cream/$username/$id"(controller: 'iceCream', action: 'delete')

接下来,我们创建一个控制器操作,该操作为已登录用户名中删除了一个特定的口味

/server/grails-app/controllers/demo/IceCreamController.groovy
package demo
import grails.plugin.springsecurity.SpringSecurityService
import grails.plugin.springsecurity.annotation.Secured
import groovy.transform.CompileDynamic
import groovy.transform.CompileStatic

@CompileStatic
@Secured(['ROLE_USER']) (1)
class IceCreamController {
    static responseFormats = ['json']

    static allowedMethods = [index: 'GET', save: 'POST', delete: 'DELETE',]
    IceCreamService iceCreamService

    UserIceCreamService userIceCreamService

    SpringSecurityService springSecurityService

    def delete(Long id) {
        String username = loggedUsername()
        if ( !username ) {
            render status: 404
            return
        }
        respond iceCreamService.removeIceCreamFromUser(username, id) ?: [status: 500]
    }
    @CompileDynamic
    protected String loggedUsername() {
        springSecurityService.principal?.username as String
    }
1 @Secured注释指定了此控制器的访问控制权限 - 需要验证和 ROLE_USER

控制器操作使用一个服务作为协作者

/server/grails-app/services/demo/IceCreamService.groovy
package demo

import grails.compiler.GrailsCompileStatic
import grails.gorm.DetachedCriteria
import grails.gorm.transactions.ReadOnly
import grails.gorm.transactions.Transactional
import groovy.transform.CompileStatic
import groovy.util.logging.Slf4j
import org.springframework.validation.ObjectError
@Slf4j
@CompileStatic
@Transactional
class IceCreamService {
    Boolean removeIceCreamFromUser(String loggedUsername, Long id) {
        IceCream iceCreamEntity = IceCream.load(id)
        User loggedUser = User.load(loggedUsername)
        UserIceCream.where {
            user == loggedUser && iceCream == iceCreamEntity
        }.deleteAll()
    }
}

4.12 WebSocket

我们服务端最终功能是通过 websocket 连接向客户端推送“会话超时”事件。我们将使用另一个 Grails 插件,Spring WebSocket 插件,来支持此功能。

通过向我们的build.gradle文件中添加另一行来安装此插件

/server/build.gradle
compile 'org.grails.plugins:grails-spring-websocket:2.3.0'

我们现在必须实现三个类才能让 Web 套接字会话超时正常工作

  1. 一个配置类来配置我们的 WebSocket 连接

  2. 一个“侦听器”类来追踪何时创建新的认证令牌

  3. 一个“调度器”类周期性地检查“已过期”的会话,并通过 WebSocket 连接推送事件。

由于这些类不是特定于 Grails 的,因此我们将它们作为 Groovy 类创建于 server/src/main/groovy 下。

以下是这三个类完整代码

/server/src/main/groovy/demo/CustomWebSocketConfig.groovy
package demo

import grails.plugin.springwebsocket.DefaultWebSocketConfig
import groovy.util.logging.Slf4j
import org.springframework.beans.factory.annotation.Value
import org.springframework.context.annotation.Configuration
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker
import org.springframework.web.socket.config.annotation.StompEndpointRegistry

@Slf4j
@Configuration
@EnableWebSocketMessageBroker
class CustomWebSocketConfig extends DefaultWebSocketConfig {

    @Value('${allowedOrigin}')
    String allowedOrigin (1)

    @Override
    void registerStompEndpoints(StompEndpointRegistry stompEndpointRegistry) { (2)
        log.info 'registerStompEndpoints with allowedOrigin: {}', allowedOrigin
        stompEndpointRegistry.addEndpoint("/stomp").setAllowedOrigins(allowedOrigin).withSockJS()
    }
}
1 从 application.yml 中加载我们的 allowedOrigin 配置属性
2 配置 WebSocket 连接以接受来自我们的客户端服务器的请求
/server/src/main/groovy/demo/TokenCreationEventListener.groovy
package demo

import org.springframework.context.ApplicationListener
import grails.plugin.springsecurity.rest.RestTokenCreationEvent

class TokenCreationEventListener implements ApplicationListener<RestTokenCreationEvent> {

    void onApplicationEvent(RestTokenCreationEvent event) { (1)

        User.withTransaction { (2)
            User user = User.where { username == event.principal.username }.first()
            user.lastLogin = new Date()
            user.save(flush: true)
        }
    }
}
1 我们正在扩展一个 ApplicationListener 接口,该接口是 Spring Framework 的一部分,并且允许我们“侦听”特定应用程序事件。在本例中,我们将侦听 RestTokenCreationEvent。您可以在 Spring Security REST 插件文档 中找到要侦听的其他事件。
2 这里需要 withTransaction 方法,因为我们的自定义类默认无法访问 GORM(与控制器和服务不同)。User 域类实际上并不重要——我们可以在这里使用任何域类。withNewSession 将启动一个 GORM/Hibernate 事务,并允许我们查询数据库和保留更改。有关详细信息,请参阅 GORM 文档
/server/src/main/groovy/demo/SessionExpirationJobHolder.groovy
package demo


import groovy.time.TimeCategory
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Value
import org.springframework.messaging.simp.SimpMessagingTemplate
import org.springframework.scheduling.annotation.Scheduled

class SessionExpirationJobHolder {

    @Autowired
    SimpMessagingTemplate brokerMessagingTemplate (1)

    @Value('${timeout.minutes}') (2)
    Integer timeout

    @Scheduled(cron = "0 * * * * *") (3)
    void findExpiredSessions() {
        Date timeoutDate
        use( TimeCategory ) { (4)
            timeoutDate = new Date() - timeout.minutes
        }

        User.withTransaction {
            List<User> expiredUsers = User.where { //Query for loggedIn users with a lastLogin date after the timeout limit
                lastLogin != null
                lastLogin < timeoutDate
            }.list()

            //Iterate over the expired users
            expiredUsers.each { user ->
                user.lastLogin = null //Reset lastLogin date
                user.save(flush: true)

                (5)
                brokerMessagingTemplate.convertAndSend "/topic/${user.username}".toString(), "logout"
            }
        }
    }
}
1 此类由 spring-websocket 插件提供,并且允许我们通过 WebSocket 通道推送事件
2 从 application.yml 中加载我们的 timeout.minutes 属性
3 每一分钟运行一次 Run 方法
4 使用 Groovy 的 TimeCategory DSL 进行时间操作
5 为每个过期的用户向特定于用户的“频道”发送 WebSocket 消息——我们使用其用户名作为每个频道的唯一键

有了这些类,我们必须将其插入 Spring 环境中,方法是在我们的 resources.groovy 文件中将其添加为“bean”。如下所示编辑该文件。

/server/grails-app/conf/spring/resources.groovy
import demo.UserPasswordEncoderListener
import demo.SessionExpirationJobHolder
import demo.TokenCreationEventListener
import demo.CustomWebSocketConfig

// Place your Spring DSL code here
beans = {
    userPasswordEncoderListener(UserPasswordEncoderListener) (1)
    webSocketConfig(CustomWebSocketConfig)                 (2)
    tokenCreationEventListener(TokenCreationEventListener) (3)
    sessionExpirationJobHolder(SessionExpirationJobHolder) (4)
}
1 注册密码编码器侦听器
2 WebSocket 的自定义设置
3 侦听新的访问令牌并设置 loginDate
4 检查过期的会话

接下来,为了让已调度的 SessionExpirationJobHolder 类按计划实际触发,我们必须在应用程序中启用调度(默认情况下未开启)。我们通过编辑应用程序的 Application.groovy 文件(注意大写!)来实现这一目标。

/server/grails-app/init/demo/Application.groovy
package demo

import grails.boot.GrailsApp
import grails.boot.config.GrailsAutoConfiguration

@EnableScheduling
class Application extends GrailsAutoConfiguration {
    static void main(String[] args) {
        GrailsApp.run(Application, args)
    }
}

最后,您可能还记得我们在上面的类中引用了几个新的配置属性。让我们将它们添加到我们的 application.yml 文件(将以下行添加到文件末尾)。

/server/grails-app/conf/application.yml
allowedOrigin: https://localhost:3000 # accepted origin URL for websocket connections
timeout:
    minutes: 1 #config setting for timeout
Grails 应用程序可以读取通过多种方式设置的配置值,包括 YAML 文件、Groovy 文件和系统属性。请参阅Grails 文档,以了解更多有关如何使用配置文件的信息。

Grails 后端服务器的所有操作 - 我们支持 CORS(开箱即用)、websocket、身份验证、RESTful Web 服务、计划方法和持久化到 PostgresSQL。

4.13 React

现在,我们准备转向我们应用程序的客户端部分。对于此步骤,我们将使用 上一篇“Web 应用程序”文章 的 Github 存储库中的代码。您可以在此处访问该代码:https://github.com/mvolkmann/ice-cream-app/tree/master/src

下载(或`git clone`)上述 URL 中的源文件并将它们复制到`client/src`目录中(覆盖任何现有文件)。

~ cd ../
~ git clone https://github.com/mvolkmann/ice-cream-app tmp
~ cp -Rv tmp/* ice-cream/client/

这将使`client`子项目与 上一篇文章 中的冰淇淋应用程序相同。

现在,我们删除我们不需要的文件。

~ cd ice-cream/client
~ rm -rf database/ server/ css/ images/

这应该让你拥有以下位于`client`下的目录

-rw-r--r--  LICENSE
-rw-r--r--  README.md
drwxr-xr-x  build
-rw-r--r--  build.gradle
drwxr-xr-x  node_modules
-rw-r--r--  package.json
drwxr-xr-x  public
drwxr-xr-x  src
-rw-r--r--  yarn.lock

以及位于`client/src`下的以下文件

-rw-r--r--  App.css
-rw-r--r--  App.js
-rw-r--r--  App.test.js
-rw-r--r--  config.js
-rw-r--r--  ice-cream-entry.js
-rw-r--r--  ice-cream-list.js
-rw-r--r--  ice-cream-row.js
-rw-r--r--  index.css
-rw-r--r--  index.js
-rw-r--r--  login.js
-rw-r--r--  main.js

我们只需编辑其中 2 个`src`文件,并更新我们的`package.json`,即可将 React 应用程序与我们新的 Grails 后端连接起来。

首先,如下所示编辑`client/package.json`

/client/package.json
{
  "name": "ice-cream-app",
  "version": "0.1.0",
  "private": true,
  "devDependencies": {
    "eslint": "^3.17.1",
    "eslint-plugin-flowtype": "^2.30.3",
    "eslint-plugin-react": "^6.10.0",
    "react-scripts": "0.8.4"
  },
  "dependencies": {
    "react": "^15.4.2",
    "react-dom": "^15.4.2",
    "sockjs-client": "^1.1.4",
    "stompjs": "^2.3.3"
  },
  "scripts": {
    "build": "react-scripts build",
    "coverage": "npm test -- --coverage",
    "lint": "eslint src/**/*.js server/**/*.js",
    "start": "react-scripts start",
    "test": "react-scripts test --env=jsdom"
  }
}

我们已删除 Node Express 服务器所需的一些包,并使用 Spring Websockets 库支持`socket.io`包,`sockjs-client`和`stompjs`,我们之前已对其进行了配置(`socket.io`专为 Node 应用程序设计,与 Spring Websockets 不兼容)。

我们还需要编辑`config.js`文件。这是 Grails 提供的 React 配置文件,它仅仅存储一些配置变量,包括设置 API 基本 URL 的`SERVER_URL`值。如下所示编辑该文件

/client/src/config.js
import pjson from './../package.json';

export const SERVER_URL = 'http://localhost:8080';
export const CLIENT_VERSION = pjson.version;
export const REACT_VERSION = pjson.dependencies.react;

现在,我们必须更新我们冰淇淋应用程序中的两个 React 组件,`Login`和`App`。

如下所示编辑`login.js`文件(与 原始代码 的更改用`//NEW:`注释标记)。

/client/src/login.js
import React, {Component, PropTypes as t} from 'react';
import 'whatwg-fetch';

function onChangePassword(event) {
  React.setState({password: event.target.value});
}

function onChangeUsername(event) {
  React.setState({username: event.target.value});
}

class Login extends Component {
  static propTypes = {
    password: t.string.isRequired,
    restUrl: t.string.isRequired,
    username: t.string.isRequired,
    timeoutHandler: t.func //NEW: propType for our timeoutHander function
  };

  // This is called when the "Log In" button is pressed.
  onLogin = async () => {
    const {password, restUrl, username, timeoutHandler} = this.props;
    const url = `${restUrl}/login`;

    try {
      // Send username and password to login REST service.
      const res = await fetch(url, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({username, password})
      });

      if (res.ok) { // successful login
        const text = await res.text(); // returns a promise

        //NEW: Spring Security REST returns the token in the response body, not the Authorization header
        const token = `Bearer ${JSON.parse(text).access_token}`;

        React.setState({
          authenticated: true,
          error: null, // clear previous error
          route: 'main',
          token
        });

        timeoutHandler(username) //NEW: Connects to a user-specific websocket channel

      } else { //NEW: Any error response from the server indicates a failed login
        const msg = "Invalid username or password";
        React.setState({error: msg});
      }
    } catch (e) {
      React.setState({error: `${url}; ${e.message}`});
    }
  }

  // This is called when the "Signup" button is pressed.
  onSignup = async () => {
    const {password, restUrl, username, timeoutHandler} = this.props;
    const url = `${restUrl}/signup`;

    try {
      const res = await fetch(url, {
        method: 'POST',
        headers: {'Content-Type': 'application/json'},
        body: JSON.stringify({username, password})
      });

      if (res.ok) { // successful signup
        const text = await res.text(); // returns a promise
        const token = `Bearer ${JSON.parse(text).access_token}`; //NEW: See above

        React.setState({
          authenticated: true,
          error: null, // clear previous error
          route: 'main',
          token
        });

        timeoutHandler(username) //NEW: Connect to user-specific websocket channel


      } else { // unsuccessful signup
        let text = await res.text(); // returns a promise
        if (/duplicate key/.test(text)) {
          text = `User ${username} already exists`;
        }
        React.setState({error: text});
      }
    } catch (e) {
      React.setState({error: `${url}; ${e.message}`});
    }
  };

  render() {
    const {password, username} = this.props;
    const canSubmit = username && password;

    // We are handling sending the username and password
    // to a REST service above, so we don't want
    // the HTML form to submit anything for us.
    // That is the reason for the call to preventDefault.
    return (
      <form className="login-form"
            onSubmit={event => event.preventDefault()}>
        <div className="row">
          <label>Username:</label>
          <input type="text"
                 autoFocus
                 onChange={onChangeUsername}
                 value={username}
          />
        </div>
        <div className="row">
          <label>Password:</label>
          <input type="password"
                 onChange={onChangePassword}
                 value={password}
          />
        </div>
        <div className="row submit">
          {/* Pressing enter in either input invokes the first button. */}
          <button disabled={!canSubmit} onClick={this.onLogin}>
            Log In
          </button>
          <button disabled={!canSubmit} onClick={this.onSignup}>
            Signup
          </button>
        </div>
      </form>
    );
  }
}

export default Login;

最后,我们可以编辑`App.js`文件,以支持我们新的 websocket 通道,以及我们的`SERVER_URL`配置设置。与上面类似,所有相对于 原始代码 的更改都用`NEW`注释标记。

/client/src/App.js
import React, {Component} from 'react';
import Login from './login';
import Main from './main';
import 'whatwg-fetch'; // for REST calls
import {SERVER_URL} from './config'; //NEW: Base url for REST calls
import './App.css';

// This allows the client to listen to sockJS events
// emitted from the server.  It is used to proactively
// terminate sessions when the session timeout expires.
import SockJS from 'sockjs-client'; //NEW: SockJS & Stomp instead of socket.io
import Stomp from 'stompjs';

class App extends Component {
  constructor() {
    super();

    // Redux is a popular library for managing state in a React application.
    // This application, being somewhat small, opts for a simpler approach
    // where the top-most component manages all of the state.
    // Placing a bound version of the setState method on the React object
    // allows other components to call it in order to modify state.
    // Each call causes the UI to re-render,
    // using the "Virtual DOM" to make this efficient.
    React.setState = this.setState.bind(this);
  }

  //NEW: Remove the top-level websocket config in favor of user-specific channels (set on login)

  // This is the initial state of the application.
  state = {
    authenticated: false,
    error: '',
    flavor: '',
    iceCreamMap: {},
    password: '',
    restUrl: SERVER_URL, //NEW: Use our SERVER_URL variable
    route: 'login', // controls the current page
    token: '',
    username: ''
  };

  /**
   * NEW: Clears the token and redirects to the login page.
   * No logout API call is needed because JWT tokens
   * expire w/o state changes on the server */
  logout = async () => {
      React.setState({
        authenticated: false,
        route: 'login',
        password: '',
        username: ''
      });
  };

  /**
  * NEW: This function will be passed into the Login component as a prop
  * This gets a SockJS connection from the server
  * and subscribes to a "topic/[username]" channel for timeout events.
  * If one is received, the user is logged out. */
  timeoutHandler = (username) => {
    const socket = new SockJS(`${SERVER_URL}/stomp`);
    const client = Stomp.over(socket);

    client.connect({}, () => {
      client.subscribe(`/topic/${username}`, () => {
        alert('Your session timed out.');
        this.logout();
      });
    }, () => {
      console.error('unable to connect');
    });
  };

  render() {
    // Use destructuring to extract data from the state object.
    const {
      authenticated, error, flavor, iceCreamMap,
      password, restUrl, route, token, username
    } = this.state;

    return (
      <div className="App">
        <header>
          <img className="header-img" src="ice-cream.png" alt="ice cream" />
          Ice cream, we all scream for it!
          {
            authenticated ?
              <button onClick={this.logout}>Log out</button> :
              null
          }
        </header>
        <div className="App-body">
          {
            // This is an alternative to controlling routing to pages
            // that is far simpler than more full-blown solutions
            // like react-router.
            route === 'login' ?
              <Login
                username={username}
                password={password}
                restUrl={restUrl}
                timeoutHandler={this.timeoutHandler} //NEW: Pass our timeoutHandler as a prop to <Login>
              /> :
            route === 'main' ?
              <Main
                flavor={flavor}
                iceCreamMap={iceCreamMap}
                restUrl={restUrl}
                token={token}
                username={username}
              /> :
              <div>Unknown route {route}</div>
          }
          {
            // If an error has occurred, render it at the bottom of any page.
            error ? <div className="error">{error}</div> : null
          }
        </div>
      </div>
    );
  }
}

export default App;

5 运行应用程序

照如下所示运行服务器应用程序

~ ./gradlew server:bootRun
Grails application running at http://localhost:8443 in environment: development

照如下所示运行客户端应用程序(已启用 HTTPS)

~ export HTTPS = true
~ ./gradlew client:bootRun
Starting the development server...

Compiled successfully!

The app is running at:

  https://localhost:3000/

您应该能够浏览到 https://localhost:3000,创建新用户并登录。试用此应用!在您的用户帐户中添加/移除一些口味。一分钟后,您的用户会话将结束,您将被注销(您可以在 Grails 应用的 application.yml 文件中更改此时长)。如果您打开多个浏览器并在不同时间使用不同用户登录,则每个用户只有在自己的会话过期时才会被单独注销。

恭喜!您已成功用 Grails 后端运行 Ice Cream 应用。

5.1 部署

部署本身是一个大话题,我们不会对它进行深入探讨。与许多 Java Web 应用一样,我们的 Grails 后端可以作为 WAR 文件部署到任何 JEE 容器中(这将需要一些额外的配置),或者更干净地作为独立 JAR 文件部署(不需要独立服务器来运行)。

我们的 React 应用可以使用 npm run build(或 yarn build)构建,就像任何 create-react-app 项目,并部署到标准 Web 服务器。显然,必须注意确保 React 客户端可以访问 API 后端(可能需要据此设置 SERVER_URL 变量)。

将 Grails/React 应用打包并部署到单个可执行 JAR 文件中的诱人方式是将这两个应用捆绑在一起。Grails 指南中以教程形式介绍了此方法:“结合 React 配置文件项目”

5.2 摘要

当然,此项目有点不同寻常,因为它采用了使用一种技术堆栈设计的应用并使用另一种技术堆栈去重新实现。但是,希望此练习向您展示了 Grails 框架的能力和灵活性,特别是新 Grails 后端针对同一 React 应用所需的最小更改量。

Grails 提供了许多我们没有机会或时间在此应用中展示的强大 Web 开发功能 - 包括 JSON 视图HAL+JSON 支持自定义数据验证和约束NoSQL 支持多租户等等。请查看 Grails Guides 网站,了解有关使用 Grails 构建有趣且强大的应用的更多教程(包括多个 包含 React 的指南)。

6 Grails 帮助

Object Computing, Inc. (OCI) 赞助了本指南的创建。提供各种咨询和支持服务。

OCI 是 Grails 所在地

结识团队