Google Search 淘气三千问: Q7~Q9

前言见:Google Search 淘气三千问: Q1~Q5

Q7: Google 是怎么做线上实验的?

在我 18 年写的这篇博客《ABTest 平台设计 - 如何进行流量分桶》里,我就引用了 Google 2010 年 KDD 发表的层叠并行实验平台论文《Overlapping Experiment Infrastructure: More, Better, Faster Experimentation》。国内很多公司的在线实验平台,可能都是通过参考这篇论文而起步的,所以实验平台这块不再多说,感兴趣的可以看下论文,或者我之前的系列文章。

这里我想说的是实验变量的控制。在上一篇博客《Google Search 淘气三千问: Q6》中提到了一些特征变量,在看原始文档时你会发现它的类型可能会是 QualityNsrVersionedFloatSignal,其实这就是一个带版本的浮点数组。

简单看规律,那就是如果一个特征是 ground truth 类统计特征,它在协议中就只是一个数值类型;如果一个特征是模型打分类特征,它可能就会是一个带版本的数值数组。

这也就是说,Google 在设计协议的时候,就已经考虑到了哪些特征需要做实验,而哪些不用。这会让他们的线上实验更加容易和系统化。

BTW,在 Google ContentWarehouse API 中读到 Google 把线上实验称作 Live Experiment,简称 LE,这将对我们阅读其它参考资料时有所帮助(见Q8)。

GoogleApi.ContentWarehouse.V1.Model.QualityNsrExperimentalNsrTeamData
Experimental NsrTeam data. This is a proto containing versioned signals which can be used to run live experiments. This proto will not be propagated to MDU shards, but it will be populated at query time by go/web-signal-joins inside the CompressedQualitySignals subproto of PerDocData proto. See go/0DayLEs for the design doc. Note how this is only meant to be used during LEs, it should not be used for launches.

Q8: Google 做 Live Experiment 时关注哪些核心指标?

在美国司法部起诉 Google 的案子中,一个由 Pandu Nayak 起草的标题为《Ranking Newsletters » 2014 Q3 Ranking Newsletters » Aug 11 - Aug 15, 2014》的文件被作为证据提供,里面提到了 Google 2014 年在做 LE 时观察的几个核心指标:

  • CTR: 点击率,这个可能不用解释
  • Manual Refinement: 手工优化(Query)的平均次数,当你对搜索结果不满意时,你可能会手工修改 Query 内容再次发起搜索
  • Queries Per Task: 单任务 Query 数,解决一个需求时的平均搜索次数
  • Query lengths (in char): Query 平均长度,以字符为单位
  • Query lengths (in word): Query 平均长度,以单词为单位
  • Abandonment: 平均放弃(次数?),当你在搜索完成后不再继续搜索时,被视为一次放弃
  • Average Click Position: 平均点击位置,在搜索结果页中用户可能会点击多条结果,对多条结果的位置进行平均。
  • Duplicates: 重复搜索行为,可能是因为网络、速度等问题,导致用户重试

这肯定不是 Google 做 LE 时观察的所有指标,但肯定是其中最重要的几个。因为这份文件讨论的内容是 2014 年 Google 用户在桌面和手机端的行为和意图差异,以决定 Google 在两端的工作计划,这在当时应该是非常重要的一件事。

Q9: Google 怎么衡量 Query 的用户满意度?

在《Ranking Newsletters » 2014 Q3 Ranking Newsletters » Aug 11 - Aug 15, 2014》这个文件里,还提到一个很关键的信息,就是 Google 怎么衡量搜索 Query 的用户满意度(在 one-box 直接满足的场景)。这可是搜索引擎的核心问题,因为你只有知道用户对什么满意,才能保证你的产品方向是对的。

衡量 Query 满意度的第一个指标,是 singleton abandonment。singleton abandonment 是指一次孤立的搜索行为,即在这次搜索前用户没有搜索任何 Query,在这次搜索后用户也没有更换 Query 进行第二次搜索。

文件里提到一个有意思的点:Google 之前的一个研究发现,非 singleton abandonment (换了很多 Query 后放弃了继续搜索)能更好的刻画不满意率,但不适合刻画满意率。虽然 singleton adandonment 不能孤立的作为一个强正向信号(用户不满意也可能放弃继续搜索),但在有直接答案的情况下,比如结果页内就能满足,比如天气、词典等,Google 认为它是一个足够的正向信号。

衡量 Query 满意度的第二个指标,被涂黑了。但这是唯二的两个指标,我非常感兴趣,所以我进行了一个大胆的猜测。我把原文片段和猜测的部分放到了下面:

第一个涂黑的地方太短,又很重要,因为文件说 Google 把它当成一个明确的正向信号。所以我猜测是 CTR,但又不确定有什么修饰词。Page CTR 这个词大家不常说,但是 Google AdSense Help 里对这个术语有定义。第二个涂黑的地方涂得不完全,露出了一些字母边缘,虽然长但还是能硬猜一下。希望不要误导大家。

把 CTR 当成一个满意信号很好理解:如果 Query 通过摘要满足了,那用户就没有其他行为了,就是 singleton abandonment;如果摘要没满足,但是用户点击消费了搜索结果,然后也没有继续搜,那就是 singleton CTR

也就是说,在衡量 One-Box(天气、词典等摘要满足的结果) 对用户 Query 满意度的影响方面,Google 使用了 singleton abandonment 和整页级别的点击率作为指标。

Google Search 淘气三千问: Q6

前言见:Google Search 淘气三千问: Q1~Q5

Q6: Google 为站点设计了哪些特征?

在 Google ContentWarehouse API 中有很多字段以 nsr 开头,有人说它代表 Neural Search Ranking,我觉得这种说法不对,因为 nsrDataProto 字段的注释是 Stripped site-level signals:

GoogleApi.ContentWarehouse.V1.Model.PerDocData

* nsrDataProto (type: GoogleApi.ContentWarehouse.V1.Model.QualityNsrNsrData.t, default: nil) - Stripped site-level signals, not present in the explicit nsr_* fields, nor compressed_quality_signals.

说明 NSR 应该是站点信号,考虑到 QualityNsrNsrData 中还有一个 nsr 字段,我猜测应该是 New Site Rank,或者 Normalized Site Rank。也许这个字段以前只是一个简单的代表站点质量的信号,后来扩展成了一系列信号的组合,但是沿用了 nsr 这个前缀。

其中字段注释非常简单,这里我尝试把他们汇编并且扩展解读一下,看看 Google 的站点特征体系都包括哪些。

首先要解释一点,Nsr 虽然是站点信号,但是它的信号汇聚粒度并不全是站点范围,而是有个 sitechunk 的概念。我猜测 sitechunk 可能会代表一个子域,或者一个比较关键的路径前缀,比如一个网站有新闻、博客或者论坛,那就会有不同的 sitechunk。这样允许 Google 针对同一个域名下的不同的频道、标签做不同的分析。所以下面讨论到所有的站点信号,都应该理解成是 sitechunk 信号。

