精通 Grails: 使用 Ajax 实现多对多关系

 

软件开发就是使用代码来模拟现实世界。例如,书籍都有作者和出版商。在 Grails 应用程序中,要为每个元素创建一个域类。GORM 为每个类创建对应的数据库表,搭建功能(scaffolding)提供基本的 Create/Retrieve/Update/Delete (CRUD) Web 界面。

接下来定义这些类之间的关系。一个出版商通常会出版多部图书,因此出版商和他的图书之间的关系就是一个简单的一对多(1:m)关系:一个 Publisher 出版多个 Book。通过在 Publisher 类中加入 static hasMany = [books:Book],创建 1:m 关系。在 Book 类中放入 static belongsTo = Publisher,可以向关系添加另一个方面 —— 级联更新和删除。如果删除一个 Publisher,所有对应的 Book 也会被删除。

很容易在底层数据库中模拟 1:m 关系型。每个表有一个用作主键的 id 字段。当 GORM 向 book 表添加一个 publisher_id 字段时,就在两个表之间建立了一个 1:m 关系。在前端,Grails 也能够很好地处理 1:m 关系。创建一个新 Book 时,自动生成的(搭建而成)HTML 表单提供一个下拉组合框,将您的选择限制在现有的 Publisher 列表中。自本系列的 第一篇文章 以来,我们已经展示了许多 1:m 关系的示例。

现在来看看一个稍微复杂一些的关系 — 多对多(m:m)关系。与 BookPublisher 之间的关系相比,模拟 BookAuthor 之间的关系要复杂得多。一部图书可以有多个作者,一个作者也可以编写多部图书。这是一个典型的 m:m 关系。在真实世界中,m:m 关系很常见。一个人可以有多个支票帐号,一个支票帐号也可以由多个人来管理。一个顾问可以为多个项目工作,一个项目也可以有多个顾问。本文将向您展示如何使用 Grails 实现 m:m 关系,我们在本系列中开发的 trip-planner 应用程序上进行构建。在讨论 trip-planner 应用程序之前,我还想谈谈图书的例子,以帮助您理解一个要点。

第三个类

在数据库中,用三个表表示 m:m 关系:前两个表您已经知道了(BookAuthor),第三个是一个连接表(BookAuthor)。GORM 向 BookAuthor 连接表添加 book_idauthor_id,而不是向 BookAuthor 表添加一个外键。连接表允许将具有一个作者的图书和具有多个作者的图书持久化。它还能够表示编写多部图书的作者。AuthorBook 外键的每种惟一组合都会在连接表中有惟一的记录。这种方法具有极大的灵活性:一本书可以有任意数量的作者,而一个作者可以编写任意数目的图书。

Dierk Koenig 曾经告诉我,“如果您认为只有两个对象共享一个简单的多对多关系,那么您对多对多关系的了解还不够透彻。还存在第三个对象,这个对象有其自己的属性和生命周期”。确实如此,BookAuthor 之间的关系已经超出了简单连接表的范围。例如,Dierk 是 Groovy in Action(Manning Publications,2007 年 1 月)的第一作者。第一作者的身份应该表示为 AuthorBook 之间的关系中的一个字段。其他各种因素也应如此:作者会按特定顺序在封面上列出;每个作者编写图书的特定章节;而且每个作者的报酬也因贡献不同而异。您可以看到,AuthorBook 之间的关系比最初计划的更加微妙。在真实世界中,每个作者都会签订一份合同,使用明确的条款详细描述他与这部图书之间的关系。或许应该创建一个一级类 Contract 来更好地表示 BookAuthor 之间的关系。

简单来讲,这意味着一个 m:m 关系实际上就是两个 1:m 关系。如果两个类有可能共享一个 m:m 关系,那么您应该更深入地研究一下,确定出具有两个 1:m 关系的第三个类并显式地定义它。

模拟航空公司和机场

现在回到 trip-planner 应用程序,首先回顾一下域模型,并查看有没有潜在的 m:m 关系。在 第一篇文章 中,我创建了一个 Trip 类,如清单 1 所示:


