65.9K
CodeProject 正在变化。 阅读更多。
Home

使用 Google Maps 和 .NET 进行半径地理编码

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.75/5 (12投票s)

2009 年 1 月 23 日

CPOL

8分钟阅读

viewsIcon

105558

downloadIcon

2416

本文将向您展示如何对现有地址进行地理编码,然后计算给定半径内的地理位置距离。

引言

几年前,我帮助一家公司的全国销售店/地点数据库集成到一个第三方网站。我们都见过那些“查找最近的商店”的定位页面。

本文将从头到尾描述如何获取自己的地址数据库,对其进行地理编码(生成经纬度值),然后按与给定地址的距离顺序显示它们。

需要具备 Web 服务、SQL Server 函数、存储过程和 IIS 的工作知识。

背景

市面上有很多关于使用 Google API 的文章和示例。作为一名程序员,我更倾向于访问 Web 服务(如其搜索服务),但总有解决办法。

这个项目的核心是从 Google Maps API 获取经纬度值。起初,我为编写 JavaScript 包装器而苦恼,然后找到了一些,但它们并不真正符合要求。经过更多时间阅读 API 文档后,我发现 API 可以返回不同的数据格式,这正是我需要的。

另外,Sharmil Y Desai 的文章“Google Maps Geocoder 的 .NET API”列出了一个很棒的小项目,可以实现一些相同的功能。

项目步骤

对于这个项目,我们需要考虑需求是什么

  1. 创建一个可地理编码的地址数据库 - 添加经纬度值。
  2. 创建一个流程来更新具有经纬度值的地址记录。
  3. 创建一个接受地址的页面/Web 服务,用于与我们的数据库进行比较。
  4. 创建一个算法来比较给定地址与我们的数据库,并返回结果。

地址表 - 数据库

我们的地址表 `tbl_GeoAddresses` 将包含基本的地址字段 - `AddressID`、`Street`、`City`、`State`、`Zip`。附加字段包括 `Geo_Lat`、`Geo_Long`、`GeoAddressUsed` 和 `GeoAddedDate`。

复制/粘贴以下脚本来创建表

/****** Object:  Table [dbo].[tbl_GeoAddresses]  Script Date: 01/23/2009 11:04:52 ******/
SET ANSI_NULLS ON
GO

SET QUOTED_IDENTIFIER ON
GO

SET ANSI_PADDING ON
GO

CREATE TABLE [dbo].[tbl_GeoAddresses](
    [AddressID] [int] IDENTITY(1,1) NOT NULL,
    [Street] [varchar](50) NULL,
    [City] [varchar](50) NULL,
    [State] [varchar](50) NULL,
    [Zip] [varchar](50) NULL,
    [Name] [varchar](50) NULL,
    [Geo_Lat] [varchar](50) NULL,
    [Geo_Long] [varchar](50) NULL,
    [GeoAddressUsed] [varchar](128) NULL,
    [GeoAddedDate] [datetime] NOT NULL,
 CONSTRAINT [PK_tbl_GeoAddresses] PRIMARY KEY CLUSTERED 
(
    [AddressID] ASC
)WITH (PAD_INDEX  = OFF, STATISTICS_NORECOMPUTE  = OFF, IGNORE_DUP_KEY = OFF,
    ALLOW_ROW_LOCKS  = ON, ALLOW_PAGE_LOCKS  = ON) ON [PRIMARY]
) ON [PRIMARY]

GO

SET ANSI_PADDING OFF
GO

ALTER TABLE [dbo].[tbl_GeoAddresses] 
ADD  CONSTRAINT [DF_tbl_GeoAddresses_GeoAddedDate]
DEFAULT (getdate()) FOR [GeoAddedDate]
GO

地理编码服务 - svcGeoSearch

由于我们需要对数据库中的地址以及传入的地址(我们将与现有地址进行比较的地址)进行地理编码以进行距离计算,因此我们将构建一个可重用的 Web 服务,该服务将返回给定地址的经纬度值。

Google API 接受各种格式的地址,从仅邮政编码,到城市/州,再到地址位置。

