SQL Azure最新版本中最激动人心的一个功能当属spatial data,也就是对几何和地理数据类型的支持。这项功能为地理位置相关应用程序的存储问题提供了一个简化统一方案。当然,绝大多数地理位置相关应用程序都需要显示地图。为了达成这个目标,微软还提供了另一个云服务:Bing Maps。
本文将引导大家结合SQL Azure和Bing Maps这两个云服务,创建一个简单的旅游计划系统。我们假设你已经熟悉以下技术:
Bing Maps开发相关知识并不是一个先决条件,因为本文会指导大家学习。ASP.NET和Silverlight的知识也不需要,因为我们会创建一个纯html客户端。
本文相关的示例代码可以自这边下载。
一个更完整的示例即将在1Code上发布,届时我们会更新这条信息。
本文的英文版即将在Community Goodies上发布。届时我们会更新这条信息。
如果你没听说过spatial data这个术语,没有关系。很简单,spatial data就是表示几何或者地理信息的数据
在中学的地理课上,我们都学习过如何用纬度和经度来表示地球上的一个位置。在许多场合下,我们完全可以在一张数据库的表中建两个列,分别存储经纬度信息。
但是,也有不少场景需要更强大的功能。例如,怎样存储一个国家的边界(大致上可以看作一条折线)?如何计算地球上两点间的距离(直线)?就算你只需要对某一个位置进行操作,你也可以将该位置设想成一个点,而不是分开的纬度和经度���个数值。
在过去,人们开发出了很多不同的解决方案来应对以上问题。每种方案都有自己的优缺点,但是没有一个是完美的。不同的程序使用不用的解决方案,也会对互操作性造成影响。
为了解决这个问题,我们必须采用一套标准。目前世界上使用最广泛的标准是由Open Geospatial Consortium (OGC)定制的OpenGIS。在OpenGIS中,几何和地理数据可以有多种表示方法,例如Well-known binary (WKB),Well-known text (WKT),以及Geography Markup Language (GML)。
WKB 提供了一种高效的方案来存储spatial数据。数据以二进制方式存放,因此占的空间很小,而且计算机也很容易对这些数据进行计算。
在SQL Azure中(以及SQL Server以及一些常见的第三方数据库提供商,例如Oracle和DB2),WKB正是spatial数据内部存放的格式。也正是因为这个原因,不同数据库间的互操作变得简单了。
可是,用肉眼来读WKB并不容易。它是针对机器设计的,而不是针对人设计的。因此,我们不准备在本文中讨论二进制格式。如果你感兴趣,你可以参照标准规约。为了让人类更容易读懂spatial数据,还有其它格式被开发出来。
WKT比WKB好懂多了。例如:
如你所见,这些数据很好的解释了它们自己是什么。
然而,数据是用文本形式存储的,因此计算机在计算数据是效率相对较低,而且占用的存储空间也较大(每个字符至少要占8位)。幸运的是,SQL Azure提供了简便方法在WKB和WKT之间切换。因此在大多数场合下,数据都用WKB来存储,而WKT通常被用于将数据展示给人类。
有关WKT的更多信息,请参考这篇Wiki文章,以及标准规约。
GML是另一种是用文本来表示spatial数据的方案。它通常比WKT长,但是它使用了另外一个标准:xml。因此程序可以很轻松的使用标准的xml库(例如LINQ to XML)来解析GML。本文不会详细讨论GML。如果你对它感兴趣,请参考 http://en.wikipedia.org/wiki/Geography_Markup_Language。
SQL Server 2008为spatial数据引入了两种新的数据类型:Geometry和Geography。现在SQL Azure完全支持它们了。如果你从未接触过spatial数据,我们推荐你完成SQL Server training course中的spatial数据相关课程。
Spatial数据类型就是单纯的数据类型。因此在创建一个列时,你可以像使用nvarchar(50)一样使用它们。
CREATE TABLE [dbo].[Travel](
[ID] [uniqueidentifier] NOT NULL,
[Place] [nvarchar](200) NOT NULL,
[GeoLocation] [geography] NOT NULL,
[Time] [datetime] NOT NULL,
CONSTRAINT [PK_Travel] PRIMARY KEY CLUSTERED
(
[ID] ASC
),
CONSTRAINT [IX_Travel] UNIQUE NONCLUSTERED
(
[Place] ASC,
[Time] ASC
)
)
请注意GeoLocation列的数据类型是geography。
数据是以二进制存储的,但是你并不需要了解二进制格式。你可以使用Microsoft.SqlServer.Types.dll程序集中的类型来创建数据。这个程序集是一个SQL CLR程序集。就是说它既可以在托管语言中使用,又可以在T-SQL中使用。
例如,在C#中根据经纬度创建一个geography对象:
SqlGeography sqlGeography = SqlGeography.Point(latitude, longitude, 4326);
上述代码中的4326是geography数据规定的使用参数,没有特殊含义。
在T-SQL中使用WKT创建一个geography对象:
Geography::STGeomFromText(@GeoLocation, 4326)
你可以选择任何你喜欢的编程序言。但是通常,如果一个对象只是临时创建(例如创建一个临时对象用于计算两点间的距离),我们直接在应用程序级别用托管语言写代码,而不需要经过数据库。尤其是当数据库位于云端而不是本地时。
如果数据要被存储至数据库,使用T-SQL编写的存储过程可以提高性能。此外,目前Entity Framework并不直接支持spatial数据。所以如果我们选择Entity Framework用于数据访问层,我们必须提供存储过程,并在EF代码中访问。
以下是我们的旅游计划程序中的InsertIntoTravel存储过程。请注意STGeomFromText方法的调用:
CREATE PROCEDURE [dbo].[InsertIntoTravel]
@ID uniqueidentifier,
@Place nvarchar(200),
@GeoLocation varchar(max),
@Time datetime
AS
BEGIN
Insert Into Travel
Values(@ID, @Place, Geography::STGeomFromText(@GeoLocation, 4326),@Time)
END
同样,这是UpdateTravel存储过程:
CREATE PROCEDURE [dbo].[UpdateTravel]
@ID uniqueidentifier,
@Place NVarchar(50),
@GeoLocation NVarchar(max),
@Time datetime
AS
BEGIN
Update [dbo].[Travel]
Set [Place] = @Place,
[GeoLocation] = Geography::STGeomFromText(@GeoLocation, 4326),
[Time] = @Time
Where ID = @ID
END
这是DeleteFromTravel存储过程:
CREATE PROCEDURE [dbo].[DeleteFromTravel]
@ID uniqueidentifier
AS
BEGIN
Delete From Travel Where ID = @ID
END
有关spatial数据的更多信息,请访问http://msdn.microsoft.com/en-us/library/bb933876.aspx。
刚刚已经说过了,目前Entity Framework并不直接提供对spatial数据的支持。但是它真的是一个很好很强大的O/R mapping框架,仅仅因为不支持spatial数据就把Entity Framework彻底扔掉是一种很不明智的选择。我们来找找看在你的EF模型中使用spatial数据的方法。
如果你针对上述Travel表创建一个EF模型,你会发现GeoLocation列并不存在于模型中,而且你无法手工在存储模型中创建一个spatial类型的属性。然而,EF是支持二进制数据的,而刚才也说过,SQL Azure在内部就是用二进制格式来表示数据的。因此我们可以在数据库中创建一个视图,把spatial数据转化成二进制数据。
CREATE VIEW [dbo].[TravelView]
AS
SELECT ID, Place, CAST(GeoLocation AS varbinary(MAX)) ASGeoLocation, Time
FROM dbo.Travel
现在针对该视图常见一个EF模型,你会发现一切正常。
EF中的视图都是只读的。不过我们刚才已经创建过了必要的存储过程,因此我们只需要通过function imports将存储过程导入到EF模型中,并且调用它们来保存修改。以下是EF的ObjectContext类的分部类代码。请注意在SaveChanges方法中,我们仅仅是在调用存储过程:
internal partial class TravelEntities { internal List<TravelEntity> InsertedEntities = new List<TravelEntity>(); internal List<TravelEntity> UpdatedEntities = new List<TravelEntity>(); internal List<TravelEntity> DeletedEntities = new List<TravelEntity>(); public override int SaveChanges(System.Data.Objects.SaveOptions options) { try { foreach (var item in this.InsertedEntities) { this.InsertIntoTravel(Guid.NewGuid(), item.Place, item.GeoLocationText, item.Time); } foreach (var item in this.UpdatedEntities) { this.UpdateTravel(item.ID, item.Place, item.GeoLocationText, item.Time); } foreach (var item in this.DeletedEntities) { this.DeleteFromTravel(item.ID); } } catch (Exception ex) { throw new EntitySqlException("An error occurred when modifying data in the database. Please refer to inner exception for more detail.", ex); } return 0; } }
GeoLocationText是在entity类的分部类中定义的一个属性。它代表了地理位置的WKT形式,不过并不会被存到数据库中。
internal partial class TravelEntity { internal string GeoLocationText { get; set; } }
目前我们的程序还很简单,但是我们必须考虑将来可能的扩展。因此我们一定要采用SOA。此外,我们打算创建一个AJAX客户端,AJAX客户端无法直接和Entity Framework打交道,必须通过一个web service。既然我们是在暴露数据,WCF Data Services自然是首选项。
我们可以直接使用EF模型作为data contract,但是这种方案至少有两个问题:第一,大多数客户端程序并不关心我们如何存储数据。它们希望使用常见的Latitude和Longitude属性,而不是WKT或WKB。许多客户端程序开发人员可能根本不知道spatial数据。第二,如果键来我们的模型发生了变化,data contract也不得不变化,那么所有的客户端程序都可能会停止运行。所以我们有必要创建一个对客户端程序而言更有好的data contract:
[DataServiceKeyAttribute("ID")] public class Travel { public Guid ID { get; set; } public string Place { get; set; } public DateTime Time { get; set; } public double Latitude { get; set; } public double Longitude { get; set; } }
在WCF Data Services中使用我们自己的data contract,就意味着我们不得不创建一个reflection provider,而不能依赖于内置的object context provider。不管怎样,我们都需要reflection provider的,因为目前object context provider并不支持EF entity类中没有被映射到模型上的自定义属性。所以事实上我们并不需要比原来多学太多的代码……
本文不会深入探讨如何创建reflection provider,毕竟这和云开发并没有太大的关系。你可以参考http://msdn.microsoft.com/en-us/library/dd723653.aspx上的详细信息。你还可以阅读这一系列深入探讨reflection provider的博客文章。简而言之,就是要创建一个实现了IUpdateable接口的类,并且实现必要的方法。以下是完整的代码。我们把data contract (Travel)转换成了EF entity (TravelEntity),并将存储数据的工作委任给EF。
public class TravelDataServiceContext : IUpdatable { private TravelEntities _entityFrameworkContext = new TravelEntities(); private List<Travel> _travels; private List<Travel> _insertedTravels = new List<Travel>(); private List<Travel> _updatedTravels = new List<Travel>(); private List<Travel> _deletedTravels = new List<Travel>(); public TravelDataServiceContext() { this._travels = new List<Travel>(); foreach (var entity in this._entityFrameworkContext.TravelEntitySet) { LatLong geoLocation = this.WKBToLatLong(entity.GeoLocationBinary); this._travels.Add(new Travel() { ID = entity.ID, Place = entity.Place, Time = entity.Time, Latitude = geoLocation.Latitude, Longitude = geoLocation.Longitude }); } } public IQueryable<Travel> Travels { get { return this._travels.AsQueryable<Travel>(); } } #region IUpdatable Members public void AddReferenceToCollection(object targetResource, string propertyName, object resourceToBeAdded) { throw new NotImplementedException(); } public void ClearChanges() { throw new NotImplementedException(); } public object CreateResource(string containerName, string fullTypeName) { try { Type t = Type.GetType(fullTypeName, true); object resource = Activator.CreateInstance(t); if (resource is Travel) { this._insertedTravels.Add((Travel)resource); } return resource; } catch (Exception ex) { throw new InvalidOperationException("Failed to create resource. See the inner exception for more details.", ex); } } public void DeleteResource(object targetResource) { if (targetResource is Travel) { Travel travel = (Travel)targetResource; this._deletedTravels.Add(travel); } } public object GetResource(IQueryable query, string fullTypeName) { object resource = query.Cast<object>().SingleOrDefault(); if (fullTypeName != null && resource.GetType().FullName != fullTypeName) { throw new ApplicationException("Unexpected type for this resource."); } return resource; } public object GetValue(object targetResource, string propertyName) { throw new NotImplementedException(); } public void RemoveReferenceFromCollection(object targetResource, string propertyName, object resourceToBeRemoved) { throw new NotImplementedException(); } public object ResetResource(object resource) { if (resource is Travel) { this._updatedTravels.Add((Travel)resource); } return resource; } public object ResolveResource(object resource) { return resource; } public void SaveChanges() { foreach (Travel t in this._insertedTravels) { TravelEntity entity = new TravelEntity() { Place = t.Place, Time = t.Time, GeoLocationText = this.LatLongToWKT(t.Latitude, t.Longitude) }; this._entityFrameworkContext.InsertedEntities.Add(entity); } foreach (Travel t in this._updatedTravels) { TravelEntity entity = new TravelEntity() { ID = t.ID, Place = t.Place, Time = t.Time, GeoLocationText = this.LatLongToWKT(t.Latitude, t.Longitude) }; this._entityFrameworkContext.UpdatedEntities.Add(entity); } foreach (Travel t in this._deletedTravels) { // For delete, we only need ID. TravelEntity entity = new TravelEntity() { ID = t.ID }; this._entityFrameworkContext.DeletedEntities.Add(entity); } this._entityFrameworkContext.SaveChanges(); } public void SetReference(object targetResource, string propertyName, object propertyValue) { throw new NotImplementedException(); } public void SetValue(object targetResource, string propertyName, object propertyValue) { try { var property = targetResource.GetType().GetProperty(propertyName); if (property == null) { throw new InvalidOperationException("Invalid property: " + propertyName); } property.SetValue(targetResource, propertyValue, null); } catch (Exception ex) { throw new InvalidOperationException("Failed to set value. See the inner exception for more details.", ex); } } #endregion private string LatLongToWKT(double latitude, double longitude) { SqlGeography sqlGeography = SqlGeography.Point(latitude, longitude, 4326); return sqlGeography.ToString(); } private LatLong WKBToLatLong(byte[] wkb) { using (MemoryStream ms = new MemoryStream(wkb)) { using (BinaryReader reader = new BinaryReader(ms)) { SqlGeography sqlGeography = new SqlGeography(); sqlGeography.Read(reader); return new LatLong() { Latitude = sqlGeography.Lat.Value, Longitude = sqlGeography.Long.Value }; } } } } public struct LatLong { public double Latitude; public double Longitude; }
现在服务已经准备就绪,下一步就是创建客户端程序了。Bing Maps针对AJAX和Silverlight都提供了SDK。针对其它客户端,你也可以直接使用SOAP或REST SDK。本文主要演示如何创建一个AJAX客户端,Silverlight客户端实现起来要比AJAX客户端容易多了,你应该很快就能自动上手。
创建地图之前,你必须自https://www.bingmapsportal.com/注册一个帐号。在注册过程中,你会获得一个密码用于你的应用程序中。
var map; var mapCredential = 'your credential';
第一步是将Bing Maps相关脚本引用到HTML文件中:
<script type="text/javascript" src="http://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx"></script>
然后加载地图:
$(document).ready(LoadMap); function LoadMap() { map = new VEMap('MainMap'); map.SetCredentials(mapCredential); // Cannot use onclick since it fires when panning the map. // map.AttachEvent('onclick', Map_OnClick); map.AttachEvent('onmousedown', Map_OnMouseDown); map.AttachEvent('onmouseup', Map_OnMouseUp); map.AttachEvent("onmouseover", Map_OnMouseOver); map.LoadMap(new VELatLong(31, 121)); }
VEMap类的构造函数使用一个参数,它是一个HTML元素,表示地图的承载体。你可以简单使用一个div:
<div id="MainMap" style="width: 100%; height: 600px;"/>
VEMap支持标准的DOM事件,例如onmousedown,从而使得我们能够为地图提供交互功能。你并不需要些代码来实现标准的操作,例如平移和缩放。撰写事件处理程序更多地是为了提供自定义行为。例如,当用户点击地图时,我们可以在点击的位置创建一个pushpin 。请注意标准操作,例如平移,也会触发onclick事件,因此通常我们会选择处理onmousedown/up事件。
下述代码演示了如何在点击位置穿件一个pushpin。部分代码调用了Bing Maps REST service,我们会在下一章节中介绍。现在,请注意怎样获得点击位置。e.MapX/Y返回的是地图坐标系中的xy坐标。我们需要通过PixelToLatLong方法将它转换成纬度和经度。
var mouseDownLocation;
function Map_OnMouseDown(e) { mouseDownLocation = new VEPixel(e.mapX, e.mapY); } function Map_OnMouseUp(e) { var pixel = new VEPixel(e.mapX, e.mapY); // Only add a pushpin if the user is not panning the map. if (mouseDownLocation != null && mouseDownLocation.x == pixel.x && mouseDownLocation.y == pixel.y) { var latLong = map.PixelToLatLong(pixel); // Invoke the Location REST service to obtain information of the clicked place. $.ajax( { url: 'http://dev.virtualearth.net/REST/v1/Locations/' + latLong.Latitude + ',' + latLong.Longitude + '?o=json&jsonp=LocationCallback&key=' + mapCredential, dataType: 'jsonp', jsonp: 'LocationCallback', success: LocationCallback }); } }
function LocationCallback(result) { if (result.resourceSets.length > 0) { var resourceSet = result.resourceSets[0]; if (resourceSet.resources.length > 0) { var resource = resourceSet.resources[0]; // Code related to data manipulation omitted.
// Add a pushpin.
var point = new VEShape(VEShapeType.Pushpin, new VELatLong(resource.point.coordinates[0], resource.point.coordinates[1]));
point.SetTitle(resource.name); map.AddShape(point);
// Code related to data manipulation omitted.
} } }
接下来,我们调用Bing Maps REST service取得点击位置的更详细的信息。在回调函数中,创建一个pushpin并且显示到地图上。为了达成这个目的,使用适当的数据创建一个VELatLong对象,并且调用VEMap.AddShape。你可以使用SetTitle方法设置在用户鼠标移到该pushpin上之时展示的数据。在这个例子中,我们显示该位置的名称(例如城市名)。
Bing Maps同时提供了SOAP和REST服务。既然我们使用的是AJAX客户端,我们自然选择REST服务。Bing Maps REST Services同时支持XML和JSON。同理,在AJAX客户端中我们通常选择JSON。
上述代码已经展示了如何调用简单的REST API。注意到o这个query string的值是json,意味着我们希望服务以JSON格式返回数据。Bing Maps REST Services支持JSONP,因此你可以自AJAX客户端跨域访问服务。此外,尽管在这边没有演示,但其实Bing Maps REST Services也针对Silverlight和Flash提供了cross domain policy files。最后,注意每个请求都必须提供密码。你可以自https://www.bingmapsportal.com/注册以获取密码。
有关Bing Maps REST Services的更多信息,请参考http://msdn.microsoft.com/en-us/library/ff701713.aspx。有关SOAP Services的信息可以自http://msdn.microsoft.com/en-us/library/cc980922.aspx取得。
有关Bing Maps AJAX SDK的更多信息,请参考http://msdn.microsoft.com/en-us/library/bb429619.aspx和http://www.microsoft.com/maps/isdk/ajax/。有关Silverlight SDK的信息可以自http://msdn.microsoft.com/en-us/library/ee681884.aspx和http://www.microsoft.com/maps/isdk/silverlight/取得。
现在你知道了如何显示地图以及创建pushpin。下一步当然就是将数据存储至/加载自SQL Azure数据库了,使用刚才创建的WCF Data Services。
别忘了WCF Data Services就是REST services。你可以使用标准的jQuery像访问一般的REST API那样调用它。用GET查询,用POST插入,用PUT更新,用DELETE删除。
$.ajax( { type: 'GET', url: dataServiceUri + '?$orderby=Time', dataType: 'json', success: LoadDataCompleted });
function PostToDS() { var item = SearchItems(this); $.ajax( { type: 'POST', url: dataServiceUri, contentType: 'application/json; charset=utf-8', data: JSON.stringify(item.Value), datatype: 'json' }); } function PutToDS() { var item = SearchItems(this); $.ajax( { type: 'PUT', url: dataServiceUri + "(guid'" + this + "')", contentType: 'application/json; charset=utf-8', data: JSON.stringify(item.Value), datatype: 'json' }); } function DeleteFromDS() { $.ajax( { type: 'DELETE', url: dataServiceUri + "(guid'" + this + "')", contentType: 'application/json; charset=utf-8', datatype: 'json' }); }
在POST和PUT操作中,你需要提供符合OData标准的数据。为了简化数据序列化的工作,我们使用标准的JSON stringifier。
现在我们为程序添加一个新功能。当我们在列表中选择一个地点时,如果将鼠标移到地图上的某个pushpin,就显示两地间的距离。为了达成这个目标,首先要在WCF Data Services中创建一个自定义服务操作。
[WebGet]
public double DistanceBetweenPlaces(double latitude1, double latitude2,double longitude1, double longitude2)
{
SqlGeography geography1 = SqlGeography.Point(latitude1, longitude1, 4326);
SqlGeography geography2 = SqlGeography.Point(latitude2, longitude2, 4326);
return geography1.STDistance(geography2).Value;
}
上述代码又一次使用了spatial数据来计算两地间的距离。
在客户端,我们可以轻松调用这个操作,并使用VEShape.SetDescription和VEMap.ShowInfoBox将距离展示在地图上:
var url = dataServiceUri + '/DistanceBetweenPlaces?'
+ 'latitude1=' + selectedItem.Value.Latitude
+ '&longitude1=' + selectedItem.Value.Longitude
+ '&latitude2=' + latlong.Latitude
+ '&longitude2=' + latlong.Longitude;
$.ajax(
{
type: 'GET',
url: url,
dataType: 'json',
success: function (result)
{
pushpin.SetDescription('Distance between ' + selectedItem.Value.Place + ' and ' + pushpin.Title + ': ' + result.d.DistanceBetweenPlaces);
map.ShowInfoBox(pushpin);
}
});
当然啦,要创建一个完整的程序,还有很多工作要做。例如在客户端创建��维护数据,展示数据,跟踪数据状态,等等。这些代码比较长,而且大多数代码都和SQL Azure以及Bing Maps没有直接关系,因此我们不准备贴在这里。你可以随时参考示例代码。如果你觉得理解代码有困难,请参考这篇博客文章,他会告诉你如何在AJAX客户端访问WCF Data Services。当然,基本的DOM,CSS,以及jQuery UI的知识也会很有帮助的。
在这片博客文章中,我们看到了如何组合SQL Azure和Bing Maps的威力,创建一个旅游计划系统,展示一张地图,并且把旅游景点位置信息存放至SQL Azure。当多个云被组合起来之时,我们就可以创建新时代的用户体验,并且获得一个统一的数据存储中心。