清单 1. Trip

                
class Trip { 
  String name
  String city
  Date startDate
  Date endDate
}

第二篇文章 中,我添加了一个 Airline 类(如清单 2 所示),以演示一个简单的 1:m 关系:


清单 2. Airline

                
class Airline { 
  static hasMany = [trip:Trip]
  String name
  String frequentFlier
}

这些类在当时具有其特定的用途 — 用于说明某一点的简单占位符 — 但是它们并不是一个严格的域模型。现在对最初的类进行改进,并添加一些更加健壮的内容。

我使用以前的方法创建了 Trip 类,因为当时看起来还不错。我当时说 “我正计划到芝加哥旅行” 或 “我计划在下个月 15 号到 20 号去纽约”。citystartDateendDate 这样的字段似乎是 Trip 的自然属性。但是,现在看来,Trip 可能还会涉及到更多因素。

我住在科罗拉多州丹佛市 — 美国联合航空公司的中心城市。这意味着我通常可以直接飞到最终的目的地,但有时候需要中转多次。有时候一次旅行涉及到多个城市:“我正飞往波士顿,星期一到星期五我要在那里教课。当我在东海岸时,我需要在华盛顿附近参加星期六的一场会议。我将在星期天下午飞回来”。即使我幸运地找到了到一个特定城市的直达航班,而且我不会飞到其他城市,我的旅行涉及到的航班仍然不止一次 — 飞往目的地的航班和返回的航班。一个 Trip 可以包含多个 Flight。清单 3 定义 TripFlight 之间的关系:


清单 3. TripFlight 之间的 1:m 关系

                
class Trip{
  static hasMany = [flights:Flight]
  String name
}

class Flight{
  static belongsTo = Trip
  String flightNumber
  Date departureDate
  Date arrivalDate
}

请记住,使用 belongsTo 字段设置关系意味着,如果删除 Trip,也会删除所有相关的 Flight。如果我为空中交通管制员构建一个系统,我可能希望制定不同的架构决策。或者如果我尝试为同乘一个航班的多位乘客构建一个系统(一个 Flight 可以有多个 Passengers,一个 Passenger 也可以有多个 Flights),那么将一个航班绑定到一个特定的乘客可能是一个问题。但是我不会尝试为数百万乘客模拟全世界每天运行的数千次航班。在我的简单例子中,一个 Flight 的所有任务就是进一步描述一个 Trip。如果对于我来说,某个 Trip 不再重要,那么每个对应的 Flight 也是如此。

现在,我应该使用 Airline 类做什么呢?一个 Trip 可能涉及到多个不同的 Airline,而一个 Airline 又可以用于多个不同的 Trip。这两个类之间是一种明确的 m:m 关系,但是 Flight 似乎是添加 Airline 的恰当位置,如清单 4 所示。一个 Airline 可以有多个 Flight,而一个 Flight 只能有一个 Airline


清单 4. 将 AirlineFlight 关联

                
class Airline{
  static hasMany = [flights:Flight]
  String name
  String iata
  String frequentFlier
}

class Flight{
  static belongsTo = [trip:Trip, airline:Airline]
  String flightNumber
  Date departureDate
  Date arrivalDate
}

您应该注意到两点。首先,Flight 中的 belongsTo 字段由一个值转变为多个值的散列映射(hashmap)。一个 Trip 可以有多个 Flight,一个 Airline 也可以有多个 Flight

其次,我向 Airline 添加了一个新的 iata 字段。这个字段用于填写 International Air Transport Association (IATA) 编码。IATA 为每个航空公司分配了一个惟一的编码 — UAL 表示 United Airlines、COA 表示 Continental、DAL 表示 Delta,等等(参见 参考资料,获取 IATA 编码的完整列表)。

