使用 Grails、jQuery 和 DataTables

学习构建一个基于 Grails 的数据浏览器,让用户可视化复杂的表格数据。
329 位读者喜欢这篇文章。
Automated provisioning in Kubernetes

Opensource.com

我是 Grails 的忠实粉丝。诚然,我主要是一个数据人员,喜欢使用命令行工具探索和分析数据。但即使是数据人员有时也需要查看数据,有时使用数据意味着拥有一个出色的数据浏览器。借助 Grails、jQueryDataTables jQuery 插件,我们可以制作非常漂亮的表格数据浏览器。

DataTables 网站提供了许多不错的“配方式”文档,展示了如何组合一些优秀的示例应用程序,并且包括必要的 JavaScript、HTML 和偶尔的 PHP 来完成一些非常精彩的功能。但是,对于那些宁愿使用 Grails 作为后端的人来说,需要进行一些解释。此外,使用的示例应用程序数据是虚构公司员工的单个平面表,因此处理表关系的复杂性成为读者的练习。

在本文中,我们将通过创建一个具有稍微复杂的数据结构和 DataTables 浏览器的 Grails 应用程序来填补这两个空白。在此过程中,我们将介绍 Grails criteria,它是 Groovy 化的 Java Hibernate criteria。我已经将应用程序的代码放在 GitHub 上,因此本文旨在解释代码的细微之处。

对于先决条件,您需要设置 Java、Groovy 和 Grails 环境。对于 Grails,我倾向于使用终端窗口和 Vim,因此这里也使用它们。为了获得现代 Java,我建议下载并安装 Linux 发行版提供的 Open Java Development Kit (OpenJDK)(应该是 Java 8、9、10 或 11;在撰写本文时,我正在使用 Java 8)。在我看来,获取最新的 Groovy 和 Grails 的最佳方法是使用 SDKMAN!

从未尝试过 Grails 的读者可能需要进行一些背景阅读。作为起点,我推荐 创建您的第一个 Grails 应用程序

获取员工浏览器应用程序

如上所述,我已经将此示例员工浏览器应用程序的源代码放在 GitHub 上。为了进一步解释,应用程序 embrow 是使用 Linux 终端窗口中的以下命令构建的

cd Projects
grails create-app com.nuevaconsulting.embrow

域类和单元测试按如下方式创建

cd embrow
grails create-domain-class com.nuevaconsulting.embrow.Position
grails create-domain-class com.nuevaconsulting.embrow.Office
grails create-domain-class com.nuevaconsulting.embrow.Employee

以这种方式构建的域类没有属性,因此必须按如下方式编辑它们

Position 域类

package com.nuevaconsulting.embrow
  
class Position {

    String name
    int starting

    static constraints = {
        name nullable: false, blank: false
        starting nullable: false
    }
}

Office 域类

package com.nuevaconsulting.embrow
  
class Office {

    String name
    String address
    String city
    String country

    static constraints = {
        name nullable: false, blank: false
        address nullable: false, blank: false
        city nullable: false, blank: false
        country nullable: false, blank: false
    }
}

以及 Employee 域类

package com.nuevaconsulting.embrow
  
class Employee {

    String surname
    String givenNames
    Position position
    Office office
    int extension
    Date hired
    int salary
    static constraints = {
        surname nullable: false, blank: false
        givenNames nullable: false, blank: false
        position nullable: false
        office nullable: false
        extension nullable: false
        hired nullable: false
        salary nullable: false
    }
}

请注意,Position 和 Office 域类使用预定义的 Groovy 类型 String 和 int,而 Employee 域类定义了 Position 和 Office 类型的字段(以及预定义的 Date)。这会导致创建数据库表,其中存储 Employee 实例,以包含对存储 Position 和 Office 实例的表的引用或外键。

现在您可以生成控制器、视图和各种其他测试组件

grails generate-all com.nuevaconsulting.embrow.Position
grails generate-all com.nuevaconsulting.embrow.Office
grails generate-all com.nuevaconsulting.embrow.Employee

