程序员人生 网站导航

深入浅出Symfony2 - 结合MongoDB开发LBS应用

栏目:access时间:2014-10-18 08:00:00

http://www.infoq.com/cn/articles/depth-study-of-Symfony2

简介

随着近几年各类移动终端的迅速普及,基于地理位置的服务(LBS)和相关应用也越来越多,而支撑这些应用的最基础技术之一,就是基于地理位置信息的处理。我所在的项目也正从事相关系统的开发,我们使用的是Symfony2+Doctrine2 ODM+MongoDB的组合。

我们将这些技术要点整理成文,希望能够通过本文的介绍和案例,详细解释如何使用MongoDB进行地理位置信息的查询和处理。在文章的开头,我们也会先介绍一下业界通常用来处理地理位置信息的一些方案并进行比较,让读者逐步了解使用MongoDB查询及处理地理位置信息的优势。

本文使用了Symfony2和Doctrine2作为Web应用的开发框架,对于想了解Symfony2的数据库操作的读者来说阅读本文也可以了解和掌握相关的技术和使用方法。

 


不管是什么LBS应用,一个共同的特点就是:他们的数据都或多或少包含了地理位置信息。而如何对这些信息进行查询、处理、分析,也就成为了支撑LBS应用的最基础也是最关键的技术问题。1. LBS类应用特点

而由于地理位置信息的特殊性,在开发中经常会有比较难以处理的问题出现,比如:由于用户所在位置的不固定性,用户可能会在很小范围内移动,而此时经纬度值也会随之变化;甚至在同一个位置,通过GPS设备获取到的位置信息也可能不一样。所以如果通过经纬度去获取周边信息时,就很难像传统数据库那样做查询并进行缓存。

对于这个问题,有读者可能会说有别的处理方案,没错,比如只按经纬度固定的几位小数点做索引,比如按矩阵将用户划分到某固定小范围的区域(可以参考后文将会提到的geohash)等方式,虽然可以绕个弯子解决,但或多或少操作起来比较麻烦,也会牺牲一些精度,甚至无法做到性能的最优化,所以不能算作是最佳的解决办法。

 

而最近几年,直接支持地理位置操作的数据库层出不穷,其操作友好、性能高的特性也开始被我们慢慢重视起来,其中的佼佼者当属MongoDB。

MongoDB在地理位置信息的处理上有什么优势?下面我们通过一个简单的案例来对比一下各种技术方案之间进行进行地理位置信息处理的差异。

2. 几个地理位置信息处理方案的对比和分析

1. 确定功能需求

对于任何LBS应用来说,让用户寻找周围的好友可能都是一个必不可少的功能,我们就以这个功能为例,来看看各种处理方案之间的差异和区别。

我们假设有如下功能需求:

  • 显示我附近的人
  • 由近到远排序
  • 显示距离

2. 可能的技术方案

排除一些不通用和难以实现的技术,我们罗列出以下几种方案:

  1. 基于MySQL数据库
  2. 采用GeoHash索引,基于MySQL
  3. MySQL空间存储(MySQL Spatial Extensions)
  4. 使用MongoDB存储地理位置信息

我们一个个来分析这几种方案。

方案1:基于MySQL数据库

MySQL的使用非常简单。对于大部分已经使用MySQL的网站来说,使用这种方案没有任何迁移和部署成本。

而在MySQL中查询“最近的人”也仅需一条SQL即可,

SELECT id, ( 6371 * acos( cos( radians(37) ) * cos( radians( lat ) ) * cos( radians ( lng ) - radians(-122) ) + sin( radians(37) ) * sin( radians( lat ) ) ) ) AS distance FROM places HAVING distance < 25 ORDER BY distance LIMIT 0 , 100;

注:这条SQL查询的是在lat,lng这个坐标附近的目标,并且按距离正序排列,SQL中的distance单位为公里。

但使用SQL语句进行查询的缺点也显而易见,每条SQL的计算量都会非常大,性能将会是严重的问题。

先别放弃,我们尝试对这条SQL做一些优化。

可以将圆形区域抽象为正方形,如下图

根据维基百科上的球面计算公式,可以根据圆心坐标计算出正方形四个点的坐标。

然后,查询这个正方形内的目标点。

SQL语句可以简化如下:

SELECT * FROM places WHERE ((lat BETWEEN ? AND ?) AND (lng BETWEEN ? AND ?))

这样优化后,虽然数据不完全精确,但性能提升很明显,并且可以通过给lat lng字段做索引的方式进一步加快这条SQL的查询速度。对精度有要求的应用也可以在这个结果上再进行计算,排除那些在方块范围内但不在原型范围内的数据,已达到对精度的要求。

可是这样查询出来的结果,是没有排序的,除非再进行一些SQL计算。但那又会在查询的过程中产生临时表排序,可能会造成性能问题。

方案2:GeoHash索引,基于MySQL

