自本系列的 第一篇文章 開始,我就一直在構建一個 trip-planner 應用程序。目前基本的模型-視圖 -控制器(Model-View-Controller,MVC)框架已經准備就緒,我將加入一些外部技術,具體來講,我將 加入地圖功能。雖然我可以表示 “我的旅程是從丹佛到羅利,途經聖何塞和西雅圖”,但地圖將能更好 地描述旅途路線。您可能知道西雅圖和羅利是在美國的兩端,但地圖能夠幫助您顯示出兩個城市之間的距 離。
這個應用程序有什麼用?本文的末尾為您提供一個大體的介紹。請訪問 http://maps.google.com 並 在搜索框內輸入 IATA 代碼 DEN。將出現丹佛國際機場(Denver International Airport),如圖 1 所 示(更多的 IATA 代碼,參見 上個月的文章)。
圖 1. 丹佛機場(由 Google Map 友情提供)
除了能顯示您在 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 所示:
清單 1. 來自地理編碼請求的 XML 結果
<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 服務
到目前為止,通過學習 精通 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
許多人都知道為了易於使用,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 onload="load()" onunload="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 的在線文檔。
結束語
地圖添加到 Grails 應用程序需要具備 3 個條件。
第一個條件是對數據進行地理編碼。有許多免費的地理編碼器,它們可以將人類能識別的地理位置轉 換為緯度/經度點。幾乎能夠對所有的內容進行地理編碼:街道地址、城市、縣城、國家、郵政區碼、電 話號碼、IP 地址等,甚至包括機場的 IATA 代碼。
在您找到合適的地理編碼器之後,創建一個 Grails 服務以把遠程 Web 服務調用封裝在可重用方法調 用中。服務是為在單一區域對象上超越簡單 CRUD 操作的方法而准備的。在默認情況下,服務並不與 URL 相關聯,但是您可以輕易地在控制器中創建一個閉包,使這些服務可以通過 Web 找到。
最後,利用免費的 Web 地圖繪制 API(比如 Google Map)在地圖上描繪緯度/經度點。這些免費的服 務通常也要求您的應用程序可以免費訪問。如果您希望地圖是隱私的,請考慮使用開放源代碼 API(比如 OpenLayers),它提供了和 Google Map 一樣的用戶體驗,但沒有 Google Map 那樣的使用限制(參見 參考資料)。您將需要提供自己的地圖繪制層,但可以將整個應用程序托管到自己的服務器上,從而保持 它的隱私性。
在下一篇文章,我將討論使 Grails 應用程序適用於移動電話的方法。您將看到如何優化 iPhone 的 顯示視圖。我還將演示通過電子郵件從 Grails 發送信息,這個信息將在移動電話中顯示為 SMS 消息。 那時,您將享受精通 Grails 的樂趣。