使用 Google Maps 和 .NET 进行半径地理编码
本文将向您展示如何对现有地址进行地理编码,然后计算给定半径内的地理位置距离。
引言
几年前,我帮助一家公司的全国销售店/地点数据库集成到一个第三方网站。我们都见过那些“查找最近的商店”的定位页面。
本文将从头到尾描述如何获取自己的地址数据库,对其进行地理编码(生成经纬度值),然后按与给定地址的距离顺序显示它们。
需要具备 Web 服务、SQL Server 函数、存储过程和 IIS 的工作知识。
背景
市面上有很多关于使用 Google API 的文章和示例。作为一名程序员,我更倾向于访问 Web 服务(如其搜索服务),但总有解决办法。
这个项目的核心是从 Google Maps API 获取经纬度值。起初,我为编写 JavaScript 包装器而苦恼,然后找到了一些,但它们并不真正符合要求。经过更多时间阅读 API 文档后,我发现 API 可以返回不同的数据格式,这正是我需要的。
另外,Sharmil Y Desai 的文章“Google Maps Geocoder 的 .NET API”列出了一个很棒的小项目,可以实现一些相同的功能。
项目步骤
对于这个项目,我们需要考虑需求是什么
- 创建一个可地理编码的地址数据库 - 添加经纬度值。
- 创建一个流程来更新具有经纬度值的地址记录。
- 创建一个接受地址的页面/Web 服务,用于与我们的数据库进行比较。
- 创建一个算法来比较给定地址与我们的数据库,并返回结果。
地址表 - 数据库
我们的地址表 `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 数据是最简单的方法。
我将非常乐意听到您对本文的任何评论或反馈 - 在此处发布,并投票!