QualityNsrNsrData 中,有以下这些特征:

  • smallPersonalSite:为个人博客小站提权的分数。我一直觉得谷歌对博客网站很友好,果然在站点特征体系中有专门的提权打分。
  • siteAutopilotScore:如果一个网站的内容都是自动生成的,它会被称为是一个 Autopilot Website。这个分数是描述这个站点下所有页面自动生成方面评分的一个汇总值。
  • isVideoFocusedSite:如果站点有超过一半的页面是视频播放页面,而且它又不是一些知名的视频网站,那么这个特征就是 true。
  • ugcScore:与上面分数相似,可能是这个站点每个页面是否为 UGC 内容的评分的一个汇总值。
  • videoScore:与 ugcScore 相似,可能是这个站点每个页面是否为视频播放页的评分的汇总。
  • shoppingScore:与 ugcScore 相似,可能是这个站点每个页面是否为商品购买页的评分的汇总。
  • localityScore: 与 ugcScore 相似,可能是这个站点每个页面是否为 LBS 服务页的评分的汇总,不过这里提到了一个叫做 LocalAuthority 的模块/策略,希望以后能弄懂它。
  • articleScoreV2: 与 ugcScore 相似,可能是这个站点每个页面是否为文章页的评分的汇总。
  • healthScore: 与 ugcScore 相似,可能是这个站点每个页面是否为医疗健康页的评分的汇总。
  • ymylNewsV2Score:无注释,YMYL 是 Your Money Your Life 的缩写,这里可能是指这个站点每个页面是否为敏感(健康、金融相关的)新闻页的评分的汇总。
  • clutterScore: 判断站点是否加载了很多乱七八糟的内容,比如加载了很多不同来源的广告之类。
  • clutterScores: 带版本的 clutterScore。
  • racterScores: 站点级别 AGC 分类打分;
  • titlematchScore:大概是这个网站每个网页的标题,能匹配上多少 Google Query 的一个综合评分;
  • siteQualityStddevs: 站点质量标准差,从名字判断,可能来自于站点所有网页的站点质量得分的统计。从这些方差指标可以看出,Google 很在乎站点内容的一致性,可能对页面质量参差不齐的站点有打压。
  • chromeInTotal:站点级别的 Chrome 访问量;
  • impressions:站点在 Google 搜索结果中的展现次数;
  • chardEncoded: 有人说 chard 代表 CHrome AveRage Duration,站点平均停留时间,我本来猜测可能是 CHrome Average Returning Days,或者 CHrome Average Retention Data。核心就是我觉得这是一个留存指标,留存比时长更能体现网站的受欢迎程度。但注释中又说它是 site quality predictor based on content,所以也许我的理解是错的,也许 c 是 Content?但是 hard 是什么,我实在猜不出来了。
  • chardVariance:站点(首页) chard 的方差。
  • chardScoreEncoded: 站点中所有页面的 chard 得分;
  • chardScoreVariance:站点所有页面 chard 得分的方差。
  • nsrVariance: 站点首页 nsr 与站点所有页面质量均值的差;
  • siteQualityStddev: 站点所有页面质量的方差,与 nsrVariance 不同,它衡量的是页面之间的方差;
  • tofu: 与 chard 一样,都是基于 content 算出来的一个得分。tofu 是豆腐块的意思,在网页里可能代表了页面内有多少个豆腐块区域,或者有多少个豆腐块广告。
  • pnavClicks:PNAV 大概是指 Primary Navigation,即站点的主要导航链接。这个值是对主要导航链接点击数的一个分母,可能在某个地方记录了这个站点每个导航链接的点击数,这样就能算出来哪些导航更受欢迎,也许是用在搜索结果页中展示站点的关键导航上;
  • pnav: 一个分位值,可能是主要导航链接占页面链接数比例?
  • vlq: 视频低质量模型的打分,猜测 LQ 代表 Low Quality。
  • vlqNsr: 针对低质量视频站点设计的一个额外的 nsr 打分,有可能是为了避免这些站点 nsr 得分过低,导致一些用户需求不满足(例如某类视频)。
  • siteLinkOut: 这个站点所有外链的平均得分;
  • siteLinkIn: 这个站点所有内链(反向链入的页面)的平均得分;
  • siteChunkSource: sitechunk 来源,可能是记录怎么分的 chunk;
  • spambrainLavcScores:这个没有注释,看起来是 Google 有一个 spambrain,会给站点一个 Lavc 分数,应该表示网站是否有 spam 行为的打分;
  • sitePr:站点的 PageRank。
  • nsr: 也许是最原始的 Normalized Site Rank,用一个分值代表站点质量。
  • versionedData: 实验版本的 nsr 值,当算法升级后 nsr 计算逻辑与以前不同时,先拿它用来做实验;
  • priorAdjustedNsr: 先验调整 nsr,用于判断当前站点的 nsr 在它所属的 slice 里比平均 nsr 高还是低;
  • ketoVersionedData: 带版本的 keto 数据,包括站点得分和站点得分在所有站点中的分位值。keto 可能代表了一个策略,含义未知。
  • nsrOverrideBid: 用来干预 nsr,当它的值提供并且大于 0.001 时,直接覆盖掉 nsr。也就是说可以通过人工干预调高或者调低某个站点的 nsr。
  • nsrEpoch: nsr 最早的获取时间;
  • siteChunk: nsr 对应的主 sitechunk,即分析出来的 sitechunk 对应的文档 URL;但文档中提到在一些稀有情况下,它可能基于网页中的一个标记。我猜测像一些 Single Page Application,URL 全部使用 # 页内标记,这种情况下只能使用页内标记来标记 sitechunk。
  • secondarySiteChunk: nsr 对应的二级 sitechunk,如果存在的话,划分比 sitechunk 粒度更细。
  • i18nBucket: 属于哪(几)种语言,这是一个 int 值,也许会是一个 bitmap,可以把站点放入多个语言桶中。
  • language: 站点的语言,暂不清楚与 i18nBucket 的差异,因为它也是一个 int 值。
  • isCovidLocalAuthority: 是否为 Covid 本地官方网站,也许是在疫情期间对官方网站消息的提权;
  • isElectionAuthority: 是否为(美国)选举相关的官方网站;
  • directFrac: 无注释,我猜测是 Chrome 输入 URL 直接访问的 PV 占所有访问量的占比。
  • site2vecEmbedding: 看起来像是将上面的每个站点 nsr 特征,综合起来表达成了一个稀疏的 embedding,可能是 one-hot 编码那种,也可能是稀疏模型编码;
  • site2vecEmbeddingEncoded:这里是一个压缩版本的 embedding,主要用于 SuperRoot。
  • nsrdataFromFallbackPatternKey: 如果为真,代表以上的 nsr 特征都来自于其它站点;
  • url:站点的 URL;
  • host: 站点的域名或者主机名;
  • clusterId:站点所属站群的 ID,被一个叫做 Tundra 的生态项目所使用,这个项目在文档中也出现过多次,希望后面能弄清楚它的含义。站群一般是指页面互相之间有链接的一批站点,会被用来做 SEO 提升 pagerank,看起来 Google 对这种行为是有识别的。
  • clusterUplift: 与上面提到的 Tundra 项目有关,主要看站群是不是小站,是不是本地站,可能是用于站点的提、降权;
  • metadata: 记录了一些在不同系统里查找 nsr 数据的 key,或者一些数据的生成时间。

Google Search 淘气三千问:Q1~Q5

前言

两个多月前(2024年5月27日),Google 的一份名为 GoogleApi.ContentWarehouse 的 API 文档受到 SEO 圈的关注,由于这份文档的内容和 Google Search 副总裁 Pandu Nayak 在 2023 年美国司法部(DOJ)起诉 Google 的案件中的证词和 Google 的一些专利高度一致,因此其真实性被广泛认可。

后续有媒体称 Google 发言人回应了文档泄露的问题(没有承认、也没有否认):

A Google spokesperson sent us the following statement:

“We would caution against making inaccurate assumptions about Search based on out-of-context, outdated, or incomplete information. We’ve shared extensive information about how Search works and the types of factors that our systems weigh, while also working to protect the integrity of our results from manipulation.”

此前大部分讨论仅限于猜测 API 文档中的各种信号在排名算法中的作用,以及对谷歌是否在排名算法上欺骗了大家。很少人意识到,这篇 2500 页的文档可以作为以往 Google 公开论文的补充,一本叫做《如何构建一个世界级(成功的)搜索引擎》的武功秘籍撕下来的几页。

而偏偏我对这本武功秘籍非常好奇,试图在这几页上再加一些批注,就有了 《Google Search 淘气三千问》这个系列。这个系列会有几篇文章,我不知道,主要看我能想到多少问题。

为什么叫“淘气三千问”这个名字?可以把它看成是一种传承吧,懂的都懂,不懂的也不影响阅读。

为避免误导读者,这个系列所有的回答里,来自公开信息的我都会标注来源,没有标注来源的,你可以认为是 inaccurate assumptions about Search based on out-of-context, outdated, or incomplete information。当未来有了更新的信息,我有可能回到博客来更新这些 assumptions(公众号文章无法更新)。

如果你有新信息可以给我,或者纠正文中的错误,欢迎评论或者到公众号“边际效应”私信,谢谢!

Q1: Google 的索引分了几层(Tier)?依据什么?

Google 在 2012 年的论文《Indexing the World Wide Web: The Journey So Far 》中提到产业实践中大规模索引都是会分成多个桶(tier),一般按照更新频率来分:

The way we have described search indices so far makes a huge assumption: there will be a single unified index of the entire web. If this assumption was to be held, every single time we re-crawled and re-indexed a small set of fast-changing pages, we would have to re-compress every posting list for the web and push out a new web index. Re-compressing the entire index is not only time consuming, it is downright wasteful. Why can we not have multiple indices -- bucketed by rate of refreshing? We can and that is what is standard industry practice. Three commonly used buckets are:

1. The large, rarely-refreshing pages index
2. The small, ever-refreshing pages index
3. The dynamic real-time/news pages index

...

Another feature that can be built into such a multi-tiered index structure is a waterfall approach. Pages discovered in one tier can be passed down to the next tier over time.

在 Google ContentWarehouse API 里有这样一段 :

GoogleApi.ContentWarehouse.V1.Model.PerDocData

* scaledSelectionTierRank (type: integer(), default: nil) - Selection tier rank is a language normalized score ranging from 0-32767 over the serving tier (Base, Zeppelins, Landfills) for this document. This is converted back to fractional position within the index tier by scaled_selection_tier_rank/32767.

可以看到,Google 仍然是把索引分了 3 层,现在我们有了它们的名字,分别是:Base(基础)、Zeppelins(飞艇) 和 Landfills(垃圾填埋场)。在每一层之内,scaledSelectionTierRank 这一归一化分数决定了它所在位置的分位数。分位数最大值是 32767,猜测也许是 Google 在索引存储里只给它留了 15 bits(2^15=32768)。

但从索引分层的名字来看,这三层并不(全)是按照时效性分的,至少第三层,看着是按照文档质量分的。因为你把文档放到“垃圾填埋场”中,大概率因为它的质量较差而不是不再更新。那么 scaledSelectionTierRank 也许就代表了层内的文档质量等级。

网友 avanua 对这三层的命名提供了一个解读,我觉得非常合理,因为我一直困惑第二层为什么叫做 Zeppelins:

我觉得 Tier 命名和质量无关,可能只是用来描绘更新频率:
​Zeppelins 在气流中起起伏伏
Base
​Landfills 几乎不会再翻动,上下层叠关系是固定的

Q2: Tier 内的 scaledSelectionTierRank 有什么作用?

在 《Indexing the World Wide Web: The Journey So Far》中提到,在倒排拉链中最好按照文档实际的影响力对文档列表进行排序。如果仅仅是这样,那么只需要知道文档 0 比文档 10000 更重要即可,那么额外记录一个打分的目的,其实是可以让这个分数参与排序过程。文档在某个 Query 下的得分,是文档影响力得分乘以文档在 Query-term 下的权重。

Since it made sense to order the posting lists by decreasing term frequency, it makes even more sense to order them by their actual impact. Then all that remains is to multiply each posting value by the respective query term weight, and then rank the documents. Storing pre-computed floating-point document scores is not a good idea, however, since they cannot be compressed as well as integers. Also, unlike repeated frequencies, we can no longer cluster exact scores together. In order to retain compression, the impact scores are quantized instead, storing one of a small number of distinct values in the index.

