使用 PHP 和 Google 电子表格创建用户贡献地图

Pamela Fox,Google Maps API 团队
2007 年 11 月

目标

网络上有很多围绕地理位置和兴趣建立的社区:喜爱博物馆、欧洲大教堂、州立公园等的人们。因此,始终需要开发者(比如您!)创建一个系统,让用户可以向地图贡献地理标记的位置,而这正是我们接下来要做的。 本文结束时,您将拥有一个系统,用户可以在其中注册、登录和添加带有地理标记的地点。该系统将使用 AJAX 作为前端、PHP 作为服务器端脚本,并使用 Google 电子表格进行存储。如果您习惯使用 MySQL 数据库进行存储,可以轻松修改此处的代码,改为使用 MySQL 数据库后端。

本文分为以下几个步骤:


设置电子表格

我们将使用 Google 电子表格来存储此系统的所有数据。我们需要存储两种类型的数据:用户账号信息和用户添加的地点,因此我们将为每种数据类型创建一个工作表。我们将使用工作表的列表 Feed 与工作表进行交互,该 Feed 依赖于工作表中的第一行(包含列标签),以及后续的每一行(包含数据)。

访问 docs.google.com,然后创建新的电子表格。将默认工作表重命名为“用户”,并创建名为“用户”“密码”和“会话”的列。 然后添加另一个工作表,将其重命名为“Locations”,并创建名为“user”“status”“lat”“lng”和“date”的列。或者,如果您不想进行这么多手动操作,请下载此模板,然后通过“文件”菜单中的“导入”命令将其导入 Google 电子表格。

用户账号信息需要保持私密(仅对电子表格所有者即您可见),而用户添加的地点将显示在公开可见的地图上。幸运的是,Google 电子表格允许您有选择地决定电子表格中的哪些工作表可以公开,哪些工作表应保持私密(默认)。如需发布“地点”工作表,请点击“发布”标签页,然后点击“立即发布”,选中“自动重新发布”复选框,然后在“发布哪些部分?”下拉菜单中选择“仅发布‘地点’工作表”。以下屏幕截图中显示了正确的选项:

使用 Zend GData Framework

Google Spreadsheets API 提供了一个 HTTP 接口,用于执行 CRUD 操作,例如检索行、插入行、更新行和删除行。Zend Framework 在 API(以及其他 GData API)之上提供了一个 PHP 封装容器,因此您不必担心实现原始 HTTP 操作。Zend Framework 需要 PHP 5。

如果您还没有 Zend 框架,请下载并将其上传到您的服务器。您可在此处下载该框架: http://framework.zend.com/download/gdata

您应修改 PHP include_path 以包含 Zend 库。您可以通过多种方式实现此目的,具体取决于您在服务器上拥有的管理权限级别。一种方法是在使用该库的任何 PHP 文件中的 require 语句上方添加以下行:

ini_set("include_path", ".:/usr/lib/php:/usr/local/lib/php:../../../library/");

如需进行测试,请在 demos/Zend/Gdata 文件夹中通过命令行输入以下内容来运行电子表格演示:

php Spreadsheet-ClientLogin.php --user=YourGMailUsername --pass=YourPassword

如果成功,您应该会看到电子表格列表。如果您收到错误消息,请检查您的包含路径是否设置正确,以及您是否已安装 PHP 5。

创建全局函数

我们将为社区地图编写的所有 PHP 脚本都将使用通用包含项、变量和函数,这些内容将放在一个文件中。

在文件开头,我们将添加必要的语句来包含和加载 Zend 库,这些语句取自 Spreadsheets-ClientLogin.php 示例。

然后,我们将定义将在整个文件中使用的常量:电子表格键和两个工作表 ID。如需查找电子表格的相关信息,请打开该电子表格,点击“发布”标签页,然后点击“更多发布选项”。从“文件格式”下拉列表中选择“ATOM”,然后点击“生成网址”。您会看到类似如下的内容:

http://spreadsheets.google.com/feeds/list/o16162288751915453340.4016005092390554215/od6/public/basic

电子表格键是“/list/”后面的长字母数字字符串,工作表 ID 是其后面的 3 个字符长的字符串。如需查找其他工作表 ID,请从“哪些工作表?”下拉菜单中选择其他工作表。

