ABTest 平台设计 - 灰度发布和早鸟用户

上篇《ABTest 平台设计 - 实验开关和分组信息传递》简单介绍了 ABTest 实验开关和数据收集的一些实现,从流量划分、到实验开关、到数据收集,基本实现了 ABTest 的主要功能。下面则扩展谈一下 ABTest 的衍生功能:

基于 ABTest 的灰度发布

与 ABTest 一样,灰度发布也是圈出来一部分流量进行新功能的线上验证,验证基本能力没有问题之后再逐渐扩大覆盖面,支持扩展到全流量。

灰度发布本身也有很多种机制,例如最常用的:上线时先上单副本,再扩展到多副本,再扩展到单机房,再扩展到其它机房。这种方式非常自然,逐步扩量观察保证了服务稳定性。

但这样在上线的中间过程中,总不可避免地会出现一些用户体验问题。比如用户相邻的多次刷新请求被路由到版本不同的副本上,导致请求结果的跳变

既然 ABTest 同样具备划分流量的能力,而且这种划分对于单个用户来说是稳定的,其实在很多情况下可以利用 ABTest 能力来实现灰度发布。

但基于 ABTest 的灰度发布,要求在架构上提供一些支持

比如要发布新版本的网站静态文件(css/js 等),可以全量发布多个版本,然后通过 ABTest 圈定部分用户路由到新版本的静态文件,其余则路由到原版本的静态文件。

比如要发布新版本的服务程序,可以用新的 Docker 部署新版本程序,通过 ABTest 圈定部分用户请求路由到新的 Docker 上,其余则路由到线上的 Docker 上。

基于 ABTest 的灰度发布,很多情况下可以简化服务的部署和回滚操作,也保证了用户在灰度上线期间的体验稳定性。

服务好早鸟用户

在很多时候,企业的内外部总存在着一些早鸟用户,他们对灰度 / AB 新功能有着非常迫切的需求。

最典型的早鸟用户,就是公司的老板。当你开发了一个新功能,向上汇报了一下这功能多好多好。老板会问:

“为什么我没有看到这个新功能?”

你可能不得不解释说:

“老板你的 ID 没有被随机分到实验组里。”,

或者:

“老板这个功能只上了广州机房,北京访问不了。”

还有一种早鸟用户,是产品经理、测试人员,甚至可能包括提交 BUG 的外部用户。他们需要去回归新功能的线上效果是否达到预期,而且甚至他们需要一直不停地在不同的 AB 分支上切来切去比较效果。

这时候就需要一种灵活的机制,让这些早鸟用户有办法切换 AB 功能。

很多人最直接想到的方法,是在随机分桶之外搞一个 ID 分桶,收集上来早鸟用户的 ID,手动配置实验分组。这能在一定程度上解决问题,但设想这样的场景:

“老板,把你 UserID 发来一下,我给你配一下小流量实验。”

老板心里肯定在嘀咕,我要给了你 UserID,岂不是我看过啥发过啥你都能查出来了?这以后还能有隐私么?

而且配置 ID 的方式会增加运维的工作量,尤其是用户需要切来切去的时候。所以这时候不如提供一些强制命中灰度 / AB 的后门功能,能够大幅度降低沟通和维护的复杂度。

这种给早鸟用户的后门可以有很多种做法:

写 Cookie 机制。提供一个特殊的 URL,访问该 URL 就会种下一个强制命中的实验分组 Cookie,此后带着这个分组 Cookie 的访问都会中这个实验。这种适用于 WEB 端产品。APP 端使用的话,需要做一些 Cookie 同步工作。

配置注入机制。提供一个二维码,二维码内容是一段特殊的代码,APP 扫描到该二维码,就会被注入实验分组配置。这种适用于 APP 端产品。

隐藏功能机制。在某些内容上连续点 N 下,就会弹出一个配置面板,可以用来查看和调整当前所中的实验分组。

ABTest 平台如果能够提供这样的后门机制,将会大大方便与早鸟用户的沟通和合作。

下篇,我们聊一下流量分布问题。

ABTest 平台设计 - 实验开关和分组信息传递

通过上文《ABTest 平台设计 - 如何进行流量分桶》可以知道如何把用户流量科学地分配到不同的实验分组中,下面就面临一个问题:如何根据分组信息控制产品功能?

一种直观做法

最显而易见的做法,是直接在系统中传递分组信息,同时使用分组信息作为实验功能的开关

比如说在服务端系统的接入层,通常是 Nginx 或者其它入口模块,对流量进行分组,为每个请求添加一个抽样分组字段,字段内容类似于 “Exp1Group1,Exp3Group2,...”。然后所有对下游的请求,都带着这个分组字段。那么下游的各个模块,都可以根据这个分组字段来决定程序逻辑。用伪码表示就是:

if 抽样分组字段 包含 Exp1Group1:
do 实验1 的 A逻辑
elif 抽样分组字段 包含 Exp1Group2:
do 实验1 的 B逻辑
else
do 全流量逻辑

上面的伪码有些缺点,就是抽样分组信息 Exp*Group* 写死在了代码里,这样灵活性太差。所以,即使直接使用分组字段,通常也是将分组字段作为配置项写到代码里,这样更方便测试和部署。

客户端带来的挑战

在服务端直接使用抽样分组信息作为实验功能开关尚且可以忍受,因为一旦有问题调整下重新上线并不困难,但是在客户端 APP 中这样做就行不通了。客户端版本一旦发布,再想改动就面临着诸多的问题,比如发布审核周期问题,用户拒绝升级问题等等。

还有一个问题就是冗余代码和数据控制的问题。服务端功能 ABTest 转全以后,可以同时删除实验分组和对应的实验功能代码。客户端的实验代码是很难删除的,而且无法预判哪个分支会转全,所以没法直接删除实验分组。这会导致线上的实验越来越多,无法控制。

功能配置和分组配置分离的设计

回过头来思考这个需求:用户中了实验分组 A ,就走实验 A 逻辑。 其实可以加一层抽象:用户中了实验分组 A,就下发 A 对应的功能配置,实现走实验 A 逻辑

就拿客户端 ABTest 来说,大实验可能是换一下 APP 版式,增加或者减少一个新功能;小实验可能是调整一下字体和字号,调整一下背景颜色或者图片等。其实本质上是控制一些功能的配置项。ABTest 可能会下线,但这些配置项会一直在产品中存在。

功能配置和分组配置分离还能带来很大的灵活性,也就是说云端可以创建新的 ABTest 尝试不同的功能配置组合,而不需要硬编码固定的 ABTest。

假设你本来有两个实验,标题大小实验和内容大图实验。如果硬编码情况下,你只能做原始实验方案的对比。如果用功能配置的话,等这两个实验完了,你还可以实验不同标题和内容大小图的结合实验,这是不需要再开发和发版的。

当然,动态配置在工程上如何更合理地实现,也是一个值得探讨的话题,这个留待以后再说。

实验分组信息记录

做 ABTest 的目的,主要是为了收集用户对实验的反馈,方法则是查看不同分组下用户数据指标和用户行为的对比和变化。这些数据的记录,往往是靠各种系统日志。

一种比较原始的办法,是用系统日志跟用户中实验分组的时间信息去 join,来区分 AB 分组。比如用户 A 在 1 号到 10 号分到了 A 分组,那么可以认为该用户 1 号到 10 号的日志都属于 A 分组,用来统计 A 分组指标。

这种方式存在着明显的缺点:一是实验的开启和关闭点不会是整点,而进行精确到秒的日志时间 join 成本太高,很多时候不得不抛弃首尾两天不足整天的用户日志;二是用户中实验分组在产品上的生效往往不是实时的。尤其是在客户端上,用户正在用一个功能的时候,很难瞬间将该功能切换成另一种样式,往往是在用户在下一次重入初始化的时候才开启实验样式,否则很容易引起崩溃。

对统计更友好的分组信息记录,是在每条关键的系统/客户端日志中都添加分组信息字段。这样虽然增加了一些冗余信息,但会使所有的关键数据记录都有实验分组这一列,用它做筛选即可进行指标的对比和用户行为序列的分析。

小细节:分组编码

上面谈到每条日志中都要记录用户所属的实验分组信息,这种冗余程度要求分组信息有着非常集约的编码设计,才能尽量减少传输和存储的数据量。可能的选择有以下几种:

  • 奔放派 :直接使用实验名+分组名作为分组信息。比如一个用户中了两个实验的不同分组,那就是:“ExpTitle_GroupA|ExpImage_GroupC”。奔放派的好处是日志可读性很高。
  • 婉约派:将实验名和/或分组名精简为两个 ID,形如:“10010_1|10080_3”。好处是虽然可读性低了些,但毕竟直观。
  • 理工派:将实验名和分组名精简为一个ID,形如:“12345|14523”。对于理工科思维来说,只要 ID 不重复就行,为啥要分成俩字段?
  • 极客派:将 ID 用 62 进制表示,形如:“3d7|3Mf”。这个世界上,只有麻瓜才用 10 进制。
  • 抠门派:将 ID 数组用 protobuf (varint)表示,有时候需要 base64 一下,形如:“算了举例太费劲”。好处就是一般人看不懂。

当然,可能还有其它的组合,大家意会就好了。

下篇,我们聊一下灰度发布和早鸟用户

ABTest 平台设计 - 如何进行流量分桶

在 2018 年,我相信 ABTest 这个名词已经不用过多地解释了。但我发现很多公司,尤其是初创企业,虽然能理解这件事是什么,却不知道这件事该怎么做,以及该怎么做好。

这一系列文章,就是想讲清楚在设计具体的 A/B 测试平台这种基础架构时,要考虑哪些问题,以及有哪些推荐的做法。

今天先谈一谈:

如何进行用户分桶

我们都知道互联网产品的 ABTest 主要是围绕用户进行的实验,从统计意义上观察用户对不同的产品设计、交互体验、业务流程的反馈,从而指导产品的改进方向。

那么很重要的一点就是,怎么圈定哪些用户进行 A 实验,哪些用户进行 B 实验。

一种错误做法

在我工作过的一家公司,他们是这样做的:

“使用用户的 UserID 对 1000 取模分成 1000 个桶,然后选择不同的桶分配给 A 或者 B。”

我问研发人员为什么这么做?他们给的理由是:

“UserID 是自增 ID,跟用户注册顺序有关,有一定的随机性。可以保证用户随机地分到 A 或者 B 中。”

A/B 的流量圈定的一个重要原则就是无偏,不然无法进行对比评估。上面的做法看起来倒也有一定的道理。(还常见的一种做法是,用手机尾号最后一位来进行分桶,优惠多少就看你手机尾号是否运气好了 ^_^ )

单单考虑孤立实验,这样做也无可厚非。但如果考虑到长期交叉、连续的实验,这样做有很大的问题。

首先,这种设计只能进行单层实验,也就是说一份流量只能通过一个实验。

如果实验人员选择了在任意一个桶中同时进行 X, Y 两个实验的话,那两个实验的结果就会相互干涉,导致最终结果不可信。例如:在尾号为 001 的桶里进行了两个促销活动“降价10%”和“满100减10块”的实验,最终 001 桶的用户订单数比其它桶高,那到底是哪个促销更有效果呢?

