免费 HTTPS 证书-从 StartSSL 到 Let's Encrypt

在国内严峻的流量劫持情况下,为了避免网站、APP 中出现莫名其妙的广告,同时也为了保护用户隐私,从 HTTP 切换到 HTTPS 是很多网站不得不做出的选择。

随着技术的进步,还有一个同样重要的原因,那就是 HTTP/2 也建立在 SSL/TLS 基础之上,想充分利用 HTTP/2 的新特性也要求网站切换到 HTTPS。

从去年开始,我的个人站点就切换到了 HTTPS + HTTP/2 协议,通过 Nginx 实现协议切换是非常容易的事情,仅要求一个合法的证书即可完成配置。当时我选择的证书服务商是 StartCom,可以为有限个域名签发免费的有效期一年的 HTTPS 证书。虽然 HTTPS 增加了计算上的开销,但对于个人网站的流量来说,这点儿开销可以忽略不计了。

原来一直都好好的,前几天访问网站的时候,Chrome 忽然提示网站证书不可信任。想起来在公司听到的 Chrome 撤销对 Symantec 签发证书的信任,感觉不好!上网一搜,果然 Chrome 也撤销了对 StartCom 根证书和签发证书的信任:《Distrusting WoSign and StartCom Certificates》,而且快半年了。只是因为我的 Chrome 一直没升级到 56 版本以上,所以没有觉察到。于是没办法,只能寻找新的证书提供商了。

本来都打算花钱买个证书了,后来发现一个很值得信赖的证书提供商:Let’s Encrypt 。虽然是一个免费的服务,但后面有一堆知名组织和公司的支持:ISRG, Mozilla, Cisco, Chrome, Facebook 等,看起来比以前的一些免费证书服务靠谱多了,非常值得一用。

它家的证书有个局限,就是有效期只有 3 个月,然后就需要续签。这也很容易理解,一般免费都是有其它代价的。但是按照它给的安装步骤走下来,才发现它有着一套非常合理的设计。首先在你的服务器上安装一个软件 certbot,用参数指定你的网站根目录。这个证书机器人就会自动在你根目录下添加一个验证文件。远程证书服务器会访问你的网站,通过访问这个验证文件是否在你给定的域名路径下,自动验证你是否拥有域名的所有权。验证成功以后,证书就会自动签发到你服务器上的配置目录,只需要修改 Nginx 的配置,使用签发下来的证书就好了。

那每 3 个月的续签怎么办? certbot 本身就支持更新证书的参数,而且可以配置服务器自动重启的 hook,只需要把更新指令添加到服务器的 crontab,每 3 个月或者每 1 个月运行一次即可,完全不需要人工操作。

最后感叹一下,Apple 和 Google 真是互联网安全的业界良心!虽然看似专制霸道,但有多少 APP、网站是因为 Apple 的 App Transport Security 的要求才全站切 HTTPS 的?有多少证书提供商是因为 Chrome 时不时就吊销证书的行为才控制住了干坏事的冲动?真心希望中国的互联网大公司在此类事情上,不仅独善其身,也能兼济天下。

海口到三亚自驾

出于某种难以启齿但众所周知的原因 (≖ ‿ ≖)✧,这次清明小长假去三亚休假,选择了从北京先飞海口,然后再自驾到三亚。

那为什么选择自驾而不是高铁呢?两个原因:一是带着一个两岁多的娃,换乘高铁+出租太麻烦;二是酒店订了海棠湾的万丽,本身离三亚市区较远,交通不便。

最早开始考虑的是 Avis 租车,他家近期好像在扩张中国的业务,租车价格比较便宜。但无论是网页还是 APP,都无法走到下单那一步,感觉是正好遇到了系统故障。后来考虑到 Avis 海口站离机场有段距离,还需要叫他们来接送站比较麻烦,于是转而投奔在海口美兰机场停车场设点的一嗨租车。车型上犹豫了半天,由于行李是一个 28 寸大箱子 + 一个孩子伞车,最后通过“后备箱大小”一项成功排除其它经济车型,选定斯柯达明锐 1.6L。

一嗨租车租下了美兰机场露天停车场的一片区域,停那两辆商务面包车作为门店开展业务。整体来说租还车还算方便,只是航班刚到达时去租车人比较多,需要排一会儿队。给我的明锐是 17 年 1 月份的新车,左后方被蹭了一片,其余地方完好。明锐的掀背式后备箱真挺大的,容量完全不输帕萨特、天籁这样的 B 级车,放进去箱子和伞车还空了一大片。

如果想看海,可以不走高速,走海边的公路。但我考虑到旅途疲惫和休假的旅行目的,还是走东线高速直奔海棠湾而去。从海口到海棠湾,驾车时间大约是两个半小时,中间加了一次油。路边服务区还是挺多的,中国石化和中国石油加油站都有,可以使用这两家全国通用的加油卡。因为高速免费,所以油费略贵,92 号汽油比北京贵一块二左右。跟高速收费比算下来还是划算的。

接下来说说明锐这辆车,这个车在体验上还真是一个优缺点十分鲜明的车。只说它有的配置,没有的配置比如定速巡航等就不谈了。后备箱很大,前面已经说过了。四个车窗都是一键升降,在这个级别的合资车上不算普遍。布艺座椅配色太丑,看起来档次感低。

油表非线性,这个让人很费解。加满油以后,开 200 公里,油表下降不到 1/8,按照这个算法,满箱可以开 1600 公里,这绝对是不可能的事情。后来的 300 公里,油表就下降了 3/8 多。整个行程跑了大约 500 公里,油表下降 1/2 略多,这样计算整箱油大概是 900 多公里。我本来以为这是租车公司的特殊调校,目的是让你摸不清油耗,还车时卡不准油量。后来搜了下论坛,发现还真不是个别现象,很多人声称自己表显能跑一千三四百公里。

虽然油表不准,但是这车还是很省油的,满箱一千公里不好说,八百应该能轻松跑到。不过这种省油调教也不是没有代价,就是变速箱提速反应太慢。比如在高速上八九十公里的时候,如果想提速到 120,必须非常缓慢地踩油门提速,稍微深踩一点儿,变速箱就会由 6 挡降到 5 挡,发动机转速立马飙升到近 4000 转,整车噪音难以忍受。从网上搜到的百公里加速数据,明锐 1.6L 版本是最慢的,也是符合体验。

我在大部分情况下,对机械设备的表现都期望是线性的。油门线性、加速线性、油耗线性,这样在操作时才会更流畅。所以明锐 1.6L 这样的车,实在是欣赏不来。

最后吐槽一下海南的高速。限速摄像头和探头实在是太多了,连匝道都有限速 40 的摄像头,而且在相似的路况下,限速居然还从 120 到 100 变来变去,再加上一个没有定速巡航的车,开起来真的是挺累。

合并 Debian 补丁的 OpenBSD netcat Linux 源码

前几天在我的 CentOS 4.3 古董服务器上想使用 ProxyCommand 给 ssh 配置 socks 代理,ssh -o "ProxyCommand nc -X 5 -x 127.0.0.1:1080 %h %p" 选项在我的 OSX/Ubuntu 上挺好用的,但是在 CentOS 4.3 却发现 " invalid option -- x",没有这个参数。

本来我以为是 netcat 没有更新到最新,特地去下载了 GNU netcat 最新的源码包,结果源码编译后还是没有 “-x” 这个参数。后来仔细看 man page,看起来根本就不是一个版本。调研了下,才发现 netcat 居然有好多的版本:

你们城里人可真会玩儿啊!

因为 OSX/Ubuntu 都是用的 OpenBSD netcat(移植或修改版),所以 -x/X 参数是存在的,能够实现代理功能;但是 CentOS 4 因为版本太老,用的还是 GNU netcat。本来找个移植后的源码包,直接编译安装就好了呗。但可是,我 Google 了半天 (打脸,谁让你还用 CentOS 4),还是没找到能直接编译的 OpenBSD netcat Linux 源码包,最后还是在 Arch 的某个网站上找到使用 Debian 源码进行 Patch 然后再编译的脚本,才搞明白怎么能在 Linux 编出来 OpenBSD netcat 。

可能是出于易维护的考虑吧,Debian 把源码分成了两个包,一个是原始的 OpenBSD netcat,一个是 Debian 的 N 个 Patch 源码。编译时要先把 Patch 打到 OpenBSD 源码上,然后再编译。可这样的过程不是维护者很难理解,为什么不多发布个打完 Patch 的源码呢?而且这种补丁包形式也没个官方网站介绍下,真的好难懂。

为了避免其它古董 Linux 发行版用户再有我这样的苦恼,我把 patch 后的代码上传到了 Github: https://github.com/solrex/netcat ,有需要的朋友可以自取。

Caffe 神经网络配置 - All in one network

很多人使用 Caffe 配置神经网络的时候,习惯于撰写两个配置文件,一个叫 train_val.prototxt,在训练的时候使用;一个叫 deploy.prototxt,在预测的时候使用。这两个文件的本质区别,往往在输入、输出层不同。train_val.prototxt 里包含 train/test 的输入数据和标签,但出于效率考虑,train/test 都是分 batch 进行的,而输出的往往是 acc/loss;deploy.prototxt 里只包含 test 的输入,而且一般是每次输入一个数据(没有标签),输出的也不是 acc/loss,而是预测值(Top N 类别或者预测概率)。可以把 deploy.prototxt 看成可以往线上部署的网络配置文件,来一个用户请求,执行 network 的 forward,预测返回给用户结果。

这样做没什么不可以,而且很多开源的例子都是这么做的。但实际操作中,有一个很麻烦的地方是,当你在频繁调整模型的时候,每次修改隐层都要同时修改两个 .prototxt 让人很烦恼。Caffe 的配置文件不像 Keras 那样,每层就是简单的一行代码,而是一个 Protobuf 的 txt message,有很多行,这样电脑的一屏显示不全,就需要花精力去仔细 diff 两个文件。

其实我们有更好的办法,使用 Caffe 的 proto 协议实现 All in one network。那就是充分利用 NetStateRule 这个结构,结合 phase 和 stage/not_stage,实现不同场合下 layer 的过滤。

message NetStateRule {
  // Set phase to require the NetState have a particular phase (TRAIN or TEST)
  // to meet this rule.
  optional Phase phase = 1;

  // Set the minimum and/or maximum levels in which the layer should be used.
  // Leave undefined to meet the rule regardless of level.
  optional int32 min_level = 2;
  optional int32 max_level = 3;

  // Customizable sets of stages to include or exclude.
  // The net must have ALL of the specified stages and NONE of the specified
  // "not_stage"s to meet the rule.
  // (Use multiple NetStateRules to specify conjunctions of stages.)
  repeated string stage = 4;
  repeated string not_stage = 5;
}

以 Caffe 里的 example/minist/lenet_train_test.prototxt 为例 ,那怎么把它改成 all in one 的 prototxt 呢?