然后,我们将创建 3 个函数:setupClient、getWkshtListFeed 和 printFeed。在 setupClient 中,我们将设置 Gmail 用户名和密码,通过 ClientLogin 进行身份验证,并返回 Zend_Gdata_Spreadsheets 对象。在 getWkshtListFeed 中,我们将返回给定电子表格键和工作表 ID 的电子表格列表 Feed,并提供可选的电子表格查询(链接)。printFeed 函数取自 Spreadsheets-ClientLogin.php 示例,可能对您进行调试很有用。它将接收一个 Feed 对象,并将其输出到屏幕上。

执行此操作的 PHP 如下所示 (communitymap_globals.php):

<?php
ini_set("include_path", ".:/usr/lib/php:/usr/local/lib/php:../../../library/");
require_once 'Zend/Loader.php';
Zend_Loader::loadClass('Zend_Gdata');
Zend_Loader::loadClass('Zend_Gdata_ClientLogin');
Zend_Loader::loadClass('Zend_Gdata_Spreadsheets');
Zend_Loader::loadClass('Zend_Http_Client');

define("SPREADSHEET_KEY", "o16162288751915453340.4016005092390554215");
define("USER_WORKSHEET_ID", "od6");
define("LOC_WORKSHEET_ID", "od7");
 
function setupClient() {
  $email = "your.name@gmail.com";
  $password = "yourPassword";
  $client = Zend_Gdata_ClientLogin::getHttpClient($email, $password,
          Zend_Gdata_Spreadsheets::AUTH_SERVICE_NAME);
  $gdClient = new Zend_Gdata_Spreadsheets($client);
  return $gdClient;
}
 
function getWkshtListFeed($gdClient, $ssKey, $wkshtId, $queryString=null) {
  $query = new Zend_Gdata_Spreadsheets_ListQuery();
  $query->setSpreadsheetKey($ssKey);
  $query->setWorksheetId($wkshtId);
  if ($queryString !== null) {
    $query->setSpreadsheetQuery($queryString);
  }
  $listFeed = $gdClient->getListFeed($query);
  return $listFeed;
}
 
function printFeed($feed)
{
  print "printing feed";
  $i = 0;
  foreach($feed->entries as $entry) {
      if ($entry instanceof Zend_Gdata_Spreadsheets_CellEntry) {
         print $entry->title->text .' '. $entry->content->text . "\n";
      } else if ($entry instanceof Zend_Gdata_Spreadsheets_ListEntry) {
         print $i .' '. $entry->title->text .' '. $entry->content->text . "\n";
      } else {
         print $i .' '. $entry->title->text . "\n";
      }
      $i++;
  }
}
 
?>

注册新用户

若要注册新用户,我们需要一个面向用户的 HTML 页面,其中包含文本字段和提交按钮,以及一个 PHP 后端脚本,用于将用户添加到电子表格中。

在 PHP 脚本中,我们首先包含全局脚本,然后从 GET 变量中获取用户名和密码值。然后,我们设置一个电子表格客户端,并使用查询字符串请求用户工作表的列表 Feed,以将结果限制为仅包含用户名列等于传递给脚本的用户名的行。如果我们未在列表 Feed 结果中获得任何行,则可以放心地继续操作,因为传入的用户名是唯一的。在列表 Feed 中插入行之前,我们先创建一个列值的关联数组:用户名、使用 PHP 的 sha1 函数加密的密码,以及会话的填充字符。然后,我们对电子表格客户端调用 insertRow,并传入关联数组、电子表格键和工作表 ID。如果返回的对象是 ListFeedEntry,则输出“成功!”消息。

执行此操作的 PHP 如下所示 (communitymap_newuser.php):

<?php
 
require_once 'communitymap_globals.php';
 
$username = $_GET['username'];
$password = $_GET['password'];
 
$gdClient = setupClient();
 
$listFeed = getWkshtListFeed($gdClient, SPREADSHEET_KEY, USER_WORKSHEET_ID, ('user='.$username));
$totalResults = $listFeed->totalResults;
if ( $totalResults != "0") {
  // Username already exists
  exit;
}
 
$rowArray["user"] = $username;
$rowArray["password"] = sha1($password);
$rowArray["session"] = "a";
 
$entry = $gdClient->insertRow($rowArray, SPREADSHEET_KEY, USER_WORKSHEET_ID);
if ($entry instanceof Zend_Gdata_Spreadsheets_ListEntry) {
  echo "Success!";
}
?>

在注册网页中,可以加入 Google Maps API,这样就能使用其称为 GDownloadUrl 的 XMLHttpRequest 包装器函数。当用户点击提交按钮时,我们将从文本字段中获取用户名和密码,根据这些值构建一个参数字符串,然后对脚本网址和参数调用 GDownloadUrl。由于我们要发送敏感信息,因此使用 GDownloadUrl 的 HTTP POST 版本(通过将参数作为第三个实参发送,而不是将其附加到网址)。在回调函数中,我们将检查响应是否成功,并向用户输出适当的消息。