从上文中有理由认为,scaledSelectionTierRank 就是文中提到的量化以后的文档影响力得分,量化就是将其归一化到 32768 个分档之中。

Q3: Google 搜索系统主要分成几个部分?

通过 API 和其它公开文档,目前我能够分析出来的搜索系统组成部分有以下这些。随着阅读的深入,可能还会有新的部分加进来。

爬虫系统:Trawler

在 Google ContentWarehouse API 中有一系列 API 以 Trawler 为前缀,并且从上下文中看出来 Trawler 是一个实体系统并且有一个研发团队。

GoogleApi.ContentWarehouse.V1.Model.TrawlerCrawlTimes
GoogleApi.ContentWarehouse.V1.Model.TrawlerFetchReplyData
GoogleApi.ContentWarehouse.V1.Model.TrawlerHostBucketData

* TotalCapacityQps (type: number(), default: nil) - The following four fields attempt to make things simpler for clients to estimate available capacity. They are not populated yet as of 2013/08/21. Even after they are populated, they may change. So talk to trawler-dev@ before you use the fields. Total qps for this hostid

去重系统:WebMirror

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.CompositeDocAlternateName

Alternate names are some urls that we would like to associate with documents in addition to canonicals. Sometimes we may want to serve these alternatenames instead of canonicals. Alternames in CompositeDoc should come from WebMirror pipeline.

每个 CompositeDoc 都有一些替代的 URL,这些 URL 来自 WebMirror 流水线,那么 WebMirror 应该是识别重复文档的一套系统。

离线索引构建系统:Segindexer + Alexandria

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.AnchorsAnchor

* sourceType (type: integer(), default: nil) - ... In the docjoins built by the indexing pipeline (Alexandria), ...

所以 Alexandria 应该是建库系统。而 Segindexer 和 Alexandria 曾经并行出现过:

GoogleApi.ContentWarehouse.V1.Model.ClassifierPornClassifierData

* imageBasedDetectionDone (type: boolean(), default: nil) - Records whether the image linker is run already. This is only used for Alexandria but NOT for Segindexer.

考虑到关键的表示原始文档内容的 compositedoc.proto 在 Segindexer 目录下:

GoogleApi.ContentWarehouse.V1.Model.NlpSaftDocument

* bylineDate (type: String.t, default: nil) - Document's byline date, if available: this is the date that will be shown in the snippets in web search results. It is stored as the number of seconds since epoch. See segindexer/compositedoc.proto

从名字和上述信息有理由怀疑 Segindexer 是在 Alexandria 之前,决定了索引分层,或者分 vertical 的一个分类模块。

在线索引服务系统:Mustang 和 TeraGoogle

在 《Indexing the World Wide Web: The Journey So Far》中我们知道,TeraGoogle 是 Google 在 2005 年实现的一套 large disk-based index 服务系统。而在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.CompressedQualitySignals

A message containing per doc signals that are compressed and included in Mustang and TeraGoogle.

这里将 Mustang 和 TeraGoogle 并列,有理由认为 Mustang 是 2005 年之后 Google 开发的一套替代或者部分替代 TeraGoogle 的在线索引服务系统。

实时索引服务系统:Hivemind(muppet)

Realtime Boost 这篇文档中有一张图,揭示了一个新网页进入索引的过程,其中实时索引的系统叫做 Hivemind。但 Hivemind 应该是个集群名,实际的索引系统方案可能是叫做 muppet。因为在 RealtimeBoost Events - DesignDoc 这篇文档中也提到了一个用 muppet 支持的系统,叫做 ModelT,这是一个索引实时事件的索引系统。

实时索引的建库系统可能与非实时索引不同,叫做 Union。

查询汇聚系统:SuperRoot

在 Google ContentWarehouse API 中多次出现 SuperRoot 这一模块,而在 Jeff Dean 2009 年 WSDM 的 《Challenges in Building Large-Scale Information Retrieval Systems》 分享第 64 页,SuperRoot 被描述为聚合 Web、Images、Local、News、Video、Blogs 和 Books 所有检索子系统的汇聚模块,这个定位也许没有变。

Query 改写模块:QRewrite

RealtimeBoost Events - DesignDoc 这篇文档中说:

Currently in production RealtimeBoostServlet runs in QRewrite detects spikes on news documents published that match the SQuery (including syns). It does so by issuing one or two RPCs to Realtime-Hivemind.

The RealtimeBoostResponse containing the Spike is sent down to Superroot in the QRewrite response and it is currently used by a few Search features (such as TopStories) to trigger faster and rank fresher documents for queries that are spiking for a given news event.

所以 QRewrite 是会被 SuperRoot 调用的一个 Query 改写模块,它会将用户实际发起的搜索 Query 改写成 SQuery,S 可能是代表 Superroot。而到真正发起检索时,会进一步转成检索 Query,比如发给 muppet 的 Query 叫做 MQuery。

摘要模块:SnippetBrain

在 Google ContentWarehouse API 里有这样一段:

GoogleApi.ContentWarehouse.V1.Model.MustangReposWwwSnippetsSnippetsRanklabFeatures

* displaySnippet (type: GoogleApi.ContentWarehouse.V1.Model.QualityPreviewRanklabSnippet.t, default: nil) - Snippet features for the final chosen snippet. This field is firstly populated by Muppet, and then overwriten by Superroot if SnippetBrain is triggered.
  • 看起来 SnippetBrain 是一个可选的摘要生成模块。

入口服务:GWS

Google Web Server,这个大家都知道,还有 Wikipedia 词条

Q4: TeraGoogle 是怎样一套系统?

根据论文《Indexing the World Wide Web: The Journey So Far》和专利《US7536408B2: Phrase-based indexing in an information retrieval system》,TeraGoogle 应该有以下几个属性:

  • Disk-based Index:索引存储在磁盘上,在需要的时候读入到内存中,而且往往不需要全部读入,针对重要的文档有一些优化;
  • Phrase-based Indexing:构建索引的时候不仅仅有 term 索引,还会建设多 term 的短语索引,这样索引库里会有更多的倒排链;
  • Document-Partitioned Index:将索引分 Shard 的时候,按照文档进行分片,即同一批文档的所有拉链放在同一个 Shard 上,这样每个 Shard 上有所有的拉链,查询在一个节点内即可完成。在论文中只对比了 Document-Partitioned 和 Term-Partitioned 二者的差异,在 Jeff Dean 2009 年 WSDM 的 《Challenges in Building Large-Scale Information Retrieval Systems》 分享第 17 页确认了 Google 的选择。

Q5: Google 的文档是什么概念?

从 Google ContentWarehouse API 里:

GoogleApi.ContentWarehouse.V1.Model.CompositeDoc
Protocol record used for collecting together all information about a document. 

可以看到 CompositeDoc 是在系统里非常重要的概念,它定义了一个文档的所有信息。在它的所有字段中我们发现,url 又是一个可选的字段,这也就是说,文档并不一定需要是一个网页。像 localinfo,看起来就像是一个 POI 信息。也就是说在 Google 的系统里,不一定只有网页索引,可能每个 POI 点、图片、商品也是一种文档,所以它使用 CompositeDoc (复合文档) 而不是 WebPage 作为整个系统里对文档的刻画。

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。

TCP Fast Open by Google 浅析

Google 将在今年 12 月的 ACM CoNEXT 会议上发表他们在改善 Web 应用响应时延方面的一个工作,通过修改 TCP 协议利用三次握手时进行数据交换的“TCP Fast Open”。虽然 paper 是两天前才释出,但相关的 RFC 草案则早在 2011 年 3 月份就提交到了 IETF,并且在两周前进行了一次 UPDATE,这里是 DIFF

对于 TCP Fast Open 方案的内容,淘宝的一个朋友已经根据 RFC 草案进行了解读。我就不再赘述,感兴趣的朋友可以去看 paper 或者 RFC。我这里只是想讨论一下这个东西的应用前景。

由于对背景并没有做深入了解,我相信已经有很多人尝试去做过类似的工作,但我想类似的工作应该没有得到过大规模的应用。对于已经成型很久很久的 TCP 协议,让人很难有修改它的欲望,因为改那么底层的东西意味着很多很多的麻烦。

但是是否愿意付出代价,有一个前提是有没有足够的好处。TFO 给出的好处是:在 RTT (Round Trip Time) 比较低时,客户端页面加载时间优化大约在 4%~5%;RTT 越长,好处越大,平均大约在 25%。

Google TCP Fast Open Evaluation
Google TCP Fast Open Evaluation

除了页面加载变快改善了用户体验之外,TFO 给服务器端也带来了一些好处。由于每个请求都节省了一个 RTT,相应地也减少了服务器端的 CPU 消耗。paper 中给出的数据是每秒事务数由 2876.4 上升到 3548.7。

虽然 paper 中大部分时间在强调 TFO 对 web 页面加载的显著加速作用,但我认为即使 TFO 能成为互联网标准,它目前的状态离成为标准还有很长一段距离,因而在短期内它是无法影响到主流互联网世界的。但这并不意味着它没有机会,依小弟的愚见,目前它的推广应用可能有两个方向:

1. 移动互联网。移动互联网的 RTT 目前远大于传统互联网(常理推测,需数据支撑),因而一个 RTT 节省的效果无法被忽视;另外移动互联网终端操作系统多样化,不像桌面终端系统那么单一。 Google 自己就掌握着其中一个很重要的 android,百度也计划推出自己的“易平台”。这些互联网公司有动力去改善移动用户访问自身网站的用户体验。