最后,您应该注意到我制定了另一个架构决策,这次加入了 Airline 和常飞顾客(frequent-flier)编号之间的关系。由于我假设只有一个用户使用这个系统,因此可以将 FrequentFlier 作为 Airline 类的一个属性。每个航空公司最多只有一个 frequent-flier 编号,所以这是最简单的可能的解决方案。如果这次旅行计划的需求发生了更改,而且我需要支持多个用户,那么就出现了另一个 m:m 关系。一个乘客可以有多个 frequent-flier 编号,一个航空公司也可以有多个 frequent-flier 编号。创建一个连接表来管理这个关系会非常适合。现在我将使用简单的解决方案,但是如果需求发生了更改,我会将 FrequentFlier 字段标记为未来的一个重构点。

城市还是机场?

现在将 City 添加到代码中 — 或许不用添加。尽管您可能会说,“飞往芝加哥” 理论上讲是飞往一个机场。我是飞往芝加哥的 O'Hare 机场还是 Midway 机场?当我飞往纽约时,是飞往 LaGuardia 还是 JFK?显然我需要一个 Airport 类来替代简单的 City 字段。清单 5 展示了 Airport 类:


清单 5. Airport

                
class Airport{
  static hasMany = [flights:Flight]
  String name
  String iata
  String city
  String state
  String country
}

在清单 5 中可以看到,iata 字段又回来了。这次 DEN 表示 Denver International Airport,ORD 表示 Chicago O'Hare,MDW 表示 Chicago Midway,等等。您也许想创建一个 State 类并设置一个简单的 1:m 关系,或者甚至创建一个 Location 类来封装 city、state 和 country。我将把这个困难的任务留给您自己来完成。

现在我将 Airport 添加到 Flight 类,如清单 6 所示:


清单 6. 将 Airport 关联到 Flight

                
class Flight{
  static belongsTo = [trip:Trip, airline:Airline]
  String flightNumber
  Date departureDate
  Airport departureAirport
  Date arrivalDate
  Airport arrivalAirport
}

但是,这一次我显式地创建 departureAirportarrivalAirport 字段,而不是隐式地使用 belongsTo 字段。用户界面看起来没有任何不同 — 这些字段都将使用组合框来显示 — 但是类之间的关系稍微有些不同。删除一个 Airport 不会连带删除相关联的 Flight,而删除一个 TripAirline 则会删除相关联的 Flight。我在此处提供了两种方法,以说明将各种类关联起来的不同方式。实际上,您可以自己决定是否希望类保持严格的引用完整性(换句话说,所有删除操作都是级联的)或者允许更松散的关系。

多对多关系的实际效用

现在,就绪的对象模型可以很好地模拟真实世界。我一年中要经历许多旅行,经历许多不同的航空公司,飞往许多不同的机场。将所有这些关系联系起来的就是一个 Flight

查看一下底层数据库,我只看到了期望看到的表,如清单 7 中的 MySQL show tables 命令的输出所示:


清单 7. 底层数据库表

                
mysql> show tables;
+----------------+
| Tables_in_trip |
+----------------+
| airline        | 
| airport        | 
| flight         | 
| trip           | 
+----------------+

airline、airport 和 trip 表中的所有列均与对应的域类中的字段匹配。flight 是连接表,表示其他表之间的复杂关系。清单 8 展示了 Flight 表中的字段:


清单 8. Flight 表中的字段

                
mysql> desc flight;
+----------------------+--------------+------+-----+
| Field                | Type         | Null | Key |
+----------------------+--------------+------+-----+
| id                   | bigint(20)   | NO   | PRI |
| version              | bigint(20)   | NO   |     |
| airline_id           | bigint(20)   | YES  | MUL |
| arrival_airport_id   | bigint(20)   | NO   | MUL |
| arrival_date         | datetime     | NO   |     |
| departure_airport_id | bigint(20)   | NO   | MUL |
| departure_date       | datetime     | NO   |     |
| flight_number        | varchar(255) | NO   |     |
| trip_id              | bigint(20)   | YES  | MUL |
+----------------------+--------------+------+-----+


用于创建新 Flight 的搭建的 HTML 页面为所有的相关表提供了组合框,如图 1 所示:


图 1. 用于添加航班而搭建的 HTML 页面
用于添加航班而搭建的 HTML 页面