其次,这种设计在长期会造成桶间用户行为有偏

也许刚开始因为其随机性,桶间用户行为差异很小。但第一个实验过后,桶间就开始有了行为差异——这也是 ABTest 的目标。N 个实验过后,桶间行为的差异可能就变得非常大了。

比如你总是在 001 桶的用户上实验幅度较大的促销活动,那么 001 桶的用户留存就会显著高于其它桶。那实验人员为了让实验效果更好看,可能会偷偷地继续选择 001 桶进行实验。

最后,这种设计的实验效率太低。因为一份流量只能通过一个实验,无法对流量进行充分的利用。

那该如何设计用户分桶,才能满足 ABTest 的需求呢?

一种正确方法

目前业界应用最多的,是可重叠分层分桶方法。

具体来说,就是将流量分成可重叠的多个层。因为很多类实验从修改的系统参数到观察的产品指标都是不相关的,完全可以将实验分成互相独立的多个层。例如 UI 层、推荐算法层、广告算法层,或者开屏、首页、购物车、结算页等。

单单分层还不够,在每个层中需要使用不同的随机分桶算法,保证流量在不同层中是正交的。也就是说,一个用户在每个层中应该分到哪个桶里,是独立不相关的。具体来说,在上一层 001 桶的所有用户,理论上应该均匀地随机分布在下一层的 1000 个桶中。

通过可重叠的分层分桶方法,一份流量通过 N 个层可以同时中 N 个实验,而且实验之间相互不干扰,能显著提升流量利用率。

从实操上来说,我们通常采取下面的方法:

首先,确定 Layer,确定请求 Tag。例如从 UserID,DeviceID、CookieID、手机号 中选一个,支持匿名流量的,一般会选用 DeviceID 或者 IMSI 等作为请求 Tag。

然后,选一个你喜欢的 Hash 函数,尽量选个使用方便、随机性更强的;

最后,通过 Hash(Layer, Tag) % 1000 确定每层分桶。如果 Hash 函数支持 seed,那么使用 Layer 作为 seed,否则作为 SALT,即将 "Layer+Tag" 作为输入参数。

在完成分桶以后,还可以进行一定的流量筛选。例如来自北京和上海的用户,可以允许分别进行不同的实验。

可重叠分层分桶方法的系统性介绍,可以参见 Google 在 KDD 2010 发表的论文 《Overlapping Experiment Infrastructure: More, Better, Faster Experimentation》,感兴趣的同学可以延伸阅读一下。

更多样的选择

但分层方法并不能让所有人满意,尤其是对并行实验非常多的大公司来说。因为同一层可能有不同份额的其它小流量实验在线上,新实验能够拿到的流量非常有限,需要等待同层其它实验结束,会非常影响迭代效率。而新实验未必一定会修改其它实验的参数,或者影响其它实验的效果。

所以一些公司也逐渐开始探索无分层,也可以称为无限分层的方法。具体的做法是,每个实验都可以看成独立的层,只保证层间流量分桶的正交性,所有的实验都可能存在重叠情况。

这样做用户就有大概率同时中同一类实验,例如两个实验人员同时在实验 UI 布局,实际上用户只能看到一种布局(因为代码分支有先后逻辑),那么实验结果就不可信了。

由于系统上无法保证实验间不冲突,那么只能从组织上来避免冲突,或者从数据上尽早观察到冲突的存在来解决冲突。这对组织的管理能力和企业的数据能力提出了更高的要求。

下篇,我们聊一下实验的开关和分组信息传递

ETH ICAP 地址协议算法实现

以太坊钱包生成收款码时,有些是直接拿裸地址例如 "0x0728F0...75445F" 生成收款二维码,例如 TrustWallet;还有些是使用 "iban:" 开头的 ICAP 串来生成收款二维码,例如 imToken 1.0。

IBAN 本身是国际上一部分银行间转账使用的账号代码格式,以太坊社区在 IBAN 地址格式上做了一些扩展,用来编码以太坊地址和校验码,用作地址交换使用,叫做 ICAP (Inter exchange Client Address Protocol)。

IBAN 编码看起来很简单,但在实现上字母到数值的转换方法挺 trick 的,需要花一些时间进行理解。为简化理解,下面我拿一个例子来说明整个编码过程。

假设我们已经有了一个以太坊地址:0x730aEA2B39AA2Cf6B24829b3D39dC9a1F9297B88,下面是生成对应 ICAP 地址的过程:

第一步:将原始 16 进制以太坊地址转换成为 36 进制地址:

16 进制 ETH 地址:0x730aEA2B39AA2Cf6B24829b3D39dC9a1F9297B88
36 进制 ETH 地址:DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4

第二步:为 36 进制 ETH 地址拼接上国家码 "XE" 和空校验字符串 "00" 形成 36 进制待校验字串:

36 进制 ETH 地址: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4
36 进制待校验字串: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4XE00

第三步:将 36 进制待校验字串逐字符转成 10 进制数字字串:

36 进制待校验字串: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4XE00
10 进制待校验字串: 1315273521273029152915344143118231017341572926622101234174331400

第四步:将 10 进制大整数对 97 取模,然后用 98 - 模数:

校验码:42 = 98 - 1315273521273029152915344143118231017341572926622101234174331400 % 97

第五步:将校验码替换空校验字符串,然后重新安排 XE** 到地址前,并加上前缀:

36 进制待校验字串: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4XE00
36 进制已校验字串: DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4XE42
36 进制 IBAN 号: iban:XE42DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4

可以用这个 IBAN 号生成二维码,用支持 iban 地址的 app 扫描二维码验证下是否能解析到正确的 ETH 原始地址。

ICAP 地址生成和校验的实现,可以参考下面这段 Java 代码,可直接用于 Android 客户端:

package com.yangwenbo;

import java.math.BigInteger;

/**
 * Ethereum ICAP (Inter exchange Client Address Protocol) Address Converter
 * Convert Ethereum Address from/to ICAP iban address
 *
 * @ref https://github.com/ethereum/wiki/wiki/Inter-exchange-Client-Address
 * -Protocol-(ICAP)
 */
public class EthICAP {
  private static String ICAP_XE_PREFIX = "XE";
  private static String IBAN_SCHEME = "iban:";
  private static String IBAN_MOD = "97";

  /**
   * Build ICAP iban address from ethereum address.
   *
   * @param ethAddress ethereum address
   * @return ICAP iban address
   * @example input:  0x730aea2b39aa2cf6b24829b3d39dc9a1f9297b88
   * return: iban:XE42DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4
   */
  public static String buildICAP(String ethAddress) {
    if (!ethAddress.startsWith("0x") || ethAddress.length() != 42) {
      throw new IllegalArgumentException("Invalid ethereum address.");
    }
    BigInteger ethInt = new BigInteger(ethAddress.substring(2), 16);
    String base36Addr = ethInt.toString(36).toUpperCase();
    String checkAddr = base36Addr + ICAP_XE_PREFIX + "00";
    String base10Str = "";
    for (Character c : checkAddr.toCharArray()) {
      base10Str += new BigInteger(c.toString(), 36);
    }
    Integer checkSum = 98
        - (new BigInteger(base10Str)).mod(new BigInteger(IBAN_MOD)).intValue();
    String icapAddress = IBAN_SCHEME + ICAP_XE_PREFIX 
        + checkSum.toString() + base36Addr;
    return icapAddress;
  }

  /**
   * Decode ethereum address from ICAP iban address
   *
   * @param icapAddress ICAP iban address
   * @return ethereum address
   * @example input:  iban:XE42DFRZLRUTFTFY4EVINAHYF7TQ6MACYH4
   * return: 0x730aea2b39aa2cf6b24829b3d39dc9a1f9297b88
   */
  public static String decodeICAP(String icapAddress) {
    if (!isValid(icapAddress)) {
      throw new IllegalArgumentException("Invalid icap address.");
    }
    BigInteger ethInt = new BigInteger(icapAddress.substring(9), 36);
    String base16Addr = ethInt.toString(16).toLowerCase();
    return "0x" + base16Addr;
  }

  /**
   * Check ICAP iban address validation
   *
   * @param icapAddress ICAP iban address
   * @return true if valid; false if invalid
   */
  public static boolean isValid(String icapAddress) {
    if (!icapAddress.startsWith("iban:XE") || icapAddress.length() != 40) {
      return false;
    }
    String base10Str = "";
    for (Character c : icapAddress.substring(9).toCharArray()) {
      base10Str += new BigInteger(c.toString(), 36);
    }
    for (Character c : icapAddress.substring(5, 9).toCharArray()) {
      base10Str += new BigInteger(c.toString(), 36);
    }
    Integer checkSum
        = (new BigInteger(base10Str)).mod(new BigInteger(IBAN_MOD)).intValue();
    return checkSum == 1;
  }
}

专门用于批量空投的 ETH 智能合约

2018 年 4 月份,美链 BeautyChain (BEC) 爆出了智能合约漏洞,导致市值 60 亿的代币价格几乎归零,这在币圈里也算是一场大事件了。

那么漏洞主要出在什么地方呢?主要是 BEC 在标准的 ERC20 接口之外,自己添加了一个 batchTransfer 接口。一般而言,添加这种接口主要是为了便于空投时批量转账,可是 BEC 在这个接口设计上犯了两个错误:

  1. 没有使用安全的数值计算,存在数值溢出漏洞。这也是在网上被广泛传播的漏洞分析。
  2. 没有限定 batchTransfer 的使用范围。如果它限制了 batchTransfer 只能合约拥有者调用,即使存在漏洞也不能被人利用了。

BEC 为了便于空投增加的这个接口可谓代价惨重,但如果他们知道还有不需要修改代币主合约就能批量转账的办法,不知道会不会吐一口老血?

批量转账,指的是在一笔 ETH 交易中转多笔代币到不同的账户,一般用于 ERC20 代币项目启动时对用户进行空投(有人叫糖果发放)。

批量空投的好处主要有两个,一是省 GAS 费,但事实上省得不多;二是省时间,这是最主要目的。以太坊是以交易为粒度打包,如果转账只能单对单,即使一次发起多笔单对单的交易,等待这些交易被打包的时间也非常漫长,而且还有笔数上限限制。将多笔转账放到同一个交易中,被打包确认的速度就会非常快。一般 ERC20 代币项目启动时都会大撒币,空投地址动辄都是几万几十万,批量空投接口对效率会有上百倍的提升。

空投合约基本原理:ERC20 可以通过 approve 和 transferFrom 两个接口授权其它地址一定的额度。那既然是这样,我们也可以授权一个合约地址来花自己的代币,如果这个合约支持批量转账,那么就可以通过这个合约来实现批量空投了。

下面是具体实操流程:

假设已经存在一个 ERC20 代币的合约,合约地址为“TOKEN_ADDR”,而你的钱包里已经有了 100 万 TOKEN,你的钱包地址是“WALLET_ADDR”。

STEP1: 用自己的钱包部署支持批量转账的空投合约,假设创建成功后地址为“AD_ADDR”。下面给出最关键的部分,完整合约参考 github 链接 。这里 transferFrom 取的是 msg.sender,理论上来讲不加 onlyOwner 限定这个合约也可以给其它人使用,但为了安全还是加上较为稳妥。