2. 互联网企业数据中心。虽然数据中心内部访问时延很低,但对于典型的请求/响应的服务而言,减少一次 RTT 带来的好处还是有吸引力的,最起码能减少计算能力浪费和增加吞吐吧。再加上很多企业内部使用的都是定制的开源操作系统和定制的网络库,升级的代价并不是那么高。如果我是企业基础设施的负责人,我想我会很慎重地考虑这个方案的。

技术人员的眼界

意识到眼界的重要性,最初是在大学时学长的交流会上。南大数学系有一个传统,每年总有那么两三次组织高年级的同学开经验交流会。这些交流会可能有明确主题,例如留学或是找工作,也可能没有明确主题。幼稚如我,在大一阶段拒绝参加任何形式的社团或者活动,认为踏踏实实做好眼前的事情足矣,闲暇时间基本花费在小说上。后来的种种经验证明,这是多么傻的一种做法!

我在数学方面是一个资质一般的学生,是几位师长和朋友打开了我在计算机行业的眼界,得以投身到信息技术的洪流中。写到这里忽然觉得有变成回忆长文的迹象,就此打住,几位是谁就不介绍了。唯一值得一提的是,李开复先生也是其中之一,这也是我从不参于对他的争议和讽刺话题的原因。在帮助和启发中国学生方面,我觉得他是一个值得尊敬的人。

但是在进研究生院之后,我自己闭塞了眼界的发展。一方面有当时身处的环境使然的原因,另一方面有成绩羞见江东父老的自闭自卑成分。非常后悔的有两点,一是虽然在某个研究专题方面有所收获,但在计算机科学系统性知识方面的进展不够大;二是身处远离实业的象牙塔,虽然亲身经历了一次严重的经济危机,却没能趁机深入地去学习和观察隐藏在这场危机之后的经济和科技发展规律。

我用来勉励自己,有时候也用来鼓励他人的一句话是:“只要你读的是自己不知道的东西,那就是在进步。”因而我经常不择食地去读一些书,了解一些知识。但渐渐地认识到了一点,当你着手去学习时,眼界决定了你汲取知识的效率和效果。具体到计算机科学,眼界可能决定了你读的书是经典还是垃圾,花时间在过时的还是新兴的技术上。

这个周末在家看吴军先生的《浪潮之巅》,爱不释手。作为一个曾经拜读过 Google 中国黑板报上“浪潮之巅”系列连载的读者,我本以为这会是本信息企业史,但阅读后发现新增的三分之一内容更精彩。我不曾想过一个技术人员的眼界会如此之广,能够从各个角度去观察和分析身处的这个行业、这个时代。我不否认对于书中的一些观点我并不完全赞同,但作者的眼界和思考能力着实让我佩服。

现在我已经初步开始自己的职业生涯,未来该何去何从,心中有一些惶恐。该打造哪方面的能力,亦不知该如何着手。有一些短期的规划,但并无具体的长远目标,我知道自己又到了一个眼界的瓶颈期。都说中国的年青人被房子票子绑架了,我希望自己能在忧愁这些之外,分些时间来读读好书,交交优秀的朋友,想想深刻的问题,聊以自勉。

注册 Google Voice 的曲折经历

昨天 iron-feet 同学给我讲了不少 Google Voice 的好处,搞得我也心痒痒的(技术男的通病),想去注册一个。但后来发现只有收到邀请才能注册,Google 官方的邀请可能要等很长时间,于是我就在 Twitter 上发推求邀请,非常感谢好心的 @liyong3 同学(blog),马上就给我发了邀请。

前面是好运,下面就是悲剧的开始。在注册之前我也知道注册 Google Voice 的麻烦之处:不允许中国网络访问,要有出国代理或VPN;激活时不允许绑定非美国号码,要申请到一个虚拟的美国号码,并转发电话到自己的聊天软件。我就是在激活上出了问题。

在目前网上流传的几个可以申请美国号码的服务里:ipkall 注册时无论如何都会出现密码错误;Gizmo 已经被 Google 收购,目前不提供注册;Freedigits 早就不提供注册了。所以从我的体验来看,能用的就只剩下:http://www.virtualphoneline.comhttp://www.groovytel.com 了。其实这两家网站的页面风格一模一样,很可能是一个公司的产品。

http://www.virtualphoneline.com 之所以流行跟谷奥那篇介绍注册 Google Voice 的文章有关,但是 virtualphoneline 注册的免费虚拟号码只能试用 24 天,不过能以更多的形式(10 种)转发来电;至少从声明上来看,http://www.groovytel.com 要好一些,免费号码能试用 3 年,但是转发形式少了(6种)些。不过还好他们都支持转发到 Gtalk。

我一开始就注册了这两个服务的号码,也设置了转发到 Gtalk。但在 Google Voice 里尝试打了几十次激活电话,也没收到一个来电。于是我就对网上流传的各种方案进行尝试,包括网上没有的方案。整整尝试了一下午,才收到了那么几次 virtualphoneline+nonoh 的电话,但是很悲剧,nonoh 的拨号盘不能配合 virtualphoneline 输入认证码。于是我只好无奈放弃了。

到了晚上 11 点多我看 Google Reader 的时候,心仍有些不甘。看了一个视频,发现别人 Gtalk 有个联系人叫做 service@gtalk2voip.com,我顺手也加上了。然后将 groovytel 改回转发到 Gtalk,看文章的时候一会儿过去打一下,一会儿过去打一下,没想到还真给我打通了。按照网上的方法,先输一个数字,回车,再输一个数字,回车,就通过了验证。

总结一下下午激活失败的可能原因:

1. 没有加 service@gtalk2voip.com 机器人为联系人。其实我不确定这个有没有用,反正加个机器人也不麻烦。

2. 可能在通话的高峰期打电话。从我看 groovytel 和 virtualphoneline 的通话记录,发现很多通话没应答只持续 5 秒钟。我不知道是不是意味着如果 5 秒钟没有接通 Gtalk 他们就放弃了连接。按说北京的下午在美国是凌晨呀,不应该是高峰的。

反正不管怎样,结果表明还是多尝试好,要有耐心,反正 Google Voice 貌似也没有限制可以尝试多少次,多次尝试打不通就换个时间打。我在 Twitter 上也碰到和我一样没能激活的好友,也许大家可以借鉴一下这里的经验。

解决 GAppProxy Set-Cookie 和 HTTPS Cert Bugs

我自己写了一个类似 GAppProxy 的工具,支持 Python 和 PHP,有兴趣可以看这里

研究 GAppProxy 有两个原因:一、最近 Twitter 不能用,而我常用的 GAppProxy 却不支持我登录 Twitter;二、我最近在琢磨 SSL 证书的问题,正好用 GAppProxy 登录 Twitter 也有证书错误。

第一个 BUG:Set-Cookie Bug

GAPPProxy 目前对 Cookie 的处理有一些问题,主要出在对 header 中的多个 Set-Cookie 域处理错误,就会导致用户登录一些网站错误,无法获得正确的会话 Cookie。

举例,当服务器返回的 header 中有多个 Set-Cookie 域时,比如一般的 wordpress 返回的 header 中,Set-Cookie 域至少有三个:

Set-Cookie:
wordpress_776c41a2fee8d137928f3750eb1f0736=admin%7C1247298611%7C8b89cfc80161853957182ddfc481cd72;
path=/wp-content/plugins; httponly
Set-Cookie:
wordpress_776c41a2fee8d137928f3750eb1f0736=admin%7C1247298611%7C8b89cfc80161853957182ddfc481cd72;
path=/wp-admin; httponly
Set-Cookie:
wordpress_logged_in_776c41a2fee8d137928f3750eb1f0736=admin%7C1247298611%7C545dcea44d5e69aec5c1203c64bee061;
path=/; httponly

GAPPProxy 会把它作为一个串传给本地浏览器:

Set-Cookie:
wordpress_776c41a2fee8d137928f3750eb1f0736=admin%7C1247298611%7C8b89cfc80161853957182ddfc481cd72;
path=/wp-content/plugins; httponly,
wordpress_776c41a2fee8d137928f3750eb1f0736=admin%7C1247298611%7C8b89cfc80161853957182ddfc481cd72;
path=/wp-admin; httponly,
wordpress_logged_in_776c41a2fee8d137928f3750eb1f0736=admin%7C1247298611%7C545dcea44d5e69aec5c1203c64bee061;
path=/; httponly

这样本地浏览器对 Cookie 的设置就会错误。解决办法很简单,将这个长串用split(', ')切开,同样设置三个 Set-Cookie 域即可。

Update 20090710/Solrex:
有人评论说 ', ' 也是可能在 Cookie 中出现的合法字符串;那么我就另外想了一个办法,先用正则表达式替换将 ', ***=' 替换成 'n***=',再用 'n' 对字符串进行切割。由于在 Cookie 中正常出现的 ', ' 后面会首先跟着 ';' 或 ',',然后才可能出现 =,因此用 ‘, ([^,;]+=)’ 匹配就可以了。而且这次把修改放到服务器端了,原来的客户端就不需要修改了

Patch:

Index: fetchserver/fetch.py
===================================================================
--- fetchserver/fetch.py    (revision 92)
+++ fetchserver/fetch.py    (working copy)
@@ -29,6 +29,7 @@
from google.appengine.ext import webapp
from google.appengine.api import urlfetch
from google.appengine.api import urlfetch_errors
+import re
# from accesslog import logAccess

@@ -153,14 +154,12 @@
             if header.strip().lower() in self.HtohHdrs:
                 # don't forward
                 continue