调优用户界面

迄今为止,m:m 讨论的焦点一直围绕如何使用类和数据库表模拟关系。我希望您能够看到,科学就是一门艺术。作为 Grails 开发人员,您可以利用许多出色的技巧来改进关系的行为和副作用。现在将焦点转移到用户界面上,您将会看到一些非常优秀的方法,使用它们调整 m:m 关系的显示。

正如我在前一节中演示的,默认情况下,Grails 使用选择字段来显示 1:m 关系。这个起点还不错,但是您可能想在不同的环境中使用其他 HTML 控件。选择字段只显示当前值;您必须下拉该列表才能查看所有可能的值。尽管这在可用屏幕空间非常有限的情况下是最佳选择,但您可能会觉得使所有的选项都显示出来是一种更好的解决方案。单选按钮适合于显示所有可能的选择并将选择限制为单个值。复选框显示所有可能的选择并允许选择多个选项。

所有这些控件都适合显示数量有限的选择,但它们不能扩展到数百或数千个可能值。例如,如果我需要向最终用户提供全世界所有的航空公司(大约 650 家),没有一种标准 HTML 控件能够处理这么大的数据量。这时就需要开发人员做出判断了。对于这个应用程序,我不需要显示所有 650 家航空公司。我一生中飞过的不同航空公司可能还不到 12 家。在一些情况下,使用选择字段显示航空公司选项很可能就足够了。

要了解 Grails 如何为 Airline 创建选择字段,请输入 grails generate-views Flight。查看一下 grails-app/views/flight/create.gsp。选择字段是使用 <g:select> 标记在一行代码中生成的。如果不熟悉 Grails TagLibs,请参阅 上个月的文章。清单 9 展示了使用中的 <g:select> 字段:


清单 9. 使用中的 <g:select> 字段

                
<g:select optionKey="id" 
          from="${Airline.list()}" 
          name="airline.id" 
          value="${flight?.airline?.id}" ></g:select>

在 Web 浏览器中选择 View > Source 查看这是如何呈现的,如清单 10 所示:


清单 10. 呈现的选择字段

                
<select name="airline.id" id="airline.id" >
<option value="1" >UAL - United Airlines</option>
<option value="2" >DAL - Delta</option>
<option value="3" >COA - Continental</option>
</select>

<g:select> 标记的 optionKey 属性指定一个 类的哪个字段将会存储在关系的另一端的多个 字段的值中。Airline 表的主键(airline.id)会作为 Flight 表的外键。在选择字段中,请注意 airline.id 是可选值(调用 Airline.toString() 方法来显示值)。如果您想要更改选项的排列顺序,可以将 GORM 调用由 Airline.list() 更改为 Airline.listOrderByIata()Airline.listOrderByName() 或想要使用的任何其他字段。

使用 Ajax 处理大量选项

默认的选择控件是显示实际航空公司数量的不错选择。不幸的是,对于机场情况则有所不同。我在一年中可能会到达 40 或 50 个不同的机场。依我的经验,在一个字段中提供超过 15 或 20 个选择就有点令人讨厌了。

幸运的是,机场的 IATA 编码在行业中得到了广泛应用。我研究航班的时候会见到它们。在预订航班时也会 在收据上显示。甚至在机票上也能见到。与要求用户滚动查找数百个可能的机场相比,要求他们输入 IATA 编码一个不错的替代方法。

回顾一下我在本文开始部分介绍的 Book 示例。Amazon.com 在其主页上提供了显示所有库存图书的选择字段了吗?没有 — 它提供了一个文本字段,您可以在其中输入图书的标题、作者,如果您喜欢,甚至还可以输入国际标准图书编号(International Standard Book Number,ISBN)。我将在此处使用相同的技巧来处理 trip-planner 应用程序中的机场。