在项目 Zip 文件中,请参考 `svcGeoSearch` 项目。您需要在 `web.config` 中添加您自己的 Google Maps 密钥。

对 Google Maps API 的主要调用在此行中完成

Dim gmapRequest As String = "http://maps.google.com/maps/geo?key=" & _
     ConfigurationManager.AppSettings("GMapKey") & _
     "&q=" & address & "&sensor=false&output=xml"

输出类型由 `output` 参数定义,该参数接受 {kml, xml, csv}。XML 输出消除了对其他包装器的需求,因为我们可以轻松解析 XML 文件。

主要解析在以下几行中完成

Try
    coordinatesNodeList = xmlGeo.GetElementsByTagName("coordinates")
    coordinates = coordinatesNodeList.Item(0).InnerText
    coordinates = coordinates.Substring(0, coordinates.LastIndexOf(","))
Catch ex As Exception
    statusNodeList = xmlGeo.GetElementsByTagName("code")
    statusCode = statusNodeList.Item(0).InnerText
    coordinates = statusCode & "," & statusCode
End Try

此处可以执行一些额外的错误检查/验证,但对于大多数情况,前面的代码就足够了。状态码错误会返回给诸如查询过多(每天超过 15,000 次)、未知地址等情况。要查看 Google Maps 状态码的完整列表,请点击此处

通常会返回经纬度值,但如果未返回,并且结果是状态码,则会从服务中返回,例如“620,620”。现在,在您的本地 IIS 服务器上设置此服务。如果您使用 IIS 7,请将其设置为一个应用程序。启动后,您将看到服务显示。输入一个地址或一些地址变体,您将获得返回的经纬度值。

<?xml version="1.0" encoding="utf-8" ?>
<string xmlns="http://svcGeoSearch/">-118.2370170,34.0597670</string>

地理编码我们的数据库 - ProviderGeoCodingScheduledTask

既然我们有了一个可以返回给定地址经纬度值的工具,我们就可以创建一个应用程序来对我们表中的所有地址执行此操作。

在项目 Zip 文件中,请参考名为 `ProviderGeoCodingScheduledTask` 的控制台应用程序。该项目使用了先前创建的 Web 服务,您需要添加此 Web 引用,并删除现有的(`GeocodingService`)。在 `Module1.vb` 中,将第 41 行更改为引用您的 Web 服务名称。

Dim GeoSearch As New GeocodingService.svcGeoSearch

此文件(`Module1.vb`)中的代码相当直观。我们首先选择所有记录,并将每个字段设置为一个变量进行处理。

添加了一些规则,例如 `OmitRecord()`,如果地址中出现特定关键字,则该记录将被省略。

在 `ParseStreet()` 中解析地址,以查找诸如“suite #”、“ste”和“unit”之类的其他限定词。这些地址部分被剥离,因为它们与地理编码无关,Google API 也不接受(不信的话,试试在 `svcGeoSearch` 地址中输入这些地址类型)。

某些邮政编码可能显示为“90044”或“900443342”。Google 接受的格式是“90044-3342”,这由以下代码处理。

If zip.Length() > 5 Then
    zip = zip.Insert(5, "-")
End If

请求之间还内置了 1/2 秒的延迟。

最后在 SQL 更新中添加了地理编码,同时添加了 `GeoAddressUsed`。这包含用于查找经纬度的实际地址(因为原始地址的某些部分可能已被剥离)。