name: "LeNet"
layer {
  name: "mnist"
  type: "Data"
  top: "data"
  top: "label"
  include {
    phase: TRAIN
  }
  transform_param {
    scale: 0.00390625
  }
  data_param {
    source: "examples/mnist/mnist_train_lmdb"
    batch_size: 64
    backend: LMDB
  }
}
layer {
  name: "mnist"
  type: "Data"
  top: "data"
  top: "label"
  include {
    phase: TEST
  }
  transform_param {
    scale: 0.00390625
  }
  data_param {
    source: "examples/mnist/mnist_test_lmdb"
    batch_size: 100
    backend: LMDB
  }
}

首先,我们要明确解决的是 TEST phase 的冲突(验证集和测试集的 input/output 不同),不用去管 TRAIN phase。而为了解决 TEST phase 的冲突,就需要通过为 NetStateRule 增加参数来实现。min_level/max_level 和 stage/not_stage 都可以做这个事情,但我习惯用 stage,因为文字看起来比数字更直观一些。所以我会在原来的 train_val.prototxt 里再增加一个 TEST 输入层,通过 stage 区分不同的应用场景,如下所示:

layer {
  name: "mnist"
  type: "Data"
  top: "data"
  top: "label"
  include {
    phase: TEST
    not_stage: "predict"    # 在 predict 时过滤掉这一层
  }
  transform_param {
    scale: 0.00390625
  }
  data_param {
    source: "examples/mnist/mnist_test_lmdb"
    batch_size: 100
    backend: LMDB
  }
}
# 增加 deploy 的输入层
layer {
  name: "data"
  type: "Input"
  top: "data"
  input_param { shape: { dim: 1 dim: 1 dim: 28 dim: 28 } }
  include {
    phase: TEST
    stage: "predict"    # 在 predict 时加上这一层
  }
}

在 caffe.bin train 时,由于 solver.prototxt 没有提供特殊的参数,所以只包含 batch_size 100 的 TEST 输入层;在预测的时候,设置 stage='predict' 参数(设置方式下文有介绍),网络的输入层就变成了 dim: 1 的 TEST 输入层了。

同理,对输出层也是一样,在 loss layer 加上 exclude stage: "predict" 的参数,预测时就无需提供 label 和计算 loss 了,如下所示:

layer {
  name: "accuracy"
  type: "Accuracy"
  bottom: "ip2"
  bottom: "label"
  top: "accuracy"
  include {               #
    phase: TEST           #
    not_stage: "predict"  # 在 predict 时过滤掉 accuracy 层
  }                       #
}
layer {
  name: "loss"
  type: "SoftmaxWithLoss"
  bottom: "ip2"
  bottom: "label"
  top: "loss"
  exclude {           # 注意是 exclude
    phase: TEST       #
    stage: "predict"  # 在 predict 时过滤掉 loss 层
  }                   #
}

这样,你就能得到一个 all in one 的网络配置 lenet_train_val_deploy.prototxt,可以统一用它进行训练和预测,修改隐层再也不用拷贝来拷贝去了。其实使用 NetStateRule 可以进行各种组合,其它的参数组合也能实现 all in one 的网络设置,但我上面介绍的这种配置方法有个好处是完全不用修改原来的 solver.prototxt。也就是 default 走 non-predict,显式走 predict。

那怎样显式提供 stage='predict' 参数呢?在 caffe.bin 命令行可以使用:

$ caffe.bin test --stage="predict" --model="train_val_deploy.prototxt" \
--weights="iter_N.caffemodel"

当然,这时候输入层可能要换成其它的类型,不能是 Input 类型,不然 caffe 没法读取数据。使用 Input 类型时,就得用 Python/C++ 来加载数据。使用 stage="predict" 初始化 Python 和 C++ 的方法如下:

Python:
net = caffe.Net("train_val_deploy.prototxt", caffe.TEST, stages=['predict'],
                weights="iter_N.caffemodel")
C++:
caffe::vector<caffe::string> stages;
stages.push_back("predict");
caffe::Net *net = new caffe::Net("train_val_deploy.prototxt", caffe::TEST, 0, &stages);

手机上的 AI - 在 Android/iOS 上运行 Caffe 深度学习框架

目前在云端基于各种深度学习框架的 AI 服务已经非常成熟,但最近的一些案例展示了在移动设备上直接运行深度神经网络模型能够带来很大的处理速度优势。比如 Facebook 在官方博客上发布的可在移动设备上进行实时视频风格转换的应用案例 “Delivering real-time AI in the palm of your hand”。其中提到 Caffe2go 框架加上优化后的网络模型,可以在 iPhone6S 上支持 20FPS 的视频风格转换。Google Tensorflow 也提供了 iOS 和 Android 的 example

Caffe 是一个知名的开源深度学习框架,在图像处理上有着非常广泛的应用。Caffe 本身基于 C++ 实现,有着非常简洁的代码结构,所以也有着很好的可移植性。早年也已经有了几个 github 项目实现了 Caffe 到 iOS/Android 平台的移植。但从我的角度来看,这些项目的编译依赖和编译过程都过于复杂,代码也不再更新,而且最终产出的产品包过大。caffe-compact 最接近我的思路,但是在两年前未完工就已经不更新了。

从我个人在 APP 产品上的经验来看,移植深度学习框架到 APP 中,不仅仅是能不能跑,跑不跑得快,还有个很重要的因素是包大小问题。因为一般用深度学习模型只是实现一个产品功能,不是整个产品。一个产品功能如果对 APP 包大小影响太大,很多 APP 产品都无法集成进去。我希望依赖库能尽量地精简,这样打包进 APP 的内容能尽量地少。所以我在春节期间在 github 上启动了一个 Caffe-Mobile 项目,将 Caffe 移植到 Android/iOS 上,并实现了以下目标:

NO_BACKWARD:手机的电量和计算能力都不允许进行模型训练,所以不如干脆移除所有的后向传播依赖代码,这样生成的库会更小,编译也更快。

最小的依赖。原始的 Caffe 依赖很多第三方库:protobuf, cblas, cuda, cudnn, gflags, glog, lmdb, leveldb, boost, opencv 等。但事实上很多依赖都是没必要的:cuda/cudnn 仅支持 GPU, gflags 仅为了支持命令行工具,lmdb/leveldb 是为了在训练时更高效地读写数据,opencv 是为了处理输入图片,很多 boost 库都可以用 c++0x 库来替换。经过精简和修改部分代码,Caffe-Mobile 的第三方库依赖缩减到两个:protobuf 和 cblas。其中在 iOS 平台上,cblas 用 Accelerate Framework 中的 vecLib 实现;在 Android 平台上, cblas 用交叉编译的 OpenBLAS 实现。

相同的代码基,相同的编译方式。两个平台都采取先用 cmake 编译 Caffe 库(.a or .so),然后再用对应平台的 IDE 集成到 app 中。编译脚本使用同一个 CMakeList.txt,无需将库的编译也放到复杂的 IDE 环境中去完成。

可随 Caffe 代码更新。为了保证开发者能追随最新 Caffe 代码更新,我在修改代码时使用了预编译宏进行分支控制。这样进行 diff/patch 时,如果 Caffe 源码改动较大,merge 时开发者可以清楚地看到哪些地方被修改,是如何改的,更方便 merge 最新更新。

除了 Caffe 库外,在 Caffe-Mobile 项目中还提供了 Android/iOS 两个平台上的最简单的 APP 实现示例 CaffeSimple,展示了在手机上使用 Caffe example 里的 MNIST 示例(深度学习领域的 Hello World)训练出来的 LeNet 模型预测一个手写字符 “8” 图片的过程和结果。 Caffe-Mobile 项目的地址在:https://github.com/solrex/caffe-mobile 欢迎体验,感兴趣的同学们也可以帮忙 Star 下 :)

北京交警APP的复议难

北京交管局算比较与时俱进的公家单位了,微博“北京交警”一直保持着高发文频率,还出了一个独立的“北京交警 APP”,可以办理进京证、处理事故、处理违法罚款等。但是,在个别细节上规章制度还是跟不上用户体验优化的步伐,这周我就遇到了类似的问题。

10月的某一天,我在某个路口欲直行,走到近前才发现,路口对面被施工隔离完全挡住,还有一辆混凝土车在施工,于是只好右转离开。隔了两天,忽然收到违法短信:

您的小型汽车***于2016-10-06 17:05在***路口 南向北,因未按照指示交通标线指示行驶的违法行为,被房山交通支队拍摄记录【北京交管局】

上网一查,未按交通标线行驶罚款 100 元,不扣分。虽然钱不多,但觉得不合理啊。交通队的法制科只有工作日办公,于是趁着给孩子打疫苗的一个工作日,到房山交通支队办公大厅“讨个说法”。

到地方先找法制科,门卫打电话后,通知我先去大厅处理违法,再上去找法制科“行政复议”。到大厅一看,嗬,排了一百多号人。于是我就想起了“北京交警APP”,麻利儿地登陆,处理,缴款,3 分钟完事儿了。

然后上楼找法制科,到地方先问我,“处罚决定书”呢?然后我就懵逼了,啥?!我APP交的罚款啊,哪有处罚决定书?那警察一听我用APP交的罚款,立马就方了,跟我说,那你这趟算白跑了!

经过一番解释,我才明白:行政复议只能向执法单位的上级行政单位申请。各执法站一般是交通支队下属的大队,所以交通支队法制科可以对执法站打印的处罚决定进行复议。而北京交警APP上处理的违章,处罚单位是对应的交通支队,只能由支队的上级单位,也就是北京交管局处理。

然而,北京交管局在二环路上。考虑来回油钱和堵车时间,我只能当这 100 块钱捐给交管局了。

最后提醒诸位:如果对交通违法处罚有异议,千万不要在网上、APP或者其它自助设备上处理违法行为。最好到交通大队执法站打出处罚决定书,然后根据通知书找对应支队法制科复议。

700行代码帮你迈出打造专属Jarvis的第一步

前几天,Mark Zuckerberg 写了一篇博客《Building Jarvis》 ,立即风靡科技圈。智能家庭,Bill Gates 弄了一个,Zuckerberg 也搞了一个,科技圈的大佬们纷纷动手,让小民们看着很眼馋。

在《Building Jarvis》这篇文章中,Zuckerberg 写到:

These challenges always lead me to learn more than I expected, and this one also gave me a better sense of all the internal technology Facebook engineers get to use, as well as a thorough overview of home automation.

注意到这些酷炫的技术,都是 internal technology Facebook engineers get to use。那么到底有没有可能,使用公开领域的服务,构建一个类似于 Jarvis 的系统呢?

正好这段时间,我也在做一个基于人工智能技术的简单 APP:WhatIsWhat。这个 APP 目前很简单,甚至可以称得上简陋,但可能对你构建自己的 Jarvis 会有所帮助或启发。

什么是什么
什么是什么

背景

某天闲聊的时候,有个妈妈同事说,她家宝宝问她很多东西不懂,只好去搜索,发现百度百科的不少词条有个“秒懂百科”,用视频讲解百科词条,宝宝很爱看。只是可惜宝宝不认字,不会自己搜索。然后我就想,要是有个工具,能用语音问问题,语音或者视频回答问题,那挺不错啊,就有了这个 APP。

