Odoo 在 OCA 有一个 geospatial 项目,这个 addon 存在很多年了,但一直没有进入 Odoo 官方的法眼,始终在 OCA 游荡,但是非常活跃,第一时间跟进 Odoo 的版本进化。
随着 Odoo 官方不顾后劲同学的死活每年更新一个大版本,OCA 的 addon 很多已经放弃了更新,消沉于在茫茫代码的海洋。这也是最近几年我们常常看到开源软件的一种新模式,通过商业公司的强力支持,不断抛弃那些附着在开源软件上的‘吸血者’。曾经 OCA 有成百上千大的 addon,现在大多都不灵了。比如 Google 的 Android 非常明显,不把追随者累吐血而亡是绝对不能罢休的。
但是 OCA/geospatial 没有沦落,它挺住了。
LBS 的简单需求
一个 简单的 LBS 服务需求,按照与用户距离排序找一些饭店给客户做选择,这样就需要拿到用户的地理位置,与数据库中的已经存在的饭店信息进行距离计算并且排序。
这个需求看上去如此简单,也如此常见,美团、点评之类的软件都‘轻松’实现了。但是实际上这个需求在一个普通数据库里面是无法实现的。
因为,客户的位置是变的,每个客户的请求都是不同的客户位置,也就是数据库的数据排序不是完全根据数据库的存储信息,而是要根据不同的客户的位置。
另外距离的计算,不是欧式距离那么简单,如果你曾经在‘石器时代’了解 GIS 系统,那么你多少了解 GIS 中通过坐标计算距离的方法不是算个欧式距离那么简单。参见
https://www.jianshu.com/p/9ed3b4dcd32a
实现方法
PostgreSQL 通过 PostGIS 扩展支持 GIS,Odoo 通过 geospatial 支持 PostGIS,所以要先安装 PostGIS,不同平台安装方法不一样。我尝试使用 Homebrew 在 Mac上安装失败,放弃了。直接在 Debian Linux 通过 apt 按装 PostGIS 没有问题。
在 Github 上 clone OCA/geospatial 的代码,geospatial 实际上包含了几个 addons,其中 base_geoengine 就够满足我们的需求了。
Odoo addon 会有一些 hook,可以在 addon 安装之前之后或者完成加载之后运行,base_geoengine 就利用了这个机制,为数据库动态加载了 PostGIS 的扩展。
Model 定义
# 饭店位置
geo_point = fields.GeoPoint("地址位置", srid=4326)
# 当前用户的距离
session_distance = fields.Float("距离", digits=(16,1), compute='_compute_session_distance')
其中的 srid 是所选取的空间参考系统ID。(A Spatial Reference System Identifier(SRID) is a unique value used to unambiguously identify projected, unprojected, and local spatial coordinate system definitions. These coordinate systems form the heart of all GIS applications.)
其中 GeoPoint 就是 geoengine base 提供的。
距离计算
@api.depends()
def _compute_session_distance(self):
_longtitude = self._context.get("longtitude")
_latitude = self._context.get("latitude")
if not _longtitude or not _latitude:
for model in self:
model.session_distance = 0
return
cr = self.env.cr
for model in self:
if not model.geo_point:
model.session_distance = 0
continue
sql = "select ST_X(geo_point) as lon, ST_Y(geo_point) as lat from %s where id=%s" % (self._table, model.id)
cr.execute(sql)
r = cr.fetchone()
X = r[0]
Y = r[1]
sql = "SELECT ST_Distance(ST_GeomFromText('POINT(%s %s)', 4326)::geography, ST_GeomFromText('POINT(%s %s)', 4326)::geography) from %s" % (X, Y, _longtitude, _latitude, self._table)
cr.execute(sql)
model.session_distance = round(cr.fetchone()[0], 1)
session distance 这个 field 是通过计算而来(不是存储而来),每次客户请求这个数据的时候都根据当前的context 中客户的经纬度来计算。
按照距离排序
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
_longtitude = self._context.get("longtitude")
_latitude = self._context.get("latitude")
_logger.info("context ... %s" % self._context)
# if order not default return default search
if not _longtitude or not _latitude or order:
return super(self.__class__, self)._search(args, offset, limit, order, count, access_rights_uid)
if _longtitude and _latitude and not order:
query = self._where_calc(args)
order_by = "ORDER BY distance asc"
from_clause, where_clause, where_clause_params = query.get_sql()
where_str = where_clause and (" WHERE %s" % where_clause) or ''
limit_str = limit and ' limit %d' % limit or ''
offset_str = offset and ' offset %d' % offset or ''
distance_str = "ST_distance(geo_point::geography, ST_GeomFromText('POINT(%s %s)', 4326)::geography) as distance" % (self._context.get("longtitude"), self._context.get("latitude"))
query_str = 'SELECT %s, id FROM %s %s' % (distance_str, self._table, where_str + order_by + limit_str + offset_str)
self.env.cr.execute(query_str, where_clause_params)
res = self.env.cr.fetchall()
# TDE note: with auto_join, we could have several lines about the same result
# i.e. a lead with several unread messages; we uniquify the result using
# a fast way to do it while preserving order (http://www.peterbe.com/plog/uniqifiers-benchmark)
def _uniquify_list(seq):
seen = set()
return [x for x in seq if x not in seen and not seen.add(x)]
return _uniquify_list([x[1] for x in res])
return super(self.__class__, self)._search(args, offset, limit, order, count, access_rights_uid)
搜索排序 overload 了 _search 函数。 其中使用了 PostGIS 的函数计算距离,并且按照距离排序。
Model 初始数据
<record id="location_thing_id_1" model="location.some_model">
<field name="geo_longtitude">116.601144</field>
<field name="geo_latitude">39.948574</field>
...
<field name="geo_point">POINT(116.601144 39.948574)</field>
</record>
如果在定义 geo point 的时候没有指定 srid,那么这个 POINT 值也无法确定意义。