Using updateCommand As New SqlCommand("UPDATE tbl_GeoAddresses SET Geo_Lat=@Geo_Lat,
    Geo_Long=@Geo_Long, GeoAddressUsed=@GeoAddressUsed,
    GeoAddedDate=GetDate() WHERE AddressID=@AddressID AND ZIP=@OrigZip", sqlConn)
    With updateCommand
        .CommandType = CommandType.Text
        .Parameters.Add("Geo_Lat", SqlDbType.VarChar).Value = geoLat
        .Parameters.Add("Geo_Long", SqlDbType.VarChar).Value = geoLong
        .Parameters.Add("GeoAddressUsed", SqlDbType.VarChar).Value = formattedAddress
        .Parameters.Add("AddressID", SqlDbType.Int).Value = AddressID
        .Parameters.Add("OrigZip", SqlDbType.VarChar).Value = origzip
        updateCount = .ExecuteNonQuery()
    End With
End Using

作为控制台应用程序,您可以将其设置为 Windows 计划任务,以防该表定期更新新地址。根据数据的大小,您可能需要添加额外的过滤规则,例如,仅更新过去五天未更新的记录。

SELECT * FROM tbl_GeoAddresses WHERE GeoAddedDate < GetDate() - Day(5)

GeoAlgorithm - SQL 函数 - CalcDistanceBetweenLocations

到目前为止,我们有了一个返回经纬度值的 Web 服务,以及一个包含经纬度值的地址数据库表。现在我们需要一种方法来计算两个经纬度点之间的距离 - {lat, long} 和 {lat, long} 之间的距离。

最有效的方法是在我们的 SQL Server 上创建一个标量值函数来为我们处理计算。

/****** Object:  UserDefinedFunction [dbo].[CalcDistanceBetweenLocations]
        Script Date: 01/23/2009 12:23:39 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

CREATE FUNCTION [dbo].[CalcDistanceBetweenLocations]
      (@LatitudeA       FLOAT = NULL,
       @LongitudeA       FLOAT = NULL,
       @LatitudeB       FLOAT = NULL,
       @LongitudeB       FLOAT = NULL,
       @InKilometers      BIT = 0
       )
RETURNS FLOAT
AS
BEGIN
      -- just set @InKilometers to 0 for miles or 1 for km
      -- ex:  SELECT dbo.CalcDistanceBetweenLocations (30.123,27.1,28.14,32.23, 0)

      -- select field1, field2, dbo.CalcDistanceBetweenLocations(lat1, long1, lat2,
      -- long2, 0) as distance from yourtable
      -- where dbo.CalcDistanceBetweenLocations(lat1, long1, lat2,
      -- long2, 0) <= 10 --within the ten miles range
      DECLARE @Distance FLOAT

      SET @Distance = (SIN(RADIANS(@LatitudeA)) *
              SIN(RADIANS(@LatitudeB)) +
              COS(RADIANS(@LatitudeA)) *
              COS(RADIANS(@LatitudeB)) *
              COS(RADIANS(@LongitudeA - @LongitudeB)))

      --Get distance in miles
        SET @Distance = (DEGREES(ACOS(@Distance))) * 69.09

      --If specified, convert to kilometers
      IF @InKilometers = 1
            SET @Distance = @Distance * 1.609344

      RETURN @Distance

END

此函数接受四个值 - 点 A 的经纬度各一个,点 B 的经纬度各一个,最后一个参数用于输出单位,以英里或公里为单位 - 使用 0 表示英里,1 表示公里。

此函数仅执行一对点 A 和 B 的计算。但是,我们的整个地址表呢?

地理编码我们的表 - SPROC - sproc_ReturnGeoProviders

为了计算我们地址表中的点与另一个点之间的距离,我们将创建一个存储过程来接受另一个点的经纬度,使用距离计算函数,并返回指定半径内的地址。

/****** Object:  StoredProcedure [dbo].[sproc_ReturnGeoProviders] 
        Script Date: 01/23/2009 12:27:50 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO

CREATE PROCEDURE [dbo].[sproc_ReturnGeoProviders]
    @clientLat        Float = Null,
    @clientLong        Float = Null,
    @maxRadius        Int = Null
AS
BEGIN
    CREATE TABLE #Listings
    (
    AddressID varchar(50), Street varchar(50), City varchar(50), State varchar(50), 
        Zip varchar(50), Name varchar(50), Geo_Lat varchar(50), Geo_Long varchar(50),
         GeoAddressUsed varchar(128), Distance Decimal(18,12)
    )
    INSERT INTO #Listings (AddressID, Street, City, State, Zip, Name, Geo_Lat,
             Geo_Long, GeoAddressUsed, Distance)
    SELECT AddressID, Street, City, State, Zip, Name, Geo_Lat, Geo_Long,
             GeoAddressUsed,
    dbo.CalcDistanceBetweenLocations(@clientLat, @clientLong,
             tbl_GeoAddresses.Geo_Lat, tbl_GeoAddresses.Geo_Long, 0) AS Distance
    FROM tbl_GeoAddresses
    WHERE dbo.CalcDistanceBetweenLocations(@clientLat, @clientLong,
             tbl_GeoAddresses.Geo_Lat, tbl_GeoAddresses.Geo_Long, 0) <= @maxRadius
    ORDER BY Distance ASC
    
    SELECT * 
    FROM #Listings
    -- temp table is already ordered by distance
END

正如您所见,存储过程接受给定点的经纬度值以及半径值。

为了能够对结果进行排序,我们创建了一个临时表,该表在其调用过程的生命周期内有效,添加我们的结果,进行排序,然后返回该表。

这里发生了关键的计算。

dbo.CalcDistanceBetweenLocations(@clientLat, @clientLong, _
    tbl_GeoAddresses.Geo_Lat, tbl_GeoAddresses.Geo_Long, 0)

在这里,我们将函数与传递给存储过程的经纬度值进行匹配,并将其与表的经纬度字段进行比较,返回每个记录与给定点的距离。

指定半径内的地址 - wsPublic/svcProviderSearch

数据库函数和存储过程的代码位于此项目中 - `wsPublic/DatabaseProcs.txt`。

我们几乎将所有组件都放在一起以使其正常工作。我们有了一个返回经纬度值的服务,一个包含地理编码地址的数据库,以及一种计算地址到给定点的距离的方法。接下来我们需要创建一个接口来输入该给定点。为此,我们将使用一个 Web 服务。请参考项目 Zip 文件中的 `wsPublic/svcProviderSearch.asmx`。我们实际上应该查看项目中的 `App_Code` 文件夹以获取 `svcProviderSearch.vb`。这包含处理传入请求的代码。

已经存在一个 `DataSet` 文件,但如果您想创建自己的新文件,请右键单击 `App_Code` 文件夹,选择“添加新项...”/“DataSet”。打开 DataSet 文件(`.xsd`),然后右键单击主窗格。选择“添加”/“TableAdapter”,然后创建或使用现有数据库连接字符串。此 TableAdapter 将连接到我们新的存储过程。

在选择/创建数据库连接字符串后,单击“下一步”选择“命令类型”。选择“使用现有存储过程”选项。在“选择”下拉列表中,选择“sproc_ReturnGeoProviders (dbo)” - 这是我们刚刚创建的存储过程。单击“下一步”两次,然后“向导结果”将显示存储过程中存在问题。这是因为我们在其中创建了一个名为 `#Listings` 的临时表。只需单击“完成”。

现在,在 `svcProviderSearch.vb` 中,查看 `GetLocalProvidersGeo()` 函数。此 WebMethod 接受一个邮政编码(或地址)和一个最大半径值。我们需要对传入的邮政编码(或地址)进行地理编码。由于我们主要处理邮政编码,而我们的地址表可能很大,因此,与其首先命中 Google Maps API,不如先查看我们自己的数据库,看看我们是否具有给定邮政编码的经纬度值。

Dim sqlQuery As String = String.Format(
    "SELECT TOP 1 * FROM tbl_GeoAddresses WHERE ZIP='{0}'", strZip)
Dim hasGeoCode As Boolean = False

' first check if we have the lat/long values for the given zip code.
' if not, then access the service
sqlCmd.Connection.Open()
Dim dr As SqlDataReader = sqlCmd.ExecuteReader()
While dr.Read()
    geoLat = dr("Geo_Lat")
    geoLong = dr("Geo_Long")
    hasGeoCode = True
End While
sqlCmd.Connection.Close()
dr.Close()

If Not hasGeoCode Then
    latlong = Me.GetLatLong(pZip)

    If latlong.Length > 0 Then
        geoLat = Trim(latlong.Substring(0,
            latlong.IndexOf(","))).Replace("<point><coordinates>", "")
        geoLong = Trim(latlong.Substring(latlong.IndexOf(",") + 1,
            (latlong.Length - latlong.IndexOf(",") - 1)))
    End If
End If

现在我们有了给定邮政编码的经纬度值,我们将用最后一行返回一个结果 `DataTable`。

Return adpSearch.GetData(CType(geoLat, Double), _
       CType(geoLong, Double), CType(pRadius, Int32))

运行代码

我在 `svcProviderService` 中包含了 `GetLatLong()` 函数,但我们目前感兴趣的是 `GetLocalProvidersGeo()`。

启动此 Web 服务并选择 `GetLocalProvidersGeo()`。我的表中只有两个地址,要全部返回,我使用了 100 英里的半径和一个本地邮政编码。

<?xml version="1.0" encoding="utf-8" ?>
<sproc_ReturnGeoProvidersDataTable xmlns="http://wsPublic/">
<xs:schema id="NewDataSet" xmlns="" 
    xmlns:xs="http://www.w3.org/2001/XMLSchema" 
    xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xs:element name="NewDataSet" msdata:IsDataSet="true"
     msdata:MainDataTable="sproc_ReturnGeoProviders" msdata:UseCurrentLocale="true">
<xs:complexType>
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="sproc_ReturnGeoProviders">
<xs:complexType>
<xs:sequence>
  <xs:element name="AddressID" type="xs:string" minOccurs="0" />
  <xs:element name="Street" type="xs:string" minOccurs="0" />

  <xs:element name="City" type="xs:string" minOccurs="0" />
  <xs:element name="State" type="xs:string" minOccurs="0" />
  <xs:element name="Zip" type="xs:string" minOccurs="0" />
  <xs:element name="Name" type="xs:string" minOccurs="0" />
  <xs:element name="Geo_Lat" type="xs:string" minOccurs="0" />
  <xs:element name="Geo_Long" type="xs:string" minOccurs="0" />
  <xs:element name="GeoAddressUsed" type="xs:string" minOccurs="0" />
  <xs:element name="Distance" type="xs:decimal" minOccurs="0" /> 
  </xs:sequence>
  </xs:complexType>
  </xs:element>
  </xs:choice>
  </xs:complexType>
  </xs:element>
  </xs:schema>
  <diffgr:diffgram xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"
      xmlns:diffgr="urn:schemas-microsoft-com:xml-diffgram-v1">
  <DocumentElement xmlns="">
  <sproc_ReturnGeoProviders diffgr:id="sproc_ReturnGeoProviders1" msdata:rowOrder="0">
  <AddressID>2</AddressID>
  <Street>988 N Hill St # 201</Street>
  <City>Los Angeles</City>
  <State>CA</State>
  <Zip>90012</Zip> 
  <Name>Empress Pavilion Restaurant</Name> 
  <Geo_Lat>-118.2366158</Geo_Lat> 
  <Geo_Long>34.0684130</Geo_Long> 
  <GeoAddressUsed>988 N Hill St, Los Angeles, CA 90012</GeoAddressUsed> 
  <Distance>1.818756506951</Distance> 
  </sproc_ReturnGeoProviders>
  <sproc_ReturnGeoProviders diffgr:id="sproc_ReturnGeoProviders2" msdata:rowOrder="1">
  <AddressID>1</AddressID> 
  <Street>617 S Olive St</Street> 
  <City>Los Angeles</City> 
  <State>CA</State> 
  <Zip>90014</Zip> 
  <Name>Cicada Restaurant</Name> 
  <Geo_Lat>-118.2537740</Geo_Lat> 
  <Geo_Long>34.0493890</Geo_Long> 
  <GeoAddressUsed>617 S Olive St, Los Angeles, CA 90014</GeoAddressUsed> 
  <Distance>2.905266224802</Distance> 
  </sproc_ReturnGeoProviders>
  </DocumentElement>
  </diffgr:diffgram>
  </sproc_ReturnGeoProvidersDataTable>

您可以在另一个项目中轻松地使用此 Web 服务来将这些值输出到网页或其他应用程序。

关注点

有很多接口可以与 Google Maps API 进行交互。大多数似乎都过于复杂。在这种特定情况下,简单地检索 XML 数据是最简单的方法。

我将非常乐意听到您对本文的任何评论或反馈 - 在此处发布,并投票!

© . All rights reserved.