GeoHash是一种地址编码,通过切分地图区域为小方块(切分次数越多,精度越高),它能把二维的经纬度编码成一维的字符串。也就是说,理论上geohash字符串表示的并不是一个点,而是一个矩形区域,只要矩形区域足够小,达到所需精度即可。(其实MongoDB的索引也是基于geohash)

如:wtw3ued9m就是目前我所在的位置,降低一些精度,就会是wtw3ued,再降低一些精度,就会是wtw3u。(点击链接查看坐标编码对应Google地图的位置)

所以这样一来,我们就可以在MySQL中用LIKE ‘wtw3u%’来限定区域范围查询目标点,并且可以对结果集做缓存。更不会因为微小的经纬度变化而无法用上数据库的Query Cache。

这种方案的优点显而易见,仅用一个字符串保存经纬度信息,并且精度由字符串从头到尾的长度决定,可以方便索引。

但这种方案的缺点是:从geohash的编码算法中可以看出,靠近每个方块边界两侧的点虽然十分接近,但所属的编码会完全不同。实际应用中,虽然可以通过去搜索环绕当前方块周围的8个方块来解决该问题,但一下子将原来只需要1次SQL查询变成了需要查询9次,这样不仅增大了查询量,也将原本简单的方案复杂化了。

除此之外,这个方案也无法直接得到距离,需要程序协助进行后续的排序计算。

方案3:MySQL空间存储

MySQL的空间扩展(MySQL Spatial Extensions),它允许在MySQL中直接处理、保存和分析地理位置相关的信息,看起来这是使用MySQL处理地理位置信息的“官方解决方案”。但恰恰很可惜的是:它却不支持某些最基本的地理位置操作,比如查询在半径范围内的所有数据。它甚至连两坐标点之间的距离计算方法都没有(MySQL Spatial的distance方法在5.*版本中不支持)

官方指南的做法是这样的:

GLength(LineStringFromWKB(LineString(point1, point2)))

这条语句的处理逻辑是先通过两个点产生一个LineString的类型的数据,然后调用GLength得到这个LineString的实际长度。

这么做虽然有些复杂,貌似也解决了距离计算的问题,但读者需要注意的是:这种方法计算的是欧式空间的距离,简单来说,它给出的结果是两个点在三维空间中的直线距离,不是飞机在地球上飞的那条轨迹,而是笔直穿过地球的那条直线。

所以如果你的地理位置信息是用经纬度进行存储的,你就无法简单的直接使用这种方式进行距离计算。

方案4:使用MongoDB存储地理位置信息

MongoDB原生支持地理位置索引,可以直接用于位置距离计算和查询。

另外,它也是如今最流行的NoSQL数据库之一,除了能够很好地支持地理位置计算之外,还拥有诸如面向集合存储、模式自由、高性能、支持复杂查询、支持完全索引等等特性。

对于我们的需求,在MongoDB只需一个命令即可得到所需要的结果:

db.runCommand( { geoNear: "places", near: [ 121.4905, 31.2646 ], num:100 })

查询结果默认将会由近到远排序,而且查询结果也包含目标点对象、距离目标点的距离等信息。

由于geoNear是MongoDB原生支持的查询函数,所以性能上也做到了高度的优化,完全可以应付生产环境的压力。

方案总结

基于MongoDB做附近查询是很方便的一件事情。

MongoDB在地理位置信息方面的表现远远不限于此,它还支持更多更加方便的功能,如范围查询、距离自动计算等。

接下来,我们结合Symfony2来详细地演示一些使用MongoDB进行地理位置信息处理的例子。

3. 结合Symfony2演示

运行环境

参考环境:Nginx1.2 + PHP5.4 + MongoDB2.4.3 + Symfony2.1

建立coordinate和places两个document文件,前者是作为places内的一个embed字段. 为方便演示效果,这里同时设置了两个索引 2d 和 2dsphere

Document/Coordinate.php /** * @MongoDBEmbeddedDocument */ class Coordinate { /** * @MongoDBField(type="float") */ public $longitude; /** * @MongoDBField(type="float") */ public $latitude; ... } Document/Place.php /** * @MongoDBDocument(collection="places") * @MongoDBChangeTrackingPolicy("DEFERRED_EXPLICIT") * @MongoDBIndexes({ * @MongoDBIndex(keys={"coordinate"="2d"}), * @MongoDBIndex(keys={"coordinate"="2dsphere"}) * }) */ class Place { /** * * @MongoDBId(strategy="INCREMENT") */ protected $id; /** * @MongoDBField(type="string") */ protected $title; /** * @MongoDBField(type="string") */ protected $address; /** * @MongoDBEmbedOne(targetDocument="HenterGEOGEOBundleDocumentCoordinate") */ protected $coordinate; /** * @MongoDBDistance */ public $distance; ... }