随着近几年语音识别准确率的大幅度提升,语音交互技术已经步入到非常成熟的阶段了。公开领域也有讯飞、百度等好几家免费服务可用,只是关注和使用这些的一般都是企业,个人开发者并不多。其实从我工作上的背景出发,语音交互背后的技术都是非常熟悉的。下面我就以我使用的百度语音开放平台为例,解释下能有哪些免费的语音交互服务可用。

语音识别

要想宝宝能使用语音问问题,首先需要有一个语音转文字的技术,我们一般称之为“语音识别”。从 20 世纪 70 年代 IBM 把 HMM 应用到语音识别技术上来以后,语音识别准确率一直在稳步提升。但到了 2000 年以后,语音识别的效果改进停滞了,而且一停就是 10 年。直到 2010年,Geoffrey Hinton、邓力和俞栋在微软研究院将深度学习引入语音识别技术后,平地一声惊雷,语音识别的准确率才又开始一次大跃进。

可以这样说,20 年前的语音识别和六七年前的语音识别,没有太大区别。但现在的语音识别技术,和六七年前的语音识别技术,是有革命性改进的。如果你还根据几年前的经验,认为语音识别是个 Tech Toy,识别结果充满了错漏。不妨试试最新的语音识别产品,比如讯飞语音输入法、百度语音搜索,结果会让你很吃惊的。

值得高兴的是,讯飞和百度都将最新的语音识别技术免费开放给所有人使用。比如百度的语音识别服务,单个应用每天可以免费调用 5 万次,而且可以通过申请提升这个免费上限。只需要到它的平台上注册成为开发者(不需要任何费用),申请新建一个应用,下载最新版的 SDK,参考文档集成到 APP 里就行了。

语音合成

如果想让手机使用语音回答问题,还需要一个文字转语音的技术,我们一般称之为“语音合成”或者“TTS”。语音合成在准确率方面的问题上,没有语音识别那么显著,但更大的困难来自于“怎么让机器发出的声音更像人声?”有很多个方面的考量,比如情绪、重音、停顿、语速、清晰度等等。现代的语音合成产品,一般都支持选择发声人(男声、女声、童声)和调整语速的功能。很多小说阅读器都配备的“语音朗读”,就是语音合成技术的典型应用。

讯飞和百度也都免费开放了自家的语音合成技术,也是类似于语音识别的SDK集成即可。值得一说的是,Google 在今年 9 月发表了自家的 WaveNets 语音合成模型,号称将 TTS 发声和人声的差距缩短了 50%(可以到这个页面体验一下),所以我们可以期待公开的语音合成服务效果有更进一步的改进。

WaveNets 效果
WaveNets 效果

语音唤醒

就像两个人交谈时你必须得称呼对方名字,他才知道你是在对他说话,机器也是一样。对着手机屏幕的时候,可以通过点击麦克风按钮来实现唤醒语音输入,但在远处或者不方便点击时(比如开车),需要用特定的指令唤醒它接收并处理你的输入。就像我们熟悉的“Hey,Siri”和“OK,Google”,我们一般称之为“语音唤醒”。

一般情况下,唤醒指令不依赖语音识别,也就是说,它纯粹是使用声学模型匹配你的声音。这样做也有好处,就是不依赖网络,待机功耗也更低。

讯飞的语音唤醒功能是收费的,但是百度的语音唤醒功能是免费的,可以定制自己的唤醒词,然后下载对应唤醒词的声学模型包,集成到语音识别 SDK 中即可。

如果希望打造一个专属的 Jarvis 的话,这个唤醒词声学模型最好是使用自己的语音训练出来的,这样召准率才能更高。但很遗憾,百度的免费语音唤醒还不支持这点,只能用百度语料库训练出来的模型。

自然语言理解

关于自然语言理解,Zuckerberg 的 《Building Jarvis》已经解释得非常充分了,这是一个非常复杂和困难的技术领域。讯飞和百度也都在自身语音识别能力基础上,开放了自然语言理解的能力。用户甚至可以在云端自定义自己的语义,这样识别后不仅能拿到一个纯文本识别结果,还可以获取结构化的分析后结果。

百度语义理解
百度语义理解

我对 WhatIsWhat 这个 APP 的要求很简单,只需要理解“什么是什么?”这个问题即可。我没有用到百度的语义理解能力,而是简单地写了一个正则表达式匹配,主要是希望后续能充分利用语音识别的 Partial Result 对性能进行优化。

问题回答

目前很多搜索引擎(比如谷歌、百度)对语音发起的搜索,在给出搜索结果的同时,往往附带着一句或者几句语音的回答。但搜索引擎针对的往往是开放领域的搜索词,所以语音回答的覆盖比例并不高。限定到“什么是什么”这个特定的领域,百度百科的满足比例就高了。尤其是秒懂百科,使用视频的方式讲解百科词条,样式非常新颖。

在这个最初的版本中,我只采取了秒懂百科的结果。也就是先抓取百科结果页,提取秒懂百科链接,然后打开秒懂百科结果页。为了让播放视频更方便,我用 WebView 执行了一个自动的点击事件,这样第一条视频结果在打开页面后会直接播放,不需要再点击。

演示视频

下面是“WhatIsWhat”这个 APP 的演示视频,请点击查看,因为录音设备的冲突,视频的后半部分没有声音,敬请谅解。

演示视频,点击查看

源代码地址

你可以到 https://github.com/solrex/WhatIsWhat 这个链接查看“WhatIsWhat”的全部源代码。代码总共 700 多行,不多,需要有一点儿 Android 和 Java 基础来理解。

总结

WhatIsWhat 是从一个朴素 idea 出发的非常简单的 APP,这个产品集成了“语音识别、语音合成、语音唤醒、自然语言理解”几类人工智能服务。想要实现 Jarvis,可能还需要人脸识别、智能对话、开放硬件 API 等几项能力,并且需要一定的工程能力将这些功能整合起来。

虽然 WhatIsWhat 与 Jarvis 的复杂度不可比,但它演示了如何使用公共领域已有的人工智能服务,构造一个落地可用的产品。更重要的是,它便宜到不需花一分钱,简单到只有 700 行代码。

就像 Zuckerberg 所说“In a way, AI is both closer and farther off than we imagine. ”虽然很多人并没有意识到语音交互这类 AI 技术能够那么地触手可及,但技术的开放对 AI 应用普及的影响是巨大的。在这一点上,国内的人工智能产业巨头们做得并不差。这篇文章,WhatIsWhat 这个 APP,只能帮你迈出第一步,希望不远的将来,我们能够有更多的开放 AI 服务,使得搭建自己的专属 Jarvis 变成一件轻而易举的事情。

博朗耳温枪的使用误区和故障维修指南

昨天中午,一个同事急忙慌地找我请教:“宝宝发烧了怎么办?”我叮嘱了他两句注意事项。然后他又晃着手里的博朗耳温枪问我:“这玩意儿居然坏了,附近哪里有卖?”因为我略微研究过一下耳温枪,就说拿来我看看。抠开电池盖,我就明白了,非常平静淡定地跟他说了一句:“哥,电池装反了。”

其实也不能怪他,作为一个中国消费者,看到博朗耳温枪的电池仓设计,是稍微有点儿蒙逼的,见下图:

耳温枪电池仓
耳温枪电池仓

看到这样的电池仓,你肯定认为左边的弯簧片对着“-”极,右边的直簧片对着“+”极,两节电池应该装到同一方向,我同事就犯了同样的错误。但事实上,电池正负极应该是这样的:

耳温枪电池方向
耳温枪电池方向

不过,装电池的时候,稍微注意一点儿电池仓的正负极说明,也就还好了。

除了电池方向以外,不少人在使用博朗耳温枪的时候,还有其它误区(谁让你们不看说明书!)。

误区一:耳套永远不更换,无论脏了或者破损。实话说,每次使用都更换耳套,那是土豪做法。宝宝每次发烧,敏感的大人总是半小时一小时就会量一次耳温,平均2块钱一个耳套,发烧一周期耳套的钱都够买个新耳温枪了。但耳套使用久了,被孩子各种抓,各种磨损,也容易出现裂缝或者污物,要经常注意观察,必要时用酒精棉签擦拭一下污物,看看有没有破损,有破损了要及时更换。

误区二:耳套没装紧,导致无法测温。更换耳套时,不要触摸枪头,直接按下耳套下方按钮弹出旧耳套,然后再装上新耳套。新耳套要把枪头下的红点凸起压下去,才能实现正常测温。

耳温枪耳套卡位
耳温枪耳套卡位

误区三:随意将耳温枪插入宝宝外耳道,读数波动较大。耳温枪测量的是鼓膜发出的红外线,所以要保证宝宝外耳道的清洁,以及插入的方向性。必要时需要稍微向上或向后提起一下宝宝耳朵,以使耳道平直,保证测温准确。

排除使用上的问题和电池问题,如果还是无法测量,也有可能是耳温枪本身坏了。我就遇到过电池触点氧化导致接触不良,时好时坏的情况。如果在保修期外,可以尝试自己拆卸一下排查问题原因。拆卸方法如下:

首先打开电池盖,卸去电池,在电池仓内找到唯一的一个固定螺丝,用六角梅花螺丝刀拧开。然后用指甲盖插入外壳缝隙,使劲滑动抠开外壳,如下图:

耳温枪掰开外壳
耳温枪掰开外壳

最终外壳会一分两半,像下面这样:

耳温枪一分两半
耳温枪一分两半

这时候可以观察一下电路板有没有氧化、烧蚀的现象。如果有氧化的情况,可以用酒精棉签使劲擦拭一下氧化层,改善接触情况。下图红圈内就是那个氧化了的电池触点被擦拭后的情况,明显能看到亮度和光泽度和右侧触点没法比:

耳温枪电路板
耳温枪电路板

然后重新装好,就免去了200大元买个新耳温枪的开销 :)

雾霾天里谈谈买什么空气净化器

在这样一个双 11 前夕的霾夜里,相信不少人的淘宝购物车里,都少不了防雾霾装备:N95 口罩,空气净化器或者滤芯。

雾霾已不是第一年,相信大家也都有了应对的经验。空气净化器的选购也经常成为热门话题,比如知乎上这个问题《如何选购空气净化器》,涵盖了不少有价值的观点。CADR,CCM,噪音分贝,在这些指标之外,我觉得还有一个功能亟待引起重视,那就是空气净化机中内置的室尘传感器精度。

空气净化器做为一种家用消费电器,使用体验是非常重要的一部分。没有谁家里的空气净化器是一直以最高档位全速运行的,几乎所有的净化器在那种情况下的噪音都让人难以忍受。与空调靠温度控制一样,大部分人都会选择使用自动模式,期待净化器在污染物多的时候强力运行,污染物少的时候低速运行。而自动模式下,最关键的部件就是室尘传感器。

可恰恰这个部件,与滤网、噪音、风机相比,大部分情况下都会被人忽视。目前市场上的主流净化器,大部分仍然在使用红外传感器,优点是成本低,缺点是精度不够。关于传感器的知识,可以看看这个知乎问题《空气净化器里的红外探头和激光探头有什么区别?哪种好一些?