以下屏幕截图和代码展示了示例注册页面 (communitymap_register.htm):


<!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>
  <title> Community Map - Register/Login </title>

  <script src="http://maps.google.com/maps?file=api&v=2&key=abcdef"
      type="text/javascript"></script>
  <script type="text/javascript">

  function register() {
    var username = document.getElementById("username").value;
    var password = document.getElementById("password").value;
    var url = "communitymap_newuser.php?";
    var params = "username=" + username + "&password=" + password;
    GDownloadUrl(url, function(data, responseCode) {
      if (data.length > 1) {
        document.getElementById("message").innerHTML = "Successfully registered." + 
          "<a href='communitymap_login.htm'>Proceed to Login</a>.";
      } else {
        document.getElementById("message").innerHTML = "Username already exists. Try again.";
      }
    }, params);
  }

  </script>

  </head>
  <body>
  <h1>Register for Community Map</h1>
  <input type="text" id="username">
  <input type="password" id="password">

  <input type="button" onclick="register()" value="Register">
  <div id="message"></div>
  </body>
</html>

用户登录

为了让用户能够登录我们的系统,我们需要一个面向用户的 HTML 页面来提示用户输入其用户名和密码,还需要一个 PHP 脚本来验证登录信息、创建会话 ID 并将其传递回登录页面以设置 Cookie。用户将通过会话 Cookie 在后续网页上保持登录状态。

在 PHP 脚本中,我们首先包含全局脚本,然后从 GET 变量中获取用户名和密码值。然后,我们设置一个电子表格客户端,并使用查询字符串请求用户工作表的列表 Feed,以将结果限制为仅包含用户名列等于传递给脚本的用户名的行。

在返回的行中,我们将检查传入的密码的哈希值是否与存储在电子表格中的哈希值匹配。如果存在,我们将使用 md5、uniqid 和 rand 函数创建会话 ID。然后,我们将使用会话更新电子表格中的相应行,并在行更新成功时将其输出到屏幕上。

执行此操作的 PHP 如下所示 (communitymap_loginuser.php):

<?php
 
require_once 'communitymap_globals.php';
 
$username = $_POST['username'];
$password = $_POST['password'];
 
$gdClient = setupClient();
 
$listFeed = getWkshtListFeed($gdClient, SPREADSHEET_KEY, USER_WORKSHEET_ID, ('user='.$username));
 
$password_hash = sha1($password);
$row = $listFeed->entries[0];
$rowData = $row->getCustom();
foreach($rowData as $customEntry) {
  if ($customEntry->getColumnName()=="password" && $customEntry->getText()==$password_hash) {
    $updatedRowArray["user"] = $username;
    $updatedRowArray["password"] = sha1($password);
    $updatedRowArray["session"] = md5(uniqid(rand(), true));
    $updatedRow = $gdClient->updateRow($row, $updatedRowArray); 
    if ($updatedRow instanceof Zend_Gdata_Spreadsheets_ListEntry) {
      echo $updatedRowArray["session"];
    }
  }
}
?>