坐标保存以longitude, latitude这个顺序(没有明确的限制和区别,但我们在此遵循官方的推荐)。

另外,为直观显示查询效果,默认使用百度地图标记查询数据。

程序说明

我们用到的代码包是doctrine/mongodb-odm-bundle(下文称ODM),这个代码包提供了在Symfony2环境下的MongoDB数据库支持,使用这个代码包,可以让我们更加方便的在Symfony2环境下操作MongoDB数据库。。

ODM封装了MongoDB中常用的一些地理位置函数,如周边搜索和范围搜索。

ODM中的操作默认距离单位是度,只有geoSphere支持弧度单位(必须在参数中指定spherical(true))

4. MongoDB的地理位置查询

注意事项

  1. 下文大多数直接对MongoDB的数据库操作将使用Mongo Shell进行演示。在演示网站页面和功能时,将结合Symfony2、Doctrine-MongoDB进行演示。
  2. 本文演示所用的MongoDB版本为2.4.3,版本号比较新,所以某些查询方式在低版本里面并不支持。
  3. 以places这个collection为例,大部分例子都需要类似下面格式的测试数据支持: { "_id" : 2, "coordinate" : { "longitude" : 121.3449, "latitude" : 31.17528 }, "title" : "仅售75元,市场价210元的顶呱呱田鸡火锅3-4人套餐,无餐具费,冬日暖锅,欢迎品尝", "address" : "闵行区航新路634号" }

地理位置索引:

MongoDB地理位置索引常用的有两种。

  • 2d 平面坐标索引,适用于基于平面的坐标计算。也支持球面距离计算,不过官方推荐使用2dsphere索引。
  • 2dsphere 几何球体索引,适用于球面几何运算

关于两个坐标之间的距离,官方推荐2dsphere:

MongoDB supports rudimentary spherical queries on flat 2d indexes for legacy reasons. In general, spherical calculations should use a 2dsphere index, as described in 2dsphere Indexes.

不过,只要坐标跨度不太大(比如几百几千公里),这两个索引计算出的距离相差几乎可以忽略不计。

建立索引:

> db.places.ensureIndex({'coordinate':'2d'}) > db.places.ensureIndex({'coordinate':'2dsphere'})

查询方式:

查询方式分三种情况:

  1. Inclusion。范围查询,如百度地图“视野内搜索”。
  2. Inetersection。交集查询。不常用。
  3. Proximity。周边查询,如“附近500内的餐厅”。

而查询坐标参数则分两种:

  1. 坐标对(经纬度)根据查询命令的不同,$maxDistance距离单位可能是 弧度 和 平面单位(经纬度的“度”):

    db..find( { : { $nearSphere: [ , ] , $maxDistance: } } )
  2. GeoJson $maxDistance距离单位默认为米:

    db..find( { : { $nearSphere : { $geometry : { type : "Point" , coordinates : [ , ] } , $maxDistance : } } } )

案例A:附近的人

查询当前坐标附近的目标,由近到远排列。

可以通过$near或$nearSphere,这两个方法类似,但默认情况下所用到的索引和距离单位不同。

查询方式:

> db.places.find({'coordinate':{$near: [121.4905, 31.2646]}}) > db.places.find({'coordinate':{$nearSphere: [121.4905, 31.2646]}})

查询结果:

{ "_id" : 115, "coordinate" : { "longitude" : 121.4915, "latitude" : 31.25933 }, "title" : "仅售148元,市场价298元的星程上服假日酒店全日房一间入住一天, 节假日通用,精致生活,品质享受", "address" : "虹口区天水路90号" } …(100条)

上述查询坐标[121.4905, 31.2646]附近的100个点,从最近到最远排序。

默认返回100条数据,也可以用limit()指定结果数量,如

> db.places.find({'coordinate':{$near: [121.4905, 31.2646]}}).limit(2)

指定最大距离 $maxDistance

> db.places.find({'coordinate':{$near: [121.4905, 31.2646], $maxDistance:2}})

结合Symfony2进行演示:

这里用near,默认以度为单位,公里数除以111(关于该距离单位后文有详细解释)。

/** * @Route("/near", name="near") * @Template() */ public function nearAction(){ $longitude = (float)$this->getRequest()->get('lon',121.4905); $latitude = (float)$this->getRequest()->get('lat',31.2646); //2km $max = (float)$this->getRequest()->get('max', 2); $places = $this->getPlaceRepository()->createQueryBuilder() ->field('coordinate')->near($longitude, $latitude) ->maxDistance($max/111) ->getQuery()->toarray(); return compact('places','max','longitude','latitude'); }

通过 domain.dev/near 访问,效果如下:

longitude: xxx, latitude: xxx为当前位置,我们在地图上显示了周边100条目标记录

案例B:区域内搜索

MongoDB中的

------分隔线----------------------------
------分隔线----------------------------

最新技术推荐