软硬件交互

通过 Bridge App 与高德地图 API实现数据交互

这篇教程将通过一个实际案例讲述如何一步一步地从地图平台的 API 上获取数据,并与ProtoPie Connect之间实现消息收发。教程中的斜体字部分为主干内容所依赖的背景知识或辅助知识,如果发现自身对斜体字部分的内容事先已经了解,可以略过不看。

ProtoPie

August 25, 2023

通过 Bridge App 与高德地图 API实现数据交互

素材文件

1. Pie文件下载
2. Bridge App文件
import socketio
import re
import requests

#Server Addr
address = 'http://127.0.0.1:9981'

#socket.io client
client = socketio.Client()

#Gaode API Key
GaodeKey='643XXXXXXXXXXX'

@client.on('connect')
def on_connect():
    print('Connected')
    client.emit('ppBridgeApp', { 'name': 'GaodeMapApp' })
    client.emit('ppMessage', { 'messageId': 'Init'})

@client.on('ppMessage')
def on_message(data):
    messageId = data['messageId']
    messageValue = data['value'] if 'value' in data else None
    if messageValue==None: return

    switcher = {
        "From": PoiSearch,
        "To":PoiSearch,
        "Navigation":Navigate,
    }
    
    return switcher[messageId](messageValue,messageId)

def PoiSearch(location,messageId):
     response = requests.get('https://restapi.amap.com/v5/place/text?parameters', {
        'key': GaodeKey,
        'keywords': location
     })
     if messageId=='From':
           client.emit('ppMessage', { 'messageId': 'From1','value':response.json()['pois'][0]['name'] })
           client.emit('ppMessage', { 'messageId': 'From1Pos','value':response.json()['pois'][0]['location'] })
           client.emit('ppMessage', { 'messageId': 'From2','value':response.json()['pois'][1]['name'] })
           client.emit('ppMessage', { 'messageId': 'From2Pos','value':response.json()['pois'][1]['location'] })
           client.emit('ppMessage', { 'messageId': 'From3','value':response.json()['pois'][2]['name'] })
           client.emit('ppMessage', { 'messageId': 'From3Pos','value':response.json()['pois'][2]['location'] })

     elif messageId=='To':
           client.emit('ppMessage', { 'messageId': 'To1','value':response.json()['pois'][0]['name'] })
           client.emit('ppMessage', { 'messageId': 'To1Pos','value':response.json()['pois'][0]['location'] })
           client.emit('ppMessage', { 'messageId': 'To2','value':response.json()['pois'][1]['name'] })
           client.emit('ppMessage', { 'messageId': 'To2Pos','value':response.json()['pois'][1]['location'] })
           client.emit('ppMessage', { 'messageId': 'To3','value':response.json()['pois'][2]['name'] })
           client.emit('ppMessage', { 'messageId': 'To3Pos','value':response.json()['pois'][2]['location'] })
    
           
def Navigate(location,messageId):
         
         #将location切割为起点终点的经纬度
         locations=location.split('|')
         fromLocation=locations[0]
         toLocation=locations[1]

         response = requests.get('https://restapi.amap.com/v5/direction/driving?parameters', {
        'key': GaodeKey,
        'origin': fromLocation,
        'destination':toLocation,
        'strategy':'0'
        })
         client.emit('ppMessage', { 'messageId': 'Path1','value':response.json()['route']['paths'][0]['steps'][0]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path2','value':response.json()['route']['paths'][0]['steps'][1]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path3','value':response.json()['route']['paths'][0]['steps'][2]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path4','value':response.json()['route']['paths'][0]['steps'][3]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path5','value':response.json()['route']['paths'][0]['steps'][4]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path6','value':response.json()['route']['paths'][0]['steps'][5]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path7','value':response.json()['route']['paths'][0]['steps'][6]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path8','value':response.json()['route']['paths'][0]['steps'][7]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path9','value':response.json()['route']['paths'][0]['steps'][8]['instruction'] })
         client.emit('ppMessage', { 'messageId': 'Path10','value':response.json()['route']['paths'][0]['steps'][9]['instruction'] })

client.connect(address)


    

图文教程

适读人群:原型设计师、UI设计师、非开发技术出身的交互设计从业者
这篇教程将通过一个实际案例讲述如何一步一步地从地图平台的 API 上获取数据,并与ProtoPie Connect之间实现消息收发。教程中的斜体字部分为主干内容所依赖的背景知识或辅助知识,如果发现自身对斜体字部分的内容事先已经了解,可以略过不看。

1.地图平台 API 能提供什么?在哪能找到这些 API?

1.1 透过表象理解原理

无论是在网页上还是手机 App 上,地图软件大家可谓司空见惯,我们常常会使用百度地图高德地图腾讯地图 等地图网站或者手机 App 进行位置搜索以及导航。
除了这些官方网站和 App,我们在很多场景下也会用上地图,比如外卖点餐用的美团,可以在 App 中查看骑手位置;菜鸟裹裹查快递,可以在 App 中查看快递大致的当前位置;嘀嘀打车,可以在微信小程序中看到车的位置等等。之所以这些 App、微信小程序、网页中能看到地图,并不是每一家企业都自行建设了地图,而是在 App 的开发过程中,将在线地图商制作好的在线地图“嵌入”了自家的 App 中,并根据地图的使用量向在线地图商缴纳一定的使用费。
基于国家治理原因,地理勘测和地图发布是需要持有法定牌照才能进行的,因此只有持牌的合法企业才能成为在线地图商,上述的百度、高德、腾讯就是这类企业
1
这是广州地铁的官方 App,但在地图界面左下角出现了“高德地图”的字样,说明广州地铁 App 中嵌入了高德地图。同时,我们还能看到这个 App 支持导航功能,输入起点和终点,呈现的地图上会出现相应的虚拟航迹:
2
也就是说,在线地图商提供了一种服务:给定起点和终点,返回路线轨迹

1.2 探寻地图服务之源

那么,我们去哪里找这种可以嵌入、进行数据互动的服务呢?通常,在线地图商出于盈利目的,会将提供这些能力的服务放置到一个网站上供查阅,譬如:
当我们不确定相应的地图有没有这类开放能力时,也可以搜索XX地图开放平台来进行判断和访问:
3
搜索“必应地图开放平台”后可见到搜索结果显示必应地图有相应的开放平台
在进入这类开放平台网站(部分网站需要注册登录才能看到详细内容)后,通常都可以在菜单中找
4
高德地图开放平台的开发支持栏目
5
百度地图开放平台的开发文档栏目
作为新手,对这类开发文档栏目下琳琅满目的子菜单项通常会有一种选择困难的无力感😒。但因为我们的目的是与原型通信,所以其实并没有那么复杂,只需要记住一点就行:寻找Web服务/Web端这类分类项下的API项
这类开发文档通常是以展现端来划分的,所以可以看到会分为安卓、iOS等等的类别,而Web服务是没有显著特定的呈现端的,仅作为单纯的数据交换,因而用途最为广泛,接入也最为简单。在我们制作原型时,需要的只是外部传来的数据,并不是要做一个实际的 App 在手机上运行,所以这里选择了 Web 服务下的 API。
以高德开放平台为例,点击 Web服务 API即可进入相关的说明页。
6
高德地图开放平台开发支持栏目下的Web服务API项
进入后,在左侧可以看到高德地图开放平台这类 API 所能提供的能力,也就是我们可以获取到哪些数据或得到什么功能:
7
高德地图开放平台-Web服务API所能提供的能力
如下图,点击这些项可以在右侧看到相应的描述内容,大致可以判断这个 API 提供了什么能力、用于什么场景,据此可以考虑哪些功能能在原型上体现。一些技术性较强的内容目前看不懂也没关系,可以先略过,在下文中会以两个实例进行具体说明,以提高感性认识。

1.3 获取数据接入资格