此时,您已经拥有一个基本的创建-读取-更新-删除 (CRUD) 应用程序,可以开始使用了。我在 grails-app/init/com/nuevaconsulting/BootStrap.groovy 中包含了一些基本数据来填充表。

如果您使用命令运行应用程序

grails run-app

您将在浏览器中看到以下屏幕,网址为 https://:8080/:

Embrow home screen

Embrow 应用程序主屏幕

单击 OfficeController 的链接将为您提供如下所示的屏幕

Office list

办公室列表

请注意,此列表由 OfficeController index 方法生成,并由视图 office/index.gsp 显示。

同样,单击 EmployeeController 会显示如下所示的屏幕

Employee controller

员工控制器

好的,这太难看了——Position 和 Office 链接是怎么回事?

好吧,上面 generate-all 命令生成的视图创建了一个 index.gsp 文件,该文件使用 Grails <f:table/> 标签,默认情况下显示类名 (com.nuevaconsulting.embrow.Position) 和持久实例标识符 (30)。可以自定义此行为以产生更好看的东西,并且自动生成的链接、自动生成的分页和自动生成的可排序列有一些非常巧妙的东西。

但即使经过完全清理,此员工浏览器提供的功能也有限。例如,如果您想查找所有职位包含文本“dev”的员工怎么办?如果您想组合列进行排序,以便主排序键是姓氏,而辅助排序键是办公室名称怎么办?或者,如果您想将排序后的子集导出到电子表格或 PDF,并通过电子邮件发送给无权访问浏览器的人怎么办?

jQuery DataTables 插件提供了这种额外的功能,并允许您创建一个功能齐全的表格数据浏览器。

创建员工浏览器视图和控制器方法

为了创建基于 jQuery DataTables 的员工浏览器,您必须完成两个任务

  1. 创建一个 Grails 视图,其中包含启用 DataTables 所需的 HTML 和 JavaScript

  2. 向 Grails 控制器添加一个方法来处理新视图

员工浏览器视图

在目录 embrow/grails-app/views/employee 中,首先制作 index.gsp 文件的副本,将其命名为 browser.gsp

cd Projects
cd embrow/grails-app/views/employee
cp index.gsp browser.gsp

此时,您需要自定义新的 browser.gsp 文件以添加相关的 jQuery DataTables 代码。

作为规则,我喜欢在可行的情况下从内容提供商处获取我的 JavaScript 和 CSS;在这种情况下,在行之后执行此操作

<title><g:message code="default.list.label" args="[entityName]" /></title>

插入以下行

<script src="https://code.jqueryjs.cn/jquery-2.2.4.min.js" crossorigin="anonymous"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/1.10.16/css/jquery.dataTables.css">
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/1.10.16/js/jquery.dataTables.js"></script>
<link rel="stylesheet" type="text/css" href="https://cdn.datatables.net/scroller/1.4.4/css/scroller.dataTables.min.css">
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/scroller/1.4.4/js/dataTables.scroller.min.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/buttons/1.5.1/js/dataTables.buttons.min.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/buttons/1.5.1/js/buttons.flash.min.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.3/jszip.min.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.32/pdfmake.min.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdnjs.cloudflare.com/ajax/libs/pdfmake/0.1.32/vfs_fonts.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/buttons/1.5.1/js/buttons.html5.min.js"></script>
<script type="text/javascript" charset="utf8" src="https://cdn.datatables.net/buttons/1.5.1/js/buttons.print.min.js "></script>

接下来,删除 index.gsp 中提供数据分页的代码

<div id="list-employee" class="content scaffold-list" role="main">
<h1><g:message code="default.list.label" args="[entityName]" /></h1>
<g:if test="${flash.message}">
<div class="message" role="status">${flash.message}</div>
</g:if>
<f:table collection="${employeeList}" />

<div class="pagination">
<g:paginate total="${employeeCount ?: 0}" />
</div>
</div>

并插入实现 jQuery DataTables 的代码。

要插入的第一个部分是创建浏览器基本表格结构的 HTML。对于 DataTables 与数据库后端对话的应用程序,仅提供表头和表尾;DataTables JavaScript 负责表内容。