contract Airdropper is Ownable {
    function multisend(address _tokenAddr, address[] dests,
                       uint256[] values) public onlyOwner returns (uint256) {
        uint256 i = 0;
        while (i < dests.length) {
           ERC20(_tokenAddr).transferFrom(msg.sender, dests[i], values[i]);
           i += 1;
        }
        return(i);
    }
}

STEP2: 用自己的钱包授权空投合约地址 AD_ADDR 100 万 TOKEN 额度。即通过代码或者 remix 执行 approve(AD_ADDR, 1000000*精度),注意这是 ERC20 合约里的接口,需要将交易发往 TOKEN_ADDR。

STEP3: 检查 AD_ADDR 是否得到了 100 万 TOKEN 授权。通过代码或者 remix 执行 allowance(WALLET_ADDR, AD_ADDR),如果结果是 100 万 TOKEN,说明空投合约已经得到你的 100万额度授权。

STEP4: 用自己的钱包调用空投合约的 multisend 接口发起批量空投。通过代码或者 remix 执行 multisend(TOKEN_ADDR, [addr1, addr2, ...], [value1, value2, ...]),执行成功即能实现批量转账。这是空投合约里的接口,需要将交易发往 AD_ADDR。

这里最容易混淆的是几个地址,TOKEN_ADDR/WALLET_ADDR/AD_ADDR,在每一步操作中要想明白调用的是哪个合约的接口,参数应该填哪个地址。 第一步只需要操作一次,第四步可以操作很多次,第二步可根据需求随时调整授权额度。

有了这个空投合约,你就可以将自己钱包里任意类型代币都通过批量转账方式空投出去。额度可控,任意账户均可用,还避免了在代币主合约里额外增加非 ERC20 标准接口带来的风险。

为什么两笔 Token 转账消耗 GAS 不同

大家都知道,以太坊 Token 转账的过程就是智能合约执行的过程,所以每笔交易都会根据智能合约的执行情况消耗一定数量的 GAS 作为交易手续费,同时也限制了交易对资源的滥用。

大家都能接受交易有手续费的概念,因为银行转账,或者支付宝微信转账,也都有可能产生手续费。大部分情况下,手续费是按照金额一定比例收取的。但会出乎很多人意料的是,在以太坊上即使往同一个地址里转账同一种 Token,消耗的 GAS 也有可能不同。

随便在 etherscan.io 上找一个近期有多笔 Token 交易的地址(其实找了好一会儿),比如这个 : https://etherscan.io/address/0x5debb351b536eb1a61be12810abe614485167f46#tokentxns

可以看到近期正好有两笔 Rating Token 转入到这个账户,对比下 GAS 的消耗:

可以看到两笔转账消耗的 GAS 分别是 37434 和 22434,差了 15000。这就奇怪了,第一笔转账的 Token 金额比第二笔少,但是消耗的 GAS 反而更多,这完全不合理啊!

其实这种现象的解释也很简单,通过对比两笔交易的 VMTRACE 可以发现,最大的差异出现在一条指令上:“SSTORE”。第一笔交易的 SSTORE 消耗了 20000 GAS,第二笔交易只消耗了 5000 GAS。

这时候只好去查文档了,以太坊黄皮书 https://ethereum.github.io/yellowpaper/paper.pdf 附录G:FEE SCHEDULE。发现下面这段说明:

当 SSTORE 将存储的值从 0 改成非 0 时,消耗 20000 GAS,其它情况下消耗 5000 GAS。真相大白,不是以太坊乱收费,文档就是这么写的。

虽然明白了原理,但这的确颠覆了我们的认知,转账的 GAS 费用居然跟对方的账户余额有关!

不过这也解释了一件事:为什么我们在 etherscan 上经常能看到那么多 *.9999999 的转账?可能很多交易所或者黑客早就弄明白了这件事,故意留一点点金额在账户里,减少未来转账的 GAS 手续费。

JS Bridge API - 安卓和iOS统一设计探讨

1 背景

APP 开发过程中,为了追求开发效率、更新成本、性能和交互体验的平衡,经常会采取 Hybrid 的 APP 端架构。用基于 HTML5 的 WEB APP 实现易变的业务部分,用原生代码实现对效率、权限、数据交换等有要求的功能部分,然后通过 JS Bridge 打通两者,实现 JS 与 原生代码的相互调用,完成整个产品功能。

但谈到 APP 开发,大家都知道至少存在两个平台,那就是 Android 和 iOS。这两个系统采取不同的原生开发语言,也有不同的 Webview 浏览器环境。但 WEB APP 是跨平台的,所以跨浏览器的调用总归需要在一个层面上得到统一,这样才不需要专门针对两个平台开发不同的 WEB APP。

下面先对在目前的技术框架下有哪些 JS - NA 相互调用方式做一下综合介绍,然后基于上述技术提出几种跨平台 JS Bridge API 统一设计思路,最后扩展讨论下 JS Bridge 设计中的一些值得注意的点。

2 在原生代码中调用 JS 代码

2.1 Android Platform

loadUrl 方法

Android Webview 的 loadUrl 接口,可以直接在 Java 代码中执行 Javascript 脚本。在 API 23(Android 6.0)及之前,这里的 Javascript 脚本能够获取当前加载页面的变量,甚至执行当前加载页面里定义好的函数。也就是说,传入的 JS 脚本是在当前加载页面的上下文中执行的。

    // Javascript: 在网页内定义一个函数,显示一个消息,返回一个内容
    function propose(msg) {
      alert(msg);
      return "Yes!";
    }

    // Java: 执行当前加载页面中定义好的一个函数 propose()
    webView.loadUrl("javascript:propose('Will you merry me?');");

可惜的是,这种方法:

  • 只能执行 JS,无法获取返回结果,需要用其它的方式(下文介绍)获取返回结果;
  • 而且会触发一次页面的刷新,可能会导致焦点丢失,软键盘消失之类的问题;
  • 在 Android 7.0 以后,存在兼容性问题;

evaluateJavascript 方法

不过,如果 APP 适配的版本在 API 19(Android 4.4)以后,也可以使用 Webview 的 evaluateJavascript 接口 。这也是更为推荐的做法,因为避免了上面 loadUrl 的问题。

    // Java: 执行当前加载页面中定义好的一个函数 propose()
    webView.evaluateJavascript("propose('Will you merry me?')", new ValueCallback() {
      @Override
      public void onReceiveValue(String answer) {
        // 拿到 answer 是 "Yes!"
      }
    });

间接方法:Web Event 分发

这种方法很少有人提到,因为它是一种间接的调用方法。Web Event 接口提供了一种在 DOM 里进行广播的机制,那也就意味着原生代码可以不知晓 JS 的函数名,而只是广播一个事件,由页面内的 JS 决定是否处理这个 Event。这能够避免 JS 代码执行的异常,更常用于原生代码主动通知页面某些信息更新的场景。

    // Javascript: 在网页内定义一个函数,显示一个消息,返回一个内容
    function propose(e) {
      alert(e.msg);
      return "Yes!";
    }
    // 注册 WebDomEvent handler
    window.addEventListener("propose_event", propose);
    // Java: 
    webView.evaluateJavascript("var e=new Event('propose_event'); e.msg='Will you merry me?'; window.dispatchEvent(e);", new ValueCallback() {
      @Override
      public void onReceiveValue(String answer) {
        // nothing
      }
    });

这种方法也存在无法获取返回结果的问题,也需要用其它的方式(下文介绍)获取返回结果。不过在使用到 Event 通知的场景下,我们一般也不需要返回。

2.2 iOS Platform

讲到 iOS,必须提到两个不同的 WebView,一个是过时但广泛使用的 UIWebView,另一个是建议且逐渐流行的 WKWebView。

UIWebView: stringByEvaluatingJavaScriptFromString 方法

UIWebView 提供了 stringByEvaluatingJavaScriptFromString 接口 ,并且能够获得返回结果。

    // OC: 执行当前加载页面中定义好的一个函数 propose()
    [_webView stringByEvaluatingJavaScriptFromString:@"propose('Will you merry me?')"];

这个方法的主要问题在于,它是一个同步的方法。它可能会阻塞 UI 线程,不太适合执行复杂的调用。

UIWebView: JavaScriptCore

在 iOS 7 之后,苹果提供了一个获取 UIWebView 中 JSContext 的方法,直接将 JS 执行环境暴露给原生代码。这样就可以在原生代码中任意执行 JS 代码了。同时,这个接口也可以用于 JS 调用原生代码的能力,下文中会介绍。

    // OC: 获取 JSContext 
    JSContext *context = [_webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"]
    [context evaluateScript:@"propose('Will you merry me?')"];

WKWebView: evaluateJavaScript 方法

可以看到,JavaScriptCore 使用起来极其方便,但在 WKWebView 中我们享受不到这种方便了。因为 WKWebView 的页面渲染是在独立的进程中,在当前进程无法直接拿到 JSContext。

不过 WKWebView 提供了一个更好的 evaluateJavaScript 接口 ,可以传入一个回调函数,实现了 JS 的异步调用。

    // OC: 执行当前加载页面中定义好的一个函数 propose() 
    [_webView evaluateJavaScript:@"propose('Will you merry me?')" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
      // 拿到 result 是 "Yes!", error 是 nil
    }];

可以看到,evaluateJavaScript 接口与上文 Android evaluateJavaScript 接口极为类似。

间接方法:Web Event 分发

当然,由于 Event 接口是 WEB 标准,iOS 上也可以同样进行 Event 分发。场景和作用请看上文,不再赘述,简单代码如下:

    // Javascript: 在网页内定义一个函数,显示一个消息,返回一个内容
    function propose(e) {
      alert(e.msg);
      return "Yes!";
    }
    // 注册 WebDomEvent handler
    window.addEventListener("propose_event", propose);
    // OC: 执行当前加载页面中定义好的一个函数 propose() 
    [_webView evaluateJavaScript:@"var e=new Event('propose_event'); e.msg='Will you merry me?'; window.dispatchEvent(e);" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
      // nothing
    }];

3 在 JS 代码中调用原生代码

3.1 Android Platform

addJavascriptInterface 方法

Android 从 API 1 就开始提供了 addJavascriptInterface 接口,用这个接口可以很方便地把原生的方法注入到 JS 上下文中,可以说比 iOS 做得好很多。

    // Java: 定义一个类,提供一个接口,返回一个内容
    class NativeApis {
      @JavascriptInterface
      public String propose(String msg) {
        return "Yes!";
      }
    }
    webView.addJavascriptInterface(new NativeApis(), "Bridge");
    // Javascript: 执行一个 native 的方法
    alert(window.Bridge.propose("Will you merry me?"));

但问题在于在 API 17 (Android 4.2) 之前这个方法存在安全漏洞,攻击者可以执行任意代码。在 API 17 及以后,通过显式地给出 @JavascriptInterface 限定暴露的接口,避免了安全漏洞。但在 API 17 以前,不建议使用此方法,可以考虑下面的 work around。

URL 拦截:shouldOverrideUrlLoading