从我个人的体验来讲,红外传感器的精度可谓是低的可怕,往往在室内 PM2.5 浓度达到一两百时,红外传感器还以为空气质量良好。这样迟钝的空气净化器,用和不用又有什么区别呢?除非能够忍受长时间高档位运行的噪音,或者手动根据粉尘浓度来调整净化器运行档位,不然还是建议选择一款使用高精度室尘传感器的净化器,因为这样才能确保你在其它配置上花的每一分钱,都实现了它的价值。

其实,在这个“AI First”的时代,更高精的传感器、更聪明的人工智能算法,才是消费产品的未来。那些落后于时代的产品,注定是被市场淘汰的命运。

差点晕倒在健身房

今晚下班忙完,抬头一看,平常一起吃饭的小伙伴们一个个都消失不见了。惆怅之下看了下路况,居然尚可,于是觉得不如回家吃饭。

开车路上看了看时间,觉得今天到家还早。因为十一假期琐事已经半月没去健身,下定决心要去健身房。于是把车停到健身房附近路边,换鞋背包打卡进店,忘了吃饭的事儿。

这个小破地下健身房器械不多,杠铃铁倒不少。我至今不敢玩杠铃,就在有限的几个坐式器械上来来回回。力量很弱,基本上砝码都选 21Kg 或 28Kg,15 次一组勉强能练 3~5组。自从周围又开了两家健身房后,这家店就有了个奇特的现象,就是教练比会员多。我每次去健身,都要躲着教练们满含期待的目光,默默地吭哧吭哧。

这次满头大汗吭哧完后,回更衣室拿 iPad 去跑步机跑步。要问 iPad 和跑步有什么关系?那是因为跑步时候看看电影比较容易跑久一些。结果刚要从更衣室出来,忽然一阵眩晕,赶紧坐在椅子上歇了会儿。这时候我才猛然意识到,坏了,晚饭怎么没吃,不会是低血糖了吧?头晕,恶心,四肢发软,大口喘气,情节的发展越来越像,我只好忍着眩晕,蹲在地上随便冲了把澡,换上衣服躺了会儿。大概过了十分钟左右,才算缓过劲儿来。

要说眩晕,这也不是第一次了,大学军训时候晕倒两回我会随便说吗。节奏一般是这样,先眩晕,然后恶心,然后喘粗气,然后四肢无力不受控制,然后反胃,然后眼前慢慢拉上黑幕,然后瘫倒在地,意识尚存,被扶到旁边休息一会儿能缓过劲儿来。不过我觉得那两次应该属于中暑,跟小时候爸爸夏天让我站着理发一个效果,只不过小时候难受了就能坐坐,军训时候不能。

这次可能还是在饥饿的情况下坐着练了较久,没意识到低血糖的问题,直到站起来后大脑供血不足才发现。好歹应对得当,没直接倒掉让别人把我扶起来。总体来说还是一个词,大意了。

Android HTTPUrlConnection EOFException 历史 BUG

这是一个影响 Android 4.1-4.3 版本的 HTTPUrlConnection 库 BUG,但只会在特定条件下触发。

我们有一个 Android App,通过多个并发 POST 连接上传数据到服务器,没有加入单个请求重试机制。在某些 Android 机型上发现一个诡异的 bug,在使用中频繁出现上传失败的情况,但是在其它机型上并不能复现。

经过较长时间的排查,我们找到了上传失败出现的规律,并认为它跟 HTTP Keepalive 持久化连接机制有关。具体的规律是:当 App 上传一轮数据后,等待超过服务端 Nginx keepalive_timeout 时间后,再次尝试上传数据,就会出现上传失败,抛出 EOFException 异常。

更准确的特征可以通过连上手机的 adb shell 观察 netstat:当 App 上传一轮数据后,可以观察到有 N 个到服务器的连接处于 ESTABLISHED 状态;当等待超过服务端 Nginx keepalive_timeout 时间后,可以观察到这 N 个到服务器的连接处于 CLOSE_WAIT 状态;当上传失败后,发现部分 CLOSE_WAIT 状态的连接消失。

Java 的 HTTP Keepalive 机制,一直是由底层实现的,理论上来讲,不需要应用层关心。但从上面的 BUG 来看,对于 stale connection 的复用,在部分 Android 机型上是有问题的。为此我稍微研究了一下 Android 的源代码,的确发现了一些问题。

在 2011年12月15日,Android 开发者提交了这样一个 Commit,Commit Message 这样写到:

Change the way we cope with stale pooled connections.

Previously we'd attempt to detect if a connection was stale by
probing it. This was expensive (it relied on catching a 1-millisecond
read timing out with a SocketTimeoutException), and racy. If the
recycled connection was stale, the application would have to catch
the failure and retry.

The new approach is to try the stale connection directly and to recover
if that connection fails. This is simpler and avoids the isStale
heuristics.

This fixes some flakiness in URLConnectionTest tests like
testServerShutdownOutput and testServerClosesOutput.

Bug: http://b/2974888
Change-Id: I1f1711c0a6855f99e6ff9c348790740117c7ffb9

简单来说,这次 commit 做了一件事:在修改前,是在 TCP 连接池获取连接时,做 connection isStale 的探测。Android 开发者认为这样会在获取每个 connection 时都有 1ms 的 overhead,所以改成了在应用层发生异常时,再重试请求。 但是这个重试有个前提,就是用户的请求不能是 ChunkedStreamingMode,不能是 FixedLengthStreamingMode,这两种模式下,底层无法重试。很不幸地是,我们正好使用到了 FixedLengthStreamingMode 带来的特性。