-            ## there may have some problems on multi-cookie process in urlfetch.
-            #if header.lower() == 'set-cookie':
-            #    logging.info('O %s: %s' % (header, resp.headers[header]))
-            #    scs = resp.headers[header].split(',')
-            #    for sc in scs:
-            #        logging.info('N %s: %s' % (header, sc.strip()))
-            #        self.response.out.write('%s: %srn' % (header, sc.strip()))
-            #    continue
+            # NOTE 20090710/Solrex: Fix multi-cookie process problem
+            if header.lower() == 'set-cookie':
+                scs = re.sub(r', ([^,;]+=)', r'n1', resp.headers[header]).split('n')
+                for sc in scs:
+                    self.response.out.write('%s: %srn' % (header, sc.strip()))
+                continue
             # other
             self.response.out.write('%s: %srn' % (header, resp.headers[header]))
             # check Content-Type

第二个 BUG:HTTPS Cert Bug

简单地来说,GAppProxy HTTPS 连接的实现是一个欺骗本地浏览器的过程,类似于中间人攻击。它首先用 GAE 获得页面的明文,抓到本地,然后假冒 HTTPS 站点与本地浏览器通信。因此它就需要提供一个 SSL 证书,来完成 HTTPS 连接的建立。

但这就有一个问题,SSL 证书哪儿来的?目前 GAppProxy 对所有的站点都使用一个证书,而且这个证书是未经任何授权 CA 认证的证书,因此就会产生很多错误。首先,该证书中的授权机构不是可信 CA,Firefox 和 IE 中捆绑的证书中没有该 CA 的证书;其次,该证书的 CN (Common Name)与站点域名不同,不可用于站点的通信。因此可能每次都需要用户自己点击添加证书例外。

为了避免这种缺陷,我想出来的做法是——自己做 CA,正所谓做事情要专业,要骗就骗彻底点,骗得浏览器神不知鬼不觉。首先,建立 CA 的密钥,自己给自己签发一个证书作为 CA 的根证书,将该 CA 的证书安装到 Firefox 浏览器中,在运行时使用该 CA 为每个 HTTPS 连接的站点签发对应于该站点的证书。由于 Firefox 中已经安装了该 CA 的证书,那么所有该 CA 签发的证书都能够通过 Firefox 的检测了。

这个方法比较麻烦,而且需要电脑上有 openssl,对于 Linux 完全没有问题,对于 Windows 可能就有点儿困难了。所以我这里就给出个思路,具体实现就不谈了。
您可以在 http://share.solrex.org/ibuild/ 找到我修改后的代码,感兴趣的话可以下载下来看看。

Linux 下 Firefox 变身 Google Chrome

几乎可以达到以假乱真的效果,屏幕截图请看:

Linux 下 Firefox 变身 Chrome

要求:
1. KDE 4 ---> Gnome 的窗口无法隐藏标题栏,影响变身效果(这也是目前我觉得 KDE 更好玩的原因之一)
2. Firefox 3.5 以上 ---> 3.5 以下无法使用 Chromifox Extreme 主题,只能使用 Basic Chromifox Extreme 主题
3. Chromifox Companion 扩展 ---> 配合 Chromifox Extreme 主题修改工具栏图标
4. Hide Menubar 扩展 ---> 隐藏菜单栏,可以使用 alt 呼出

步骤:
1. 安装 Firefox 3.5。下载 tar.bz 包之后解压到 /opt 目录下,比如目录名为 /opt/firefox,修改 /usr/bin/firefox 软链接到 /opt/firefox/firefox。
2. 安装 Flash 插件。在 /opt/firefox/plugins 目录下建立软链接 libflashplayer.so 到 /usr/lib/flashplugin-installer/libflashplayer.so。
3. 打开 Firefox,查看版本是否 3.5,安装 Chromifox Extreme 主题
4. 安装 Chromifox Companion 扩展
5. 安装 Hide Menubar 扩展
6. 设置隐藏标题栏。右击标题栏,Advanced->Special Application Settings->Preferences->No border,下拉选择 Apply Initially,勾上后面的复选框。
7. 重启 Firefox。

Update:在 Ubuntu 下安装 Google Chrome (开发者版)和 Google Earth

$ echo "deb http://dl.google.com/linux/deb/ stable main" > google-chrome.list
$ sudo mv google-chrome.list /etc/apt/sources.list.d/
$ wget https://dl-ssl.google.com/linux/linux_signing_key.pub -O - | sudo apt-key add -
$ sudo apt-get update
$ sudo apt-get install google-chrome googleearth

遗憾的是,Chrome4Linux 目前还不支持中文。

Google 拼音词库转 Vimim 词库脚本

我写了一个将 Google 拼音输入法词库转换为 Vimim 词库的脚本,贴在这里,希望对大家有用。

#!/bin/bash
iconv -f gbk -t utf-8 "$@" | sed -e 's/ //g;s/^M$//g' | awk 'NR==1 {a=$3; printf "%s %s",$3,$1; next; }{ if($3==a) printf " %s",$1;else printf "n%s %s",$3,$1; a=$3;}' | sort  -d

(注意:上面那个 ^M 在 vim 中的输入方法是 Ctrl+vm。)

使用方法:
1. 在 Google 拼音输入法“属性设置->词典”选项页,将 Google 输入法词库导出为 .dic 文件,例如 google.dic。
2. 将 google.dic 拷贝到 Linux 中,或者使用 Cygwin,进入到包含 google.dic 的目录。
3. 下载本邮件附件 google2vimim,给它增加可执行权限 chmod u+x google2vimim。
4. ./google2vimim google.dic > vimim.pinyin.txt,得到的 vimim.pinyin.txt 就是符合 Vimim 规范的词库。

PS: 是的,我忘记了 r 的作用,所以上面脚本可以完全替换为:

#!/bin/bash
iconv -f gbk -t utf-8 "$@" | sed -e 's/ //g;s/r$//g' | awk 'NR==1 {a=$3; printf "%s %s",$3,$1; next; }{ if($3==a) printf " %s",$1;else printf "n%s %s",$3,$1; a=$3;}' | sort  -d

脚本的最新版本下载地址可以是:http://share.solrex.org/scripts/google2vimim

Google 音乐搜索

/* 这不是一篇商业软文——虽然我希望可以有钱赚 :) */

Google 音乐搜索

今天无意中溜到和菜头的博客,看到他对谷歌音乐搜索的评论。然后试了一把,您还别说,真不错。

虽然这个音乐搜索并不像一个搜索网站而更像一个常见的音乐站,但是界面足够清爽,而且链接不会失效。我平时访问某些音乐站时最讨厌的就是页面上一堆链接,而且很多音乐是从别的网站盗链过来,经常失效。

最赞的是那个音乐播放器,列表功能很强大,而且还可以同步显示歌词。而且经过测试发现网速足够快,一首歌缓冲个两三秒就下载完了,完全可以在线听。我觉得以后没有必要在电脑中保存音乐了,这样一来,使用 Linux 播放 mp3 的 ID3 标签问题也解决了,而且 Linux 下也一直没有找到可以同步显示歌词的软件。

但是一个问题是播放列表无法保存,为什么不能把播放列表和 Google 帐户联系起来呢?

Patch for Libjingle with GCC 4.2.4 on Ubuntu

It is a svn diff result, not a patch, actually.

So, what is Libjingle? Quoted from http://code.google.com/p/libjingle/:

Libjingle, the Google Talk Voice and P2P Interoperability Library, is a set of components we provide to interoperate with Google Talk's peer-to-peer file sharing and voice calling capabilities. The package includes source code for Google's implementation of Jingle and Jingle-Audio, two proposed extensions to the XMPP standard that are currently available in draft form.

You can check out the head revision of Libjingle from its svn repository using command:

svn checkout http://libjingle.googlecode.com/svn/trunk/ libjingle-read-only

Then ``./autogen.sh'' and ``make'' as we usually do for building a *nix software. You will find many errors during ``./autogen.sh'' and ``make''. To fix them, first, some LIBs should be installed:

sudo apt-get install build-essential libexpat1-dev libglib2.0-dev libogg-dev libssl-dev libasound2-dev libspeex-dev openssl libortp7-dev libmediastreamer0-dev libavcodec-dev

I am not very sure if these LIBs are enough. If you have some problem with this, please let me know.

Even if you have all of these LIBs installed, you will still get some errors such as:

../../talk/base/stringutils.h:272: error: extra qualification 'talk_base::Traits::' on member 'empty_str'
../../talk/base/base64.h:26: error: extra qualification ‘talk_base::Base64::’ on member ‘Base64Table’
../../talk/base/base64.h:27: error: extra qualification ‘talk_base::Base64::’ on member ‘DecodeTable’

So here is a patch for source code errors like this. IMPORTANT NOTE: gcc version 4.2.4 on Ubuntu 8.04, libortp7.

Index: talk/p2p/base/sessionmanager.h
===================================================================
--- talk/p2p/base/sessionmanager.h    (revision 7)
+++ talk/p2p/base/sessionmanager.h    (working copy)
@@ -156,7 +156,7 @@

   // Creates and returns an error message from the given components.  The
   // caller is responsible for deleting this.