这是一种曲线救国的方式,那就是通过加载非标准 Scheme( 非 http/s, 非 ftp)的 URL,用一个非法(或者叫自定义)的 URL 传递参数。当页面中的 Javascript 动态插入一个 iframe 元素时,iframe 的 url 会被 WebView 通过 shouldOverrideUrlLoading 方法传给 WebViewClient 判断是否需要加载该 URL。在这里可以拦截自定义的 URL Scheme,通过 encode 到 URL 中的信息传递参数。

    // Java: 解析要加载的 URL,如果是自定义 scheme,调用相关的函数
    class MyWebViewClient extends WebViewClient {
      @Override
      public boolean shouldOverrideUrlLoading(WebView view, String url) {
        if (url.startsWith("bridge://")) {
          // 解析 // 后面的 action 和参数,调用相关的函数
        }
        return true;
      }
    }
    webView.setWebViewClient(new MyWebViewClient());
    // Javascript: 用不可见 iframe 打开一个自定义 URL,参数需要 urlencode
    bridgeFrame = document.createElement('iframe');
    bridgeFrame.style.display = 'none';
    bridgeFrame.src = 'bridge://propose?msg=Will%20you%20merry%20me%3F';
    document.documentElement.appendChild(bridgeFrame);

URL 拦截的问题也是无法直接拿到原生代码的返回结果,需要用 URL 字符串参数传入一个回调函数,然后用上文讲到的原生代码调用 JS 的方式回调传回结果。

弹出框拦截

Android Webview 可以定义一些接口,重载 onJsAlert()、onJsConfirm()、onJsPrompt() 这些回调方法。当 JS 控制弹出框时,这些回调会被调用,进而可以通过约定的特殊内容格式判断是真正的弹出框,还是 JS 到 NA 的调用。由于 onJsPrompt 可以返回结果,所以更合适一些。

    // Java: 重载 onJsPrompt 方法,提取 prompt 内容判断是否需要拦截
    class MyWebViewClient extends WebChromeClient {
      @Override
      public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
        if (message.startsWith("bridge://")) {
          // 解析 // 后面的 action 和参数,调用相关的函数
          result.confirm("Yes!");
        }
        return true;
      }
    }
    webView.setWebViewClient(new MyWebViewClient());
    // Javascript: 调用 prompt 弹框,使用特定内容格式以利于拦截
    alert(window.prompt('bridge://propose?msg=Will%20you%20merry%20me%3F'));

Local Server

APP 可以在手机的本地地址 127.0.0.1 上启动一个 HTTP/WebSocket 服务,浏览器内的 JS 可以通过本地回环网络连接到这个服务,把 APP 视为一个服务端,进行正常的 B/S 通信,也可以实现在 JS 中调用原生代码。

使用这种方式时,额外注意一点是要进行有效地鉴权。因为除了 APP 内的 WebView,手机内其它的 APP 也可以访问这个服务,很可能会造成一些安全问题。所以可能需要 NA 在加载 Webview 的时候,通过 Cookie/URL参数/JS 上下文环境传入合法的 Token,才能保证其安全性。

还有一点是,如果不幸出现了端口冲突,需要有办法去解决。

3.2 iOS Platform

URL 拦截:shouldStartLoadWithRequest

UIWebView 原生并没有提供任何可以在 JS 代码中调用 NA 方法的 API,但 UIWebView 也可以通过与 Android 相同的方式进行 URL 拦截,进而间接实现 JS 到 NA 的传参。

    // UIWebView
    - (BOOL)webView:(UIWebView *)webView 
    shouldStartLoadWithRequest:(NSURLRequest *)request 
     navigationType:(UIWebViewNavigationType)navigationType;

这个方式在 WKWebView 上,依然有效,只是叫做 decidePolicyForNavigationAction

    - (void)webView:(WKWebView *)webView 
    decidePolicyForNavigationAction:(WKNavigationAction *)navigationAction 
    decisionHandler:(void (^)(WKNavigationActionPolicy))decisionHandler;

UIWebview: JavaScriptCore

大概苹果官方也觉得这种方式太 ugly,所以后来在 iOS 7 以后,提供了一个好一些的接口,就是 JavaScriptCore。在页面加载完后,可以获取当前加载页面的 JavaScript 上下文执行环境 JSContext。然后可以把一些原生方法注入到 JSConext 中,这样页面内的 JS 就可以直接调用到这些注入的方法了。

    // OC: 获取 JSContext,将原生方法注入进去
    JSContext *context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
    context[@"propose"] = ^(msg) {
      return @"Yes!";
    };
    // Javascript: 调用 prompt 弹框,使用特定内容格式以利于拦截
    alert(window.propose('Will you merry me?'));

WKWebView: WKScriptMessageHandler 方法

然后到了 WKWebView,JSContext 不好使了。不过 WKWebView 提供了另外一个方法,那就是 WKScriptMessageHandler。在创建一个 WKWebView 的时候,可以通过配置将一个 WKScriptMessageHandler 对象指针和 NAME 传进去。这样在加载页面中,通过 window.webkit.messageHandlers.NAME.postMessage 就可以将消息传给原生的 WKScriptMessageHandler 对象。

    // OC: 编写 Message 回调,并注册 Message Handler
    @interface Brige : NSObject 
    - (void)userContentController:(WKUserContentController *)userContentController
          didReceiveScriptMessage:(WKScriptMessage *)message {
      if ([message.name isEqualToString:@"Bridge"]) {
        // 处理 message
      }
    }
    ...
    _bridge = [[Brige alloc] init];
    [[_webView configuration].userContentController addScriptMessageHandler:_bridge name:@"Bridge"];
    // Javascript: 发消息给注入的 Message Handler
    window.webkit.messageHandlers.Bridge.postMessage("Will you merry me?");

WKScriptMessageHandler 同样也是无法直接返回结果。

WKWebView: 弹出框拦截

与 Android 类似,WKWebView 也提供了弹出框的回调函数,可以通过此类函数实现参数的传递。

    // WKUIDelegate
    - (void)webView:(WKWebView *)webView 
    runJavaScriptAlertPanelWithMessage:(NSString *)message 
    initiatedByFrame:(WKFrameInfo *)frame 
    completionHandler:(void (^)(void))completionHandler;
    
    - (void)webView:(WKWebView *)webView 
    runJavaScriptConfirmPanelWithMessage:(NSString *)message 
    initiatedByFrame:(WKFrameInfo *)frame 
    completionHandler:(void (^)(BOOL result))completionHandler;
    
    - (void)webView:(WKWebView *)webView 
    runJavaScriptTextInputPanelWithPrompt:(NSString *)prompt 
        defaultText:(NSString *)defaultText 
    initiatedByFrame:(WKFrameInfo *)frame 
    completionHandler:(void (^)(NSString *result))completionHandler;

Local Server

见上文中对 Android Local Server 调用方式的讨论。

4 notify-fetch-run 间接机制

上文中讲到的很多还是较为直接的 JS-NA 相互调用方法,其实还有一些更开脑洞的方法。比如 notify-fetch-run 机制,不需要直接传递参数或者代码,只需要传递一个信号,然后通过可以共同访问的第三方传递真正的参数,进行执行。

4.1 notify

如果仅仅把相互调用简化成一个 0/1 信号,那除了上面讲到的内容,还有太多东西可以做为信号。比如 event,比如通过远程服务器通知之类,下面讲一个比较奇葩的方法:

notify 中的奇葩:online/offline event

HTML5 中有一对标准的 event,叫做 online/offline,可以反应当前浏览器的联网状况。而 WebView 呢,可以通过 webView.setNetworkAvailable() 来控制联网状态。那也就意味着,原生代码只要控制 webView 的联网状态变化,就可以发送 0/1 信号给 JS。JS 收到 0/1 信号后,可以通过下文 JS 调用原生的方式获取原生代码要传入的内容,然后执行这些内容。

这种方式最大的问题在于,需要非常精巧地设计整个状态流转。因为传入的信号信息量非常少,而且正常情况下网络状况的变化也会触发这两个 event。

4.2 fetch

fetch 也可以有很多种,只要是 JS 和 NA 都能访问到的目标,都可以做第三方信息交换。比如本地 socket,远端网站,或者本地文件 file://,或者 cookie,localstorage。

5 安卓 & iOS 统一 API

我们讨论 Android & iOS API 的统一,主要是在 JS 里的统一,因为只有 JS 是跨平台的。统一 API 有两种实现方法:

  • 一种是通过封装的统一,就是说 JS 与原生代码的底层通信方式是不同的,但通过一个嵌入 WebView 的 JS 库实现 API 的统一。
  • 另一种是无需封装的统一,也就是在底层通信的接口就保持了统一,在两端的 JS 代码上是完全一致的。

5.1 JS 调用原生代码

URL 拦截(Android & iOS)

从上文介绍的方法就可以直接看出,通过 URL 拦截实现 JS 调用原生代码是统一适用于所有平台的方法,而且没有版本限制。所以很多 JSBridge 都使用了这种方法以做到最大的兼容性。

    // Android Java: 解析要加载的 URL,如果是自定义 scheme,调用相关的函数
    class MyWebViewClient extends WebViewClient {
      @Override
      public boolean shouldOverrideUrlLoading(WebView view, String url) {
        Uri uri = Uri.parse(url);
        // FIXME 异常处理
        if (uri.getScheme().contentEquals("bridge")) {
          if (uri.getAuthority().contentEquals("propose")) {
            view.evaluateJavascript(uri.getQueryParameter("callback") + "('Yes!')", null);
          }
        } else {
          view.loadUrl(url);
        }
        return true;
      }
    }
    webView.setWebViewClient(new MyWebViewClient());
    // iOS OC: 解析要加载的 URL,如果是自定义 scheme,调用相关的函数
    - (BOOL)webView:(UIWebView*)webView shouldStartLoadWithRequest:(NSURLRequest*)request navigationType:(UIWebViewNavigationType)navigationType {
      NSURL * url = [request URL];
      if ([[url scheme] isEqualToString:@"bridge"] && [[url host] isEqualToString:@"propose"]) {
        NSArray *params =[url.query componentsSeparatedByString:@"&"];
        for (NSString *paramStr in params) {
          if ([paramStr hasPrefix:@"callback"]) {
            NSArray *kv = [paramStr componentsSeparatedByString:@"="];
            [webView stringByEvaluatingJavaScriptFromString:[kv[1] stringByAppendingString: @"('Yes!')"]];
          }
        }
        return NO;
      }
      return YES;
    }
    // 统一的 Javascript: 用不可见 iframe 打开一个自定义 URL,参数需要 urlencode
    bridgeFrame = document.createElement('iframe');
    bridgeFrame.style.display = 'none';
    bridgeFrame.src = 'bridge://propose?msg=Will%20you%20merry%20me%3F&callback=showResult';
    document.documentElement.appendChild(bridgeFrame);

这种方法的问题:

  1. 对 URL 格式有 UrlEncode 的要求,对于要传递复杂参数的情况不友好。比如我们需要在参数中传递一个正常的 URL,就需要对这个参数进行两次 UrlEncode,才能保证解码不出问题。
  2. 通过 iframe 打开 URL 的方式不太直观,也缺少调用成功的返回确认,需要在 JS 端再封装一下。

对象植入(Android & iOS UIWebView)