// Code snippet of: libcore.net.http.HttpURLConnectionImpl.java
while (true) {
  try {
    httpEngine.sendRequest();
    httpEngine.readResponse();
  } catch (IOException e) {
    /*
     * If the connection was recycled, its staleness may have caused
     * the failure. Silently retry with a different connection.
     */
    OutputStream requestBody = httpEngine.getRequestBody();
    // NOTE: FixedLengthOutputStream 和 ChunkedOutputStream
    // 不是 instance of RetryableOutputStream
    if (httpEngine.hasRecycledConnection()
        && (requestBody == null || requestBody instanceof RetryableOutputStream)) {
      httpEngine.release(false);
      httpEngine = newHttpEngine(method, rawRequestHeaders, null,
          (RetryableOutputStream) requestBody);
      continue;
    }
    httpEngineFailure = e;
    throw e;
}

由于 BUG 的根源在 Android 的核心库 libcore 中。这次改动影响了从 4.1 到 4.3 的所有 Android 版本, Android 4.4 网络库的 HTTP/HTTPS 从 libcore 切换到 okhttp,所以4.4以后的 Android 版本不受影响。

既然底层不重试,那么只有在应用层重试,所以我们在应用层增加了最多『http.maxConnections+1』次重试机制,以修复此问题。在重试的时候,尝试使用一个 stale connection 会导致 EOFException,底层也会自动关闭这个连接。『http.maxConnections+1』次重试保证即使连接池中全都是 stale connection,我们也能获得一个可用的连接。

网上应该也有人遇到过这个 BUG,所以我也在这个 StackOverflow 问题下做了回答

VirtualBox 还能这样玩

在工作中经常遇到这样的情况:忽然发现开源界有了个新玩意儿,但是下载到自己电脑上一看,不支持我的操作系统,或者不支持这个版本的操作系统。只能老老实实下载某个版本的 Linux 安装镜像,然后开始安装配置虚拟机,等把环境都折腾得差不多了,已经忘了自己装系统来干什么了。

我曾经想过直接从网上寻找构建好的虚拟机镜像,最终发现并不容易。但我很遗憾没有早些遇见 Vagrant,因为它更进一步地满足了上述的需求。

Vagrant 能做什么呢?一句话来说,就是它用简单的命令封装了虚拟机创建、分发和配置的过程。如果把 VirtualBox 比作 dpkg、rpm,Vagrant 就是 apt-get、yum。用 Vagrant 可以非常简单地下载网上封装好的虚拟机镜像(叫做 box),然后启动起来,并且登录进去。配置虚拟机的端口转发等功能也变得非常容易,并且可以脚本化。

当然,Vagrant 也可以将配置好的虚拟机打包成 box 分发给别人。这就带来很大一个好处,那就是用它可以非常简单地实现团队开发环境的统一。创建一个 Linux 虚拟机,安装好必要的开发工具、运行环境,配置好代码仓库,打包成 box,然后再分发一个 Vagrantfile 支持启动虚拟机后执行一个脚本更新代码。新人只需要下载 Vagrantfile,然后 vagrant up。Bingo! 就可以 ssh 上去开发了。

当开发环境出现各种问题时,非常简单地用 vagrant 重新配置下即可。这大大避免了追查『在我的环境里执行没错啊!』这种问题的麻烦发生。

对于团队来说,开发环境的 box 还是自己打包更为合适,能够尽可能避免从网上下载的 box 带来的安全问题。不过对于普通用户来说,最酷的就是有各种来自官方或非官方现成的 box 可玩,不用痛苦地自己去一遍遍重装操作系统。比如以下几个:

虽然事实上 Vagrant 已经支持了 VMware 和 KVM,不过资源上就没有 VirtualBox 那么丰富了,大家有兴趣也可以尝试一下。

Google App API Protocol - Voice Search

Google 在移动平台(Android 和 iOS)上提供了独立的 Search App,但它不仅仅是用一个移动浏览器封装了 Google Web Search,而是做了很多移动应用相关的改进。这个系列文章,通过抓包对 Android Google App 与 Server 间通讯协议进行简单分析,管中窥豹,以见一斑。

  1. Google App API Protocol - Text Search
  2. Google App API Protocol - Voice Search
  3. Google App API Protocol - Search History

语音搜索

语音搜索与文本搜索不同,必须先进行语音识别(语音转文字),拿到识别结果后才能进行搜索,拿到搜索结果。语音识别过程是一个录音数据流上传过程,一般采取的都是流式分片上传。Google App 的语音搜索,仍然是沿袭 Google 一贯注重效率的风格,使用两个 HTTPS 请求,完成了录音数据的流式上传,以及识别结果、搜索结果和语音播报的流式下发。具体的做法是:

在用户发起语音请求时,Google App 与 Google 服务器同时建立两个 HTTPS POST 连接,以 URL 参数 "pair" 标识这是同一用户的一对连接:

https://www.google.com/m/voice-search/up?pair=4fc4d987-3f06-49d5-9f3a-986937a5e5fe
https://www.google.com/m/voice-search/down?pair=4fc4d987-3f06-49d5-9f3a-986937a5e5fe

Google App 基于 chunked 编码,在 up 连接中实现录音数据流式上传,在 down 连接中实现识别结果、搜索结果和语音播报的流式下发。

语音搜索 up 连接

在 up 连接中,Google App 仅利用了 POST 请求通路的 chunked 编码流式上传录音数据,直到所有数据上传完成,Google 服务器才会返回一个 Content-Length 为 0 的 200 响应消息。而且 POST 请求的 HTTP Header/Params 很简单,仅仅包含基本的信息。

Host	www.google.com
Connection	keep-alive
Transfer-Encoding	chunked
Cache-Control	no-cache, no-store
X-S3-Send-Compressible	1
User-Agent	Mozilla/5.0 (Linux; Android 4.4.4; HM 1S Build/KTU84P)
                AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 
                Mobile Safari/537.36 GSA/5.9.33.19.arm
Content-Type	application/octet-stream
Accept-Encoding	gzip, deflate

这里要注意 Cotent-Type 是 application/octet-stream,这代表上传的是二进制数据流,需要用专门的协议才能 decode 到真正内容。这给解析协议带来了很大的困难。简单观察二进制数据流,发现部分内容是遵从 protobuf 协议格式,但直接使用 application/x-protobuffer 的 Dilimited List 封包格式进行 docode 并不成功。无奈之下,只好采取比较笨的方法,观察二进制数据流,跳过那些明显不符合 protobuf 协议格式的部分,尽最大努力把 protobuf 消息先解包出来。然后再根据已经解包出来的部分,逐步推测未解析部分的编码格式。经过较长时间的分析,拿到了 Google App 语音搜索 up 连接的请求消息封包格式大致如下:

(10 bytes Header)
(Big Endian Fixed32 of Msg Len)(Msg)
(Big Endian Fixed32 of Msg Len)(Msg)
......
(Big Endian Fixed32 of Msg Len)(Msg)

对于 Header 长度是否恒为 10 字节,我持怀疑态度,有待通过更多分析发现规律。做了很多二进制码的分析工作,到最后才发现消息结构如此简单,让人不得不抱怨:Google 你直接用和文本搜索一样的 application/x-protobuffer 格式不得了吗?还搞得那么复杂!不过也许有一定历史原因吧。

和文本搜索一样,先建立一个 Empty protobuf Message,逐步去推导流式协议里 Message 的结构,尽最大猜测推导出来的 Google 语音搜索请求消息的 .proto 可能是这样的:

$ more voicesearch.proto
package com.google.search.app;

message VoiceSearchRequest {
    optional int32 fin_stream = 3;
    optional UserInfo user_info = 293000;
    optional VoiceSampling voice_sampling = 293100;
    optional VoiceData voice_data = 293101;
    optional ClientInfo client_info = 294000;
    optional UserPreference user_preference = 294500;
    optional Empty VSR27301014 = 27301014;
    optional Empty VSR27423252 = 27423252;
    optional Double VSR27801516 = 27801516;
    optional GetMethod get_method = 34352150;
    optional Empty VSR61914024 = 61914024;
    optional Empty VSR77499489 = 77499489;
    optional Empty VSR82185720 = 82185720;
}

message UserInfo {
    optional Empty lang = 2;
    optional Empty locale = 3;
    optional string uid = 5;
    optional GoogleNow google_now = 9;
}

message GoogleNow {
	optional string auth_url = 1;
	optional string auth_key = 2;
}

message VoiceSampling {
    optional float sample_rate = 2;
}

message VoiceData {
    optional bytes amr_stream = 1;
}

message ClientInfo {
	optional string type = 2;
	optional string user_agent = 4;
	repeated string expids = 5;
	optional string os = 8;
	optional string model = 9;
	optional string brand = 11;
}

message UserPreference {
    optional Favorites favorites = 1;
    optional Empty UP4 = 4;
    optional Empty UP25 = 25;
}

message Favorites {
	optional string lang = 9;
	repeated Empty favorites_data = 22;
}

message GetMethod {
	optional Empty params = 1;
	optional HttpHeader headers = 2;
	optional string path = 3;
}

message HttpHeader {
    repeated string name = 1;
    repeated string text_value = 2;
    repeated bytes binary_value = 4;
}

基于这个 .proto,对语音输入『北京天气』的 POST 请求消息进行解码,最终的结果摘要如下:

$ more up.txt
######## Data block info: offset=0xa blockSize=3717
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=3654
user_info {
  lang {
    1: "cmn-Hans-CN"
    2: 1
  }
  locale {
    1: "zh_CN"
    2: 2
  }
  uid: "******"
  google_now {
    auth_url: "https://www.googleapis.com/auth/googlenow"
    auth_key: "******"
    7: 1
  }
  8: "w "
}
voice_sampling {
  sample_rate: 16000.0
  3: 9
}
client_info {
  type: "voice-search"
  user_agent: "Mozilla/5.0 (Linux; Android 4.4.4; ......"
  expids: "p2016_01_27_20_58_17"
  expids: "8501679"
  expids: "8501680"
  expids: "8502094"
  expids: "8502157"
  expids: "8502159"
  expids: "8502312"
  expids: "8502347"
  expids: "8502369"
  expids: "8502376"
  expids: "8502490"
  expids: "8502491"
  expids: "8502618"
  expids: "8502679"
  expids: "8502705"
  expids: "8502947"
  expids: "8502986"
  expids: "8503012"
  expids: "8503037"
  expids: "8503109"
  expids: "8503110"
  expids: "8503132"
  expids: "8503133"
  expids: "8503157"
  expids: "8503158"
  expids: "8503208"
  expids: "8503212"
  expids: "8503214"
  expids: "8503303"
  expids: "8503306"
  expids: "8503367"
  expids: "8503368"
  expids: "8503404"
  expids: "8503512"
  expids: "8503559"
  expids: "8503585"
  expids: "8503604"
  expids: "8503606"
  expids: "8503729"
  expids: "8503730"
  expids: "8503751"
  expids: "8503752"
  expids: "8503755"
  expids: "8503805"
  expids: "8503815"
  expids: "8503832"
  expids: "8503835"
  expids: "8503844"
  expids: "8503853"
  expids: "8503855"
  expids: "8503907"
  expids: "8503925"
  expids: "8503927"
  expids: "8503994"
  expids: "8504022"
  expids: "8504059"
  os: "Android"
  model: "KTU84P"
  brand: "HM 1S"
  1: ""
  10: "300601416"
  12: 720
  13: 1280
  14: 320
  17: "assistant-query-entry"
}
user_preference {
  favorites {
    favorites_data {
      1: 2
      2: "fresh"
      9: "cmn-Hans-CN"
    }
    favorites_data {
      1: 2
      2: "favorite-phone"
      9: "cmn-Hans-CN"
    }
    favorites_data {
      1: 2
      2: "favorite-email"
      9: "cmn-Hans-CN"
    }
    favorites_data {
      1: 2
      2: "favorite-person"
      9: "cmn-Hans-CN"
    }
  }
  UP4 {
    1: 10
    2: 250
    3: 1
  }
  UP25 {
    1: 0
  }
  3: 5
  5: 1
  7: 0
  13: 1
  14: 1
  20: 1
  22: 0
}
VSR27301014 {
  1: 460
  2: 0
  3: 460
  4: 0
  5: 1
}
VSR27423252 {
  1: "rbshUd2M8t4"
}
VSR27801516 {
  d7: 0.6037593483924866
}
get_method {
  params {
    1: "noj"
    1: "tch"
    1: "spknlang"
    1: "ar"
    1: "br"
    1: "ttsm"
    1: "client"
    1: "hl"
    1: "oe"
    1: "safe"
    1: "gcc"
    1: "ctzn"
    1: "ctf"
    1: "v"
    1: "padt"
    1: "padb"
    1: "ntyp"
    1: "ram_mb"
    1: "qsubts"
    1: "wf"
    1: "inm"
    1: "source"
    1: "entrypoint"
    1: "action"
    2: "1"
    2: "6"
    2: "cmn-Hans-CN"
    2: "0"
    2: "0"
    2: "default"
    2: "ms-android-xiaomi"
    2: "zh-CN"
    2: "utf-8"
    2: "images"
    2: "cn"
    2: "Asia/Shanghai"
    2: "1"
    2: "5.9.33.19.arm"
    2: "200"
    2: "640"
    2: "1"
    2: "870"
    2: "1458289692542"
    2: "pp1"
    2: "vs-asst"
    2: "and/assist"
    2: "android-assistant-query-entry"
    2: "devloc"
  }
  headers {
    name: "User-Agent"
    name: "X-Speech-RequestId"
    name: "Cookie"
    name: "Host"
    name: "Date"
    name: "X-Client-Instance-Id"
    name: "X-Geo"
    name: "X-Client-Opt-In-Context"
    name: "X-Client-Data"
    text_value: "Mozilla/5.0 (Linux; Android 4.4.4; ......"
    text_value: "rbshUd2M8t4"
    text_value: "******"
    text_value: "www.google.com.hk"
    text_value: "Fri, 18 Mar 2016 08:28:13 GMT"
    text_value: "c6aef75ce70631e9518ae8e7011bb3d7957b24d59b62fd1b9d378262b3690cff"
    text_value: "w CAEQDKIBBTk6MTox"
    binary_value: "\037\213\b\000......"
    binary_value: "\b\257\363\206......gsa"
  }
  path: "/search"
  5: 0
}
VSR61914024 {
  1: 1
}
VSR77499489 {
  1: 1
}
VSR82185720 {
  1: "\b\001"
}
1: "voicesearch-web"
2: 1
4: 0

######## Data block info: offset=0xe93 blockSize=39
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=39
get_method {
  headers {
    name: "X-Geo"
    text_value: "w CAEQDKIBBTE6MTox"
    3: 2
  }
}
2: 1

######## Data block info: offset=0xebe blockSize=309
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=309
voice_data {
  amr_stream: "#!AMR-WB......"
}
2: 1

######## Data block info: offset=0xff7 blockSize=309
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=309
voice_data {
  amr_stream: "......"
}
2: 1

......

######## Data block info: offset=0x4394 blockSize=309
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=309
voice_data {
  amr_stream: "......"
}
2: 1

######## Data block info: offset=0x44cd blockSize=267
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=267
voice_data {
  amr_stream: "......"
}
2: 1

######## Data block info: offset=0x45dc blockSize=4
#### Proto: Voicesearch.VoiceSearchRequest Message with Size=4
fin_stream: 1
2: 1

语音搜索 down 连接

在 down 连接中,Google App 仅利用了 POST 响应通路的 chunked 编码流式下发识别结果、搜索结果和语音播报数据。App 发起的虽然是一个 POST 请求,但在请求中并没有任何 POST Data,Content-Length 为 0。响应消息的 header 如下:

Content-Type	application/vnd.google.octet-stream-compressible
Content-Disposition	attachment
Cache-Control	no-transform
X-Content-Type-Options	nosniff
Pragma	no-cache
Content-Encoding	gzip
Date	Fri, 18 Mar 2016 08:28:14 GMT
Server	S3 v1.0
X-XSS-Protection	1; mode=block
X-Frame-Options	SAMEORIGIN
Alternate-Protocol	443:quic,p=1
Alt-Svc	quic=":443"; ma=2592000; v="31,30,29,28,27,26,25"
Transfer-Encoding	chunked

这里值得注意的,仍然是 Cotent-Type,这次是 application/vnd.google.octet-stream-compressible,又一个二进制私有协议数据流。通过与 up 流分析类似的方法,发现 Google App 语音搜索 down 连接的响应消息封包格式大致如下:

(4 bytes Header)
(Big Endian Fixed32 of Msg Len)(Msg)
(Big Endian Fixed32 of Msg Len)(Msg)
......
(Big Endian Fixed32 of Msg Len)(Msg)

可以看到,除了 Header 长度不同以外,基本与 up 连接的请求消息封包格式一样。采取同样的方式推测到 Google 语音搜索响应消息的 .proto 可能是这样的:

$ more voicesearch.proto
package com.google.search.app;
message VoiceSearchResponse {
    optional int32 fin_stream = 1;
    optional RecogBlock recog_block = 1253625;
    optional SearchResult search_result = 39442181;
    optional TtsSound tts_sound = 28599812;
}

message RecogBlock {
    optional RecogResult recog_result = 1;
    optional VoiceRecording voice_recording = 2;
    optional string inputLang = 3;
    optional string searchLang = 4;
}

message RecogResult {
    optional CandidateResults can = 3;
    repeated RecogSegment recog_segment = 4;
    optional CandidateResults embededi15 = 5;
}

message VoiceRecording {
    optional int32 record_interval = 3;
}

message RecogSegment {
    optional DisplayResult display = 1;
    optional int32 seg_time = 3;
}

message DisplayResult {
    optional string query = 1;
    optional double prob = 2;
}

message CandidateResults {
    optional CandidateResult can3 = 3;
    optional Empty can4 = 4;
}

message CandidateResult {
    repeated string query = 1; 
    repeated string queryWords = 12; 
    optional float res2 = 2;
    repeated CandidateInfo res7 = 7;
}

message CandidateInfo {
    optional CandidateInfoMore inf1 = 1;
}

message CandidateInfoMore {
    optional CandidateInfoMoreDetial more1 = 1;
    optional string more3 = 3;
}

message CandidateInfoMoreDetial {
    optional string type = 1;
    optional string detial2 = 2;
    optional float detial3 = 3;
    optional CandidateInfoMoreDetialSnip detial7 = 7;
    optional string detial8 = 8;
}

message CandidateInfoMoreDetialSnip {
    optional string query = 1;
}

message SearchResult {
    optional string header = 1;
    optional bytes bodyBytes = 3;
}

message TtsSound {
    optional bytes sound_data = 1;
    optional int32 fin_stream = 2;
    optional Empty code_rate = 3;
}

message Empty {
}

基于这个 .proto,对语音输入『北京天气』的 down 响应消息进行解码,最终的结果摘要如下:

$ more down.txt
######## Raw Data block info: offset=0x4 size=41
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=41
recog_block {
  voice_recording {
    record_interval: 140000
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x31 size=61
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=61
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北"
        prob: 0.01
      }
      seg_time: 1500000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x72 size=64
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=64
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北京"
        prob: 0.01
      }
      seg_time: 1560000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0xb6 size=67
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=67
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北京天"
        prob: 0.01
      }
      seg_time: 1980000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0xfd size=71
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=71
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北京天气"
        prob: 0.01
      }
      seg_time: 2100000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x148 size=71
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=71
recog_block {
  recog_result {
    recog_segment {
      display {
        query: "北京天气"
        prob: 0.9
      }
      seg_time: 2700000
      2: 0
    }
    1: 0
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x193 size=14
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=14
recog_block {
  voice_recording {
    1: 1
    2: 2100000
  }
}

######## Raw Data block info: offset=0x1a5 size=40
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=40
recog_block {
  voice_recording {
    1: 2
    2: 3570000
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x1d1 size=491
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=484
recog_block {
  recog_result {
    can {
      can3 {
        query: "北京天气"
        query: "北京天気"
        query: "北京天器"
        res2: 0.9156357
        queryWords: "北 京 天 气"
        queryWords: "北 京 天 気"
        queryWords: "北 京 天 器"
        6: "\020\n\030\001"
      }
      1: 0
      2: 3570000
    }
    embededi15 {
      can3 {
        query: "北京天气"
        query: "北京天気"
        query: "北京天器"
        res2: 0.9156357
        res7 {
          inf1 {
            more1 {
              type: "literal"
              detial2: "北京天气"
              detial3: 1.0
              detial7 {
                query: "北京天气"
                2: 0
                3: 75
              }
              detial8: "北 京 天 气"
            }
            more3: ""
          }
        }
        res7 {
          inf1 {
            more1 {
              type: "literal"
              detial2: "北京天気"
              detial3: 1.0
              detial7 {
                query: "北京天気"
                2: 0
                3: 75
              }
              detial8: "北 京 天 気"
            }
            more3: ""
          }
        }
        res7 {
          inf1 {
            more1 {
              type: "literal"
              detial2: "北京天器"
              detial3: 1.0
              detial7 {
                query: "北京天器"
                2: 0
                3: 75
              }
              detial8: "北 京 天 器"
            }
            more3: ""
          }
        }
        queryWords: "北 京 天 气"
        queryWords: "北 京 天 気"
        queryWords: "北 京 天 器"
      }
      1: 0
      2: 3570000
    }
    1: 1
    2: 0
  }
  inputLang: "cmn-hans-cn"
  searchLang: "cmn-Hans-CN"
}

######## Raw Data block info: offset=0x3c0 size=29759
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=29759
search_result {
  header: "HTTP/1.1 200 OK
  Content-Type: application/x-protobuffer
  Date: Fri, 18 Mar 2016 08:28:17 GMT
  Expires: -1
  Cache-Control: no-store
  Set-Cookie: ******
  Trailer: X-Google-GFE-Current-Request-Cost-From-GWS
  Set-Cookie: ******
  Content-Disposition: attachment; filename=\"f.txt\"
  
  "
  bodyBytes: "\301R\n\026IbzrVsTgFsK0jwP1iY34CQ .... charset=UTF-8H\001"
  2: 1
  4: 0
}
## Bodybytes Proto: Textsearch.SearchResponse with Size=10561
search_id: "IbzrVsTgFsK0jwP1iY34CQ"
msg_type: 97000
result {
  fin_stream: 0
  text_data: "北京天气"
  html_data: "<!doctype html><html ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
## Bodybytes Proto: Textsearch.SearchResponse with Size=16951
search_id: "IbzrVsTgFsK0jwP1iY34CQ"
result {
  fin_stream: 0
  html_data: "<style data-jiis=\"cc\" ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
## Bodybytes Proto: Textsearch.SearchResponse with Size=1511
......

######## Raw Data block info: offset=0x7803 size=81
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=81
search_result {
  bodyBytes: "D\n\026IbzrVsTgFsK0jwP1iY34CQ\......"
  2: 1
  4: 0
}
## Bodybytes Proto: Textsearch.SearchResponse with Size=68
......

######## Raw Data block info: offset=0x7858 size=107685
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=107685
......

## Bodybytes Proto: Textsearch.SearchResponse with Size=103014
......
## Bodybytes Proto: Textsearch.SearchResponse with Size=2454
......
## Bodybytes Proto: Textsearch.SearchResponse with Size=2194
......

######## Raw Data block info: offset=0x21d01 size=8480
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=8480
......
## Bodybytes Proto: Textsearch.SearchResponse with Size=7260
......
## Bodybytes Proto: Textsearch.SearchResponse with Size=1202
......

######## Raw Data block info: offset=0x23e25 size=1615
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1615
tts_sound {
  sound_data: "\377\363@\304\......"
  code_rate {
    1: 22050
  }
}

######## Raw Data block info: offset=0x24478 size=1609
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609
tts_sound {
  sound_data: "k\224ed4= \213......"
}

######## Raw Data block info: offset=0x24ac5 size=1609
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609
......

######## Raw Data block info: offset=0x25112 size=1609
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609
......

######## Raw Data block info: offset=0x2575f size=1609
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1609
......

######## Raw Data block info: offset=0x25dac size=1518
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=1518
......

######## Raw Data block info: offset=0x2639e size=7
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=7
tts_sound {
  fin_stream: 1
}

######## Raw Data block info: offset=0x263a9 size=2
#### Proto: Voicesearch.VoiceSearchResponse Message with Size=2
fin_stream: 1

协议分析和启发

针对以上解码结果,对 Google App 语音搜索协议分析如下:

双向流式通信模式

上传音频采用流式方式,这个并不难想,但是识别结果、搜索结果和语音播报音频流全部在一个流里回传,需要做很多复杂的架构升级工作,带来的收益也是很明显的:

  1. 识别结果实时上屏。语音识别在解码时,需要收集到足够的语音流之后,才能识别出来文字,也就是说文字结果的出现时间比较随机。流式的识别结果下发能实现一旦有识别中间结果,就可以用最快的速度下发到 App 端展示给用户。
  2. 减少一次搜索请求开销。从逻辑上讲,应该先进行语音识别,再用识别出的 Query 发起搜索。但 Google 把这一步放在了服务端,不需要用户再发起一次搜索结果的 GET 请求。因为 Google Search 的域名和 locale 有关,新的 GET 请求可能需要发给 google.com.hk,这就需要 App 新建一个 HTTPS 连接,开销还是比较大的。而且服务器端还可以进一步优化,比如在识别出中间结果的同时请求即时搜索,不知道 Google 有没有做。
  3. 减少一次语音播报请求开销。原理同上
  4. 提升了 TCP 信道利用率。在同等传输数据量下,减少网络连接数,受 TCP 拥塞控制策略影响,TCP 信道的传输性能能得到一定提升。

录音小块回传

从 up 解码结果来看,Google App 音频流的是以固定每 300 字节一个封包,基于 AMR-WB 压缩这基本相当于 100ms 左右的录音。说明录音数据的上传还是很频繁的,这也能够让服务端尽早地识别出中间结果。还有就是,300字节的设计比较容易 fit in 1500 左右的以太网 MTU、576 的 3G、4G MTU。

搜索结果内置

搜索结果内置,其实相当于在服务器端『代理』App 发起一次内网搜索请求,那服务器端必须要知道正常的 App 请求参数是怎样的。所以在 App 端发起语音请求时,首先向服务器端传送了一些 App 端的配置信息,比如语言、地域、终端类型、用户偏好、搜索参数、搜索 Header 等。有了这些信息,服务器端就可以伪装成 App,通过内网发起搜索请求,获取搜索结果然后把结果内置到语音搜索结果里。但注意到中文语音搜索和文本搜索的域名不同,分别是 google.com 和 google.com.hk,在机房部署上如何高效地实现全球机房的高效内网访问,这仍然是个架构难题,这里无法窥知答案。

对文本搜索结果的分析我们了解到,Google App 的搜索结果是用 Protobuf Message 数组的方式下传的。但语音搜索并没有使用与文本搜索相同的数组元素 Message,所以文本搜索结果是以 bytes 方式,将序列化后的文本搜索 Message 放到语音搜索 Message 中的一个字段中。这也许是为了协议解耦,避免单方面协议改动带来的同步问题。但可能是基于效率考虑,多个文本搜索 Message 可能会被放到同一个语音搜索 Message 中,也是以 Dilimited List 的方式序列化后放入。语音搜索模块解码出来 bytes 之后,可以直接塞给文本搜索的渲染模块渲染,与文本搜索接收的协议格式完全相同。

语音播报

语音播报的音频也是以流式下传,这样可以边播边收,也有效率优势。

Google App API Protocol - Text Search

Google 在移动平台(Android 和 iOS)上提供了独立的 Search App,但它不仅仅是用一个移动浏览器封装了 Google Web Search,而是做了很多移动应用相关的改进。这个系列文章,通过抓包对 Android Google App 与 Server 间通讯协议进行简单分析,管中窥豹,以见一斑。

  1. Google App API Protocol - Text Search
  2. Google App API Protocol - Voice Search
  3. Google App API Protocol - Search History

HTTPS 抓包分析

Google Service 已经全面普及了 HTTPS 接入,所以想探索 Google 的通讯协议,首先必备的是 HTTPS 抓包能力。所谓的 HTTPS 抓包,实质上是通过代理服务器实现对测试手机上 HTTPS 连接的中间人攻击,所以必须在测试手机上安装代理服务器的 CA 证书,才能保证测试手机相信 HTTPS 连接是安全的。

有很多测试用代理服务器支持 HTTPS 抓包,例如 FiddlerCharles,HTTPS 配置具体可以参见它们的官方文档:Configure Fiddler to Decrypt HTTPS TrafficSSL CERTIFICATES.

文本 IS 请求

Google App 上的文本搜索请求,并不一定以用户按下搜索按钮才开始,而是在输入过程中就可能发生,类似于 Instant Search 即时搜索。这一切发生在输入文本过程中的 "/s?" 请求内。

Google 在接口上,已经将搜索推荐和即时搜索合二为一,通过发起对 "https://www.google.com[.hk]/s?" 的 GET 请求,根据用户已经输入的短语,获取搜索推荐词。如果 Google 认为用户已经完成输入,它会在这个请求的应答消息中直接返回搜索结果。

以小米手机上安装的 Google App 为例,当用户输入『上海』这个词时,GET 请求的参数,主要有以下这些:

noj	1
q	上海
tch	6
ar	0
br	0
client	ms-android-xiaomi
hl	zh-CN
oe	utf-8
safe	images
gcc	cn
ctzn	Asia/Shanghai
ctf	1
v	5.9.33.19.arm
biw	360
bih	615
padt	200
padb	640
ntyp	1
ram_mb	870
qsd	3670
qsubts	1458723210129
wf	pp1
action	devloc
pf	i
sclient	qsb-android-asbl
cp	2
psi	bLxzwaswPFc.1458723200693.3
ech	2
gl	us
sla	1

请求的 HTTP Header,主要有以下这些:

Connection	keep-alive
Cache-Control	no-cache, no-store
Date	Wed, 23 Mar 2016 08:53:26 GMT
X-Client-Instance-Id	c6aef75ce70631e9518ae8e7011bb3d7957b24d59b62f...
Cookie	******
X-Geo	w CAEQDKIBBTk6MTox
X-Client-Opt-In-Context	H4sIAAAAAAAAAONi4WAWYBD4DwOMUiwcj...
X-Client-Discourse-Context	H4sIAAAAAAAAAB1PS07DMBSUehe...
X-Client-Data	CM72hgQIjfeGBAiP94YECKj4hgQIy...
User-Agent	Mozilla/5.0 (Linux; Android 4.4.4; HM 1S Build/KTU84P)
 AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/33.0.0.0 Mobile
 Safari/537.36 GSA/5.9.33.19.arm
Accept-Encoding	gzip, deflate, sdch

文本 IS 应答消息解码

文本 IS 应答消息的 HTTP Header 如下所示:

HTTP/1.1 200 OK
Content-Type	application/x-protobuffer
Date	Wed, 23 Mar 2016 08:56:47 GMT
Expires	-1
Cache-Control	no-store
Content-Disposition	attachment; filename="f.txt"
Content-Encoding	gzip
Server	gws
X-XSS-Protection	1; mode=block
X-Frame-Options	SAMEORIGIN
Alternate-Protocol	443:quic,p=1
Alt-Svc	quic=":443"; ma=2592000; v="31,30,29,28,27,26,25"
Transfer-Encoding	chunked

这里最值得关注的,是 Content-Type。application/x-protobuffer 不是一个常见的 Media Type,起初我以为它就是简单的 protobuffer message 序列化二进制内容,搜索到的一些信息也是这样说的,但用 protobuf 对其 Decode,并不能正确解析消息体。后来我还是在 Charles 的这篇文档中找到了思路,其实 application/x-protobuffer 在实现时区分单个消息和多个消息的格式(但 Content-Type 里并不显式说明)。多个消息,即 Dilimited List,的封包协议是这样的:

(Varint of Msg Len)(Msg)(Varint of Msg Len)(Msg)...(Varint of Msg Len)(Msg)EOF

解包时也很简单,Protobuf 的 Java 库提供了 Message.Builder.mergeDelimitedFrom(...) 来直接从 InputStream 里循环读取多个 Message 的封包数据。

但这时候我们还不知道应答消息里的 protobuf Message 格式,无法构建 Message 的 Builder。这时候有个简单的办法去逐步推导,也就是新建一个 Empty Message,如下所示:

$ more textsearch.proto
message Empty {}

用这个 Empty Message 构建 Builder,对 IS 的应答消息进行 Decode,将 Decode 结果打印出来时会发现所有的字段都是无名字段。然后根据对 protobuf wire data 的理解,逐步推导它的 Message 格式,尽最大的努力去猜测各个字段的作用,最终推导出来 IS 应答消息的 .proto 可能是这样的:

$ more textsearch.proto
package com.google.search.app;

message SearchResponse {
    required string search_id = 1;
    optional uint32 msg_type = 4;
    optional SearchResultPart sug = 100;
    optional SearchResultPart result = 101;
    optional Empty SR102 = 102;
}

message SearchResultPart {
    optional uint32 fin_stream = 1;
    optional string text_data = 2;
    optional string html_data = 7;
    optional string encoding = 8;
}

message Empty {
}

基于这个 .proto,对 Query 『上海天气』 IS 的应答消息进行 Decode,最终的结果摘要如下:

$ more s.txt
#### Proto: Textsearch.SearchResponse with Size=758
search_id: "iVnyVrChHsTAjwPdh4PwBA"
msg_type: 97000
sug {
  fin_stream: 1
  text_data: "[\"上海天气\",[[\"上海天气\",35,[39,70],......}]"
}

#### Proto: Textsearch.SearchResponse with Size=10561
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
msg_type: 97000
result {
  fin_stream: 0
  text_data: "上海天气"
  html_data: "<!doctype html><html itemscope=\"\" ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=16951
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<style data-jiis=\"cc\" id=\"gstyle\">......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=1511
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<title>上海天气 - Google 搜索</title></head><body ......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=68
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: ""
  encoding: "text/html; charset=UTF-8"
  3: 0
  9: 1
}
SR102 {
  1: 1
  4: ""
}
#### Proto: Textsearch.SearchResponse with Size=104557
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<div data-jiis=\"cc\" id=\"doc-info\">......</script>"
  encoding: "text/html; charset=UTF-8"
  4: 8
  4: 10
  4: 6
  4: 13
  4: 12
  4: 2
  4: 21
  4: 20
  9: 1
  10: 1
}
#### Proto: Textsearch.SearchResponse with Size=2198
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>google.y.first.push(function()......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=2146
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=7261
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 0
  html_data: "<script>......</script>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}
#### Proto: Textsearch.SearchResponse with Size=1202
search_id: "iVnyVsnMH8TAjwPdh4PwBA"
result {
  fin_stream: 1
  html_data: " <div id=\"main-loading-icon\" ......</div></body></html>"
  encoding: "text/html; charset=UTF-8"
  9: 1
}

观察到 sug_data 看似是 JSON 格式的数据,专门对 sug_data 进行 JSON Decode,得到以下结果:

## JSONArray: sug_data ##
[
  "上海天气",
  [
    [
      "上海天气",
      35,
      [
        39,
        70
      ],
      {
        "ansc": "1458723207451",
        "ansb": "2338",
        "du": "/complete/deleteitems?client=qsb-android-asbl&delq=上海天气
               &deltok=AKtL3uTL0hK_EMlKCgutzvQvzXfh2VRAgg",
        "ansa": {"l": [
          {"il": {"t": [{
            "tt": 13,
            "t": "上海天气"
          }]}},
          {"il": {
            "at": {
              "tt": 12,
              "t": "周三"
            },
            "t": [
              {
                "tt": 1,
                "t": "54"
              },
              {
                "tt": 3,
                "t": "°F"
              }
            ],
            "i": {
              "d": "//ssl.gstatic.com/onebox/weather/128/partly_cloudy.png",
              "t": 3
            }
          }}
        ]},
        "zc": 602
      }
    ],
    [
      "上海天气<b>预报<\/b>",
      0,
      [],
      {"zc": 601}
    ],
    [
      "上海天气<b>预报10天<\/b>",
      0,
      [],
      {"zc": 600}
    ],
    [
      "上海天气<b>预报15天<\/b>",
      0,
      [],
      {"zc": 551}
    ]
  ],
  {
    "q": "W9D2ISTC-TXK4QauyTt2SFqJzvo",
    "n": 0
  }
]

非 IS 搜索请求和搜索应答解码

当 IS 应答消息里有搜索结果时,点击搜索按钮不会再发起一次搜索。但如果 IS 应答只有 SUG,没有搜索结果,Google App 就会发起一次非 IS 的正常搜索请求。这次请求除了请求的 URL path 从 "/s" 变成 "/search" 以外,主要的 GET 参数保持一致,会有部分附加参数的不同。以『上海天气』(有 IS 结果)和『上海天气好不好呢』(无 IS 结果)为例,GET 参数有以下区别:

-qsd	3670
-pf	i
-sclient	qsb-android-asbl
-cp	4
-psi	bLxzwaswPFc.1458723200693.3
-ech	3
-gl	us
-sla	1
+gs_lp	EhBxc2ItYW5kcm9pZC1h...
+source	and/assist
+entrypoint	android-assistant-query-entry

而非 IS 搜索应答和 IS 应答对比,区别主要在于非 IS 搜索应答消息中,没有搜索词 SUG Message 包。

协议分析和启发

请求消息协议

搜索请求是通过 GET 协议实现的,所以请求主要分为两部分:HTTP 头和 GET 参数。从请求上来看,Google 对 GET 参数的使用是非常节省的,很多字段都是极其精简的缩写。但它倒是在 HTTP 头里放了很多比较大的数据字段,从 Header 名来猜测,应该是跟设备、登录用户相关的一些加密字段。

因为搜索请求是 App 发出的,所以理论上 GET 请求和 POST 请求的实现难度是差不多的,POST 的时候可以进行数据压缩,Header 倒是不能压缩(HTTP 1.x)。那为什么 Google 反而选择把这么长的数据放在 HTTP Header 里呢?我的猜测是为了充分利用 HTTP/2 的特性。在 HTTP/2 里有个特性,叫做 Header Compression,在多次请求时,同一个 Header 原文仅需要压缩传输一次即可。但由于现在还没有 HTTP/2 的抓包工具,所以还无法判断 Google App 是已经用上了这个特性,还是为未来的使用做好准备。不过这至少给了我们一个启发,为了充分利用底层协议的特性,应用层约定可能也需要一些适配工作。

应答消息协议

Google App 的搜索结果,并没有像普通网站服务一样,直接用标准的 HTML 协议返回一个 Web Page。而是将渲染好的 Web Page 分段放到应答消息中,由 App 端提取、拼接成最终的搜索结果页。猜测有以下几点考虑:

  1. 便于与 SUG 服务集成。很多搜索框都提供 Sug 功能,但 Google 为了让用户感觉更快,在输入过程中不仅有 Sug,还会直接显示搜索结果,桌面版上叫做『即时搜索』。移动版 App 的做法跟桌面版类似,但实现上有不同的地方。移动 App 的输入框不是 HTML 的 <input> 标签,而是一个系统原生的输入框,所以无法依赖 Javascript 去响应事件,刷新结果页。而且网络请求是 App 发起的,为了充分利用网络连接,将搜索结果集成到 SUG 结果里也是顺理成章的事情。不过这还意味着在 App 上,不能仅返回数据通过 Ajax 技术无缝刷新结果页,必须得在消息里返回整个搜索结果页。
  2. 减少解码内存使用,改善性能。如果将整个搜索结果页放到一个 Protobuf Message 中,客户端为了解码这个消息,需要申请很大一块内存。而在移动设备上,内存是很 critical 的资源,尤其是在 Android 设备上,使用大块内存会导致频繁的 GC,性能很差。
  3. 模拟 Chunked 编码。Web 服务器可以通过 HTTP 1.1 引入的 Chunked transfer encoding 将网页分块传输给浏览器,浏览器无需等待网页传输结束,就能够开始页面渲染。当网页通过 Protobuf Message 传输时,无法利用浏览器的 chunked 处理技术,只好用分拆为多个 Message 的方式模拟 chunked 模式。虽然 Android 原生的 Webview 并没有支持 chunked 的 LoadData 接口,相信 Google 自己的 App 实现一个类似功能并不困难。

但 Google 这种直接下发网页数据的做法,也存在一个问题,就是没有合法的网页 URL。合法的网页 URL,有以下几个潜在的作用,Google App 做了一些额外的工作来处理:

  1. 刷新页面。Google App 没有提供页面刷新功能;
  2. 前进后退。Google App 没有提供前进后退功能,而是通过 Search History 来满足后退功能。
  3. 浏览历史。Google App 没有提供浏览历史,只提供了搜索历史。
  4. 页面分享。Google App 没有提供页面分享功能。

Android 版本的 Google App 连搜索结果都需要用第三方浏览器打开,结合上述功能处理,可以发现 Android 版本 Google App 只是想做一个精悍的搜索应用,无意于把自己变成一个完善的浏览器。但 iOS 版本 Google App 对上述问题的处理略有不同,iOS 版本内置了一个浏览器,搜索结果可以在 App 内打开。但主要的搜索结果页,仍然是采用类似于 Android 的方式处理。也就是说,iOS 版本的浏览器,可能仅仅是一个 UIWebview。

减肥初体验:81kg-73kg

3个半月的减肥曲线
3个半月的减肥曲线

昨天,我的减肥进程触及了一个历史性的里程碑——73kg,这意味着我基本回归到了两年前的体重。

对于刚进入社会的年轻人来说,每年可能都会有一些重要事件发生,譬如搬家旅游,升职加薪,跳槽分手。不过对于我来说,两年前的这个时候的确是一个非常重要的人生节点。回顾我的博客,也是在这个时候基本停更了,因为,我老婆怀上了现在的宝宝。

新的生命总会打乱你生活的步调,从那时起我周末不再爬山运动,每天晚上回家吃饭。体重也蹭蹭地往上涨,在去年夏天达到巅峰。有时候我坐在电脑椅上,看着那一圈肥肉,自己都有点厌恶自己。然而逼我做出改变的,还是体检时医生说的一句话:“你的脂肪肝已经接近中度,要注意加强锻炼,不能再这样下去了!”

我曾经尝试过室外跑步,却发现在北京的冬天做这项运动颇不明智。寒冷、尾气和雾霾是三大阻力,以雾霾为甚。幸好小区附近新开了家健身房,地方不大,品质一般,但胜在离家近。于是在去年的11月底,办了张健身卡,开始第一次健身房室内锻炼。

在开始之前,我做了一些功课,结果发现在减脂理论这个领域真的是众说纷纭。最典型的纷争,可以参考知乎减脂标签下的各种问题和回答。健身房锻炼,还有一个流行的争议,就是私教的作用究竟有多大?对我来说,这个问题倒更好回答,我相信有人指导帮助会更好,但以目前的私教课价位来说,性价比太低,我还没有消费脱敏。(什么?200一节?你怎么不去抢?教练也不知道哪个野鸡培训毕业的,我还是自己学习如何锻炼吧!)

综合了看到的许多理论,我目前对减脂的理解和锻炼的指导是这样的:最关键要做的是两点,控制饮食和有效的有氧运动。而为了执行这两点,最关键的是降低做这两件事的阻力。所以真正的关键在于用低阻力的方式长期地进行饮食控制和有效的有氧运动。

以控制饮食来说,应该算是最有效迅速的减脂方法。但是真正严格的饮食控制,对大多数人是很难坚持的。中途退出的原因可能是:健身食谱准备太麻烦,吃的太少身体扛不住,吃的太差忍受不住美食诱惑。所以我现在倾向的策略是,在日常饮食基础上尽量选择低脂高蛋白食物,同时适当减少饭量。这样的饮食控制很容易,效果也很一般,但好在坚持的阻力也最小。有氧运动也是如此,因为健身房离家近,而且跑步机也比室外跑步舒服,所以在跑步机上坚持1小时比室外跑1小时阻力小很多。

很多人会批判只做有氧的减脂,以各种理论。也许他们说的有道理,但我至今没有加入无氧锻练。道理很简单,对我现在的状态来说,阻力太大无法坚持。我每天只能挤出来1小时锻练时间,肌肉力量还很弱,跑1小时很容易且有成就感,但是抗阻锻练就会很沮丧。

在减脂过程中遇到最大的教训是,要循序渐进,不要盲目快速提高运动量。我花了两周时间把跑步时速从8公里提升到10公里。结果是左腿胫骨正面和膝盖上方开始疼痛,自己诊断可能是胫骨骨膜炎。正好是春节前后,休息了三周才恢复。

锻炼可能是为数不多的有确定性回报的事情,但最大的问题在于坚持的难度太大。对于我来说,与琢磨各种高效的锻炼方法相比,还是寻找一些降低阻力的途径更为有效。

又一个新起点

百里画廊千家店夜拍
百里画廊千家店夜拍

咦,我为什么说又?

自毕业以来,写字的频率在逐年下降。整个 2015 年一篇都没发,大概是在微信朋友圈里耗尽了时间吧。11 月的时候,网站所在的 HostMonster 主机空间被黑客攻击,导致空间被 HostMonster 关停。关停后连 SSH 都被禁止登录。合租空间的朋友大概跟我一样,对博客暂时失去了兴趣,没及时处理,结果整个空间被清空,数据全丢了。同时,"solrex.org" 的域名也疏于维护,没去续费,不再有效了。

好在还屯着几个域名,另外还有一些早些时间的备份,数据库还在,就是媒体库的图片丢了不少。只好根据图片文件名到照片备份里去找,花了大半天,才基本上恢复了所有的图片。

经过合租的惨痛经历,新的网站是自己租用 VPS 架设的。与主机空间比,一是按照小时计费不会浪费,二是独立IP,不会被牵连,更换 IP 也比较灵活。首先尝试的是口碑甚好的 Linode,发现最大的问题是速度和丢包,而且新用户无法选择东京机房。后来换到 Vultr,有东京机房,比 Linode 配置略差,但价格优惠一半,5$/月,跟 HostMonster 租三年以上才能 4.99$ 相比,已经是良心价格了。

既然是新的开始,就得做点儿新的尝试。起了个新名字,叫做“边际效应”,这是我觉得颇为神奇的经济学现象。新建了个微信订阅号 MarginalUtility,也是同样的名字,欢迎订阅!

微信公众帐号体系的BUG

不少互联网公司都有把产品关键路径的用户体验做到很好的能力,但即使如腾讯微信这样的产品,在某些非关键路径上的体验仍然是不如人意。

在微信公众平台发布的早期,只能用QQ号注册公众帐号。很多人的QQ早已绑定微信个人号,于是只好注册一个新的QQ号用于申请微信公众号。通常这个QQ号没有任何好友,也很少登录。

一旦长期不登录公众平台和QQ,用于注册公众号的QQ就会被回收。但公众号没有注销机制,QQ回收以后公众号仍然存在,只是无法再登录。

对于长期不用的公众号,无法登录可能也不是很大的问题。BUG主要出在公众号和个人号的绑定关系上。一旦用公众号助手绑定了个人微信号,必须在公众号平台解绑,在个人微信设置里没有任何解绑选项。无法登录公众号,自然就无法将原来绑定的个人号解绑。

这就是一个授权链:QQ号->公众号->个人号。腾讯很不负责任地回收了QQ号,但对授权链后面两级绑定没有做任何处理。即不自动解除绑定关系,也无法从后向前处理遗留关系。非常遗憾地说,这种设计的确有些丢人。

莲花山森林公园野穿

3月22日,包车。鹞子峪城堡始发,登顶莲花山森林公园顶峰。下降时先路过黄土梁村,绕到庙上村下。本来在中间汉太路就可以坐车返回,但由于山势的影响,从鹞子峪城堡到汉太路要绕很大一圈,所以只能徒步到山的东侧再坐车。

路线:http://www.foooooot.com/trip/148568/

半山腰的一块山坳
半山腰的一块山坳
东进山口
东进山口
翻越山脊岩石
翻越山脊岩石
莲花山顶远望 - 悉尼的“蓝山”是不是也跟这颜色差不多
莲花山顶远望 - 悉尼的“蓝山”是不是也跟这颜色差不多
山顶次平台
山顶次平台
莲花山顶远望
莲花山顶远望

莲花池-神堂峪长城

2月16日,自驾,两辆车,车停到莲花池村和神堂峪出口。上升下降不多,坍圮严重,险处略多,个别地方由山势代替城墙,中间有一个鲤鱼背。

路线:http://www.foooooot.com/trip/137202

坍圮的城墙
坍圮的城墙
山势全景
山势全景
坍圮的城墙
坍圮的城墙
遥望城楼
遥望城楼
大角度下降
大角度下降
长城
长城
鲤鱼背 - 两边都是悬崖
鲤鱼背 - 两边都是悬崖
残雪
残雪
城楼
城楼