-  buzz::XmlElement* SessionManager::CreateErrorMessage(
+  buzz::XmlElement* CreateErrorMessage(
       const buzz::XmlElement* stanza,
       const buzz::QName& name,
       const std::string& type,
Index: talk/session/phone/linphonemediaengine.cc
===================================================================
--- talk/session/phone/linphonemediaengine.cc    (revision 7)
+++ talk/session/phone/linphonemediaengine.cc    (working copy)
@@ -80,24 +80,24 @@
     }
#endif
#ifdef HAVE_SPEEX
-    if (i->name == speex_wb.mime_type && i->clockrate == speex_wb.clock_rate) {
-      rtp_profile_set_payload(&av_profile, i->id, &speex_wb);
-    } else if (i->name == speex_nb.mime_type && i->clockrate == speex_nb.clock_rate) {
-      rtp_profile_set_payload(&av_profile, i->id, &speex_nb);
+    if (i->name == payload_type_speex_wb.mime_type && i->clockrate == payload_type_speex_wb.clock_rate) {
+      rtp_profile_set_payload(&av_profile, i->id, &payload_type_speex_wb);
+    } else if (i->name == payload_type_speex_nb.mime_type && i->clockrate == payload_type_speex_nb.clock_rate) {
+      rtp_profile_set_payload(&av_profile, i->id, &payload_type_speex_nb);
     }
#endif

     if (i->id == 0)
-      rtp_profile_set_payload(&av_profile, 0, &pcmu8000);
+      rtp_profile_set_payload(&av_profile, 0, &payload_type_pcmu8000);

-    if (i->name == telephone_event.mime_type) {
-      rtp_profile_set_payload(&av_profile, i->id, &telephone_event);
+    if (i->name == payload_type_telephone_event.mime_type) {
+      rtp_profile_set_payload(&av_profile, i->id, &payload_type_telephone_event);
     }
    
     if (first) {
       LOG(LS_INFO) << "Using " << i->name << "/" << i->clockrate;
       pt_ = i->id;
-      audio_stream_ = audio_stream_start(&av_profile, 2000, "127.0.0.1", 3000, i->id, 250);
+      audio_stream_ = audio_stream_start(&av_profile, 2000, (char *)"127.0.0.1", 3000, i->id, 250);
       first = false;
     }
   }
@@ -106,7 +106,7 @@
     // We're being asked to set an empty list of codecs. This will only happen when
     // working with a buggy client; let's try PCMU.
      LOG(LS_WARNING) << "Received empty list of codces; using PCMU/8000";
-    audio_stream_ = audio_stream_start(&av_profile, 2000, "127.0.0.1", 3000, 0, 250);
+    audio_stream_ = audio_stream_start(&av_profile, 2000, (char *)"127.0.0.1", 3000, 0, 250);
   }
 
}
@@ -114,12 +114,12 @@
bool LinphoneMediaEngine::FindCodec(const Codec &c) {
   if (c.id == 0)
     return true;
-  if (c.name == telephone_event.mime_type)
+  if (c.name == payload_type_telephone_event.mime_type)
     return true;
#ifdef HAVE_SPEEX
-  if (c.name == speex_wb.mime_type && c.clockrate == speex_wb.clock_rate)
+  if (c.name == payload_type_speex_wb.mime_type && c.clockrate == payload_type_speex_wb.clock_rate)
     return true;
-  if (c.name == speex_nb.mime_type && c.clockrate == speex_nb.clock_rate)
+  if (c.name == payload_type_speex_nb.mime_type && c.clockrate == payload_type_speex_nb.clock_rate)
     return true;
#endif
#ifdef HAVE_ILBC
@@ -171,8 +171,8 @@
#ifdef HAVE_SPEEX
   ms_speex_codec_init();

-  codecs_.push_back(Codec(110, speex_wb.mime_type, speex_wb.clock_rate, 0, 1, 8));
-  codecs_.push_back(Codec(111, speex_nb.mime_type, speex_nb.clock_rate, 0, 1, 7));
+  codecs_.push_back(Codec(110, payload_type_speex_wb.mime_type, payload_type_speex_wb.clock_rate, 0, 1, 8));
+  codecs_.push_back(Codec(111, payload_type_speex_nb.mime_type, payload_type_speex_nb.clock_rate, 0, 1, 7));
  
#endif

@@ -181,8 +181,8 @@
   codecs_.push_back(Codec(102, payload_type_ilbc.mime_type, payload_type_ilbc.clock_rate, 0, 1, 4));
#endif

-  codecs_.push_back(Codec(0, pcmu8000.mime_type, pcmu8000.clock_rate, 0, 1, 2));
-  codecs_.push_back(Codec(101, telephone_event.mime_type, telephone_event.clock_rate, 0, 1, 1));
+  codecs_.push_back(Codec(0, payload_type_pcmu8000.mime_type, payload_type_pcmu8000.clock_rate, 0, 1, 2));
+  codecs_.push_back(Codec(101, payload_type_telephone_event.mime_type, payload_type_telephone_event.clock_rate, 0, 1, 1));
   return true;
}

Index: talk/xmpp/xmppclient.h
===================================================================
--- talk/xmpp/xmppclient.h    (revision 7)
+++ talk/xmpp/xmppclient.h    (working copy)
@@ -138,7 +138,7 @@
     }
   }