放宽兼容性限制,Android 不再兼容 4.1 及以前版本,iOS 不再兼容 iOS 6 及以前版本。那就可以直接通过 Android 的 addJavascriptInterface 和 iOS 的 JSContext 实现将要调用的方法以对象的方式注入到 JS 上下文中,同时也可以直接获得返回结果。

    // Android Java: 定义一个类,提供一个接口,返回一个内容
    class NativeApis {
      @JavascriptInterface
      public String propose(String msg) {
        return "Yes!";
      }
    };
    webView.addJavascriptInterface(new NativeApis(), "Bridge");
    // iOS OC: 定义一个类,提供一个接口,返回一个内容
    // *.h 
    #import 
    @protocol BrigeProtocol 
    - (NSString *)propose:(NSString *)msg;
    @end
    
    @interface Bridge : NSObject
    @end
    // *.m
    // 永远返回 Yes
    @implementation Bridge
    - (NSString *)propose:(NSString *)msg {
      return @"Yes!";
    }
    @end
    ...
      // 注意生命周期
      bridge = [[Bridge alloc] init];
    ...
      JSContext *context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
      context[@"Bridge"] = bridge;
    // 统一的Javascript: 执行一个 native 的方法
    showResult(window.Bridge.propose("Will you merry me?"));

对象植入(Android & iOS WKWebView)

如果使用 WKWebView,那就意味着进一步放宽了兼容性限制,因为 WKWebView 不支持 iOS 7 及以前版本。上文说到,WKWebView 不支持 JavaScriptCore,但提供了一个 WKScriptMessageHandler 方法。这也意味着我们只能将调用方式尽量往 WKWebView 的方式上统一。

WKWebView 注入的对象,只能使用 postMessage 接口,而且是注入到 window.webkit.messageHandlers 。虽然 Android 的 addJavascriptInterface 不能实现属性的注入,也就是说我们无法在原生代码中在 JS 上下文中添加一个 window.webkit.messageHandlers.NAME 这样一个对象。但我们可以在 WKWebView 中通过 addUserScript 注册一个加载页面时就执行的脚本,将 window.webkit.messageHandlers.NAME 赋给 window.NAME,就实现在注入对象层面的统一。即 Android 和 iOS 里的 Brige 对象都注入到了 window 下。

然后 Android addJavascriptInterface 注入的对象也实现一个与 WKWebView 类似的 postMessage 接口,那么两端就实现了底层接口上的统一。

    // Android Java: 定义一个类似于 WKScriptMessageHandler 的类
    class NativeApis {
      private WebView mWebView;
      public NativeApis(WebView webview) {
        mWebView = webview;
      }
      @JavascriptInterface
      public void postMessage(String msg) {
        try {
          JSONObject json_obj = new JSONObject(msg);
          final String callback = json_obj.getString("callback");
          // JS 是异步线程,转到 UI 线程执行 JS
          mWebView.post(new Runnable() {
            @Override
            public void run() {
              mWebView.evaluateJavascript( callback + "('Yes!')", null);
            }
          });
        } catch (JSONException e) {
          Log.i("Bridge", "postMessage: " + e.getMessage());
        }
      }
    };
    // 初始化 NativeApis 时多一个 webView 句柄
    webView.addJavascriptInterface(new NativeApis(webView), "Bridge");
    // iOS OC: 定义 WKScriptMessageHandler 处理接口
    - (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
      // 解析 JSON,调用 callback 返回数据
      NSData *jsonData = [message.body dataUsingEncoding:NSUTF8StringEncoding];
      NSDictionary * msgBody = [NSJSONSerialization JSONObjectWithData:jsonData options:kNilOptions error:nil];
      NSString *callback = [msgBody objectForKey:@"callback"];
      [message.webView evaluateJavaScript: [NSString stringWithFormat:@"%@('Yes!')",
                                              callback] completionHandler:^(id _Nullable result, NSError * _Nullable error) {
        // FIXME 出错处理
      }];
    }
    ...
    [[_webView configuration].userContentController addScriptMessageHandler:self name:@"Bridge"];
    // 将 window.webkit.messageHandlers.Bridge 改名成 window.Bridge 与 Android 统一
    WKUserScript* userScript = [[WKUserScript alloc]initWithSource:@"if (typeof window.webkit != 'undefined' && typeof window.webkit.messageHandlers.Bridge != 'undefined') { window.Bridge = window.webkit.messageHandlers.Bridge;}" injectionTime:WKUserScriptInjectionTimeAtDocumentStart forMainFrameOnly:YES];
    
    [[_webView configuration].userContentController addUserScript:userScript];
    // 统一的Javascript: 给 Native 发送一个消息,通过回调返回结果
    message = {
      func: "propose",
      options : {
        msg: "Will you merry me?"
      },
      callback: showResult.name
    };
    window.Bridge.postMessage(JSON.stringify(message));

5.2 原生代码调用 JS

JS 调用原生代码,主要目的是为了增强 JS 的能力。而原生代码调用 JS 大部分情况下主要是为了便捷 JS 的调用,这可以分为三种情况:

  1. 主动设置上下文。每次加载页面必须执行一些 setup,将一些 JS 环境设置好,不需要每次都从服务器端获取。比如上文中讲到的 addUserScript 添加一个加载页面时的上下文环境。
  2. 主动发起与 JS 交互。在某些比较少见的场合下,原生代码可能想要主动将一些信息通知给 JS,尤其是一些不在官方 HTML5 支持能力的事件,比如语音的输入、扫码的结果、调用失败等等。
  3. 最常见的,是被动的回调 JS。也就是 JS 发起了一个调用,由于调用方式的限制无法返回,或者需要较长时间才能拿到结果,这就需要原生代码在执行完调用后通过回调回传给 JS。

主动设置上下文不需要 API 的统一。

主动发起与 JS 的交互场景比较少,可以有两种方法实现:一种是页面加载过程中将回调注册给 NA;另一种是通过 Web Event 的方式由 NA 广播给 JS 上下文。我们更建议通过 Web Event 的方式广播,这样不受页面加载状态之类的限制,交互上更简单。当然,也可以两种方法结合,增加一个 Event 到 NA 的注册,保证有效广播。

被动的回调 JS,实现上比较直观,只要在 JS 调用 NA 的接口中增加一个 callback 参数,NA 在完成之后回调记录下来的接口即可。

6 JS Bridge 设计上的更多考虑

6.1 是否使用第三方 JS Bridge 库

使用第三方 JS Bridge 库,理论上能避免很多烦恼,按照它的 step by step 指引,很容易就能配出来一个可以工作的 JS Bridge 环境。

但第三方库也有一些缺点。前面讲到,第三方库为了易用,往往在 NA 层和 JS 层都会做一套新的 Adapter API 封装,但不好意思的是,它提供的仍然是一套通用 API 封装,往往应用方还得在上面再封装一层业务 API。这也就意味着,每次 JS-NA 的调用,需要走下面的一套流程:

Created with Raphaël 2.2.0业务函数: proposeJS Adapter: 比如序列化参数等前面讲到的某种 Bridge 方法:比如 URL 拦截NA Adapter:比如某种路由机制原生函数

中间的三层是由第三方库实现的。如果不熟悉第三方库的代码,或者说第三方库在这三层做了过重的封装,那调试问题就会非常困难。

我上文讲到无需二次封装的统一 API,就是希望通过选取合适的 Bridge 方法,把 JS Adapter 这一层去掉或者让它尽量地薄。这样整个调用过程能得到充分地简化,更便于问题的追查和整体的设计。

第三方库还有一个问题就是,它往往追求大而全。比如有些第三方库就是想非常完整地支持 Hybrid App 的设计,但很多时候我们往往仅需要有限个接口调用而已。为了实现有限地一些功能,还得去了解第三方库的整体设计,有时候代价也高了些。

6.2 参数约束

由于 Javascript 是弱类型的语言,而 Java 和 OC 都是强类型的,在参数的互相传递时,需要进行严格的检查。虽说 addJavascriptInterface 等方法可以动态地注入无数个对象或者方法,但仍然不建议这样做,因为维护成本太高。就像 URL 拦截一样,搭桥的路有一条就足够了。

JS Bridge 的接口,就像是一个 RPC 协议。这个 RPC 协议需要有一个版本,这样我们知道哪些版本有哪些 API,更利于有效地调用。这个 RPC 协议需要约定哪些固定的字段,这样我们可以用在入口统一校验字段是否完整,字段类型是否可用。

6.3 出错信息

跨平台的接口,很多时候 DEBUG 比较困难,尤其是上文讲到一些方式无法直接返回结果,自然也无法直接返回错误。所以在接口上,要尽量考虑出错时错误信息的回传通道,例如接口需要提供出错的 callback。

那么问题来了,如果 callback 参数也写错了怎么办?总不能让 FE 看 APP 的 log 吧?

所以建议在接口设计上,增加一个全局错误的 Web Event,就像 Linux 系统下的 errno。任何 JS 调用 NA 失败或者回调失败,都通过这个 Event 分发出去,这样前端就很容易知道错在哪里了。

6.4 API 安全性

虽然网页是在 APP 自己的 WebView 中打开的,但因为网页天然具有的超链接性质,也很难保证所有可以点开的页面都是可信的,比如有些时候活动的落地页可能会到第三方页面等。所以对一些影响 APP 运行逻辑的关键 API 接口,需要做站点的白名单控制,避免第三方站点调用此类 API。

7 总结

这篇文章列举了可用于 JS Bridge 的各平台技术实现,建议了几种无需二次封装的 Android & iOS 平台 JS Bridge 统一 API 的可选方案,讨论了设计一个简洁、规范、安全的 JS Bridge API 需要考虑的问题和解决思路。希望对读者能有所助益。

神经网络图像输入的 resize 问题

这个标题在我的博客后台躺了快一年了,但一直没想好该怎么写。主要是没有深入地去研究这里面的问题,就随便谈谈一点粗浅的认知吧。这些认知可能不对,仅供参考,并且欢迎批评。

一、不同的 resize 方式对最终的结果有一定的影响,尤其是用随机图片评估时会更加明显。

看似用的是同一个神经网络,同一个训练集,但在输入的处理上仍然会有各种不同。比如 Inception 要求 299x299,你可以直接用 ImageMagick 将原始图片处理成 299x299 再输入,也可以用 OpenCV 读入图片后再转成 299x299,还可以直接用深度学习框架(TensorFlow/Caffe)进行 resize。甚至同一种方法也可能有各种不同的参数控制,比如最邻近插值、双线性插值、双立方插值等。通过不同的 resize 方法训练出来的网络参数,或者同一张图片不同方法 resize 后预测的输出,数值是存在差异的。如果使用的是质量较低的大规模数据集,差异可能会非常明显。

二、不同的 resize 方式对最终结果的影响无法确定。

换种说法,这可能是个玄学。这算是一个经验总结,就不多讲了。也就是说,某种 resize 方式有时可能让结果变好,有时也可能让结果变差。

三、训练、评估和线上预测时统一图片处理方式有一些好处。

有的公司在训练神经网络时使用一种框架,上线时使用另一种框架;或者训练时采取一种输入,上线时采取另一种输入。都会导致线上服务的预测结果跟评估结果不一致,导致排查问题较为复杂。