将控件由一个选择字段更改为一个文本字段非常简单。但是,在我继续使用这种解决方案的方法之前,我想要花一些时间处理它的语义。iata 字段是一个没有形式限制的文本字段,但是我不能对用户输入的任何值都接受(如果您写错了您的名字,应用程序不会责备您;但是如果您输错了 IATA 编码,它就需要给出警告)。我希望这种反馈回立即发生,因为没有什么事情比每次输入无效值之后重复提交整个 HTML 表单更令人沮丧。

所以,我不希望只是为了验证一个单个字段要在服务器间往返通信整个表单,或者每次都需要将数千个机场的 IATA 编码下载到客户端。该解决方案将数据保存在服务器上,并针对每个字段执行一个细粒度的 HTTP 请求,而不是对整个表单执行一个粗粒度的请求。这种技术称为执行 Ajax (Asynchronous JavaScript + XML) 请求(参见 参考资料,获取 Ajax 的介绍)。

要使我的 Grails 应用程序支持 Ajax,我需要对 AirportController 进行调整,以接受 Ajax 请求,还要对视图进行调整,以执行 Ajax 请求。我将从 AirportController 入手。

AirportController 已经拥有搭建好的闭包,用于返回一个 Airport 列表,并显示一个单独的 Airport。但是,这些现有的闭包返回的值是 HTML 格式的。我将添加一个返回原始数据的新闭包。一个选择是完全将 POGO 序列化,但是我的客户机是一个 Web 浏览器。不幸的是,JavaScript — 不是 Groovy — 才是 Web 浏览器支持的语言(Mozilla Foundation,您注意到了吗?)

Ajax 中的 x 提醒了我,我可以返回 XML。如果将 grails.converters 包导入 AirportController 中,返回 XML 只需要一行代码,如清单 11 所示:


清单 11. 从 controller 返回 XML

                
import grails.converters.*

class AirportController {
  def scaffold = Airport
  
  def getXml = {
    render Airport.findByIata(params.iata) as XML
  }  
}

这种解决方案的惟一问题是,JavaScript 对 XML 的原生支持要逊于 Groovy。对象关系映射器(比如 GORM)的好处在于,它可以将数据从非原生格式(存储在关系数据库中)无缝地转换为 Groovy。这个练习的 JavaScript 版本是将 Groovy 数据转换为 JavaScript Object Notation (JSON)(参见 参考资料)。幸运的是,与转换为 XML 代码一样,可以使用一行代码转换为 JSON。在清单 12 中,我向 getJson 闭包添加了一些错误处理,但在其他方面与 getXml 闭包等效:


清单 12. 从 controller 返回 JSON

                
def getJson = {
  def airport = Airport.findByIata(params.iata)
  
  if(!airport){
    airport = new Airport(iata:params.iata, name:"Not found")
  }
  
  render airport as JSON
}

要验证 JSON 转换是否生效,可以在 Web 浏览器中输入 http://localhost:9090/trip/airport/getJson?iata=den。应该会得到清单 13 中显示的响应(您也许需要在浏览器中选择 View > Source 查看 JSON 响应)。


清单 13. JSON 响应

                
{"id":1,"class":"Airport","city":
   "Denver","country":"US","iata":
"DEN","name":"Denver International Airport","state":"CO"}

返回一组航空公司的过程非常简单:render Airline.list() as JSON

现在生成了 JSON,是时候使用它了。我将把 departureAirport 的现有 <g:select> 注释掉,并替换为清单 14 中的 4 行代码:


清单 14. 使用一个文本字段替换选择字段

                
<div id="departureAirportText">[Type an Airport IATA Code]</div>
<input type="hidden" name="departureAirport.id" value="-1" 
   id="departureAirport.id"/>          
<input type="text" name="departureAirportIata" id="departureAirportIata"/>
<input type="button" value="Find" onClick="get('departureAirport')"/>

第一行是一个只读显示区域。注意,它具有一个 id。ID 在整个 HTML Document Object Model (DOM) 中必须是惟一的。稍后我将使用句柄 departureAirportText 写出 JSON 调用的结果。

提交表单时,<div> 不会被发送回服务器;但表单控件(比如输入和选择控件)会被发送回服务器。当整个表单提交到服务器时,隐藏的文本字段提供了一个存储 Airportid 的位置。