-  std::string XmppClient::GetStateName(int state) const {
+  std::string GetStateName(int state) const {
     switch (state) {
       case STATE_PRE_XMPP_LOGIN:      return "PRE_XMPP_LOGIN";
       case STATE_START_XMPP_LOGIN:  return "START_XMPP_LOGIN";
Index: talk/third_party/mediastreamer/msrtprecv.c
===================================================================
--- talk/third_party/mediastreamer/msrtprecv.c    (revision 7)
+++ talk/third_party/mediastreamer/msrtprecv.c    (working copy)
@@ -26,7 +26,7 @@
MSMessage *msgb_2_ms_message(mblk_t* mp){
     MSMessage *msg;
     MSBuffer *msbuf;
-    if (mp->b_datap->ref_count!=1) return NULL; /* cannot handle properly non-unique buffers*/
+    if (mp->b_datap->db_ref!=1) return NULL; /* cannot handle properly non-unique buffers*/
     /* create a MSBuffer using the mblk_t buffer */
     msg=ms_message_alloc();
     msbuf=ms_buffer_alloc(0);
@@ -120,7 +120,7 @@
         gint got=0;
         /* we are connected with queues (surely for video)*/
         /* use the sync system time to compute a timestamp */
-        PayloadType *pt=rtp_profile_get_payload(r->rtpsession->profile,r->rtpsession->payload_type);
+        PayloadType *pt=rtp_profile_get_payload(r->rtpsession->rcv.profile,r->rtpsession->rcv.telephone_events_pt);
         if (pt==NULL) {
             ms_warning("ms_rtp_recv_process(): NULL RtpPayload- skipping.");
             return;
Index: talk/third_party/mediastreamer/audiostream.c
===================================================================
--- talk/third_party/mediastreamer/audiostream.c    (revision 7)
+++ talk/third_party/mediastreamer/audiostream.c    (working copy)
@@ -112,7 +112,7 @@
             RtpSession **recvsend){
     RtpSession *rtpr;
     rtpr=rtp_session_new(RTP_SESSION_SENDRECV);
-    rtp_session_max_buf_size_set(rtpr,MAX_RTP_SIZE);
+    rtp_session_set_recv_buf_size(rtpr,MAX_RTP_SIZE);
     rtp_session_set_profile(rtpr,profile);
     rtp_session_set_local_addr(rtpr,get_local_addr_for(remip),locport);
     if (remport>0) rtp_session_set_remote_addr(rtpr,remip,remport);
@@ -133,7 +133,7 @@
     /* creates two rtp filters to recv send streams (remote part)*/
    
     rtps=rtp_session_new(RTP_SESSION_SENDONLY);
-    rtp_session_max_buf_size_set(rtps,MAX_RTP_SIZE);
+    rtp_session_set_recv_buf_size(rtps,MAX_RTP_SIZE);
     rtp_session_set_profile(rtps,profile);
#ifdef INET6
     rtp_session_set_local_addr(rtps,"::",locport+2);
@@ -147,7 +147,7 @@
     rtp_session_set_jitter_compensation(rtps,jitt_comp);
    
     rtpr=rtp_session_new(RTP_SESSION_RECVONLY);
-    rtp_session_max_buf_size_set(rtpr,MAX_RTP_SIZE);
+    rtp_session_set_recv_buf_size(rtpr,MAX_RTP_SIZE);
     rtp_session_set_profile(rtpr,profile);
#ifdef INET6
     rtp_session_set_local_addr(rtpr,"::",locport);
@@ -217,8 +217,8 @@
     ms_filter_set_property(stream->decoder,MS_FILTER_PROPERTY_FREQ,&pt->clock_rate);
     ms_filter_set_property(stream->decoder,MS_FILTER_PROPERTY_BITRATE,&pt->normal_bitrate);
    
-    ms_filter_set_property(stream->encoder,MS_FILTER_PROPERTY_FMTP, (void*)pt->fmtp);
-    ms_filter_set_property(stream->decoder,MS_FILTER_PROPERTY_FMTP,(void*)pt->fmtp);
+    ms_filter_set_property(stream->encoder,MS_FILTER_PROPERTY_FMTP, (void*)pt->send_fmtp);
+    ms_filter_set_property(stream->decoder,MS_FILTER_PROPERTY_FMTP,(void*)pt->send_fmtp);
     /* create the synchronisation source */
     stream->timer=ms_timer_new();
    
Index: talk/third_party/mediastreamer/msrtpsend.c
===================================================================
--- talk/third_party/mediastreamer/msrtpsend.c    (revision 7)
+++ talk/third_party/mediastreamer/msrtpsend.c    (working copy)
@@ -85,7 +85,7 @@
{
     guint32 clockts;
     /* use the sync system time to compute a timestamp */
-    PayloadType *pt=rtp_profile_get_payload(r->rtpsession->profile,r->rtpsession->payload_type);
+    PayloadType *pt=rtp_profile_get_payload(r->rtpsession->snd.profile,r->rtpsession->snd.telephone_events_pt);
     g_return_val_if_fail(pt!=NULL,0);
     clockts=(guint32)(((double)synctime * (double)pt->clock_rate)/1000.0);
     ms_trace("ms_rtp_send_process: sync->time=%i clock=%i",synctime,clockts);
Index: talk/base/base64.h
===================================================================
--- talk/base/base64.h    (revision 7)
+++ talk/base/base64.h    (working copy)
@@ -23,8 +23,8 @@
   static std::string decode(const std::string & data);
   static std::string encodeFromArray(const char * data, size_t len);
private:
-  static const std::string Base64::Base64Table;
-  static const std::string::size_type Base64::DecodeTable[];
+  static const std::string Base64Table;
+  static const std::string::size_type DecodeTable[];
};

} // namespace talk_base
Index: talk/base/stringutils.h
===================================================================
--- talk/base/stringutils.h    (revision 7)
+++ talk/base/stringutils.h    (working copy)
@@ -269,7 +269,7 @@
template<>
struct Traits<char> {
   typedef std::string string;
-  inline static const char* Traits<char>::empty_str() { return ""; }
+  inline static const char* empty_str() { return ""; }
};

///////////////////////////////////////////////////////////////////////////////

You killed all these errors? Congratulations! You can start talking with your gtalk friends with command ``call'' in talk/examples/call/ !

PS: If you are working with GCC 4.3.x, more strict checking is applied on the code. However, most errors can be fixed by adding some C headers into the #include fields, such as: <cstdlib>, <cstring>.

Google Friend Connect 挺好玩的

经常来我博客转转的朋友会发现,我最近挺享受右下角的 Google Friend Connect,尤其是那个留言板。

目前来讲我没有发现 Google Friend Connect 的 SNS 功能有多好,顶多差不离也就是和 Live Space 的朋友一样,但是我非常欣赏这个留言板。这个看起来用处不大的留言板功能,我玩得很开心,因为我发现它提供了我最喜欢的 MiniBlog 功能。

一提起 MiniBlog,大部分人都会想饭否和 Twitter,Twitter 我用的很少,饭否用过一段时间,但我都不满意。拿发贴来说,要么要到它们的网站上去,要么跟聊天机器人说话,还会看到一堆别人的广播,真没意思。Facebook 和校内的状态也可以看作 MiniBlog 的一种,但是也存在别人的网站上,也会骚扰朋友和被骚扰。

Google Friend Connect 的留言板有几个特色我很喜欢:

1. 消息存储在第三方服务器上,可以显示在多处。
2. 发言即发即见,不用和机器人说话(或者跑到别的网站上发言),再去刷新自己的页面。
3. 别人也可以回复或发言,而且可以设置为登录后才能发言,屏蔽了垃圾消息(却不用我自己管理用户)。
4. 发言不会广播给朋友(也不支持订阅),干嘛闲扯几句还去骚扰别人?爱看就过来看,不强迫别人看。
5. 留言板显示在自己的网站而不是融合在别人的服务当中。

正好估计也没多少人去用这个留言板功能,我就自娱自乐,作为自己的 Blog 的补充,有空就扯几句,挺好!

Ibus 输入法

Ibus 输入法

由于种种历史遗留问题,本人惯用的汉字输入习惯和大多数人不一样:双拼输入法,并且是智能 ABC 风格的。这也就决定了我选择输入法的时候首先看它是不是支持我习惯的风格。以前 Google 推出中文输入法的时候,我的反应就很慢,因为不确定它是否支持双拼,后来发现它对智能 ABC 风格的双拼支持的很好,我在 Win 下的输入法就换成了 Google 的。

听说 Linux 下的 Ibus 输入法已经很久了,也是因为同样的原因没去尝试。搜索 “Ibus 双拼”,往往得不到有用的信息。今天在 Ubuntu 中文社区看到一张,发现已经有双拼支持,才让我下定决心尝试一下。

Ibus 已经被打包到了 ubuntu-cn 的源里(注意,不是 ubuntu),可以直接 apt-get install ibus-pinyin。由于我的 locale 是 en_US.UTF-8,所以 im-switch 不好用,只好将输入法选择写到 ~/.profile 里:

XIM=ibus
XIM_PROGRAM=/usr/bin/ibus
XIM_ARGS=""
GTK_IM_MODULE=ibus
QT_IM_MODULE=ibus
DEPENDS="ibus"

然后 re-login。网上有各种配置方法,其实我也不知道该怎么写才对,但输入法的启动不就是 X 启动时运行一个脚本嘛,从 /etc/X11/xinit/xinput.d/ibus 拷贝出来的东西总该没错。

第一次启动输入法需要选择 engine,这一点要比 scim 好,scim 默认就把乱七八糟的 engine 都给你配置上,还需要一个个去删除。然后再找配置双拼的地方,开始怎么也找不到,为什么不在 preferences 里呀?最后才想起来去看看那个输入法的语言条,才发现和 Google 拼音一样,启动配置在语言条的最后一个图标。

然后就开始码字,使用感觉是相当的爽的,界面比 scim 好看,用户词库的记忆效果貌似也比 scim 好。而且 scim 在处理双拼时候一直有一个问题:只显示键字母而不显示拼音。就比如“将”这个字用 ABC 双拼打是“jt”,在 scim 里拼音栏只会显示“jt”,而不是把“t”转换成“iang”,ibus 就没有这个问题。

目前来讲,ibus 最大的问题是反应有一点点慢,当打字速度快的时候,可能最后一个键没有记录上就把前面的输出了。比如打“将”时,jt空格,如果空格敲得太快,可能出现的是“就t”,大概也跟我使用的机器比较慢有点儿关系。这篇文章就是用 ibus 敲的,速度还可以忍受。

我把 scim 给删掉了,准备使用 ibus 一段时间,看看它是不是会引起一些程序的工作不正常。对 scim 引起的 KDE 程序崩溃和输入法不可用的毛病我已经容忍很久了,早就祈祷着到 Google 工作的 suzhe 能够继 scim 之后推出 Google 拼音的 Linux 版,目前看来 ibus 大概可以满足一下我的大部分需求。

Google 词典和 Gtalk 翻译机器人

由于我工作的机器配置实在太低,1.8GHz CPU, 256M 内存,40G 硬盘,跑一个 Ubuntu 也是非常吃力,我平常只敢开三四个程序,这样每次切换程序还要等个十几秒,唉!

虽然我 Ubuntu 里也装了 Stardict 星际译王,但我轻易不敢再开一个程序,太慢了!现在我发现一个非常有意思的东西解决了我的困扰,Gtalk 翻译机器人。其实这是我在尝试另一项服务 Google 词典时无意中发现的,很奇怪的是,我记得曾经看到一个 Google 官方的关于 Gtalk 翻译机器人的页面,怎么再也找不到了?只搜索到一个 Gtalk 开发组的博客上的新闻链接

添加 Gtalk 翻译机器人很简单,就是选择添加好友,好友 email 为:语言缩写2语言缩写@bot.talk.google.com。比如汉英翻译的机器人名字是:zh2en@bot.talk.google.com,英汉翻译的机器人名字是:en2zh@bot.talk.google.com。当然了,还有更多,点上面的新闻链接可以查看。

用这个机器人有什么好处呢?一是方便,直接在聊天软件里就可以查词。就像我用那么落后的机器,打开一个词典软件能让它假死半分钟,而 IM 软件总是会开着的,打开一个聊天窗口显然方便和快很多;还有一个好处是 Google 将你的聊天内容记录到 Gmail 里,那么过一段时间整理一下聊天记录就是一个非常好的生词表 :)。查找聊天记录很简单,只需要在 Gmail 上方的搜索栏中输入:from: en2zh@bot.talk.google.com 再点搜索即可。

Goolge 词典也是非常好用的,但不知道为什么在 Google 首页上点 Language Tools 进去以后却没有词典的链接,只有到 more->even more 中找 Translate 才有。

Google Code

受到某同学的提醒,今天我把我的两个小项目中科院IP网关登录客户端《使用开源软件-自己动手写操作系统》源代码转移到了 Google Code 上。

CAS NET 本身就是遵从 GPL 协议的,所以我就将所有内容都转移到了 Google Code,原有的官方主页也设置了301永久重定向到 Google Code 页。

《使用开源软件-自己动手写操作系统》的电子书内容是遵从的 CC 协议,所以并没有将电子书的 TeX 源码放到 Google Code 上,只是将书中示例程序的源代码放了上去。原有的官方主页还保留,主要用做发布所有源码(TeX+Demo)用。虽然最近比较忙,第三章还是写了一部分,大概月底能发布第三章吧(不敢保证,因为期末考快到了!)。不像博客,写书总得需要大块的时间,而我能挤出来的时间确实不多。唉,我也想早点儿把它完成。

最近在申请两个公司的暑期实习生,微软和IBM。个人比较喜欢 IBM CRL 那个 Security 的职位,只是,现在由不得咱们挑啊!

Google Reader 发布共享新功能

[本博讯 2008年5月5日] Google Reader 发布了新的共享功能:Notes、Share bookmarklet 和可修改的共享页面主题。

Notes 和 Share With Note

Notes 功能和 Twitter 或者饭否有点儿相似,在侧栏原有的 Your Shared items 被替换成了一个 Your stuff,Shared items 和 Notes 成为其子项目:

Left Reader

点击 Notes 进去后,用户可以输入一些简短的句子,如下图所示,这些句子将被共享给 Google 的好友,而且可以在用户的共享阅读页面可见。

Google Reader Notes

有趣的是,Google Reader 在共享某个订阅条目的时候,也可以添加自己的备注:

Share with note

Share bookmarklet

用户可以将一个书签按钮添加到浏览器的收藏夹中,当看到有趣的页面时,点击该书签就可以共享给 Google 的好友。

Note Bookmark

可修改的共享页面主题

原有的 Google Reader 共享页面只有一个主题,现在 Google Reader 组提供了更多的主题供用户使用,看看我的共享页面效果

Google Reader Shared Page Theme

从安全的角度理解——为什么要使用 Google 的服务?

我很喜欢 Google 的一些服务 Gmail, Reader, Documents等等,而且我也一直大力倡导周围的人使用 Google 的服务。在我看到一些人仍在使用 163, sina 的信箱,抓虾的在线订阅器的时候,我不禁为他们通信的安全性担心。为什么使用 Google 的服务?一个很重要的原因是:因为它更安全。

目录:

1. 得到抓虾用户名密码的例子
2. 嗅探和可嗅探网络
3. 为什么 Google 更安全?
4. 为什么 163, sina, 抓虾 不安全?
5. 如何使用 Google 提供的安全服务?
6. 使用 Google 提供的安全服务的额外好处

1. 得到抓虾用户名密码的例子

首先,来看一个截图,看看国内某著名在线订阅器网站抓虾网对用户密码的保护有多脆弱:

Wireshark_Zhuaxia

请大家注意截图的最下方,能否看到这一行字:
email=solrex%40gmail.com&password=testtest&persistentCookie=true
solrex@gmail.com 是我在抓虾的注册帐号,而后面的 password 大家应该知道是什么东西吧!(不用试我的帐户,我的密码已经改了。)

有人会好奇这张截图怎么得到的,其实这是我在自己的电脑上用 Wireshark(一款著名的网络数据包分析软件,其前身是 Ethereal) 对通过我网卡的到抓虾网数据包进行监控的截图。Wireshark 实际上就是一款嗅探器,它能记录经过指定网络接口的所有数据包并进行分析。

2. 嗅探和可嗅探网络

为了解释如何才能得到上面的数据包,首先要介绍一下网络嗅探的原理:

嗅探,是一种黑客的窃听手段,一般是指使用嗅探器对数据流的数据截获。由以太网的知识我们知道,在以太网的冲突域中,每台主机的网卡都能接触到所有的数据包,如果数据包的目的地址是自己,网卡就接收数据包,并将包的内容向上层传递;如果数据包的目的地址不是自己,就将该包丢弃。那么如果网卡接收所有的数据包并对其进行分析呢?这就是所谓的“混杂模式”,将网卡设置为混杂模式后,就可以接收所有包,进而对同一网络中的其它主机的通信内容进行监听,这就是 Wireshark 进行嗅探的原理。

需要说明的一点是,在当前的网络下,直接进行嗅探并没有那么容易。上面所说的情况,只在以太网的冲突域中才能实现,而当前交换机的广泛使用,将冲突域限制到交换机和主机两点之间,除了自己没有其它主机,当然也无法直接对其它主机的通信内容进行监听。如果在交换网络下达到嗅探的目的,必须通过其它办法,比如 ARP 欺骗,这超出了本文的讨论范围,就不予介绍了。

虽然在交换网络下无法对其它主机进行直接嗅探,但是我们无法保证在我们的数据包经过的防火墙或者网关时候不会被监听。就比如在实验室的网络环境下,实验室的管理员在网络出口的防火墙处对数据包进行分析是易如反掌的事情。

所以我们总结一下,用户通信的数据包被监听可能发生在几种情形下:一、共享网络,网络用户通过集线器连接到网络;二、交换网络的结点不可信任,比如公司网络的出口防火墙管理员不可信;三、缺乏安全机制的无线网络,比如学校为学生提供的无线网络连接(用 WEP 加密传输的网络可以认为是缺乏安全机制);四、有ARP欺骗的交换网络。

由于很多网站的登录 session 是使用的 http 协议,在此协议下,用户名和密码都是通过明文传输的(抓虾和 sina 就是一个例子),所以当用户处在上述的网络环境下时,很有可能数据包被别人监听到。而数据包一旦被监听和分析,得到用户的信息易如反掌,南京大学小百合 BBS 最近的一篇文章:你还敢在教室中享受无线吗? ,就是一个很生动的例子。

3. 为什么 Google 更安全?

首先,因为 Google 采用了 https 协议来处理用户登录请求。https(Hypertext Transfer Protocol over Secure Socket Layer) 协议是指加强安全的 http 协议,正如它名字所示,它采用 SSL 来保证数据的加密传输。举个很有证明力的事实,如果你有任何一个银行的网上银行帐号,请打开你的网银登录窗口,查看上面的地址栏内容开头是不是:https://xxx.xxx.com/xxxx 。没有任何一家银行采用 http 协议处理网银登录请求,这说明了什么?http 协议不安全。

其次,因为 Google 使用可选的 https 协议来提供内容传输服务。虽然 Google 在其任何服务的登录请求处理中都是使用 https 协议,但是在认证完用户之后,和 Google 服务器的连接就转回到了 http 协议。这样虽然 Google 的用户名和密码不能被窃听到了,可服务的内容,比如 Gmail 邮件的邮件内容就会被恶意用户窃听到。那么如何使整个通信的内容都受到保护呢,就需要使用 Google 提供的可选 https 服务,只需要在你的浏览器地址栏内容的最前面 http:// 换成 https:// 即可。

4. 为什么 163, sina, 抓虾 不安全?

sina 和 抓虾 没有提供任何的帐户信息和内容的安全传输功能,所以在课堂上演示嗅探器工作方式的时候,演示者一般都会首先拿 sina 开刀,原因很简单,它是个大网站。

163 呢,比前面两个好一点儿,它提供了可选的帐户信息的加密传输功能,就是在登录 163 信箱的时候,在登录框下面有一个“增强安全性”的选项,如果勾选了增强安全性选项,163 就会用 https 来处理用户的登录请求。但是 163 仍然没有提供对用户内容的加密传输功能,即 163 的所有邮件在网络上都是明文传输

5. 如何使用 Google 提供的安全服务?

如果用户希望自己的帐户信息和传输内容都受到保护的话,那么就应该使用 Google 提供的 https 连接登录 Google 服务。比如Gmail 的 https 入口是:https://mail.google.com/ ,Google Reader 的 https 入口是:https://www.google.com/reader/ 。在 Google 其它的服务中,用户也可以简单地通过将地址栏的 http:// 换成 https:// 来选择使用安全传输。

6. 使用 Google 提供的安全服务的额外好处

使用安全的加密传输能保证自己传输的信息不被别人获取,这是显而易见的好处,但是使用 Google 的安全服务还有一点额外好处:避免自己的网络访问被关键词过滤。

有过访问敏感站点经验的同志可能都知道,如果网页中包含某些关键词,连接往往会被重置。就比如用户使用 Google Reader 订阅了某个激进的博客的RSS FEED,如果使用传统的 http 连接,当文章中包含敏感关键词时,Google Reader 就会与服务器断开连接。如果使用加密传输的话,传输内容就避免了被关键词过滤,就不会发生类似连接被重置的情况。

用 Google Reader 生成 Blogroll(侧栏链接列表) 和博客推介广告

我一直认为 Google Reader 是最好的在线博客阅读器,但是它的功能不仅仅是博客阅读器,下面介绍 Google Reader 还能为 Bogger 做的两个小功能。

一、用 Google Reader 生成 Blogroll(侧栏链接列表)

相信很多 blogger 都比较头疼于如何设置 blogroll:用博客程序自带的吧,不一定好看(尤其是Wordpress 自带的);自己写 HTML 吧,太费劲,尤其在添加/删除链接时,很麻烦s。那看看我用 Google Reader 生成的 blogroll 吧(也可直接到我博客主页查看效果):

Blogroll Demo by Google Reader

还是挺漂亮的吧,怎么生成的呢?其实方法很简单,如果你使用 Google Reader 来管理自己的博客订阅,只需要把你要链接的朋友的博客订阅放在一个 folder 里,比如我的就是 BlogRoll, 然后到 Settings->Tags 设置里,将 BlogRoll 这个 tag 的权限设置为 public 即可,如下图所示:

Blogroll Setting in Google Reader

看到 BlogRoll 那一行最后一个链接没?“add a blogroll to your site”,下面的操作我就不说了吧。(注意,需要博客程序支持添加 JavaScript源码,MSN Space和新浪一流恐怕没办法。)

这样做好之后,当你想在 blogroll 里添加/删除一个朋友的链接时,就可以直接在 Google Reader 里对 BlogRoll 文件夹添加/移除订阅即可。别忘了 Google Reader 也支持修改链接的名字哦!

二、用 Google Reader 生成博客推介广告

有自己的个人网站也是一件很麻烦的事情,不知道个人主页里应该写什么内容,咱不是什么牛人,也没什么可写嘛。如果页面挺空,就可以把自己的最新博客文章链接放上去啊,至少要在自己的个人主页上推广一下自己的博客嘛。看看我在一些页面上放的“博客推介广告”:

Blog AD By Google Reader

其实这是一个小 trick,Google Reader 不是支持将订阅内容共享出来嘛(就是上面那张图里的“add a clip to your site”),那么我当然可以只共享自己的博客内容喽。将自己的博客订阅单独放在一个 folder 里,然后“add a clip to your site”,^_^。

这样每次你一更新自己的博客,个人主页的链接就跟着更新了,当然了,也可以在某些支持 js 脚本的网站上做签名档...

三、(你不是学数学的吧,不是只有两个功能吗?)顺便推广一下 Firefox

知道上面的几个截图怎么生成的吗?没有使用任何其它程序,只用了 Firefox 的一个插件:Screen Grab ,功能强大吧!