有时候为了性能考虑,必须在客户端完成图片处理,resize 成较小图片后再传给服务端。而客户端往往使用的不同的库,比如 iOS 可以使用 Core Graphics 库或者 UIKit 库,Android 的 Bitmap 库,这些库在服务端是基本上无法使用的。这时候就需要知道这可能会导致线上效果与评估结果有不一致的可能,并且采取一定的措施来消减这样的不同。

react-native-navigation 简单分析和跨页跳转

虽然 react-native-navigation 是 Facebook React Native 官方文档推荐的导航库之一 ,但我也不得不说使用它做 APP 导航主框架的体验简直糟糕透了。当然,这本身可能就是 React Native 自身的问题。

1 react-native-navigation 简单分析

使用 react-native-navigation 首先得理解下它的实现。它独立于 RN Component 的 componentWillMount/componentWillUnmount 接口实现了一套自己的事件机制,最重要的可能是 willAppear/willDisappear。它提供了一套页面堆栈操作和切换动画, push 可以将目标页面切换到最上方,pop 可以返回上一页。

可能是为了性能或者设计使然,push 的时候不会销毁当前页。也就是说,在 A 页面里 push 跳转到B 页面,不会 Unmount A 页面的Component。 不过在 B 页面 pop 回 A 页面时,的确会 Unmount B 页面的Component。这也意味着,整个导航路径是一个页面堆栈,只要在堆栈里页面的 Component,都不会被 Unmount。

2 页面堆栈的问题

这有时候会导致一些很严重的问题。有些情况下,特定的 Component 可能会占用唯一的系统资源,比如:麦克风、照相机等。这些 Component 在实现的时候往往只考虑了 React Native 的接口,在 componentWillUnmount 的时候释放占用的资源。它们不会预料到与 react-native-navigation 的结合,专门提供一个 willDisappear 时释放资源的接口,而且有些情况下也未必能这样做。

如果 A 页面在使用这些 Component 已经占用了麦克风或者相机,B 页面也要使用这些 Component,那么从 A push 跳转到 B 时,A 页面的资源不会被释放,B 页面就可能会遇到麦克风不可用,或者相机无法初始化等问题。

解决这个问题,最简单的办法是调整页面交互顺序,保证使用这些独占系统资源的页面永远在堆栈的最顶端,或者使用 Modal Stack,把独占资源的 Component 放到 Modal 里去 present 然后 dismiss。

3 跨页跳转实现

react-native-navigation 只能支持页面堆栈,而且看起来只能支持 push/pop 一个页面,也就是说整个切换过程是串行的,push 顺序是 A->B->A->D ,那么 pop 顺序也只能是 D->A->B->A。

但很可惜地是,在产品经理眼中,是不存在串行页面切换这种限制的。TA 们有时候要求跳转的过程中没 A,但返回的时候要有 A;或者要求跳转的过程中有 A,但返回的时候可以跳过 A,或者甚至直接返回到堆栈最底端。

直接返回栈底很容易,react-native-navigation 提供了 popToRoot 接口,但它没有提供一下子 push 多个页面,或者一下子 pop 多个页面的功能。它也没有类似于 HTML5 的 history API,我们直接对堆栈进行操作,是不太可能的。只能通过它现有的接口想办法。

3.1 跨页 push

跳转的过程没有 A,但返回的时候要有 A,这只是一个产品需求。在实现上,是可以变成跳转过程中有 A,但是 A 被快速跳过,返回的时候才会被真正渲染。这样从用户体验上来看,并没有看到 A。代码实现上,可以考虑两种方法:

willAppear 结合 didDisappear 做状态控制

在 A 的 state 里放一个 isFirstEntry 状态,默认是 truewillAppear 里判断 isFirstEntry 则直接跳转到下个页面,render 里判断 isFirstEntry 则只渲染一个背景 View ,否则才渲染正常页面。这样就实现了在页面切换过程中跳过 A。在 的 didDisappear 里将 isFirstEntry 置为 false 。这样在返回的时候 willAppearrender 表现就和正常返回一样了。

  willAppear = () => {
    if (this.state.isFirstEntry) {
      this.props.navigator.push(...);
      return;
    }
    ...
  };
  render() {
    if (this.state.isFirstEntry) {
      // 返回背景 View
    } else {
      // 返回正常 View
    }
  }
  didDisappear = () => {
    this.setState({isFirstEntry: false});
  };

willAppear 页面计数

在需要更复杂逻辑的地方,可以在 state 里放一个 appearTimes 计数器。在 willAppear 里给计数器加一,这样每次进入页面都会增加计数。通过判断计数器的值,来决定如何 render 或者跳转。

  willAppear = () => {
    this.setState({appearTimes: this.state.appearTimes + 1});
    if (this.state.appearTimes === 1) {
      this.props.navigator.push(...);
      return;
    }
    ...
  };
  render() {
    if (this.state.appearTimes === 1) {
      // 返回背景 View
    } else {
      // 返回正常 View
    }
  }

3.2 跨页 pop

跳转的过程中有 A,但返回的时候要跳过 A,相当于可以自己操作 pop 的步长。很遗憾,react-native-navigation 没有提供这样的接口。不过我们可以采用一个 trick 的手段,来实现这个逻辑。

假设从 Root->A->B,在 A 的 state 里放一个 relayPop ,默认是 false。 在 A 跳转到 B 时,通过 props 传入一个回调:setParentRelayPop,B 可以通过这个回调修改 A 的 state relayPoptrue; 在 A 的 willAppear 中,首先判断 relayPop 是否为真,如果是真的话,代表是从 B 返回且 B 要求接力返回,那么 A 就直接 pop 返回到 A 的上级。 B 在返回时,首先通过回调设置 relayPoptrue,然后再调用 pop 接口,就实现了跨页返回。

// Screen A
  willAppear = () => {
    if (this.state.relayPop) {
      this.props.navigator.pop();  // 接力返回
      return;
    }
    ...
  };
  ...
    // 跳转逻辑某处
    this.props.navigator.push({..., passProps: {
                                  setParentRelayPop: () => this.setState({relayPop: true}) 
                                }});
// Screen B
    // 返回逻辑某处
    this.props.setParentRelayPop();
    this.props.navigator.pop();

ES/Redis/SSDB/BRPC 的 Open-Falcon 监控脚本

前些天想监控不同机房的多个 ElasticSearch 集群,结果网上找到的监控脚本都不太好用。我希望这个脚本能够并发获取多个 ES 集群的状态,而且监控的目标和上报的地址可以通过配置文件修改,不需要去脚本中查找修改位置。

了解到 Open-Falcon 的上报接口非常简单,于是就自己写了一个同时查询多个 ES 集群信息并上传到 Open-Facon Agent 的监控脚本。能够将多个集群的索引文档数、查询请求数、查询时间等关键信息收集到 Open-Falcon 中。

用了一段时间,感觉还挺不错的。后来又头疼 Redis 内存占用太高,分析困难等问题,又以同样的思路写了 Redis 的监控脚本,都是通过 info 命令获取集群的状态,把 KEY 数量,内存占用,命令数,过期的 KEY 数量等等相关的信息都收集到了 Open-Falcon 里。这样就能通过 Open-Falcon 的报表看到 Redis 使用情况的变化。

SSDB 虽然兼容 Redis 命令,但 info 命令的返回跟 Redis 差异实在是太……大了点儿。内容不一样也就算了,格式也太随意了,用纯文本画了几个表格,真让人无力吐槽。没法复用 Redis 的监控,只能自己给 info 写个 parser,将信息提取成可用的字典。

最后说一下 BRPC。BRPC 内建了一个 HTTP 服务,把内部的各种状态用 WEB 页面的形式展示出来。关键的是又提供了一套 BVAR 机制,可以用于统计内部的各种指标,自动显示到页面上。最有意思的是,它这个内建服务会识别 User-Agent,如果请求是通过 curl 发起的,返回的是一个完全不包含任何 HTML 标签的纯文本界面,可以用 yaml 解析成字典。这样就可以用跟监控 ES 完全类似的方式,通过外部请求 BVAR 页面,获取所有状态上报监控系统了。

这四种系统的监控脚本,我已经整理放到 GitHub 上了,希望能对同样需求的朋友也有所帮助:

手机 APP 应该选用哪个加密算法 - 兼吐槽 TEA

很多 APP 产品都有通信加密的需求,一部分出于市场的要求,比如苹果对于“ATS”的强制性规定,一部分出于自身安全的考虑,比如对账号和密码的保护。这些需求大部分都可以用简单的 HTTP -> HTTPS 升级来搞定,而且几乎不用付出什么成本(除加解密的计算开支外),例如使用我之前文章介绍到的 Let's Encrypt 免费证书

但还有一类特殊的需求,HTTPS 解决不了,也就是防协议分析的需求。很多 APP 开发者应该知道,只要在手机里安装一个代理 CA 证书,就可以实现中间人攻击,通过代理软件抓到 HTTPS 包的明文内容。虽然这样的攻击很难在公开网络上进行,但对自己的手机进行抓包分析,作为 APP 和服务端通信的调试手段是被广泛使用的。

协议分析能做什么呢?可以猜想到一定的 APP 内部逻辑,可以对产品数据进行作弊攻击。举个例子:你的 APP 通过某个渠道进行推广,为了统计渠道安装、注册或者日活,你往往会在 APP 中埋一个点,当 APP 启动时,发送一些信息到服务器。如果这个协议被破解了,渠道商根本不需要真正进行推广,只需要构造一些假消息发送到你的服务器就行了。仅看数据你可能会以为这个渠道推广效果特别好,其实只是骗局而已。

这类情况下,就要求对敏感协议内容进行额外的数据保护。最常用的做法,就是对协议内容进行一次额外的加密,为了性能,往往选用对称加密算法。那么问题来了,手机 APP 开发时,应该选用哪个加密算法?

关于这个选型,国内互联网圈有个怪现状值得谈一下,那就是 TEA 算法。因为该算法在腾讯有着广泛的应用,因而被很多客户端开发人员推崇。典型推荐理由往往是:“TEA加密算法不但比较简单,而且有很强的抗差分分析能力,加密速度也比较快,还可以根据需求设置加密轮数来增加加密强度”。这是真的吗?算法安全性可以直接看维基百科上 TEA 算法的介绍,我的理解是不够安全。但其实大部分用户也不那么地在乎它的安全强度,那么性能呢?加密速度真的很快吗?

这就要从历史的角度去看了。作为曾经手撸过 “DES 差分密码攻击” 代码的程序员,表示 TEA 算法的确足够简单。在 QQ 诞生的那个年代,TEA 在计算上的确有着不小的优势。但 QQ 已经 18 岁了啊同学们,18 年来中国发生了多大的变化,世界发生了多大的变化啊!

2008 年,Intel 就发布了 x86 的 AES 指令集扩展,近几年的服务器 CPU 应该都支持,不相信你
grep aes /proc/cpuinfo 就能看到 ;2011 年 ARM 也在 ARMv8 架构下直接提供了 AES 和 SHA-1/SHA-256 指令 。这意味着什么?意味着服务端和客户端在硬件上直接支持 AES,意味着原来 N 条汇编指令只需要一条 AES 指令就完成了。其实也意味着,在绝大多数情况下 AES 才应该是你的首选