上文提到,在线地图商是通过提供地图服务来盈利的,那么,他怎么区分是谁使用了地图服务?
为了区分在线地图用户,在线地图商使用 Key 机制进行区分。这种机制类似于我们旅行住酒店时,开了一间房,酒店会给一张甚至多张房卡给我们,此后酒店不需要实际去过问持卡的是谁,只需要确保对应的房卡只能开对应的房间,就可以实现管控和统计。对于在线地图的情形,在线地图商会要求我们先设立一个应用(相当于在酒店开了一个房间),然后对这一应用授予一串编码作为 Key(相当于房卡),访问地图数据时必须持 Key 进行访问(只有拿着房卡才能进入房间),由此实现管控。
以高德地图开放平台为例,首先必须要进行注册登录
9
高德地图开放平台注册页面
登录后点击右上角的控制台:
10
控制台字样所在位置
对于新手,进入控制台后,留意右侧的帐号信息栏,建议点击相应的认证选项进行实名认证,可以认证为个人开发者或企业开发者(所需材料不同),这一步虽然不是必须的操作,但会影响一些服务的免费使用额度。认证过程只需要进行一次,以后进行数据调用时不需要再重新认证。
11
开发者认证项所在位置
接下来是获取 Key 的过程,这一过程只需要进行一次,获得 Key 之后可以长期使用。点击管理 Key:
12
Key管理入口位置
会进入到我的应用界面,点击右上角的创建新应用:
13
新应用创建按钮所在位置
填入相关信息,点击新建,一个应用就建好了,对应到酒店的例子,这一步可以认为是开了一间房,但目前还没有拿到房卡。
14
新应用创建过程
点击右上角的添加 Key:
15
添加Key按钮所在的位置
在出现的界面中,如下图所示,填写一个 Key 名称,用于区分这个 Key 的用途或目的(就像如果你有多张长得一样的房卡,为了避免混淆,会给房卡打标签一样)。在服务平台栏中务必选择 Web 服务,因为我们后面要用到的 API 隶属于 Web 服务。勾选同意相关条款后点击提交。
16
添加Key过程中的选项
如下图所示,可以看到界面上已经多出来一个条目,显示了 Key 的内容,也就是一串代码,对应到酒店例子中,这就是房卡了。一般来说,不应该将 Key 借给他人使用,以免带来潜在的经济损失(上文说过,在线地图商根据这个 Key 来判断是谁访问了服务、访问了多少次)。在右侧的查看配额中,可以看到当前已经使用了多少次某项服务,以及还剩余多少次免费额度可供使用。
17
添加了Key之后的显示情况
同理的,如有必要,可以对同一应用添加多个 Key (就像持有多张房卡一样),以便于对自设的多种访问途径使用不同的 Key 来实现相应的管理控制。
到此,高德地图一侧的准备工作已经完成。重申一遍,开发者认证和获取 Key 是只需要进行一次就可以的,以后再用到高德地图的相关服务,不需要再次操作,只需要持有该 Key 即可。

2.原型里会输入哪些数据?需要呈现哪些数据?

2.1原型设计过程的两种方式

得到 Key 意味着得到了高德地图数据的访问权限,下一步就需要考量原型里要呈现哪些东西,以及要把哪些数据传给地图以获取高德地图上的结果。
一般来说,原型的设计过程有两种方式。一种是根据需求来规划界面功能,这通常用在需求明确、技术成熟的场景下,对于功能设计不需要太担心,我们称之为顺推模式(根据功能设计寻找技术支撑);另一种则是根据技术能力来思考可以形成什么样的功能亮点,结合到产品中,这通常用在前沿探索型的创意产品中,我们称之为逆推模式(根据技术能力上的可达性反过来考虑设计上能为此融合什么功能)。
在本例中,由于到目前,设计师对高德地图的 API 所能实现的能力尚不十分明确,我们以逆推的方式先练练手。
回到高德地图开放平台的技术支持文档,在上文提到过的 Web 服务 API说明页,可以看到“搜索POI 2.0”这个API:
18
搜索POI 2.0的介绍页面
什么是 POI?POI 是 Point of Interest 的缩写,在电子地图业界,POI 指的是地图上被标注/赋予的某个位置的名称,比如某个路名、某个风景名胜景点,甚至是你家门前那家店,只要是通过搜索能显示出来的,就可以视为 POI,POI 的范围是可大可小的,比如在地图上查询一个省名,地图是会对其进行显示的。同时,POI 一般具有明确的经纬度,比如查询一个省名时,一些地图上的定位标记会标在省政府的位置上,也就是说,XX省这个 POI的经纬度定义就放在了省政府所在的位置。
在功能介绍内容中,仅仅是简单描述了“开发者可通过文本关键字搜索地点信息”,从这个描述我们大致能推断到,比如搜索“医院”,地图可能会给出一些医院的地理位置信息,但具体是怎样的搜索结果呢?我们需要用一些辅助手段和方法来探明。

2.2 API 的探索和测试方法

在这一步中,我们需要一个 API 调试工具来辅助我们完成工作。常见的 API 调试工具有很多,例如:
19
考虑大家可能此前没有接触此类工具,从简便易懂角度出发,笔者在这里的推荐是 ApiPost。访问 ApiPost 官网 首页,根据自己电脑操作系统的情况,选择对应的版本进行下载。
20
Apipost下载选项
下载后安装该客户端,一路点击“下一步”直到完成即可(Windows 和 Mac 的安装方式可能有所不同,但与你安装其他软件的方式是类似的)。
21
Apipost在Windows上的安装过程
安装完后打开 ApiPost 软件,可以看到其主界面,点击右上角的加号,选择新建接口(新建接口功能是我们在这个调试软件里要使用的唯一主要功能,请记住这一步操作):
22
Apipost主界面
然后你可以看到如下图所示的界面,看上去界面上有很多你并不熟悉的条目,所以这一步常常让新手望而却步,现在只需要留意图中框出的部分就行。Apipost在Windows上的安装过程
23
Apipost接口调试界面
在这里需要与搜索 POI 2.0 的文档进行比照来填写上面框住的内容,我们回到文档看看。
24
搜索Api2.0中的关键内容
可以看到,文档中说了服务地址URL是https://restapi.amap.com/v5/place/text?parameters (上图的黄色方框),所以把这串地址填到ApiPost中的接口地址栏(上上图的黄色方框)。当使用一个 API 时,一定会有一个用于访问这个 API 的地址(看起来会像网址或IP)。
文档中还提到了请求方式是GET(上图的红色方框),所以同理的,在ApiPost中请求方式选项处(上上图的红色方框)选择GET。
接下来,文档描述了请求参数。注意在上图中,蓝色方框和绿色方框处分别是必填和可选,即前两个参数在运行这个 API 时,是必须要填写的,其中第一个参数的参数名是key,在规则说明中也说明了应该填写在上一步获取到的 Key内容;第二个参数的参数名是keyword,结合规则说明和含义,得知是要填写要搜索的地点关键字。那么在ApiPost界面中的参数名和参数值(上上图的红色圆框和黄色圆框)处,分别按照文档中的说明进行填写。参数名必须照抄文档里的参数名,参数值根据你的需要进行填写。
有的读者在此处没有找到如上上图所示的参数填写栏,留意上上图中的绿色方框,要选到Query处才会出现参数填写栏。此外,一般来说 API 的参数名是不区分大小写的,但有的 API 网站会进行区分(这完全取决于 API 开发者的意志和做法),所以通常建议按照文档上的大小写来填写参数名。
按照上文填写完成时,ApiPost 的界面应该是这样(注意,填写参数后,黄方框地址栏内的内容与你刚粘贴进去时可能会不一样,这是正常现象,不必纠正):
25
按照开发文档填写完后的Apipost界面
然后,我们可以试试点击蓝色方框框选的发送按钮,看看这个 API 会给回什么数据。
26
第一次运行搜索POI 2.0 API后的界面
可以看到有绿色字样提示返回数据校验成功,且状态显示为200,这都是 API 调试成功的表现,但是!!在左侧的红框里,我们现在看不到任何有效数据,count(数量)也写了0。
既然 API 已经运行成功了,为什么没有数据?根据count为0判断,高德地图 API 收到了我们发送的请求,但是根据给出的关键字(医院),要么没有搜到任何数据,要么搜到的数据太多了不具有搜索意义(就好像你在地图软件里搜索“路”一样,全国的XX路其实都是搜索结果)。那么我们换一个关键词或者给关键词加上地名限定看看:
27
更换关键词后的 API 返回信息
可以看到已经获得了数据,也就是 API 已经调试成功。

