自本系列的 第一篇文章 开始,我就一直在构建一个 trip-planner 应用程序。目前基本的模型-视图-控制器(Model-View-Controller,MVC)框架已经准备就绪,我将加入一些外部技术,具体来讲,我将加入地图功能。虽然我可以表示 “我的旅程是从丹佛到罗利,途经圣何塞和西雅图”,但地图将能更好地描述旅途路线。您可能知道西雅图和罗利是在美国的两端,但地图能够帮助您显示出两个城市之间的距离。
这个应用程序有什么用?本文的末尾为您提供一个大体的介绍。请访问 http://maps.google.com 并在搜索框内输入 IATA 代码 DEN
。将出现丹佛国际机场(Denver International Airport),如图 1 所示
除了能显示您在 HTML 表创建的美国机场以外,trip planner 还将在地图上把机场描绘出来。在本文中,我将使用免费的 Google Maps API。我还可以使用免费的 Yahoo! Maps API,等等(参见 参考资料)。一旦了解在线 Web 地图绘制的基本原理之后,您将发现不同的 API 之间能够合理地互换。在讨论该解决方案的地图绘制部分之前,您需要了解如何将一个简单的三个字母的字符串(如 DEN)转换为地图上的一点。
当向 Google Map 输入 DEN 时,这个应用程序在幕后进行了一些转换。您可能用街道地址(如 123 Main Street)的方式想象地理位置,但 Google Map 需要一个纬度/经度点,以便在地图上把它显示出来。这并不需要您自己设法提供纬度/经度点,应用程序会替您把人类能够识别的地址转换为纬度/经度点。这一转换过程称为地理编码(参见 参考资料)。
|
浏览 Web 时,也会发生一个类似的转换。从技术角度来说,联系远程 Web 服务器的惟一方式是提供服务器的 IP 地址。幸运的是,您不需要自己输入 IP 地址。只要将友好的 URL 输入到 Web 浏览器,它将调用域名系统(DNS)服务器。DNS 服务器会将 URL 转换为对应的 IP 地址,然后浏览器与远程服务器建立 HTTP 连接。所有这些对用户而言都是透明的。DNS 使 Web 的使用容易了很多。同样,地理编码器也使基于 Web 的地图绘制应用程序更加容易使用。
在 Web 上快速搜索免费地理编码器 会产生许多符合 trip planner 地理编码需求的结果。Google 和 Yahoo! 都提供地理编码服务,并把它作为 API 的标准部分,但针对这个应用程序,我将使用由 geonames.org(参见 参考资料)提供的免费地理编码服务。它的 RESTful API 允许我指明我提供的是 IATA 代码,而不是通用的文本搜索术语。比如,ORD 并不是指内布拉斯加州 Ord. 市的居民,ORD 指的是 Chicago O'Hare International Airport。
在 Web 浏览器中输入 URL http://ws.geonames.org/search?name_equals=den&fcode=airp&style=full
。您将看到 XML 响应,如清单 1 所示:
- <geonames style="FULL">
- <totalResultsCount>1</totalResultsCount>
- <geoname>
- <name>Denver International Airport</name>
- <lat>39.8583188</lat>
- <lng>-104.6674674</lng>
- <geonameId>5419401</geonameId>
- <countryCode>US</countryCode>
- <countryName>United States</countryName>
- <fcl>S</fcl>
- <fcode>AIRP</fcode>
- <fclName>spot, building, farm</fclName>
- <fcodeName>airport</fcodeName>
- <population/>
- <alternateNames>DEN,KDEN</alternateNames>
- <elevation>1655</elevation>
- <continentCode>NA</continentCode>
- <adminCode1>CO</adminCode1>
- <adminName1>Colorado</adminName1>
- <adminCode2>031</adminCode2>
- <adminName2>Denver County</adminName2>
- <alternateName lang="iata">DEN</alternateName>
- <alternateName lang="icao">KDEN</alternateName>
- <timezone dstOffset="-6.0" gmtOffset="-7.0">America/Denver</timezone>
- </geoname>
- </geonames>
您在 URL 中输入的 name_equals
参数是该机场的 IATA 代码。这只是在每个查询中需要更改的 URL 的一部分。fcode=airp
表明您正在搜索的特征代码是一个机场。style
参数 — short
、medium
、long
或 full
— 指定了 XML 响应的详细程度。
现在已经准备好地理编码器,下一步就是将它与 Grails 应用程序集成在一起。为此,您需要一个服务。
到目前为止,通过学习 精通 Grails 系列文章,您应该已经明白域类、控制器和 Groovy 服务器页面(Groovy Server Pages,GSP 是如何协调工作的。它们简化了在单一数据类型上执行基本的创建/检索/更新/删除(Create/Retrieve/Update/Delete,CRUD)操作。这个地理编码服务似乎略微超出了简单 Grails Object Relational Mapping(GORM)转换(从关系数据库记录到普通的旧 Groovy 对象(plain old Groovy objects,POGO))的范围。同样,这个服务很可能由多种方法使用。稍后您将看到,对 IATA 代码进行地理编码需要用到 save
和 update
。Grails 为您提供了保存常用方法的位置,并且超越了任何单个的域类:即服务。
要创建 Grails 服务,请在命令行输入 grails create-service Geocoder
。在文本编辑器中查看 grails-app/services/GeocoderService.groovy,如清单 2 所示:
清单 2. 一个无存根(stubbed-out)Grails 服务
class GeocoderService { boolean transactional = true def serviceMethod() { } } |
如果使用同一个方法进行多个数据库查询,那么将涉及到 transactional
字段。它将所有内容都包装在一个单个数据库事务中,如果任何一个查询失败,该数据库事务将回滚到原来的状态。因为在本示例中您远程地调用 Web 服务,所以可以安全地将它设置为 false
。
名称 serviceMethod
是一个占位符(placeholder),可以将其改为更具描述性的内容(服务可以包含任意多种方法)。在清单 3 中, 我把名称改为 geocodeAirport
:
清单 3. geocodeAirport()
地理编码器服务方法
- class GeocoderService {
- boolean transactional = false
- // http://ws.geonames.org/search?name_equals=den&fcode=airp&style=full
- def geocodeAirport(String iata) {
- def base = "http://ws.geonames.org/search?"
- def qs = []
- qs << "name_equals=" + URLEncoder.encode(iata)
- qs << "fcode=airp"
- qs << "style=full"
- def url = new URL(base + qs.join("&"))
- def connection = url.openConnection()
- def result = [:]
- if(connection.responseCode == 200){
- def xml = connection.content.text
- def geonames = new XmlSlurper().parseText(xml)
- result.name = geonames.geoname.name as String
- result.lat = geonames.geoname.lat as String
- result.lng = geonames.geoname.lng as String
- result.state = geonames.geoname.adminCode1 as String
- result.country = geonames.geoname.countryCode as String
- }
- else{
- log.error("GeocoderService.geocodeAirport FAILED")
- log.error(url)
- log.error(connection.responseCode)
- log.error(connection.responseMessage)
- }
- return result
- }
- }
geocodeAirport
方法的第一部分构建 URL 并进行连接。查询字符串元素先集中在一个 ArrayList
里,然后和一个 & 符号连接起来。方法的最后部分使用 Groovy XmlSlurper
解析 XML 结果并将结果存储在 hashmap 里。
Groovy 服务不可以直接从 URL 访问。如果您想在 Web 浏览器中测试这个新的服务方法,请将一个简单的闭包添加到 AirportController
,如清单 4 所示:
清单 4. 在控制器中向服务提供一个 URL
- import grails.converters.*
- class AirportController {
- def geocoderService
- def scaffold = Airport
- def geocode = {
- def result = geocoderService.geocodeAirport(params.iata)
- render result as JSON
- }
- ...
- }
如果您定义一个与服务同名的成员变量,Spring 会自动地将服务注入控制器(要想让这种方法奏效,您必须把服务名的第一个字母由大写改为小写,使它遵循 Java 风格的变量命名约定)。
要测试服务,请在 Web 浏览器中输入 URL http://localhost:9090/trip/airport/geocode?iata=den
。您将看到如清单 5 所示的结果:
清单 5. 地理编码器请求的结果
{"name":"Denver International Airport", "lat":"39.8583188", "lng":"-104.6674674", "state":"CO", "country":"US"} |
AirportController
中的 geocode
闭包只是用于对服务进行检查。因此,可以把它删除,或者保留下来供以后的 Ajax 调用使用。下一步是重新构造 Airport
基础设施,以利用这个新的地理编码服务。
首先,把新的 lat
和 lng
字段添加到 grails-app/domain/Airport.groovy,如清单 6 所示:
清单 6. 把 lat
和 lng
字段添加到 Airport
POGO
- class Airport{
- static constraints = {
- name()
- iata(maxSize:3)
- city()
- state(maxSize:2)
- country()
- }
- String name
- String iata
- String city
- String state
- String country = "US"
- String lat
- String lng
- String toString(){
- "${iata} - ${name}"
- }
- }
在命令提示处输入 grails generate-views Airport
来创建 GSP 文件。借助 AirportController.groovy 的 def scaffold = Airport
行,从运行时开始就一直在动态搭建 GSP 文件。要想对这个视图进行更改,我必须先处理代码。
创建新的 Airport
时,我将把用户可编辑字段限制为 iata
和 city
。要想让地理编码查询能够工作,必须具备 iata
字段。我没有更改 city
,因为我喜欢由自己来提供这个信息。DEN 真的就在丹佛(Denver),但 ORD(Chicago O'Hare)却在伊里诺斯州的罗斯蒙特(Rosemont),而 CVG(俄亥俄州辛辛那提机场,Cincinnati,Ohio airport)则在肯塔基州的佛罗伦萨市(Florence)。将这两个字段留在 create.gsp 里,其余的删除。现在 create.gsp 如清单 7 所示:
清单 7. 修改 create.gsp
- <g:form action="save" method="post" >
- <div class="dialog">
- <table>
- <tbody>
- <tr class="prop">
- <td valign="top" class="name"><label for="iata">Iata:</label></td>
- <td valign="top"
- class="value ${hasErrors(bean:airport,field:'iata','errors')}">
- <input type="text"
- maxlength="3"
- id="iata"
- name="iata"
- value="${fieldValue(bean:airport,field:'iata')}"/>
- </td>
- </tr>
- <tr class="prop">
- <td valign="top" class="name"><label for="city">City:</label></td>
- <td valign="top"
- class="value ${hasErrors(bean:airport,field:'city','errors')}">
- <input type="text"
- id="city"
- name="city"
- value="${fieldValue(bean:airport,field:'city')}"/>
- </td>
- </tr>
- </tbody>
- </table>
- </div>
- <div class="buttons">
- <span class="button"><input class="save" type="submit" value="Create" /></span>
- </div>
- </g:form>
图 2 展示了所产生的表单:
图 2. 创建 Airport 表单
该表提交到 AirportController
中的 save
闭包。将清单 8 中的代码添加到控制器,以在保存新的 Airport
之前调用 geocodeAirport
:
清单 8. 修改
save
闭包
def save = { def results = geocoderService.geocodeAirport(params.iata) def airport = new Airport(params + results) if(!airport.hasErrors() && airport.save()) { flash.message = "Airport ${airport.id} created" redirect(action:show,id:airport.id) } else { render(view:'create',model:[airport:airport]) } } |
如果在命令提示处输入 grails generate-controller Airport
,方法的主要部分将与您所看到的一样。仅仅是开始的两行与默认生成的闭包不同。第一行从 geocoder 服务获得一个 HashMap
。第二行将 results
HashMap
和 params
HashMap
合并起来(当然,在 Groovy 中合并两个 HashMap
就像把它们添加到一起一样简单)。
如果数据库保存成功的话,将重定向到显示操作。幸运的是,不需要更改 show.gsp,如图 3 所示:
图 3. 显示 Airport 表单
要编辑 Airport
,必须保持 iata
和 city
字段在 edit.gsp 中不变。您可以从 show.gsp 复制和粘贴其余的字段,把它们变为只读字段(或者,如果您能从 前期文章 体会到 “复制和粘贴是面向对象编程的最低级形式” 的话,您可以把常用字段提取到一个局部模板并在 show.gsp 和 edit.gsp 中呈现它)。清单 9 展示了修改后的 edit.gsp:
清单 9. 修改 edit.gsp
- <g:form method="post" >
- <input type="hidden" name="id" value="${airport?.id}" />
- <div class="dialog">
- <table>
- <tbody>
- <tr class="prop">
- <td valign="top" class="name"><label for="iata">Iata:</label></td>
- <td valign="top"
- class="value ${hasErrors(bean:airport,field:'iata','errors')}">
- <input type="text"
- maxlength="3"
- id="iata"
- name="iata"
- value="${fieldValue(bean:airport,field:'iata')}"/>
- </td>
- </tr>
- <tr class="prop">
- <td valign="top" class="name"><label for="city">City:</label></td>
- <td valign="top"
- class="value ${hasErrors(bean:airport,field:'city','errors')}">
- <input type="text"
- id="city"
- name="city"
- value="${fieldValue(bean:airport,field:'city')}"/>
- </td>
- </tr>
- <tr class="prop">
- <td valign="top" class="name">Name:</td>
- <td valign="top" class="value">${airport.name}</td>
- </tr>
- <tr class="prop">
- <td valign="top" class="name">State:</td>
- <td valign="top" class="value">${airport.state}</td>
- </tr>
- <tr class="prop">
- <td valign="top" class="name">Country:</td>
- <td valign="top" class="value">${airport.country}</td>
- </tr>
- <tr class="prop">
- <td valign="top" class="name">Lat:</td>
- <td valign="top" class="value">${airport.lat}</td>
- </tr>
- <tr class="prop">
- <td valign="top" class="name">Lng:</td>
- <td valign="top" class="value">${airport.lng}</td>
- </tr>
- </tbody>
- </table>
- </div>
- <div class="buttons">
- <span class="button"><g:actionSubmit class="save" value="Update" /></span>
- <span class="button">
- <g:actionSubmit class="delete"
- onclick="return confirm('Are you sure?');"
- value="Delete" />
- </span>
- </div>
- </g:form>
所产生的表单如图 4 所示:
图 4. 编辑 Airport 表单
单击 Update 按钮将表单值发送到 update
闭包。将服务调用和 hashmap 合并添加到默认代码,如清单 10 所示:
清单 10. 修改
update
闭包
- def update = {
- def airport = Airport.get( params.id )
- if(airport) {
- def results = geocoderService.geocodeAirport(params.iata)
- airport.properties = params + results
- if(!airport.hasErrors() && airport.save()) {
- flash.message = "Airport ${params.id} updated"
- redirect(action:show,id:airport.id)
- }
- else {
- render(view:'edit',model:[airport:airport])
- }
- }
- else {
- flash.message = "Airport not found with id ${params.id}"
- redirect(action:edit,id:params.id)
- }
- }
到目前为止,您已经像 Google Map 一样无缝地将地理编码集成到您的应用程序里。花点时间想一想在应用程序中捕获地址的所有位置 — 顾客、雇员、远程办公室、仓库和零售点等等。通过简单地添加几个字段以存储纬度/经度坐标和加入一个地理编码服务,就能够设置一些简易的地图来显示对象 — 这正是我下一步的工作。
许多人都知道为了易于使用,Google Map 针对 Web 地图绘制设置了标准。但很少人知道到这个标准也适用于将 Google Map 嵌入到您自己的 Web 页面中。为数据点获取纬度/经度坐标是这个应用中最困难的部分,但我们已经解决了这个问题。
要将 Google Map 嵌入到 Grails 应用程序中,首先要做的是获得一个免费的 API 密匙。注册页面详细说明了使用条款。实际上,只要您的应用程序是免费的,Google 也将免费为您提供 API。这意味着您不能对 Google Map 应用程序进行密码保护、收取访问费用或把它托管在防火墙后面(做个广告:由我撰写的 GIS for Web Developers 一书将逐步指导您使用免费数据和开发源码软件构建类似于 Google Map 的应用程序;参见 参考资料。这使您不再受到 Google 的 API 使用限制的约束)。
API 密匙通常绑定到一个特定的 URL 和目录。在表单中输入 http://localhost:9090/trip
并单击 Generate API Key 按钮。确认页面将显示刚生成的 API 密匙、与密匙相关联的 URL 和一个 “get you started on your way to mapping glory” 的示例 Web 页面。
为了将这个示例页面并入到 Grails 应用程序,需要在 grails-app/views/airport 目录中创建一个名为 map.gsp 的文件。从 Google 将示例页面复制到 map.gsp。 清单 11 展示了 map.gsp 的内容:
清单 11. 一个简单的 Google Map Web 页面
- <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN"
- "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
- <html xmlns="http://www.w3.org/1999/xhtml">
- <head>
- <meta http-equiv="content-type" content="text/html; charset=utf-8"/>
- <title>Google Maps JavaScript API Example</title>
- <script src="http://maps.google.com/maps?file=api&v=2&key=ABCDE"
- type="text/javascript"></script>
- <script type="text/javascript">
- //<![CDATA[
- function load() {
- if (GBrowserIsCompatible()) {
- var map = new GMap2(document.getElementById("map"));
- map.setCenter(new GLatLng(37.4419, -122.1419), 13);
- }
- }
- //]]>
- </script>
- </head>
- <body onload="load()" onunload="GUnload()">
- <div id="map" style="width: 500px; height: 300px"></div>
- </body>
- </html>
注意,API 密匙嵌入在页面顶部的脚本 URL 里。在 load
方法中,您正在实例化一个新的 GMap2
对象。这就是出现在 <div /> 里的地图,同时 map
的 ID 出现在页面的底端。如果想让地图变大些,可以在层叠样式表(Cascading Style Sheets,CSS)的 style
属性中调整地图的宽度和高度。目前,这个地图以加利福尼亚州的帕洛阿图市为中心,缩放倍数为 13 级(0 级是最小的。级别越大越接近街道级别的视图)。您可以快速地调整这些值。同时,将一个空 map
闭包添加到 AirlineController
,如清单 12 所示:
清单 12. 添加
map
闭包
class AirportController { def map = {} ... } |
现在,浏览 http://localhost:9090/trip/airport/map,您将看到已嵌入的 Google Map,如图 5 所示:
图 5. 简单的 Google Map
现在先回到 map.gsp 并调整值,如清单 13 所示:
清单 13. 调整基本的地图
- <script type="text/javascript">
- var usCenterPoint = new GLatLng(39.833333, -98.583333)
- var usZoom = 4
- function load() {
- if (GBrowserIsCompatible()) {
- var map = new GMap2(document.getElementById("map"))
- map.setCenter(usCenterPoint, usZoom)
- map.addControl(new GLargeMapControl());
- map.addControl(new GMapTypeControl());
- }
- }
- </script>
- </head>
- <body οnlοad="load()" οnunlοad="GUnload()">
- <div id="map" style="width: 800px; height: 400px"></div>
- </body>
要查看整个美国,将尺寸设置为 800 x 400 像素是比较好的。清单 13 调整了中心点和缩放级别,使您能够看到完整的地图。您还可以添加许多不同的地图控制。清单 13 中的 GLargeMapControl
和 GMapTypeControl
分别在地图左边和右上角提供了常用的控制。在您调试时不断点击浏览器的 Refresh 按钮,查看修改后的效果。图 6 反映了对清单 13 所做的调整:
图 6. 调整后的地图
现在基本的地图已经做好, 接下来就可以添加标记了 — 为每个机场添加图钉。在将这一过程自动化之前,我在清单 14 中手工添加了一些简单的标记:
清单 14. 将标记添加到地图
- <script type="text/javascript">
- var usCenterPoint = new GLatLng(39.833333, -98.583333)
- var usZoom = 4
- function load() {
- if (GBrowserIsCompatible()) {
- var map = new GMap2(document.getElementById("map"))
- map.setCenter(usCenterPoint, usZoom)
- map.addControl(new GLargeMapControl());
- map.addControl(new GMapTypeControl());
- var marker = new GMarker(new GLatLng(39.8583188, -104.6674674))
- marker.bindInfoWindowHtml("DEN<br/>Denver International Airport")
- map.addOverlay(marker)
- }
- }
- </script>
GMarker
构造器采用了一个 GLatLng
点。bindInfoWindowHtml
方法提供了用户单击标记时在 Info 窗口内显示的 HTML 文件片段。最后,清单 14 还通过使用 addOverlay
方法将标记添加到地图。
图 7 展示了添加标记后的地图:
图 7. 带标记的地图
现在您已经知道如何添加一个单一的点,但要自动地添加数据库里的所有点,还需要做两个小的更改。第一个更改是在 AirportController
中生成 map
闭包,这会返回一个 Airport
列表,如清单 15 所示:
清单 15. 返回一个 Airport 列表
def map = { [airportList: Airport.list()] } |
接下来,需要遍历 Airport
列表并为每个 Airport 创建标记。在本系列的早期文章中,曾经介绍使用 <g:each>
标记向 HTML 表添加行。清单 16 使用这个标记创建必要的 JavaScript 行,这样才能在地图上显示 Airport
:
清单 16. 动态地为地图添加标记
- <script type="text/javascript">
- var usCenterPoint = new GLatLng(39.833333, -98.583333)
- var usZoom = 4
- function load() {
- if (GBrowserIsCompatible()) {
- var map = new GMap2(document.getElementById("map"))
- map.setCenter(usCenterPoint, usZoom)
- map.addControl(new GLargeMapControl());
- map.addControl(new GMapTypeControl());
- <g:each in="${airportList}" status="i" var="airport">
- var point${airport.id} = new GLatLng(${airport.lat}, ${airport.lng})
- var marker${airport.id} = new GMarker(point${airport.id})
- marker${airport.id}.bindInfoWindowHtml("${airport.iata}<br/>${airport.name}")
- map.addOverlay(marker${airport.id})
- </g:each>
- }
- }
- </script>
图 8 展示了自动添加了标记后的地图:
图 8. 带有多个标记的地图
这个针对 Google Maps API 的简单介绍只涉及到些皮毛,您可以进行的操作远不止这些。您可能已经决定利用事件模型来实现 Ajax 调用,当单击标记时就会返回 JavaScript Object Notation(JSON)数据。您可以使用 GPolyline
在地图上描绘一个旅途中的各段行程。可以实现无限的可能性。要获得更多的信息,可以参考 Google 的在线文档。您也可以从我的 PDF 书籍 Google Maps API 获得关于 API 的适当介绍(参见 参考资料)。
地图添加到 Grails 应用程序需要具备 3 个条件。
第一个条件是对数据进行地理编码。有许多免费的地理编码器,它们可以将人类能识别的地理位置转换为纬度/经度点。几乎能够对所有的内容进行地理编码:街道地址、城市、县城、国家、邮政区码、电话号码、IP 地址等,甚至包括机场的 IATA 代码。
在您找到合适的地理编码器之后,创建一个 Grails 服务以把远程 Web 服务调用封装在可重用方法调用中。服务是为在单一区域对象上超越简单 CRUD 操作的方法而准备的。在默认情况下,服务并不与 URL 相关联,但是您可以轻易地在控制器中创建一个闭包,使这些服务可以通过 Web 找到。
最后,利用免费的 Web 地图绘制 API(比如 Google Map)在地图上描绘纬度/经度点。这些免费的服务通常也要求您的应用程序可以免费访问。如果您希望地图是隐私的,请考虑使用开放源代码 API(比如 OpenLayers),它提供了和 Google Map 一样的用户体验,但没有 Google Map 那样的使用限制(参见 参考资料)。您将需要提供自己的地图绘制层,但可以将整个应用程序托管到自己的服务器上,从而保持它的隐私性。
在下一篇文章,我将讨论使 Grails 应用程序适用于移动电话的方法。您将看到如何优化 iPhone 的显示视图。我还将演示通过电子邮件从 Grails 发送信息,这个信息将在移动电话中显示为 SMS 消息。 那时,您将享受精通 Grails 的乐趣。