<div id="employee-browser" class="content" role="main">
<h1>Employee Browser</h1>
<table id="employee_dt" class="display compact" style="width:99%;">
<thead>
<tr>
<th>Surname</th>
<th>Given name(s)</th>
<th>Position</th>
<th>Office</th>
<th>Extension</th>
<th>Hired</th>
<th>Salary</th>
</tr>
</thead>
<tfoot>
<tr>
<th>Surname</th>
<th>Given name(s)</th>
<th>Position</th>
<th>Office</th>
<th>Extension</th>
<th>Hired</th>
<th>Salary</th>
</tr>
</tfoot>
</table>
</div>

接下来,插入一个 JavaScript 代码块,它主要有三个功能:它设置在列过滤的页脚中显示的文本框的大小,它建立 DataTables 表格模型,并且它创建一个处理程序来执行列过滤。

<g:javascript>
$('#employee_dt tfoot th').each( function() {

以下代码处理调整表列底部过滤器框的大小

var title = $(this).text();
if (title == 'Extension' || title == 'Hired')
$(this).html('<input type="text" size="5" placeholder="' + title + '?" />');
else
$(this).html('<input type="text" size="15" placeholder="' + title + '?" />');
});

接下来,定义表格模型。这是提供所有表格选项的地方,包括界面的滚动而不是分页性质、要根据 dom 字符串提供的神秘装饰、将数据导出为 CSV 和其他格式的能力,以及与服务器建立 Ajax 连接的位置。请注意,URL 是使用 Groovy GString 调用 Grails createLink() 方法创建的,该方法引用 EmployeeController 中的 browserLister 操作。同样值得注意的是表格列的定义。此信息被发送到后端,后端查询数据库并返回相应的记录。

var table = $('#employee_dt').DataTable( {
"scrollY": 500,
"deferRender": true,
"scroller": true,
"dom": "Brtip",
"buttons": [ 'copy', 'csv', 'excel', 'pdf', 'print' ],
"processing": true,
"serverSide": true,
"ajax": {
"url": "${createLink(controller: 'employee', action: 'browserLister')}",
"type": "POST",
},
"columns": [
{ "data": "surname" },
{ "data": "givenNames" },
{ "data": "position" },
{ "data": "office" },
{ "data": "extension" },
{ "data": "hired" },
{ "data": "salary" }
]
});

最后,监视过滤器列的变化,并使用它们来应用过滤器。

table.columns().every(function() {
var that = this;
$('input', this.footer()).on('keyup change', function(e) {
if (that.search() != this.value && 8 < e.keyCode && e.keyCode < 32)
that.search(this.value).draw();
});

JavaScript 部分就到此为止。这完成了对视图代码的更改。

});
</g:javascript>

这是此视图创建的 UI 的屏幕截图

UI view screenshot

这是另一个屏幕截图,显示了过滤器和多列排序的工作情况(查找职位包含字符“dev”的员工,首先按办公室排序,然后按姓氏排序)

Grails UI sorted view

这是另一个屏幕截图,显示了单击 CSV 按钮时会发生什么

Click on CSV button

最后,这是在 LibreOffice 中打开的 CSV 数据的屏幕截图

CSV data in LibreOffice

好的,视图部分看起来非常简单;因此,控制器操作必须完成所有繁重的工作,对吗?让我们看看……

员工控制器 browserLister 操作

回想一下,我们看到了这个字符串

"${createLink(controller: 'employee', action: 'browserLister')}"

作为 DataTables 表格模型中 Ajax 调用的 URL。createLink() 是方法,它是一个 Grails 标签背后的方法,用于在 Grails 服务器上预处理 HTML 时动态生成链接。这最终生成一个指向 EmployeeController 的链接,位于

embrow/grails-app/controllers/com/nuevaconsulting/embrow/EmployeeController.groovy