2.3 结合原型考虑数据逻辑

回过头来看,按照逆推的原型设计方式,也就证明了输入“北京医院”,可以在原型上显示以“北京医院”为关键字的搜索结果,当然,也可以同时显示相应的地址、所属城市、所属行政区等信息(在上图中可见这些信息)。
同理,如果按照顺推的方式,我们可以先构想场景中输入什么信息、期望获得什么信息,然后按照这个思路去寻找有没有支持的 API 来完成这一需求。
例如,在地图里,我们查询地址的原因通常是用于导航。导航功能在数据逻辑上就是给出起点和终点,由在线地图商给出导航路径。按照这个思路,我们看看 API 文档里有没有提供这种功能的 API。
28
路径规划 2.0 API 功能介绍
通过手动逐个查找,可以找到这个名为路径规划 2.0 的API,在功能介绍里,文档说明了这个 API 就是用于检索驾车路线规划方案(导航)的。
继续阅读文档,我们会发现——同样的配方,同样的味道:
29
路径规划 2.0 API中驾车路线规划的 API 说明
文档中提到了起点和终点是必填项,要填写的是经纬度。那么,在哪里获取起点和终点的经纬度?回头看搜索 POI 2.0 中的 API 调用结果,我们可以发现返回的内容里有经纬度:
30
返回内容里的经纬度
这时有的同学会问,怎么判断这两个数是经纬度?我们可以再看看搜索POI 2.0
31
搜索 POI 2.0 API 中描述了返回结果中的 location 表示的是经纬度
所以,当对一个 API 的调用过程和返回结果不是很明确时,最好的解决方式就是阅读相应的文档并加以思考。我们按照这种方式,可以获得起点和终点 POI 的经纬度。然后如法炮制调试一遍路径规划2.0 这个 API:
32
路径规划2.0的API的调试结果
可以看到正确返回了导航的结果。
至此,这个场景就被构建起来了,也得到了相应的数据逻辑:输入起点关键字→(API:搜索POI 2.0)→得到准确的起点名称和经纬度→输入终点关键字→(API:搜索POI 2.0)→得到准确的终点名称和经纬度→输入起点和终点的经纬度→(API:路径规划 2.0)→得到起点和终点之间的导航规划路线文字提示。
根据这样的数据链路和数据逻辑,就可以设计相应的原型。

3.如何构建相应的 Bridge App?

3.1 概述

有了原型之后,需要把数据引入到原型中。ProtoPie 采用中心调度机制实现这个过程,并使用Bridge App来完成对 API 的访问。
33
之所以称为中心调度机制,是因为 ProtoPie Connect 起到了中心调度的作用——ProtoPie Connect负责在各个 Pie 以及各个 Bridge App 之间传递固定格式的消息。这就像机场的航站楼,连接了登机口、地铁站、机场大巴上客点、网约车等待区……并使旅客在其间可以按旅客意图选择目标交通工具一样。参考上图,乘客(数据)在飞机(API)上的时候,乘客是不能直接进入到地铁的,只有通过登机门(Bridge App)才能进入航站楼(ProtoPie Connect),并在这个过程中在自动售票机上买了地铁票(固定格式的消息),后续才能根据地铁票进入地铁(Pie)。
ProtoPie 生态体系采用这种集成方式有一个显著的优势——Bridge App可以不拘泥于某种既定的编程语言进行开发。也就是说,如果此前你学过一点 Java 、C#,或者 VB,甚至是易语言,都可以进行 Bridge App 的开发。很多设计师看到需要“编程”就觉得头大,是因为编程的种类繁多而且看到操作界面时每一种都不熟悉,这跟开发人员看到 Adobe 全家桶那个像元素周期表一样的矩阵是一样的。
34
让开发人员觉得可怕的Adobe全家桶
回想设计刚入行的时候,大多数设计师都是从 PS 开始的吧?所以现在面对“编程”这件事,也同样可以选择一个对自己较有吸引力或者亲和力的语言来开始。在这篇教程里我会用 Python 作为范例来说明 Bridge App 的开发过程。之所以选 Python,是因为 Python 对编程规范性的要求没有那么高,而且也比较容易理解其中的概念,同时目前市场上对 Python 的营销很多,使得相关文档和问题解决过程也比较容易搜索到,如果逐渐把 Python学好了还可以用 Python 来做一些数据分析之类的工作。
之所以说 Python 对编程规范性和概念理解要求没有那么高,是因为在传统的编程语言学习中,是存在较多需要前置知识才能理解的概念的,在实际写的时候也要遵守一定的规范才能保证正确,这会使得学习曲线从一开始就比较陡峭。可以换位思考一下,作为设计师,让一名开发人员理解 PS 中的栅格化或者正片叠底,是不是发现也很难一下子说明白?

3.2 建立 Python 编程环境