在登录网页中,可以再次加入 Google Maps API,这样我们就能使用它的 XMLHttpRequest 包装器函数,该函数亦称为 GDownloadUrl。当用户点击提交按钮时,我们将从文本字段中获取用户名和密码,使用查询参数构建脚本网址,并对该脚本网址调用 GDownloadUrl。在回调函数中,我们将设置一个包含脚本返回的会话 ID 的 Cookie,或者在未返回任何内容时输出错误消息。setCookie 函数来自基于 W3C JavaScript 教程 (http://www.w3schools.com/js/js_cookies.asp) 的 cookies.js。

以下屏幕截图和代码展示了一个示例登录页面 (communitymap_login.htm):


<!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>

  <title>Community Map - Login</title>
      <script src="http://maps.google.com/maps?file=api&v=2&key=abcdef"
      type="text/javascript"></script>
  <script src="cookies.js" type="text/javascript"></script>
  <script type="text/javascript">

  function login() {
    var username = document.getElementById("username").value;
    var password = document.getElementById("password").value;
    var url = "communitymap_loginuser.php?username=" + username + "&password=" + password;
    GDownloadUrl(url, function(data, responseCode) {
      if (data.length > 1) {
        setCookie("session", data, 5);
      } else {
        document.getElementById("nessage").innerHTML = "Error. Try again.";
      }
    });
  }

  </script>
  </head>
  <body>

  <h1>Login for Community Map</h1>

  <input type="text" id="username">
  <input type="password" id="password">
  <input type="button" onclick="login()" value="Login">
  <div id="message"></div>
  </body>
</html>

让用户添加地图位置

为了让用户向我们的地图添加地点,我们需要一个面向用户的 HTML 页面,以便用户提供有关位置的信息;还需要两个 PHP 脚本,一个用于检查用户是否通过我们设置的 Cookie 登录,另一个用于将位置添加到位置工作表中。

在第一个用于检查用户是否已登录的 PHP 脚本中,我们首先包含全局脚本,然后从 GET 变量中获取会话值。然后,我们设置一个电子表格客户端,并请求用户工作表的列表 Feed,同时使用查询字符串将结果限制为仅包含会话列等于传递给脚本的会话值的行。然后,我们遍历相应 Feed 的自定义条目(与列标题对应的条目),并打印出相应会话的相应用户名(如果有)。

执行此操作的 PHP 如下所示 (communitymap_checksession.php):

<?php

require_once 'communitymap_globals.php';

$session = $_GET['session'];

$gdClient = setupClient();

$listFeed = getWkshtListFeed($gdClient, SPREADSHEET_KEY, USER_WORKSHEET_ID, ('session='.$session));

if ( count($listFeed->entries) > 0) {
  $row = $listFeed->entries[0];
  $rowData = $row->getCustom();
  foreach($rowData as $customEntry) {
    if ($customEntry->getColumnName()=="user") {
      echo $customEntry->getText();
    }
  }
}
?>

在允许用户添加位置信息的第二个 PHP 脚本中,我们首先复制 communitymap_checksession.php 中的代码,以确保用户仍处于登录状态且有效。然后,一旦我们从用户工作表中获得有效的用户名,就会从 GET 变量中获取地点、纬度和经度值。我们将所有这些值都放入一个关联数组中,还使用 PHP 的 date() 函数添加了一个“日期”值,以便我们知道用户添加相应地点的日期。我们将该关联数组、电子表格键常量和位置工作表 ID 常量传递给 insertRow 函数。然后,如果电子表格中添加了新位置的行,则输出“成功”。如果您在此步骤中遇到错误,很可能是因为列标题名称不匹配。关联数组中的键必须与电子表格键和工作表 ID 指定的工作表中的列标题相匹配。

执行此操作的 PHP 如下所示 (communitymap_addlocation.php):

<?php

require_once 'communitymap_globals.php';

$session = $_GET['session'];

$gdClient = setupClient();

$listFeed = getWkshtListFeed($gdClient, SPREADSHEET_KEY, USER_WORKSHEET_ID, ('session='.$session));

if ( count($listFeed->entries) > 0) {
  $row = $listFeed->entries[0];
  $rowData = $row->getCustom();
  foreach($rowData as $customEntry) {
    if ($customEntry->getColumnName()=="user") {
      $user = $customEntry->getText();
    }
  }

  $place = $_GET['place'];
  $lat = $_GET['lat'];
  $lng = $_GET['lng'];
  $rowArray["user"] = $user;
  $rowArray["place"] = $place;
  $rowArray["lat"] = $lat;
  $rowArray["lng"] = $lng;
  $rowArray["date"] = date("F j, Y, g:i a");
  $entry = $gdClient->insertRow($rowArray, SPREADSHEET_KEY, LOC_WORKSHEET_ID);
  if ($entry instanceof Zend_Gdata_Spreadsheets_ListEntry) {
    echo "Success!\n";
  }
}

?>

在添加位置网页中,我们可以再次加入 Google Maps API,这样便可使用 GDownloadUrl 并创建地图。网页加载完毕后,我们使用 cookies.js 中的 getCookie 函数检索会话值。如果会话字符串为 null 或空,则输出错误消息。如果不是,则在 map.checksession.php 上调用 GDownloadUrl,并传入会话。如果该方法成功返回用户名,我们会向用户显示欢迎消息,显示“添加位置”表单,并加载地图。 该表单包含一个地址文本字段、一张地图以及用于地名、纬度和经度的文本字段。如果用户尚不知道相应位置的纬度/经度,则可以在表单中输入地址并按“提交”进行地理编码。这会向 Map API 的 GClientGeocoder 发送调用,如果找到相应地址,则会在地图上放置标记,并自动填充经纬度文本字段。

用户满意后,可以按“添加营业地点”按钮。然后,在 JavaScript 中,我们将获取 user、place、lat 和 lng 的值,并使用 GDownloadUrl 将这些值发送到 communitymap_addlocation.php 脚本。

如果该脚本返回成功,我们会在屏幕上输出成功消息。

以下屏幕截图和代码展示了一个示例添加位置网页 (communitymap_addlocation.htm):

<!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>Community Map - Add a Place!</title>

    <script src="http://maps.google.com/maps?file=api&v=2.x&key=abcdef" type="text/javascript"></script>
    <script src="cookies.js" type="text/javascript"></script>
    <script type="text/javascript">
    //<![CDATA[

    var map = null;
    var geocoder = null; 
    var session = null;

    function load() {
      session = getCookie('session');
      if (session != null && session != "") {
        url = "communitymap_checksession.php?session=" + session;
        GDownloadUrl(url, function(data, responseCode) {
          if (data.length > 0) {
            document.getElementById("message").innerHTML = "Welcome " + data;
            document.getElementById("content").style.display = "block";
            map = new GMap2(document.getElementById("map"));
            map.setCenter(new GLatLng(37.4419, -122.1419), 13);
            geocoder = new GClientGeocoder();
          }
        });
      } else {
        document.getElementById("message").innerHTML = "Error: Not logged in.";
      }
    }

    function addLocation() {
      var place = document.getElementById("place").value;
      var lat = document.getElementById("lat").value;
      var lng = document.getElementById("lng").value;

      var url = "communitymap_addlocation.php?session=" + session + "&place=" + place +
                "&lat=" + lat + "&lng=" + lng;
      GDownloadUrl(url, function(data, responseCode) {
        GLog.write(data);
        if (data.length > 0) {
          document.getElementById("message").innerHTML = "Location added.";
        }
      });
    }

    function showAddress(address) {
      if (geocoder) {
        geocoder.getLatLng(
          address,
          function(point) {
            if (!point) {
              alert(address + " not found");
            } else {
              map.setCenter(point, 13);
              var marker = new GMarker(point, {draggable:true});
              document.getElementById("lat").value = marker.getPoint().lat().toFixed(6);
              document.getElementById("lng").value = marker.getPoint().lng().toFixed(6);

              map.addOverlay(marker);
              GEvent.addListener(marker, "dragend", function() {
                document.getElementById("lat").value = marker.getPoint().lat().toFixed(6);
                document.getElementById("lng").value = marker.getPoint().lng().toFixed(6);
	      });
            }
          }
        );
      }
    }
    //]]>

    </script>

  </head>

  <body onload="load()" onunload="GUnload()">
   <div id="message"></div>
   <div id="content" style="display:none">

   <form action="#" onsubmit="showAddress(this.address.value); return false">
        <p>
        <input type="text" size="60" name="address" value="1600 Amphitheatre Pky, Mountain View, CA" />
        <input type="submit" value="Geocode!" />

    </form>
      </p>

      <div id="map" style="width: 500px; height: 300px"></div>
 
 	Place name: <input type="text" size="20" id="place" value="" />
	<br/>
 	Lat: <input type="text" size="20" id="lat" value="" />
	<br/>

 	Lng: <input type="text" size="20" id="lng" value="" />

        <br/>
	<input type="button" onclick="addLocation()" value="Add a location" />
    </form>
    </div>

  </body>
</html>

创建地图

由于您在第一步中将位置信息工作表设为公开,因此无需进行服务器端编程即可创建位置信息地图。事实上,您根本无需进行任何编程。您可以使用此电子表格 -> 地图向导,它会生成地图所需的所有代码。该向导通过附加指向 Feed 的 JSON 输出的脚本标记,将工作表条目下载到网页中,并指定在 JSON 下载完成后调用的回调函数。如需了解详情,请点击此处

如需查看实现此目的的 HTML 代码示例,请点击此处:mainmap.htm。以下屏幕截图显示了相关示例:

总结

希望您现在已在服务器上运行自己的用户贡献地图系统。本文提供了此系统基本方面所需的最基本代码,但现在您已熟悉 Zend Spreadsheets 库,应该能够扩展该系统以满足您的特定需求。如果您在学习过程中遇到错误,请记住,您可以使用 PHP 中的 echo 命令或 JavaScript 中的 Maps API 的 GLog.write() 进行调试,并且随时可以在 Maps APISpreadsheets API 开发者论坛中发帖寻求更多帮助。