口说无凭,咱们可以看一下测试数据,x86 服务器 CPU 测试可以直接看 Crypto++ 的 benchmark 。可以看到 AES/CTR (128-bit key) 与 TEA/CTR (128-bit key) 的加密速度比是:4499 MB/s 比 72 MB/s,62 倍的差异!这就是硬件实现的威力。

ARM 手机 CPU 加密算法的 Benchmark,我没有找到。但为了更有说服力,我自己实现了两个测试 APP,一个 Android 版,一个 iOS 版。写技术文章多不容易啊,写博客之前先写三个晚上代码,泪目!!!代码在 https://github.com/solrex/cipher-speedAndroid 版可以直接在 Release 里扫码安装

首先介绍一下目前的旗舰 CPU,骁龙 835 (MSM8998) 的表现,测试机型是小米 6:

# Speed Test of 10MB Data Enc/Decryption #
# AES: 
* [AES/CBC/PKCS5Padding] ENC: 1146.9 KB/ms
* [AES/CBC/PKCS5Padding] DEC: 692.4 KB/ms
* [AES/CBC/NoPadding] ENC: 1118.8 KB/ms
* [AES/CBC/NoPadding] DEC: 1343.5 KB/ms
* [AES/ECB/PKCS5Padding] ENC: 990.4 KB/ms
* [AES/ECB/PKCS5Padding] DEC: 703.2 KB/ms
* [AES/ECB/NoPadding] ENC: 973.4 KB/ms
* [AES/ECB/NoPadding] DEC: 988.9 KB/ms
* [AES/GCM/NOPADDING] ENC: 13.9 KB/ms
* [AES/GCM/NOPADDING] DEC: 14.7 KB/ms
# DES: 
* [DES/CBC/PKCS5Padding] ENC: 20.1 KB/ms
* [DES/CBC/PKCS5Padding] DEC: 20.7 KB/ms
* [DES/CBC/NoPadding] ENC: 21.3 KB/ms
* [DES/CBC/NoPadding] DEC: 21.6 KB/ms
* [DES/ECB/PKCS5Padding] ENC: 26.3 KB/ms
* [DES/ECB/PKCS5Padding] DEC: 26.2 KB/ms
* [DES/ECB/NoPadding] ENC: 25.9 KB/ms
* [DES/ECB/NoPadding] DEC: 26.8 KB/ms
# 3DES: 
* [DESede/CBC/PKCS5Padding] ENC: 23.6 KB/ms
* [DESede/CBC/PKCS5Padding] DEC: 23.2 KB/ms
* [DESede/CBC/NoPadding] ENC: 23.6 KB/ms
* [DESede/CBC/NoPadding] DEC: 23.5 KB/ms
* [DESede/ECB/PKCS5Padding] ENC: 8.5 KB/ms
* [DESede/ECB/PKCS5Padding] DEC: 8.5 KB/ms
* [DESede/ECB/NoPadding] ENC: 8.5 KB/ms
* [DESede/ECB/NoPadding] DEC: 8.6 KB/ms
# TEA: 
* [TEA] ENC: 16.0 KB/ms
* [TEA] DEC: 18.1 KB/ms

可以看到,TEA:AES=16:990,这是多少倍?我都懒得算了。然后是 2 年前的中低端 CPU,联发科 Helio P10 (MT6755),测试机型是魅蓝 Note 3:

# Speed Test of 10MB Data Enc/Decryption #
# AES: 
* [AES/CBC/PKCS5Padding] ENC: 358.8 KB/ms
* [AES/CBC/PKCS5Padding] DEC: 267.9 KB/ms
* [AES/CBC/NoPadding] ENC: 438.8 KB/ms
* [AES/CBC/NoPadding] DEC: 515.0 KB/ms
* [AES/ECB/PKCS5Padding] ENC: 310.6 KB/ms
* [AES/ECB/PKCS5Padding] DEC: 222.1 KB/ms
* [AES/ECB/NoPadding] ENC: 312.4 KB/ms
* [AES/ECB/NoPadding] DEC: 319.5 KB/ms
* [AES/GCM/NOPADDING] ENC: 5.1 KB/ms
* [AES/GCM/NOPADDING] DEC: 5.7 KB/ms
# DES: 
* [DES/CBC/PKCS5Padding] ENC: 7.5 KB/ms
* [DES/CBC/PKCS5Padding] DEC: 7.7 KB/ms
* [DES/CBC/NoPadding] ENC: 7.7 KB/ms
* [DES/CBC/NoPadding] DEC: 7.8 KB/ms
* [DES/ECB/PKCS5Padding] ENC: 9.3 KB/ms
* [DES/ECB/PKCS5Padding] DEC: 9.2 KB/ms
* [DES/ECB/NoPadding] ENC: 9.3 KB/ms
* [DES/ECB/NoPadding] DEC: 9.5 KB/ms
# 3DES: 
* [DESede/CBC/PKCS5Padding] ENC: 12.5 KB/ms
* [DESede/CBC/PKCS5Padding] DEC: 12.3 KB/ms
* [DESede/CBC/NoPadding] ENC: 12.3 KB/ms
* [DESede/CBC/NoPadding] DEC: 12.5 KB/ms
* [DESede/ECB/PKCS5Padding] ENC: 3.1 KB/ms
* [DESede/ECB/PKCS5Padding] DEC: 3.1 KB/ms
* [DESede/ECB/NoPadding] ENC: 3.1 KB/ms
* [DESede/ECB/NoPadding] DEC: 3.1 KB/ms
# TEA: 
* [TEA] ENC: 6.2 KB/ms
* [TEA] DEC: 8.0 KB/ms

然后是 3 年前的旗舰 CPU,Apple A8,测试机型是 iPhone6。别问我为啥不用今年的苹果旗舰 CPU...

# Speed Test of 10MB Data Enc/Decryption #
# AES
* [AES/CBC/PKC7Padding] ENC: 76.0 KB/ms
* [AES/CBC/PKC7Padding] DEC: 111.3 KB/ms
* [AES/CBC/NoPadding] ENC: 138.2 KB/ms
* [AES/CBC/NoPadding] DEC: 450.7 KB/ms
* [AES/ECB/PKC7Padding] ENC: 305.6 KB/ms
* [AES/ECB/PKC7Padding] DEC: 735.9 KB/ms
* [AES/ECB/NoPadding] ENC: 330.0 KB/ms
* [AES/ECB/NoPadding] DEC: 673.6 KB/ms
# DES
* [DES/CBC/PKC7Padding] ENC: 23.1 KB/ms
* [DES/CBC/PKC7Padding] DEC: 24.5 KB/ms
* [DES/CBCPadding] ENC: 23.1 KB/ms
* [DES/CBCPadding] DEC: 22.8 KB/ms
* [DES/ECB/PKC7Padding] ENC: 19.4 KB/ms
* [DES/ECB/PKC7Padding] DEC: 20.8 KB/ms
* [DES/ECBPadding] ENC: 22.2 KB/ms
* [DES/ECBPadding] DEC: 22.2 KB/ms
# 3DES
* [3DES/CBC/PKC7Padding] ENC: 9.7 KB/ms
* [3DES/CBC/PKC7Padding] DEC: 9.8 KB/ms
* [3DES/CBC/NoPadding] ENC: 9.8 KB/ms
* [3DES/CBC/NoPadding] DEC: 9.8 KB/ms
* [3DES/ECB/PKC7Padding] ENC: 9.4 KB/ms
* [3DES/ECB/PKC7Padding] DEC: 9.1 KB/ms
* [3DES/ECB/NoPadding] ENC: 9.2 KB/ms
* [3DES/ECB/NoPadding] DEC: 9.4 KB/ms
# TEA
* [TEA] ENC: 10.9 KB/ms
* [TEA] DEC: 11.1 KB/ms

关于 Apple A8 的测试多说两句。我上面的 AES 性能,离 GeekBench 发布的 A8 AES Single Core 还有不少差距,不知道是不是测试方法差异导致。但总的来说,不影响结论,那就是 TEA 跟 AES 差距巨大

看到这里,可能大部分人心里已经做出选择了。即使还没做出选择的读者,我想你也可以考虑看看我的代码实现是否存在问题。不过最后还是回答一下开头提出的问题吧:

  • 如果你使用平台语言来实现对称加密,也就是 Android 上用 Java,iOS 上用 OC 或者 Swift,AES 是不二选择。这样能充分利用硬件提供的能力,安全性+性能肯定是最优,不要再想其他选项了。
  • 如果你使用 Native 语言来实现对称加密,在 Android 上使用 JNI 调用 C 编译的代码,的确不少人认为原生指令更难逆向。可能要在 ARM 架构上做个取舍,是取悦 v8 用户,还是取悦 v7 以下的用户,这可能影响到选型。不过我认为 AES 依然是一个好的选项,起码在服务器端,你肯定会节省成本。

如何实现高效的 URL 过滤算法

在上一篇文章《那么,屏蔽词系统到底该怎么做?》中,我简单讲了一下屏蔽词系统的实现思路。这篇就讲一讲另一个类似的话题,那就是如何实现高效的 URL 过滤算法。

通过某些字面特征,筛选出符合条件的 URL,对其执行特定的操作。虽然看起来像是很遥远专业的技术,但其实早就根植在你手机里的各类浏览器相关 app,以及你使用的各类互联网服务中了。举一个最简单的例子:你在微信里打开淘宝链接,背后就是一个 URL 过滤算法的实现。

还有,很多浏览器 APP 设置项里有一个开关,叫做“广告过滤”,其中很大一部分也是通过 URL 过滤实现的。那如何做到高效的 URL 过滤呢

如果拿这个问题来面试,大概率候选人会回答用正则表达式实现。其实这一点不令人惊讶,因为我曾经亲眼见过一个日活惊人的 APP 也是用正则表达式做的(真不敢相信自己的眼睛)。用正则表达式本身来实现 URL 匹配不是很大的问题,但在“广告过滤”这样的场合,意味着有成千上万的 URL 规则,很难有人能用这些规则写出来高效的正则表达式

关于这点,展开说一下。理论上来讲,把所有 URL 规则融合到一条正则表达式里,也不是不可能。比如:"http[s]{0,1}://..{0,1}(taobao|tmall).com/.",可以融合两条淘宝和天猫的 url 规则。但如果让你融合一千个不同的 url 规则到一条正则表达式里,我想很难有人有信心把它完全做对,更不敢保证后续维护这个规则库的人能做对。所以很多情况下,他们只是用几千个正则表达式实现了几千条 url 规则,想想这个匹配效率有多低!!