这一节和下一节均讲述了 Python 相关的基础知识,如果不打算用 Python 来写 Bridge App,可以直接跳到 3.4节继续阅览并触类旁通到自己熟悉的编程语言上。
如果对 Python 觉得无感,希望使用别的语言学习 Bridge App 开发,可以关注后续教程
在这一步,我们需要安装两个东西,一个是 Python 本身,另一个是 Visual Studio Code,安装好后还要进行一些配置。这步操作是一次性的,以后再写新的 Bridge App 时不需要把这两个东西再安装一次。
Python 本身,在其官网 (https://www.python.org/getit/)上可以下载,请从教程这个官网链接直接进入官网下载,千万不要试图百度搜索“python下载”这样的关键词,由于 Python 的社会性热度,有很多宣称中文 python 的网站实际上是以推荐开发工具或者兜售有害软件为目的的。
35
Python下载页
如果你使用的是Windows,点击红框处就可以下载,如果使用的是苹果电脑,应先点击绿框处的 macOS。下载后双击下载回来的文件进行安装。在安装过程的第一页记得把下方的两个选项打勾,然后再点击 Install Now。
36
Python安装过程第一项
出现下图代表安装成功,点击 Close 关闭即可:
37
为了验证装好的 Python 是否可用,如果使用的是 Windows,需要打开命令提示符(右键点开始按钮--运行--输入cmd--确定),如果使用的是苹果系统,需要打开“终端/Terminal”。
然后在其中输入pip --version,回车
注意,pip后面有一个空格,version前面是两个减号,务必输入正确才能确保显示的结果正常。
如果 Python 可用,会显示这样的一段信息:
38
如果没有出现如上图的信息,一般来说是环境变量的原因,先试试重启一遍,仍未解决时,如果对电脑知识了解较多,可以参考这篇文章 (Windows)或这篇文章 (苹果系统);如果对电脑知识了解较少,最好是求助于开发同事,因为导致这种状况的可能性很多,涉及的知识对于这篇教程而言超纲了。
安装好 Python 后,我们需要安装 Visual Studio Code(以下简称 VS Code),这是 Python 程序的编辑器之一,理论上你也可以选择别的编辑器来写 Python 程序,这里选择 VS Code 的原因是有中文界面且免费。
VS Code 可以从这个官网页面 上下载(注意不要点击官网的默认下载按钮来下载,要在这个指定页面上下载),如下图所示,根据自己的系统选 Windows 版本或 MacOS 版本就行:
39
同样的,下载好后双击下载回来的文件图标进行安装,安装过程中都采用默认选项,一路点击下一步,直到完成即可,完成后建议重启电脑。到此,工具上的准备工作就做好了。

3.3 程序的启动和调试

打开VS Code,点击左上方菜单的文件--新建文本文件:
1
在出现的界面内容中点击选择语言:
2
在出现的语言列表中输入 Python 并点击选择:
3
此时就可以动手写 python 程序啦!我们可以先试一个简单的:
4
内容就只有这么一行,让 Python 做个数学题。先把程序保存一下,按 Ctrl+S 或点击菜单栏上的文件--保存均可实现保存,Python程序文件保存时扩展名是.py
1
在首次用 VS Code 编辑 Python 程序时,右下角可能会弹出这样一个提示:
1
遇到这个提示时务必点击安装,这个扩展程序可以在后面的编程过程中帮你发现一些比较低级的编程错误。安装时 VS Code 会出现相关的安装信息,安装完成后点击刚才保存的文件名就可以切换回刚才的编程界面。
1
在 VS Code 的左侧栏第四个,有一个三角形带甲壳虫的图标,点击那个图标并在上方选择 Python:File
1
这时点击 Python:File 左侧的绿色小三角就可以启动咱们刚写好、只有一句的 Python 程序。如果刚启动就发现右下角出现这个提示:
1
那么重启一下 VS Code,应该就能解决。如果程序执行顺利,可以在下部栏中看到显示出了计算结果:
2
然后我们可以更进一步,多写几个算式,然后在行号的左边试试点鼠标,可以看到会显示小红点:
2
这时我们再按照上面的办法启动程序,可以看到程序在第一个红点(黄圈所示)处停住了,并且该行显示了高亮(红箭头所示),编辑器上方出现了一组工具(红圈所示),如果点击黄箭头所指的第一个工具图标,会看到程序又往下走了一行,到达第二个红点处。
1
留意看下方的计算结果,会发现已经执行的语句计算出结果了,而高亮的语句及后续语句还没有计算出结果,这时如果把鼠标放到第二个算式的88.88处悬停一会儿,还能直接看到88791.12的计算结果弹出。
1
这就是让程序在指定位置暂停的办法,通过这种办法可以在程序执行到一半的时候观察中间结果是否有误。左边打上的红点称为断点,这种调试方法称为断点调试,是写Python程序过程中最为常用的调试方法。

3.4 Socket.io 与键值对

相信看到这里,你已经跃跃欲试,想要马上动手写第一个 Bridge App了,哈哈!但是对于 Bridge App,还有一些背景知识需要掌握。Bridge App 和 ProtoPie 之间的通信依赖于 Socket.io 这一协议。这个看起来很官方的描述对你来说或许过于抽象。所谓“协议”,就是一组约定出来的规则,而之所以要进行这样的约定,是为了使来源和属性不同的各参与方能默契地、不需要知会对方地完成一些集成性的事情。举例来说,我们买需要用电池的小电器时,默认地看一眼电池仓,就能判断要放5号电池(AA电池)还是7号电池(AAA电池),而造小家电的厂家,在制造的时候,也直接就把电池仓做成了那样的规格。本来呢,厂家可以用任意的规格造电池仓(看看现在智能手机内的电池仓,就有奇形怪状的),电池厂也可以用任意的规格造电池(还是看看智能手机的电池,就有奇形怪状的)。
1
但全世界绝大多数使用干电池的产品,却都“不约而同”地采用了5号或7号的电池规格。之所以这样做,是为了便于消费者能买到适用的电池。绝大多数干电池厂按照5号和7号电池的规格“默契地”生产电池,小家电厂商“默契地”遵循这一规格生产产品,零售店“默契地”进货这两种规格的电池,而你去买电池的时候,“默契地”直接跟老板说要5号/7号电池,店老板也“默契地”直接把电池掏出来,而没有问你要多长多高的电池……
这种“默契”就是协议在现实中的应用,这两种电池规格我们就可以笼统地称为“电池协议”。世界上的电池厂很多、小家电厂也很多,只要遵循了“电池协议”,就不需要顾虑顾客会不会没有合适的电池可用,电池做成这么大,就是遵循了“约定出来的规则”。
回到 Socket.io 协议。为了实现设备和软件间的无障碍沟通,我们给这种沟通形式定义了一种“协议”,称为 Socket.io,主要内容是:
  1. 一次只传输一条消息。表面上来看,这点好像没有起到什么实际的作用。但试想一下,在上面的搜索 POI 2.0 API 里,API 可以一次性传回地点名称和地点的经纬度,这时如果在 ProtoPie 中,我们只设置一个变量去一次性接收地点名称和经纬度,就会导致在显示的时候,名称和经纬度会被同时显示出来,而事实上我们并不需要显示经纬度(只是需要经纬度来进行下一步的路径规划工作),这就使得我们其实应该把地点名称和经纬度分成两个变量来存放,倒推这个过程就会发现,要存放到两个变量上,就需要两条消息,也就是要传输两次,一次传地名并赋值给地名变量,一次传经纬度并赋值给经纬度变量。
  2. 一条消息里必须有一个消息名和一个消息体。这点很好理解,接收方可以根据消息名判断这条消息的来源或者属性,消息体承载了消息内容,是实际要进行传输的数据。这就像我们去银行开户填表时,页面头部的标题告诉我们这张表是什么表、大致意图是什么,表里的印制内容和填写内容才是我们实际要递交给处理人员的信息/数据。
  3. 消息体里可以含有多段消息内容,每段消息内容里可以有一个字符串或者一个“键值对(Key Value Pair)”。顺承上面办理银行开户的例子,开户申请表里有多段内容(姓名、证件种类、证件号码……),有的内容是银行以提示或告知形式印制给你看的,比如“*号为必填项”,有的是以说明+内容的形式出现的,比如“姓名”,在你填写后就成为“姓名:张三”这样的信息了。这两类其实都是有效信息,后者在信息技术上我们称为“键值对”,例子中的“姓名”是键(Key),“张三”是值(Value)。
1
以Socket.io协议的形式为例来传输这份开户申请表,看起来就会像这个样子:
1
接收方接收到这些信息后,同样可以得到一份开户申请表中应具备的所有信息。显然,采用Socket.io协议的方式,发送方和接收方就可以“有默契”地进行通信,解析消息名,知道这是什么东西,然后解析消息体,实现业务互联。

有了通信协议就可以开始进行通信,但通信过程上,有一点值得玩味地地方:我们在看民国时期的大片时,有时能看到这样的魔幻场景:国民党高官拿起电话(注意,并没有拨号),说:“给我接总统府”,下一个镜头是总统府电话铃响,总统府秘书接电话😁。之所以会出现这样的场景,是因为当时的电话并没有号码,所有的电话线连接到电话局,谁摘下话筒,电话局机房就有一盏对应的灯亮起,机房值守的妹纸就会将耳麦插到对应的插孔上并询问要呼叫谁,这时高官就可以说“给我接总统府”,妹纸再拿根线把这个呼入的电话信号跟总统府的电话连起来,从而实现通话。
这个通信系统有个显著的特点——必须有妹纸24X7、三班倒地守在机房,才能保障通信的实现。在 ProtoPie 生态中,ProtoPie Connect 就充当了这个妹纸的角色。只有 ProtoPie Connect 先开启了,一个或多个 Bridge App 才能连入,从而实现与 Pie 通信。从 Socket.io 协议的视角来看, ProtoPie Connect 的开启,意味着向所有 Bridge App 提供了接入服务(就如同妹纸上班了,就向所有的电话用户提供了电话接入服务一样)。那么,我们把 Connect 称为 Socket.io 协议上的服务端(Server),相对的,把 Bridge App 称为Socket.io 协议上的客户端(Client)。
再次思考一下民国电话的例子,还有一个关键点:所有的电话都是直接连接到电话局机房的,也就是电话线的一头在机房,另一头在高官住宅,这两个地点是有明确的地址的,比如机房在中华路734号,高官住宅在淮阴路488号,当初拉电话线的时候,只有明确了这两个地址,这根电话线才能架设成功。而这两个地址中,又以机房的地址较为重要,因为所有的电话线都要拉到机房,如果机房不对外公布地址,这些电话线就无法架设了。
同理的,Connect 上的服务器端也会有一个便于其他 Bridge App 连上所用的地址,这个地址通常以IP地址的形式出现,也就是四段数字构成的一个网络地址,比如“13.39.233.7”。我们把这个地址称为服务端地址。当 Connect 和 Bridge App 不在同一台电脑上时,Bridge App 需要得到这个服务端地址,才能连上 Connect。由于这个服务端地址十分重要,Connect 就会特意将其显示在界面左下角以示公布,如下图所示:
1
这时我们发现,Connect 上的这个地址后面还跟了个“:9981”,这又是什么东东呢?
刚才我们知道电话局机房有一个实际地理位置的地址(中华路734号),在这个地址里,所有的线都是电话线吗?显然不是的,里边可能还会有电灯线、电报机内部线路……为了对同一个地址里不同用途的线进行区分以避免接错线,我们对每一种线标记一个数字,比如把电话线标为9981,这样对于连到这个地址的线,只要看到标着9981就知道是电话线了。这里线路标号,我们称为端口号(Port)。有了服务端地址和端口号,就能明确地接入到服务端(也就是 Connect)上。
有时还会遇到一种特殊情况:电话局机房里要安装一台电话,按照上面的规律,你会发现,电话线是“从中华路734号到中华路734号”,这时架设电话线的工作人员又迷茫啦!为了避免这种情况,我们特意作了个特殊的规定,电话局机房里装电话时,要写为“从中华路734号到本地”,架设电话线的工作人员一看到有“本地”字样,就明白怎么回事了。对应到 Socket.io 协议体系的情形,当 Connect(服务端)和 Bridge App (客户端)都在同一台电脑上运行时,我们把服务端地址认定为叫做“localhost”或者“127.0.0.1”,当然后面的端口号9981维持原样。地址和端口号连着写出来的情形,用冒号来分隔开地址和端口号以便于区分。
操作系统的防火墙有时候会屏蔽9981的端口号(认为9981不是自家的线路,所以不让通进来),在进行下面的操作之前,建议先对防火墙进行设置。当然,这种设置只需要进行一次,把9981端口号放开之后,下次就不需要再设置了。
Windows 下的设置方法为,右键点击开始按钮,选择运行,输入firewall.cpl并点击确定:
1
选择高级设置:
2
点击入站规则--新建规则:
3
选择端口:
1
输入9981:
3
选择允许连接:
3
全都勾上:
1
起个名字,便于自己记住:
1
到出站规则项上再同等操作一遍:
1
苹果电脑的防火墙是根据程序来区分的,在系统偏好设置中点击安全性与隐私,再点击防火墙。如果看到防火墙是关闭状态,直接关闭窗口即可,如果是打开状态,可以点击添加+,选择 Connect 程序,然后按提示进行相应的设置,解除防火墙对其的限制。
到这里,你已经了解了整个过程的通信原理,并已经为 Bridge App 的开发做好了所有的准备工作。下一节,就正式进入编程环节了。(后面只有几个小节,前面的内容却很长,由此也可以体会到,编程本身并不难,难的是编程前的准备工作以及理解原理。)

3.5 在 Python 中引入 Socket.io

上面我们提到,要进行 Socket.io 的通信,首先要让程序知道服务端地址,这样程序才能知道向哪进行连接。
于是我们可以先在程序里写上服务端地址:
1
虽然只有这么短短的两行,对于没写过程序的同学来说,我知道在一开始会产生很多疑问(这种心情跟我刚开始学编程的时候是一样的,不同的是,当时的我并没有求助的对象,一切只能自己摸索):
  • 第一行是不是代码?
    • 用#开头的内容,在 Python 程序(以下简称程序,也就是这些规则只针对 Python 而言)里是注释,这个#符号可以在行首,也可以在正式语句的后面,程序不会去执行这些注释(所以行号的1看起来就有点灰色了),注释存在的意义就是方便你在代码里记东西,像自己做笔记一样,使你能区分每一句、每一段的功能
  • 说好了服务端地址和端口是127.0.0.1:9981,为什么前面又有个http://,难道这是个网址吗?
    • 这个问题略微有一点复杂。还记得上面银行开户申请表的例子吗?开户申请表在内容上可以遵循Socket.io的协议规范,那么,传递开户申请表的操作流程,是不是也可以有规范?通常我们必须主动跟银行说自己要开户,然后是银行给这份表,接着我们填写,填完后交回银行,银行进行登记处理。这个流程中传递的都是开户申请表,但却遵循了我(发起)--银行(给单)--我(填单)--银行(审核)这样一个规范的工作流,我们可以把这种规范工作流也看成是一种协议,因为大多数银行都是这么操作的,这个过程你是“默契地”完成的,这套工作流(协议)不仅适用于开户申请表,还适用于“大额取款申请表”、“密码找回申请表”等等操作,对吧?那么我们可以将这套工作流(协议)视为比开户申请表内容协议更为底层、适用面也更宽泛的一种协议。同理的,http / https 也是一个更为底层、适用面更宽泛的协议,这个协议支持了 Socket.io 协议的运行,同时这个协议还适用于其他大多数网站的访问(所以我们会认为这看起来是个网址)。在这里,Bridge App 和 Connect 之间被 ProtoPie 产品的开发工程师或者产品经理规定了在通信方式上用 http / https 协议来进行通信(通信内容格式的协议上才是选择了Socket.io,这两者不冲突),于是在服务端地址前面需要加上这个“http://”。
  • 每个人的IP地址不同,你需要打开Connect,登录后找到左下角的IP地址,将这里的http://127.0.0.1:9981 替换成你自己的
1
  • http到9981的这部分内容为什么用单引号引起来?
1
可以看到#后面的内容被标记了不同的颜色,这个颜色,恰恰就是上面第一个问题所提到的注释的颜色——也就是说,程序把这个#及其后面的部分看成注释了。同理,如果一串连续的字符(业界称为“字符串”)里含了任何程序里有专门用途的字符,程序就无法区分这个符号是程序的功能还是字符串的内容。为了避免这种情况,我们要求给字符串加上引号,表示这是一整个完整的东西且与其他程序代码不同,需要区分开。
  • 第二行最前面的“SERVER_ADDRESS=”是什么?
    • 学数学的时候,我们常用x、y等字母/符号来表示一些量,尤其是应用题。为什么需要这么做呢?一定程度上是为了写式子的时候简单方便,比如甲汽车的速度是乙汽车的三倍,我们设乙汽车的速度为x,就可以直接用3x来表示甲汽车的速度,如果不用这些符号来表示,这个式子就得写成“甲汽车速度=乙汽车速度X 3”,并且在后面的代入过程中,不断重复地写“甲/乙汽车速度”。同理,当我们需要重复用一个内容(可以是字符串、也可以是式子等等)时,我们可以用一个更为简单的内容去替代它,以减少后续的出错。这个替代品我们称之为“变量”(这跟 ProtoPie 里的变量概念是一样的),这里的 SERVER_ADDRESS 就是变量名称。顺便提一句为什么全是大写,业界规范来说,一个变量在程序里代表的内容如果从头到尾都不会改变,我们也会称之为“常量”(为了与那些有时是3有时是5的变量区分开),对于这种特殊的变量(或者说常量),为了显眼和区分,程序员们惯于用全大写的单词去设定其变量名。
  • 为什么这里写的是127.0.0.1而不是localhost?
    • 一般来说写 localhost 和写 127.0.0.1 得到的效果是一样的,所以这里你也可以写为 localhost。有一些特殊的情况下,因为系统配置做过改动的原因,会导致系统对这两者的解析发生差异,这时就会发生只有其中一种有效的情况。我的电脑现在就是这种情况,localhost 被我专门设置用来指向电脑上的一个网站了,于是我只好用127.0.0.1。
  • 为什么代码上面会出现不同的颜色表示?
    • 为了便于查看。程序代码里融合了变量、静态内容(像上面的字符串)、注释、符号(等号、括号、格式符号等等)、方法(程序要执行的动作,后面会讲到),并且在写的过程中很容易一不小心看走眼写错写漏一点点东西导致不能运行,为了提升效率,编辑器就会给不同类型的内容标上不同的颜色。
接下来我们继续写相应的代码内容。按前面内容所介绍的,Bridge App 主要是以一个 Socket.io 客户端的身份与服务端进行通信,因此我们要构建这个客户端,构建的方法是写上这么一句:
2
等号左边的 client,看颜色和位置就能判断也是一个变量。右边我们可以看到socketio这个词被画了波浪线,也就是说,存在一些错误,把鼠标移上去,上面会弹出一个提示,告诉你“未定义socketio”
1
所谓“未定义xxx”,意思就是程序不认识这个东西是什么,它既不是我们命名出来的一个变量,程序前面的部分也没有做过任何说明。socketio是别人定义和造好的东西(对比上面而言,SERVER_ADDRESS是咱自己造的东西),咱们需要先把别人的东西弄过来。怎么弄过来?在程序的最开头写上:
1
import是导入、引入的意思,表示我要引用别人写的一个名为 socketio的东西。写完这一句之后,可以看到socketio字样的颜色已经变了,也就是程序知道我们的意思是这是个从外面引入的东西。然而这时我们发现,较为靠下的那句(第7行)里的socketio波浪线没了,刚写的那一句里的socketio却带上了波浪线,于是再次查看是什么情况:
2
程序告诉我们“无法解析导入socketio”。意思是说,程序现在知道这是个导入的东西,但这个东西源头在哪、应该从哪获取过来,程序不知道。这时就涉及到新知识了,在上面 3.2 的内容中,我们用 pip 来查看 python 的安装是否成功,这个 pip 其实是 python 程序的包管理器。所谓包管理器,你可以想象为,就是你的包包。我们日常在包包里会放上常用的东西,纸巾、充电宝、化妆镜、气垫粉饼等等,每个人常用的东西不一样,有时候发现有的东西很常用但日常没有随身带着,就会考虑把这个东西放入包包,反过来,包包里有的东西不常用,就会把这个东西移出包包。我们打开命令提示符(Windows)或者终端(Mac),输入pip list,可以查看到现在包包里已经有了什么东西:
3
可以看到,包包里目前没有 socketio 这个东西,我们需要把这个东西放到包里,所以我们输入pipinstall python-socketio
3
可以看到最后屏幕上告诉你,python-socketio 的 5.8.0 版本已经安装好了。再用pip list 看一下:
3
可以看到包包里已经有了 python-socketio。同时你也会发现,包包里还多了一个叫做 python-engineio的东西和一个叫bidict的东西,这是因为 python-socketio 对这两个东西有依赖性(往包里装睫毛膏,那么睫毛膏的瓶子和盖子一定会跟着被放包里),所以要装就会被一起装上。然后我们回到 VS Code 里再看看,socketio 上的波浪线已经没有了,也就是这个问题已经解决了(如果你发现波浪线还在,就把 VS Code 关闭再打开一次,让其检测刷新一下)。
1
如何知道 pipinstall 后面东西的名称?首先,所有的这类可以用的东西会被索引在这个网站 (https://pypi.org/)中。其次,这更偏向于一个经验性质的工作,随接触的程序多了,慢慢就会熟悉哪些这种外部引入的东西比较常见、起到什么作用。
在第7行这条语句中,socketio 表示了引入的这个组件,后面的.Client()是组件里的一个方法,在 python 里,方法在被引用的时候是用一对括号或者括号里带参数的方式来写的。所谓方法,就是需要做的事情的固定名称,所谓参数,就是做这个事情时需要给的输入信息。比如“员工.吃饭()”,就是这样的一个无参数形式,“员工.吃东西(奶茶)”就是这样的一个有参数形式。这点在一开头可能不是很好理解,没有关系,随下文内容的深入,是能慢慢体会到的。同时这条语句最开头的 client 这个变量值得你留意和记住,毕竟后面所有要用这个 socketio 客户端的地方,都会用这个 client 来代替了。
这一整句的意思就是让 socketio 这个外来者生成一个 socket.io 协议的客户端,然后把这个客户端赋给左边那个 client 变量,也就是 client 这个变量就代表了刚生产出来的客户端了。
到这里我们可以试试运行一下这几句程序:
2
程序在界面内没有报什么错误,在红色框部分也没有错误提示,那就意味着程序是可以执行的,只是效果怎样,目前看不出来(没有定义任何输出行为)。
我们继续编写这个 Bridge App 程序。既然客户端已经产生了,下一步就是让客户端连接服务器端,写上这么一行语句:
1
还记得上面提到的“方法”这个概念吗?这里就是让 client 执行 connect 方法,并使用 SERVER_ADDRESS(那一串服务端地址+端口) 作为参数。写上之后我们再试试执行:
2
可以看到程序告诉你发生了一个异常!留意红框的部分,有相应的说明。我们遇到异常的时候,如果不知道如何解读,可以拿异常内容到搜索引擎里搜一下,看看这是个什么类型的问题、别人怎么解决。这也是编程时十分常用的解决问题的方法之一。
1
显然第一条搜索结果就告诉我们答案了——需要安装 requests。按照上面教过的方法,我们先使用pip install requests 安装这个东西:
2
然后在程序里写上 import requests (注意 import 东西的语句要靠顶写,原因后面会提到),并再次执行程序:
3
可以看到仍然提示另一个东西还没安装,仍旧使用上面的办法把 websocket-client 安装上(此处不放图了,大家可以练习一下),然后再执行程序:
3
可以看到程序上已经没有额外的错误信息被显示出来了,但程序是否确实在运行、执行了连接呢?看起来我们需要一些确认机制,来确认程序的执行结果是否符合预期。

3.6 Bridge App 注册及收发消息

实际上,一个 socket.io 客户端在刚连上服务端的时候,服务端会发回一个名为“connect”的消息,供客户端确认已经连上,所以此处我们可以利用这个特性:
1
在这里涉及了几个新知识:
1. @client.on('xxx') 表示当 client 接收到 xxx 消息时(执行这句后面的操作);
2. 第12行的 def 表示这是一个方法,方法名为 on_connect,方法不带参数,结合上一句就是说,当 client 接收到名为 connect 的消息时,执行 on_connect 这个方法。
3. 这个方法的具体内容是 print('Connected'),也就是在屏幕上显示 Connected 字样。注意方法的具体内容相比于方法的定义(def 那一句)是缩进的。在 python 里,一律使用缩进的方法表示从属关系,方法的内容从属于方法的定义,就需要这样写。这是一个硬性规则。
  1. 可以见到执行后下方的输出区就显示了“Connected”字样,表示这句执行成功了。
  2. 一定有读者会好奇为什么不把这句写到 client.connect(第15行)的下方。程序是顺序执行的,只要你没有定义循环行为,程序就会从第1行开始,向后执行。在这个例子中,如果放到下方,程序会先执行 client.connect,刚执行完时,名为connect 的消息已经从服务端发出来了,这时程序才执行到 @client.on 一句,也就是程序才知道接收到 connect 消息时应该干什么,可是这时候消息已经错过了,就会得不到既定的执行结果了。所以需要在程序编写过程中考虑事情发生的先后顺序,把可能发生的情况先跟程序说清楚后,再让该情况发生,这时程序才能知道应该怎么办。上文提到 import 东西的语句一般靠顶写,也是这个原因,把需要用的外部资源准备好了,再开始干活。
按照 ProtoPie Connect 的执行方式,在客户端刚连上来时,客户端应该向 Connect 发出一条名为“ppBridgeApp”的消息,并在这条消息中体现这个 Bridge App 的名称,以便 Connect 用小本本把这个 Bridge App 标记下来,下次再发东西过来就知道是这一个 Bridge App 发过来的了(你得考虑一个 Connect 同时连接多个 Bridge App 的情形),我们把这个过程叫 Bridge App 注册。同时,由于用小本本记下来的这个过程,在 Connect 上是没有任何显示的,我们为了求证这个事情已经做好,可以在做完这个事情后,再发出一条消息给 Connect 并让其显示出来。Connect 规定,这类要显示出来的消息必须以“ppMessage”为消息名,并且在消息内容中必须含有 messageId 字段:
1
其中的 emit 方法就是让客户端发出消息的方法,可以看到在这几行语句中,我首先对这个名为 GaodeMapApp 的 Bridge App 进行注册,让 Connect 知道来自这里的 Bridge App 叫做 GaodeMapApp。然后发出一条内容为 Init 的消息,希望 Connect 上能显示出来。
综合上面的知识,我们先打开 Connect,使服务端开起来,然后再执行这个程序,并观察 Connect 上的变化:
1
可以看到,Connect 上显示了这个 Init 字样,也就是这段程序已经执行成功。接下来我们要考虑消息的接收。上面提到,与 Connect 通信时相互收发的消息都是以 ppMessage 为消息名的,那么很自然的我们就可以写出这样的接收语句并执行看看:
1
程序语句里,接收到名为 ppMessage 后,消息内容会被传给 data 参数,然后我们解析 data 参数并用 print 功能在下方显示出消息内容。这里需要格外注意、千万不要混淆的一点是,对于 socket.io 而言,ppMessage 是消息名,messageId 和 value 这个整体是 socket.io 的消息值;而对于 Connect 而言, Hello 是消息名(也就是 messageId 虽然出现在 socket.io的消息内容里,但这才是相对于 Connect 而言的消息名),Peacock 是 Connect 上的消息值。逻辑关系如下图所示,这是初学者最容易混淆的地方。
1
所以当我在 Connect中输入以 Hello 为消息名,Peacock为消息值的消息时,在程序的 print 语句就把这个内容显示到下方的输出区了。

3.7 与高德地图开放平台连接获取数据

有了上面这些功能后,我们就可以跟高德地图开放平台进行交互了。
首先我们回顾之前设想的场景:从 Connect 中发送一个地名作为起点,然后将这个地名放到前面所学的 搜索 POI 2.0 API 中,并获取前三个搜索结果的名称和经纬度;再发送一个地名作为终点,如法炮制;
然后对选取的起点和终点进行路径规划。然后我们来处理程序。首先,我们会需要那个高德的 Key 来取得合法访问权,所以我们把 Key 放到程序里:
2
然后,在我们传出起点地名关键字的时候,我们需要在程序里区分这是一个起点的关键字(以避免跟终点地名关键字相混淆),所以我们可以假定在 Connect 上发出时我们把“From”作为 Connect 的消息名,我们所要查询的地名关键字作为消息值;同理,在发送终点时,我们用“To”作为消息名。那么就产生了这样的语句:
3
这一步乍一看比之前复杂了不少,我们逐个看一下发生了什么变化:
  • 红框部分:为了便于在后面的语句中使用 Connect 消息名和消息值,我们把 data['messageId'] 和 data['value'] 单独拎出来赋值给 messageId 和 messageValue 这两个变量,并且对于消息值,由于 Connect 规定消息值是可选的,因此有 if 'value' in data else None 这句,表示如果没有消息值,messageValue就是 None,这个 None 是 Python 里表示“不存在” 的一种特殊状态。其后又有一句 if messageValue==None: return,这里的 return 是表示退出这个方法的意思,我们现在的目的是拿消息值(起点或终点的关键字)去传给 API,如果消息上没有传过来起点或终点的关键字,那么这个操作就没有意义了,所以遇到这种情况时为了避免后续的程序错误,就选择直接退出这个方法,等待下一次 Connect 发消息过来。
  • 蓝色箭头部分:对 API 访问,就采用代码里的 requests 的 get /post 方法,之所以这里用 get,因为 API 文档里说明了请求方式是 GET (可以回头看一下前面的内容),get后面的参数就是 API 文档里所说的 API 访问 URL;一对大括号里就是必选参数,可以比照 ApiPost 里的填写项来逐个填上。
  • 绿色箭头部分:在 Connect 上的这个消息值会被传入到程序里的 messageValue 变量中,作为 keywords 的值。
  • 黄色箭头部分:在上面蓝箭头的那一句,可以看到对 API 的访问结果是赋值给 response 这个变量的,在这里我们用变量的json()方法来输出显示,就可以看到从 API 返回的数据内容,这个数据内容和之前我们用 ApiPost 进行调试时的结果是一样的,区别只是没有做“格式化(进行标准的换行和缩进处理)”。
到这一步,已经证明了程序可以访问 API 并拿到数据。但我们很快就发现,要继续推进时,存在两个问题:
  • 目前还没有区分起点和终点
  • 从 API 处拿到的数据没有向 Connect 发送和显示
为了解决这两个问题,我们再把程序改一改:
3
看上去内容多了很多,对比一下上一步,情况是这样的:
  • 这里的执行过程是,程序启动后出现了右部红框的消息,然后我在 Connect 的消息框中分别填入了 “From” “广州塔”,点击 Send 之后,得到了右侧黄框的那一堆消息,再填入了 “To” “宝墨园”,Send 之后,得到了右侧蓝框的消息。
  • 左侧三个红箭头的尾端:在这个过程中,我们很容易发现,无论在 Connect 的第一个框(消息名)填的是 From 还是 To,在程序里都需要访问一次 搜索 POI 2.0 这个 API,如果我对 From 写一段程序,再对 To 写一段程序,这两段程序的主干几乎是一样的,所以我把这个主干抽取出来,单独作为一个“方法”,名叫 PoiSearch。在解析完消息名和消息值后,就调用 PoiSearch方法,并把消息名和消息值作为参数放进去。
  • 左侧三个红箭头的头端:所示的就是 PoiSearch 方法,第一行以 def 开头,表示这是方法的定义,其后所有行都缩进至少一格,表示其后的行都从属于这一个方法,直到 client.connect 这句,这句不属于这个方法了(属于整个主程序了),所以就顶格写了。此外还可以留意到,在参数传进来时,我把 messageId 放到了messageValue 的后面,也就是说,参数的顺序是由方法的定义决定的,方法上要求 messageId 在后,那么调用方法传递参数的时候,就也是 messageId 在后。还有一点可能你会留意到的是,在方法定义里,第一个参数我把它叫做 location 而不是 messageValue,在传进来的时候,因为是按照顺序传的,messageValue 传进 PoiSearch 这个方法后,就被改名叫 location 了,这种改名行为很常见,对于一个方法,参数的定名是基于方法本身视角的,即使你把这个参数叫做 QiDianZhongDian 也是可以的,参数传进来后,就叫这个名字了。
  • 左右侧的黄色框:可以看到左侧黄色框的进入条件是 messageId 为 From。当 Connect 消息名(也就是这里的messageId)为 From 时,就会执行左侧黄色框中的语句。之所以是用两个等号,是因为在 Python 中,使用一个等号表示赋值(就像上面的SERVER_ADDRESS=XXX,把右侧的值赋给左侧),使用两个等号表示比较(相当于表示 a=b?),这点跟生活习惯有些不一样,是自己写程序时需要留意的。左侧黄色框中的语句全都是 emit,也就是全都是向 Connect 提交消息,执行完后就得到了右侧的黄色框部分。这里说明了:(1)根据一次 API 返回的结果可以引发传输多次消息;(2)印证了前文所述的“一次只传输一条消息”。
  • 左右侧的蓝色框:注意左侧蓝色框的进入条件写的是 elif messageId=='To':,这是 else if messageId == 'To' 的意思。与上面一样,全是emit,也就是发送消息的语句。这里我们具体看一下这些语句是怎样写出来的:
1
从代码上看,对于一条消息的发送,符合我们上面所学的 Socket.io 消息的知识:ppMessage是 Socket.io 的消息名,后面大括号括起来的是消息内容,而消息内容中包含了两组键值对,分别以 messageId 为键、To1 字样 或者 To1Pos 字样为值,以 value 为键,以 API 返回的一些数据为值。 上面说过,response.json() 表示的其实就是咱们在 ApiPost里得到的那一大串结果,现在咱们把程序和 ApiPost 的结果摆在一起看,很容易发现,程序代码里表示的其实就是层级关系,咱们想要得到 name 的值,而name 位于 pois 之内,所以写的时候就写成response.json()['pois'][0]['name'],对于经纬度 location,是同理的。这里要特殊说明一下的是那个[0]。其实可以看到右侧格式化之后的结果是不同层次键值对的嵌套,“name” 作为键,“五棵松(地铁站)”作为值,然后“pois”作为键,下面一大堆平级包括了name和location的东西作为 pois 的值,这样整体构成一个东西的格式我们称为 json 格式,如果你细心就会发现,pois 这个键要带的值不止一个(上图中我用黄色0、1标示出来了),发生这种多值情况时,一个显著特点是键的后面不直接跟值,而是跟了一个方括号(右侧黄色圈),此时我们要取其中的一个值就得说明要取的是第几个值,这个序号是从0开始的。所以在代码上['pois']的后面、['name']的前面就有了一个[0],表示取['pois']中的第0个值。在代码上我们分别用两条语句取了同一个 POI 项的 name(蓝箭头) 和 location(绿箭头)。同时我们原本设计的场景就是对每一个关键词取前三个搜索结果,所以对于上上图,就可以见到发出一个关键词后,我们会获得6条 Connect 消息(每个搜索结果的名称和经纬度分别用一条消息)。

3.9 完善剩余的数据逻辑

在上面我们其实已经完全可以与高德地图通信并获取数据了。还记得吗?我们的场景里还有一步,就是对上面选取的起点终点进行路径规划(导航)。在这里建议你先自己试一试能不能自行完成这步,再看最后这一节内容,然后把最后这节内容跟你自己写的相互比较一下,促进提升。
1
综合上面的知识,查阅路径规划 2.0 的API 文档,并结合 ApiPost 的调试结果后,我们可以写出如上图的方法,然后在收到消息时调用这个 Navigate 方法,就可以得到每一条路径规划提示的问题,在这里我只取了前10条发送给 Connect。这里面唯一值得注意的是红箭头标示的'strategy':'0',仔细查阅路径规划 2.0 的API 文档你会发现:
1
这是一个可选参数,标记为0之后,是按速度优先来获取路径的。如同我们平时使用车载导航时可以选速度优先、距离有限、不走高速等等选项一样,这里选的就是速度优先,当然你也可以选择其他策略。此外,我要额外提醒的是,尽管文档里说了“只返回一条路线”,但在我们解析 json 的层次结构时,仍然要严格按照 ApiPost 里所呈现的结果来逐层解析,所以你可以留意到['paths']后面仍旧有[0],也就是说,paths这个键本来可以带若干个值,现在尽管只带了一个值(第0个值),这个0仍然不可以省略。
另一方面,如果你来自处女座,或许你还能发现一个新问题:在前面的传入关键字的例子中,我们传入From+关键字以及To+关键字作为输入项,这次我们却要把用上两个输入框,用起点经纬度+终点经纬度的方式来传入消息,终究有点不那么和谐😔。有没有什么办法使得整体格式整齐划一?结合 ProtoPie Studio,我们可以实现这一点。
ProtoPie Studio 的表达式功能允许我们对变量里的内容进行拼接,然后赋值给新的变量,于是我们可以把起点坐标和终点坐标用一个“|”分隔后,整体作为消息值,然后用“Navigation”作为消息名,就可以实现“Navigation+起点坐标|终点坐标”的消息传递形式:
3
这样处理后,在接收到这个消息值时,我们需要把起点坐标和终点坐标再拆开,要用到如下语句:
2
相比于上上图的情形,作了一些改动:
  • 参数,因为现在我们改用“Navigation+起点坐标|终点坐标”这样的消息形式了,参数就又可以像之前一样把消息名一起传进来了。
  • 红箭头处:用到了一个split方法,这个方法就是把一个字符串按照你指定的分隔符来拆开。这里拆开的内容我放到了 locations 变量中了(注意这里 location 和 locations 是两个不同的变量)。
  • 蓝箭头和绿箭头:分别从 locations 中取得拆开的量,locations 里像上面的多值 json 一样,含有多个内容,可以用序号取值。你可能会留意到这又是从0开始的,对的,python 里数数都是从0开始的😂。
这样处理之后,聪明的你可能还会发现,在这段方法里,我们完全没有用到 messageId !那么我在参数里保留这个形式的意义是什么呢?我们来看最后一个知识点。
1
刚才接收消息后,我直接调用 PoiSearch 方法,是因为对于确定起点和终点,我们都使用同一个 API,而在路径规划(导航)中,我们要使用另一个 API,这使得我们要调用 Navigate 方法。怎么实现接收到不同的消息就选择不同的方法去执行?
留意上图的 switcher 结构,这个结构就可以实现这一点,把消息名和方法名在上面进行匹配,匹配完成后这个结构下方的 return switcher[messageId](messageValue,messageId) 是给这个结构注入灵魂:return 会使得当前这个接收消息的 on_message 方法结束,然后调起后面的方法,假设传入的消息是 From,后面实际上就是:
  • switcher['From']( '广州塔' , 'From')
而根据 switcher 结构里的匹配关系,switcher['From'] 实际上就是 PoiSearch,也就是说,应该被调起的方法是:
  • PoiSearch( '广州塔' , 'From')
这是不是就顺回前面的内容了?对于 Navigate 方法,也是同样的道理的。但这种调用方式要求用来匹配的方法都有着同样的参数个数和顺序(方法的参数个数和顺序在行业上我们称为“方法签名”或“函数签名”),于是你就可以看到对于 Navigate 方法,我保留了两个参数,并保持与 PoiSearch 方法同样的参数顺序。

3.10 完整的源码

到此,这个完整的 Bridge App 就完全做好啦!以下是完整的源码供参考,使用的时候,要记得先把我的高德 API Key 换成你的,以及看看是不是需要调整服务端地址,才能正常使用
import socketio
import requests

#这是服务端地址
SERVER_ADDRESS = 'http://127.0.0.1:9981'

#socket.io 客户端
client = socketio.Client()

#高德 API Key
GaodeKey='643**************************a16'

#处理刚连上的情形
@client.on('connect')
def on_connect():
    print('Connected')
    client.emit('ppBridgeApp', { 'name': 'GaodeMapApp' })
    client.emit('ppMessage', { 'messageId': 'Init'})

#处理收到的消息
@client.on('ppMessage')
def on_message(data):
    messageId = data['messageId']
    messageValue = data['value'] if 'value' in data else None
    if messageValue==None: return
    
    switcher = {
        "From": PoiSearch,
        "To":PoiSearch,
        "Navigation":Navigate,
    }
    
    return switcher[messageId](messageValue,messageId)

#执行搜索POI 2.0 API
def PoiSearch(location,messageId):
     response = requests.get('https://restapi.amap.com/v5/place/text?parameters', {
        'key': GaodeKey,
        'keywords': location
     })
     if messageId=='From':
           client.emit('ppMessage', { 'messageId': 'From1','value':response.json()['pois'][0]['name'] })
           client.emit('ppMessage', { 'messageId': 'From1Pos','value':response.json()['pois'][0]['location'] })
           client.emit('ppMessage', { 'messageId': 'From2','value':response.json()['pois'][1]['name'] })
           client.emit('ppMessage', { 'messageId': 'From2Pos','value':response.json()['pois'][1]['location'] })
           client.emit('ppMessage', { 'messageId': 'From3','value':response.json()['pois'][2]['name'] })
           client.emit('ppMessage', { 'messageId': 'From3Pos','value':response.json()['pois'][2]['location'] })

     elif messageId=='To':
           client.emit('ppMessage', { 'messageId': 'To1','value':response.json()['pois'][0]['name'] })
           client.emit('ppMessage', { 'messageId': 'To1Pos','value':response.json()['pois'][0]['location'] })
           client.emit('ppMessage', { 'messageId': 'To2','value':response.json()['pois'][1]['name'] })
           client.emit('ppMessage', { 'messageId': 'To2Pos','value':response.json()['pois'][1]['location'] })
           client.emit('ppMessage', { 'messageId': 'To3','value':response.json()['pois'][2]['name'] })
           client.emit('ppMessage', { 'messageId': 'To3Pos','value':response.json()['pois'][2]['location'] })

#执行路径规划 2.0 API
def Navigate(location,messageId):
         
    #将location切割为起点终点的经纬度
    locations=location.split('|')
    fromLocation=locations[0]
    toLocation=locations[1]

    response = requests.get('https://restapi.amap.com/v5/direction/driving?parameters', {
        'key': GaodeKey,
        'origin': fromLocation,
        'destination':toLocation,
        'strategy':'0'
    })
    client.emit('ppMessage', { 'messageId': 'Path1','value':response.json()['route']['paths'][0]['steps'][0]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path2','value':response.json()['route']['paths'][0]['steps'][1]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path3','value':response.json()['route']['paths'][0]['steps'][2]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path4','value':response.json()['route']['paths'][0]['steps'][3]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path5','value':response.json()['route']['paths'][0]['steps'][4]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path6','value':response.json()['route']['paths'][0]['steps'][5]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path7','value':response.json()['route']['paths'][0]['steps'][6]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path8','value':response.json()['route']['paths'][0]['steps'][7]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path9','value':response.json()['route']['paths'][0]['steps'][8]['instruction'] })
    client.emit('ppMessage', { 'messageId': 'Path10','value':response.json()['route']['paths'][0]['steps'][9]['instruction'] })

#执行连接
client.connect(SERVER_ADDRESS)

结束