用户将在名为 departureAirportIata 的文本字段中输入 IATA 编码。为名称和 ID 都提供相同的值可能不是很好,但 HTML 机制需要这样做。提交表单时,名称将会传回到服务器。ID 是我调用 getJson 闭包的条件。

最后,最后一行代码是一个按钮,单击它时,会调用一个名为 get 的 JavaScript 函数。稍后我将会展示 get 函数的实现。图 2 展示了新表单的外观:


图 2. 改进的表单


将 Prototype 用于 Ajax 调用

Grails 附带了一个名为 Prototype 的 JavaScript 库(参见 参考资料)。Prototype 提供了一种执行 Ajax 调用的通用方法,这种方法兼容所有主流浏览器。get 函数构建您在之前输入浏览器的 URL,然后对服务器执行异步调用。如果调用成功(返回 HTTP 200),则会调用 update 函数。清单 15 使用 Prototype 进行 Ajax 调用:


清单 15. 将 Prototype 用于 Ajax 调用

                
<g:javascript library="prototype" />     
<script type="text/javascript">
  function get(airportField){
    var baseUrl = "${createLink(controller:'airport', action:'getJson')}"
    var url = baseUrl + "?iata=" + $F(airportField + "Iata")
    new Ajax.Request(url, {
      method: 'get',
      asynchronous: true,
      onSuccess: function(req) {update(req.responseText, airportField)}
    })
  }
  
  ...
</script> 

update 函数读取 JSON 调用的结果,更新 <div> 的显示,并将隐藏字段的值更改为机场的主键(如果找到的话),或者更改为 -1(如果未找到主键)。清单 16 展示了 update 函数:


清单 16. 使用 JSON 数据更新字段

                
function update(json, airportField){
  var airport = eval( "(" + json + ")" )
  var output = $(airportField + "Text")
  output.innerHTML = airport.iata + " - " + airport.name
  var hiddenField = $(airportField + ".id")
  airport.id == null ? hiddenField.value = -1 : hiddenField.value = airport.id
}

图 3 展示了两次成功执行 Ajax 调用之后 Flight 表单的外观:


图 3. 通过 Ajax 调用填充的表单


客户端验证

最后,还需要执行一些客户端验证,以确保 departureAirportarrivalAirport 的无效值不会提交回服务器(事实上,在为用户提供选择字段或一组复选框时,不可能输入无效值。由于我允许用户输入任意格式的文本,所以我需要留意他们的输入质量)。

onSubmit 添加到 g:form 标记:

<g:form action="save" method="post" οnsubmit="return validate()" >

如果 validate 返回 true,则表单被提交到服务器。如果返回 false,则提交被取消。清单 17 展示了 validate 函数:


清单 17. validate 函数

                
function validate(){
  if( $F("departureAirport.id") == -1 ){
    alert("Please supply a valid Departure Airport")
    return false
  }
  
  if( $F("arrivalAirport.id") == -1 ){
    alert("Please supply a valid Arrival Airport")
    return false
  }
  
  return true
}

如果您觉得将选择字段转换为文本字段需要更多的工作,我同意您的观点。我没有执行这个更改以便更容易操作 — 我努力使最终用户更容易操作。但是请注意:Grails 提供的搭建功能为我了做了很多初始工作,我可以对各个部分进行一些调优。搭建功能并不意味着是全部完成。它只是避免了所有令人厌烦的事情,您可以专注于更有趣的工作。


 

结束语

除了简单的多对多关系以外,希望您还能从本文了解一些创建多对多关系的出色技巧。有时候您希望删除操作能够实现级联;有时候不希望这样。任何时候您认为两个类之间存在着简单的 m:m 关系时,可能还需要从中发现第三个类。

在表示方面,选择字段是 m:m 关系的默认选择,但它们不是惟一的选择。如果选项很少,那么单选按钮和复选框也是值得考虑的选择。如果选项很多,文本字段和 Ajax 能够很好地完成任务。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值