所以,真正在乎 URL 匹配效率的人,不会使用正则表达式。举个最典型的例子,Adblock Plus 的过滤规则(https://adblockplus.org/filters),是完全自定义了一套匹配规则。不过,Adblock Plus 在早期也是用正则表达式,而且完全就是我上面讲的那种用法,不过后来他们改进了,还专门发了篇博客(https://adblockplus.org/blog/investigating-filter-matching-algorithms , 注意里面也用到了我上篇文章提到到 Rabin-Karp )。

可是在我看来,Adblock Plus 的实现只是够用,却还不够高效。我上一篇文章提到的 Trie 树,更合适做这种事情,可能也更高效(未比较),至少更简洁。Adblock 最核心的地方,是 URL 匹配。从 Adblock Plus 定义的规则也能看出,URL 匹配其实比正则表达式匹配简单很多,无非是在普通字符串匹配之上加了一些通配符而已。

那以 Adblock Plus 的通配符为例,我来讲一下如何用 Trie 树来实现含通配符的字符串匹配

  • "*" 通配符匹配任意长的字符串
    • 包含匹配时模式串前后的 "*" 没有意义,可以直接丢掉;
    • 以中间的 "*" 做划分,原串 * 位置后面的部分进行递归子树匹配;
  • "|" 匹配网址开头结尾:对 url 预处理,扔掉 scheme 部分 "http://",头尾都加上 "|",这样自然就能匹配上模式串中的 "|" 了。
  • "^" 标记分隔符:这就更简单了,遇到 ^ 规则时,不是比较原串中字符是否与其相等,而是是否包含在某个符号表中即可。

在这些匹配规则的基础上,结合 Double Array Trie 数据结构,可以实现一个内存占用超级小但效率又非常高的 URL 过滤器了。而且 Trie 树的结构对规则的条数非常不敏感,耗时并不会随着过滤规则的增多而显著增加。

不过还得多说一句,算法是效率核心,但真正解决问题还得花很多心思在算法外。比如例外规则,规则库的动态更新等等,这里就不继续展开了。

以上只是本人的一点拙见,对 Adblock Plus 的评论也没有评测数据验证,只是希望对读者能够有些用处。此外,也欢迎大家用更好的算法来打脸

那么,屏蔽词系统到底该怎么做?

caoz 在最近一篇公众号文章《企业面试需要几轮》中提到一个面试问题:

大家都知道做互联网有很多屏蔽词要处理,那么需要对用户发布的内容,做屏蔽词过滤,先不考虑一些正则组合的情况,假设我有一个屏蔽词库,里面有几万条屏蔽词信息,然后我有个非常火爆的社区,每天用户产生海量内容,比如上百万篇文章或评论,现在我要求每篇文章都能快速通过屏蔽词库去检索,而且要求服务器可以支撑尽可能多的处理请求,毕竟这是成本啊。那么请设计一个屏蔽词过滤的算法逻辑。

碰巧我在面试中也喜欢问这个问题,可惜的是能答上来的的确不多。

单单从算法层面论,大概有 50% 的人能回答出来逐个进行 KMP 匹配(另外 50% 不知道什么是 KMP)。让他更进一步优化的话,20% 的人能想到一些粗糙的优化技巧,10% 的人能说出前缀树匹配。大部分能答出前缀树匹配的人会提到 AC 自动机,但只有一半能大概答出 AC 自动机的大概算法。到目前为止,还没有人回答过其它算法,比如 Rabin-Karp 或者 Commentz-Walter。

即使在算法层面上能回答出来 AC 自动机,在实现上候选人也只能形象地描述一下 Trie 树和失败指针,Trie 树的每一层使用一个 Set 或者哈希表数据结构。到目前为止,还没有人回答过用 Double Array 来实现 Trie,甚至连用前缀做 key 的一个大哈希表来模拟 Trie 树的优化也没有。虽然构造 Double Array Trie 的代价非常高,但内存占用非常小,检索效率也很高,特别适用于屏蔽词的场合。离线建好 Double Array Trie,推送到线上仅仅需要读入两个内存块就能完成更新,加载过程到了极简。

哪怕是回答出了高效的算法和实现,离一个真正有效的屏蔽词系统还相差很远。首先要清楚一件事,道高一尺,魔高一丈。你平时想过什么办法绕过屏蔽词系统,现在就得想办法对抗你曾经开过的脑洞。下面举几个最常见的例子:

  1. 简繁体混用,大小写混用,全半角混用。首先得做归一化,繁体转简体,大写转小写,全角转半角。
  2. 在屏蔽词中间加特殊字符。原串先过一遍,去掉特殊字符再过一遍。
  3. 字形相近,读音相似。积累相近字典,替换为常用字再过一遍,将所有的字转为拼音再过一遍。
  4. 同义词替换。加屏蔽词时考虑替换场景,或者挖掘替换词库用来替代。

上面这几种方法,说起来简单,做起来都是坑。一不小心,就把正常的内容当成包含屏蔽词干掉了。这就是为啥大家老吐槽在某些网站发帖,动不动就被屏蔽,关键就在于坏招太多,规则多了就有误伤啊!所以一个优秀的屏蔽词系统,还得尽量避免误伤情况,也有一些办法。

  • 先切词,如果命中屏蔽词的范围不在切词的边缘,应该是误伤。最简单的例子是:24口交换机。
  • 添加屏蔽词的例外规则,比如“成人牙刷”需要是“成人”的例外规则。

最近几年机器学习特别火,其实机器学习也特别适合这种场景,就是一个二分类问题嘛。只是超出了简单算法的范畴,这里就不展开了。

所以你看,就一个“屏蔽词过滤的算法逻辑”,就有那么多要探讨的东西。而且以目前国内大部分互联网产品的实现上来看,可能考虑的还没我这篇文章全面。这可真不是一个能轻松就回答完美的问题。

C++ 多线程调用 Lua 的正确姿势

上一篇文章《轻量级的 C++ Lua 传参方法 - Protobuf 反射》提到我正在 C++ 项目中使用简单的 Lua 脚本做一些灵活的程序逻辑,这样可以把多个 Lua 脚本放在数据库或者缓存里,根据不同的条件选择执行不同的脚本,而且可以在线更新这些脚本。

但在 C++ 项目中集成 Lua,遇到的第一个问题就是多线程问题。Lua 本身是不知晓宿主程序线程环境的,所以 lua_State 的多线程访问是不安全的。而多线程又是服务端程序的天然特性,我暂时还不想把所有逻辑都托管给 Lua,用它自己的多线程机制。所以看起来有下面几个选择:

一是把 Lua 脚本也看成是一个独立服务,通过 RPC 或者消息队列的方式去调用它,在 Lua 脚本内部处理性能问题。这样做相当于用 Lua 写了个服务,脚本复杂度比较高,偏离了我的本意。

二是在每个 C++ 线程里,都创建一个独立的 Lua VM。每个线程有自己的上下文,也自然就互不干扰了。这样做会浪费一些内存,但考虑到 Lua VM 几十 K 级别的大小,对服务端来说根本不算什么开销。但创建线程级的 Lua VM,内存管理上又有不同的方法。

__thread 修饰符只能约束 POD 变量,的确可以存 lua_State 指针,可是它不支持指针的销毁,必须自己额外管理 lua_State 对象,然后再把指针传给线程变量。thread_local 的确能支持复杂类型,可以在析构里销毁 lua_State,可又要求 C++11。思来想去,用pthread_key_create/delete + pthread_get/setspecific 是一个相对稳妥又较为简单的方法,即不用额外自动管理内存,又能实现在线程结束后自动析构线程自己的 Lua VM。

三是将 C++ 的线程与 Lua 的线程对应起来,使用同一个 Lua VM,但在每个 C++ 线程中都用lua_newthread 创建一个 Lua 线程 State 指针。不过在创建线程那一刻,仍然需要对主 lua_State 加锁。这其实相当于给每个 C++ 线程都创建了一个独立的 Lua 堆栈,这样在传参和执行脚本的时候就不担心有数据冲突。理论上来讲效果应该与二是类似的。

四是使用一个线程安全的对象池。将 lua_State 指针放到对象池里,需要的时候拿出来,用完再放回去,由对象池来管理创建和销毁。这就需要一个额外的内存管理容器,代码量大一些,只是对很多成熟的产品来说,可能本身就有这样的轮子。

考虑到代码量,复杂度等问题,我实际在项目中采取了二方案。不过我对 Lua 的了解还不深入,不知道是否还有更好的办法?

一种轻量级 C++ Lua 传参方法 - Protobuf 反射

虽然很多动态语言(例如 PHP)的性能在近些年有了大幅度的提升,也得到了更广泛的应用,但是在一些对性能要求比较严苛的场合,C/C++ 还是有着难以替代的优势。可 C/C++ 最大的缺点就是它的不够灵活,很小一点修改都必须得重新编译,部署,重启上线。为了增强 C/C++ 的灵活性,很多项目都选择嵌入 Lua 解析器来处理程序逻辑中的动态部分,我们也不例外。

目前我们对 Lua 的使用还是比较保守,主要是封装了一些基于特定条件的排序或者过滤规则。它的特点就是传入参数较多,但返回值特别少,基本上就是一个数字或者布尔值。最开始是使用的原始方法,手工去拼 Lua Table 作为传入参数,每加一个参数,就要手写几行添加元素的代码。最近我看到 brpc 里的 pb2json ,忽然想到完全可以用 Protobuf 的反射机制,自动拼 Lua Table。下面是基本类型的转换方法,当然,也可以用类似的方法对 Protobuf 的 map, message 等高级数据结构进行进一步封装。

void ProtoMessageToLuaTable(const google::protobuf::Message &message, lua_State *L) {
    lua_newtable(L);
    const Descriptor* descriptor = message.GetDescriptor();
    const Reflection* reflection = message.GetReflection();
    int field_count = descriptor->field_count();
    for (int i = 0; i < field_count; ++i) {
        const FieldDescriptor* field = descriptor->field(i);
        switch (field->type()) {
        case FieldDescriptor::TYPE_BOOL:
            lua_pushboolean(L, reflection->GetBool(message, field));
            break;
        case FieldDescriptor::TYPE_UINT32:
            lua_pushinteger(L, reflection->GetUInt32(message, field));
            break;
        case FieldDescriptor::TYPE_UINT64:
            lua_pushinteger(L, reflection->GetUInt64(message, field));
            break;
        case FieldDescriptor::TYPE_INT32:
        case FieldDescriptor::TYPE_SINT32:
            lua_pushinteger(L, reflection->GetInt32(message, field));
            break;
        case FieldDescriptor::TYPE_INT64:
        case FieldDescriptor::TYPE_SINT64:
            lua_pushinteger(L, reflection->GetInt64(message, field));
            break;
        case FieldDescriptor::TYPE_FLOAT:
            lua_pushnumber(L, static_cast<double>(reflection->GetFloat(message, field)));
            break;
        case FieldDescriptor::TYPE_DOUBLE:
            lua_pushnumber(L, reflection->GetDouble(message, field));
            break;
        case FieldDescriptor::TYPE_STRING:
            lua_pushstring(L, reflection->GetString(message, field).c_str());
            break;
        default:
            lua_pushnil(L);
            break;
        }
        lua_setfield(L, -2, field->name().c_str());
    }
}

其实调研了一下,发现还有一些其它的方法,比如 luabind, sol2一堆库。但这些工具更适合 C++ Lua 交互比较复杂的场合,而且也引入了额外的依赖和额外的要求(比如 C++11)。对于像我们这样的简单场景,在不引入更多依赖的情况下使用 Protobuf 反射机制,不失为一个好的选择。

免费 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 下 :)