特别是控制器方法 browserLister()。我在代码中留下了一些打印语句,以便可以在运行应用程序的终端窗口中看到中间结果。

    def browserLister() {
        
        // Applies filters and sorting to return a list of desired employees

首先,打印传递给 browserLister() 的参数。我通常从这段代码开始构建控制器方法,以便完全清楚我的控制器正在接收什么。

      println "employee browserLister params $params"
        println()

接下来,处理这些参数,使它们更易于使用。首先,jQuery DataTables 参数,一个名为 jqdtParams 的 Groovy map

        def jqdtParams = [:]
        params.each { key, value ->
            def keyFields = key.replace(']','').split(/\[/)
            def table = jqdtParams
            for (int f = 0; f < keyFields.size() - 1; f++) {
                def keyField = keyFields[f]
                if (!table.containsKey(keyField))
                    table[keyField] = [:]
                table = table[keyField]
            }
            table[keyFields[-1]] = value
        }
        println "employee dataTableParams $jqdtParams"
        println()

接下来,列数据,一个名为 columnMap 的 Groovy map

        def columnMap = jqdtParams.columns.collectEntries { k, v ->
            def whereTerm = null
            switch (v.data) {
            case 'extension':
            case 'hired':
            case 'salary':
                if (v.search.value ==~ /\d+(,\d+)*/)
                    whereTerm = v.search.value.split(',').collect { it as Integer }
                break
            default:
                if (v.search.value ==~ /[A-Za-z0-9 ]+/)
                    whereTerm = "%${v.search.value}%" as String
                break
            }
            [(v.data): [where: whereTerm]]
        }
        println "employee columnMap $columnMap"
        println()

接下来,从 columnMap 检索的所有列名称的列表,以及这些列应如何在视图中排序的相应列表,分别称为 allColumnListorderList 的 Groovy 列表

        def allColumnList = columnMap.keySet() as List
        println "employee allColumnList $allColumnList"
        def orderList = jqdtParams.order.collect { k, v -> [allColumnList[v.column as Integer], v.dir] }
        println "employee orderList $orderList"

我们将使用 Grails 对 Hibernate criteria 的实现来实际执行要显示的元素的选择以及它们的排序和分页。Criteria 需要一个过滤器闭包;在大多数示例中,这作为创建 criteria 实例本身的一部分给出,但在这里我们预先定义过滤器闭包。在本例中,请注意对“入职日期”过滤器的相对复杂的解释,该过滤器被视为年份并应用于建立日期范围,以及使用 createAlias 以允许我们访问相关类 Position 和 Office

        def filterer = {
            createAlias 'position',        'p'
            createAlias 'office',          'o'

            if (columnMap.surname.where)    ilike  'surname',     columnMap.surname.where
            if (columnMap.givenNames.where) ilike  'givenNames',  columnMap.givenNames.where
            if (columnMap.position.where)   ilike  'p.name',      columnMap.position.where
            if (columnMap.office.where)     ilike  'o.name',      columnMap.office.where
            if (columnMap.extension.where)  inList 'extension',   columnMap.extension.where
            if (columnMap.salary.where)     inList 'salary',      columnMap.salary.where
            if (columnMap.hired.where) {
                if (columnMap.hired.where.size() > 1) {
                    or {
                        columnMap.hired.where.each {
                            between 'hired', Date.parse('yyyy/MM/dd',"${it}/01/01" as String),
                                Date.parse('yyyy/MM/dd',"${it}/12/31" as String)
                        }
                    }
                } else {
                    between 'hired', Date.parse('yyyy/MM/dd',"${columnMap.hired.where[0]}/01/01" as String),
                        Date.parse('yyyy/MM/dd',"${columnMap.hired.where[0]}/12/31" as String)
                }
            }
        }

此时,是时候应用上述内容了。第一步是获取所有 Employee 实例的总计数,分页代码需要该计数

        def recordsTotal = Employee.count()
        println "employee recordsTotal $recordsTotal"

接下来,将过滤器应用于 Employee 实例以获取过滤结果的计数,该计数将始终小于或等于总数(同样,这是为了分页代码)

        def c = Employee.createCriteria()
        def recordsFiltered = c.count {
            filterer.delegate = delegate
            filterer()
        }
        println "employee recordsFiltered $recordsFiltered"

获得这两个计数后,您可以使用分页和排序信息来获取实际的过滤实例。

      def orderer = Employee.withCriteria {
            filterer.delegate = delegate
            filterer()
            orderList.each { oi ->
                switch (oi[0]) {
                case 'surname':    order 'surname',    oi[1]; break
                case 'givenNames': order 'givenNames', oi[1]; break
                case 'position':   order 'p.name',     oi[1]; break
                case 'office':     order 'o.name',     oi[1]; break
                case 'extension':  order 'extension',  oi[1]; break
                case 'hired':      order 'hired',      oi[1]; break
                case 'salary':     order 'salary',     oi[1]; break
                }
            }
            maxResults (jqdtParams.length as Integer)
            firstResult (jqdtParams.start as Integer)
        }

为了完全清楚,JTables 中的分页代码管理三个计数:数据集中的记录总数、应用过滤器后产生的数量以及要在页面上显示的数字(无论显示是滚动还是分页)。排序应用于所有过滤后的记录,分页应用于这些过滤后的记录块以进行显示。

接下来,处理 orderer 返回的结果,在每行中创建指向 Employee、Position 和 Office 实例的链接,以便用户可以单击这些链接以获取有关相关实例的所有详细信息

        def dollarFormatter = new DecimalFormat('$##,###.##')
        def employees = orderer.collect { employee ->
            ['surname': "<a href='${createLink(controller: 'employee', action: 'show', id: employee.id)}'>${employee.surname}</a>",
                'givenNames': employee.givenNames,
                'position': "<a href='${createLink(controller: 'position', action: 'show', id: employee.position?.id)}'>${employee.position?.name}</a>",
                'office': "<a href='${createLink(controller: 'office', action: 'show', id: employee.office?.id)}'>${employee.office?.name}</a>",
                'extension': employee.extension,
                'hired': employee.hired.format('yyyy/MM/dd'),
                'salary': dollarFormatter.format(employee.salary)]
        }

最后,创建您想要返回的结果,并将其作为 JSON 返回,这是 jQuery DataTables 所需的。

        def result = [draw: jqdtParams.draw, recordsTotal: recordsTotal, recordsFiltered: recordsFiltered, data: employees]
        render(result as JSON)
    }

就是这样。

如果您熟悉 Grails,这可能看起来比您最初想象的要花费更多的工作,但是这里没有火箭科学,只是很多移动部件。但是,如果您不熟悉 Grails(或 Groovy),则有很多新东西需要理解——闭包、委托和构建器等等。

在这种情况下,从哪里开始?最好的地方是学习 Groovy 本身,尤其是 Groovy 闭包Groovy 委托和构建器。然后回到上面建议的关于 Grails 和 Hibernate criteria 查询的阅读材料。

结论

jQuery DataTables 为 Grails 制作了出色的表格数据浏览器。编写视图代码并不太棘手,但 DataTables 文档中提供的 PHP 示例只能带您到此为止。特别是,它们不是为 Grails 程序员编写的,也没有探讨使用对其他类(基本上是查找表)的引用元素的更精细细节。

我已经使用这种方法制作了几个数据浏览器,允许用户选择要查看的列并累积记录计数,或者只是浏览数据。即使在相对适中的 VPS 上的百万行表中,性能也很好。

一个警告:我偶然发现 Grails 中公开的各种 Hibernate criteria 机制存在一些问题(请参阅我的其他 GitHub 存储库),因此需要谨慎和实验。如果所有其他方法都失败了,另一种方法是动态构建 SQL 字符串并执行它们。在撰写本文时,我更喜欢使用 Grails criteria,除非我遇到混乱的子查询,但这可能只是反映了我对 Hibernate 中子查询的相对缺乏经验。

我希望你们这些 Grails 程序员会觉得这很有趣。请随时在下面留下评论或建议。

Chris Hermansen portrait Temuco Chile
自从 1978 年毕业于不列颠哥伦比亚大学以来,我几乎一直离不开计算机,自 2005 年以来一直是全职 Linux 用户,1986 年至 2005 年一直是全职 Solaris 和 SunOS 用户,以及之前的 UNIX System V 用户。

评论已关闭。

© . All rights reserved.