从 ThreadLocal 的实现看散列算法

引子

最近在看 JDK 的 ThreadLocal 源码时,发现了一段有意思的代码,如下所示。

    private final int threadLocalHashCode = nextHashCode();
    /**
     * The difference between successively generated hash codes - turns
     * implicit sequential thread-local IDs into near-optimally spread
     * multiplicative hash values for power-of-two-sized tables.
     */
    private static final int HASH_INCREMENT = 0x61c88647;

    /**
     * Returns the next hash code.
     */
    private static int nextHashCode() {
        return nextHashCode.getAndAdd(HASH_INCREMENT);
    }

可以看到,其中定义了一个魔法值 HASH_INCREMENT = 0x61c88647, 对于实例变量 threadLocalHashCode, 每当创建 ThreadLocal 实例时这个值都会 getAndAdd(0x61c88647)

0x61c88647 转化成二进制即为 1640531527,它常用于在散列中增加哈希值。上面的代码注释中也解释到:HASH_INCREMENT 是为了让哈希码能均匀的分布在2的N次方的数组里。

那么 0x61c88647 是怎么起作用的呢?

什么是散列?

ThreadLocal 使用一个自定的的 Map —— ThreadLocalMap 来维护线程本地的值。首先我们先了解一下散列的概念。

散列(Hash)也称为哈希,就是把任意长度的输入,通过散列算法,变换成固定长度的输出,这个输出值就是散列值。

在实际使用中,不同的输入可能会散列成相同的输出,这时也就产生了冲突。通过上文提到的 HASH_INCREMENT 再借助一定的算法,就可以将哈希码能均匀的分布在 2 的 N 次方的数组里,保证了散列表的离散度,从而降低了冲突几率.

哈希表就是将数据根据散列函数 f(K) 映射到表中的特定位置进行存储。因此哈希表最大的特点就是可以根据 f(K) 函数得到其索引。

HashMap 就是使用哈希表来存储的,并且采用了链地址法解决冲突。

简单来说,哈希表的实现就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被 Hash 后,得到数组下标,把数据放在对应下标元素的链表上。

散列算法

先来说一下散列算法。散列算法的宗旨就是:构造冲突较低的散列地址,保证散列表中数据的离散度。常用的有以下几种散列算法:

除法散列法

散列长度 m, 对于一个小于 m 的数 p 取模,所得结果为散列地址。对 p 的选择很重要,一般取素数或 m

公式:f(k) = k % p (p<=m)

因为求模数其实是通过一个除法运算得到的,所以叫“除法散列法”

平方散列法(平方取中法)

先通过求关键字的平方值扩大相近数的差别,然后根据表长度取中间的几位数作为散列函数值。又因为一个乘积的中间几位数和乘数的每一位都相关,所以由此产生的散列地址较为均匀。

公式:f(k) = ((k * k) >> X) << Y。对于常见的32位整数而言,也就是 f(k) = (k * k) >> 28

斐波那契(Fibonacci)散列法

和平方散列法类似,此种方法使用斐波那契数列的值作为乘数而不是自己。

  1. 对于 16 位整数而言,这个乘数是 40503。
  2. 对于 32 位整数而言,这个乘数是 2654435769。
  3. 对于 64 位整数而言,这个乘数是 11400714819323198485。

具体数字是怎么计算得到的下文有介绍。

为什么使用斐波那契数列后散列更均匀,涉及到相关数学问题,此处不做更多解释。

公式:f(k) = ((k * 2654435769) >> X) << Y。对于常见的32位整数而言,也就是 f(k) = (k * 2654435769) >> 28

这时我们可以隐隐感觉到 0x61c88647 与斐波那契数列有些关系。

随机数法

选择一随机函数,取关键字的随机值作为散列地址,通常用于关键字长度不同的场合。

公式:f(k) = random(k)

链地址法(拉链法)

懂了散列算法,我们再来了解下拉链法。拉链法是为了 HashMap 中降低冲突,除了拉链法,还可以使用开放寻址法、再散列法、链地址法、公共溢出区等方法。这里就只简单介绍了拉链法。

把具有相同散列地址的关键字(同义词)值放在同一个单链表中,称为同义词链表。有 m 个散列地址就有 m 个链表,同时用指针数组 T[0..m-1] 存放各个链表的头指针,凡是散列地址为 i 的记录都以结点方式插入到以 T[i] 为指针的单链表中。T 中各分量的初值应为空指针。

对于HashMap:

HashMap

除法散列(k=16):

HashMap

斐波那契散列:

HashMap

可以看出用斐波那契散列法调整之后会比原来的除法散列离散度好很多。

ThreadLocalMap 的散列

认识完了散列,下面回归最初的问题:0x61c88647 是怎么起作用的呢?

先看一下 ThreadLocalMap 中的 set 方法

private void set(ThreadLocal<?> key, Object value) {
            Entry[] tab = table;
            int len = tab.length;
            int i = key.threadLocalHashCode & (len-1);
            ...
}

ThreadLocalMapEntry[] table 的大小必须是 2 的 N 次方(len = 2^N),那 len-1 的二进制表示就是低位连续的 N 个 1, 那 key.threadLocalHashCode & (len-1) 的值就是 threadLocalHashCode 的低 N 位。

然后我们通过代码测试一下,0x61c88647 是否能让哈希码能均匀的分布在 2 的 N 次方的数组里。

public class MagicHashCode {
    private static final int HASH_INCREMENT = 0x61c88647;

    public static void main(String[] args) {
        hashCode(16); //初始化16
        hashCode(32); //后续2倍扩容
        hashCode(64);
    }

    private static void hashCode(Integer length){
        int hashCode = 0;
        for(int i=0; i< length; i++){
            hashCode = i * HASH_INCREMENT+HASH_INCREMENT;//每次递增HASH_INCREMENT
            System.out.print(hashCode & (length-1));
            System.out.print(" ");
        }
        System.out.println();
    }
}

结果:

7 14 5 12 3 10 1 8 15 6 13 4 11 2 9 0 
7 14 21 28 3 10 17 24 31 6 13 20 27 2 9 16 23 30 5 12 19 26 1 8 15 22 29 4 11 18 25 0 
7 14 21 28 35 42 49 56 63 6 13 20 27 34 41 48 55 62 5 12 19 26 33 40 47 54 61 4 11 18 25 32 39 46 53 60 3 10 17 24 31 38 45 52 59 2 9 16 23 30 37 44 51 58 1 8 15 22 29 36 43 50 57 0 

产生的哈希码分布确实是很均匀,而且没有任何冲突。再看下面一段代码:

public class ThreadHashTest {
    public static void main(String[] args) {
        long l1 = (long) ((1L << 32) * (Math.sqrt(5) - 1)/2);
        System.out.println("as 32 bit unsigned: " + l1);
        int i1 = (int) l1;
        System.out.println("as 32 bit signed:   " + i1);
        System.out.println("MAGIC = " + 0x61c88647);
    }
}

结果:

as 32 bit unsigned: 2654435769
as 32 bit signed:   -1640531527
MAGIC = 1640531527

Process finished with exit code 0
16进制 10进制 2进制 补码
0x61c88647 1640531527 01100001110010001000011001000111 10011110001101110111100110111001

可以发现 0x61c88647 与一个神奇的数字产生了关系,它就是 (Math.sqrt(5) - 1)/2。也就是传说中的黄金比例 0.618(0.618 只是一个粗略值),即 0x61c88647 = 2^32 * 黄金分割比。同时也对应上了上文所提到的斐波那契散列法。

黄金比例与斐波那契数列

最后再简单介绍一下黄金比例,这个概念我们经常能听到,又称黄金分割点。

黄金分割具有严格的比例性、艺术性、和谐性,蕴藏着丰富的美学价值,而且呈现于不少动物和植物的外观。现今很多工业产品、电子产品、建筑物或艺术品均普遍应用黄金分割,展现其功能性与美观性。

对于斐波那契数列大家应该都很熟悉,也都写过递归实现的斐波那契数列。

斐波那契数列又称兔子数列:

  • 第一个月初有一对兔子
  • 第二个月之后(第三个月初),它们可以生育
  • 每月每对可生育的兔子会诞生下一对新兔子
  • 兔子永不死去

转化成数学公式即:

  • f(n) = f(n-1) + f(n-2) (n>1)
  • f(0) = 0
  • f(1) = 1

当n趋向于无穷大时,前一项与后一项的比值越来越逼近黄金比

最后总结下来看,ThreadLocal 中使用了斐波那契散列法,来保证哈希表的离散度。而它选用的乘数值即是2^32 * 黄金分割比

0

理清 Promise 的状态及使用

为什么会有 promise?

因为要解决回调函数的嵌套,也就是所谓的回调地狱,回调地狱长啥样大家应该有点数吧?

doSomethingA((res) =>{
  if (res.data) {
    doSomethingB(res.data, (resB) => {
      if (resB.data) {
        doSomethingC(resB.data)
      }
    })
  }
})

这样的代码不太美观,还得依靠缩进来分清层级。那解决这种回调地狱的方式有很多,最简单方式是定义好一堆具名函数直接调用。那进阶一点方式便是 promise。

promise 是什么?

通过 Promise 构造函数可以创建 promise 对象,promise 是一种通过链式调用的方式来解决回调函数传递的异步方案。
promise 对象具有状态,pending、fulfilled、rejected。状态改变之后就不会再变化。

promise 实例

通过 new 关键字初始化得到新的 promsie 对象。

const promise = new Promise(function(resolve, reject) {
    // ... do something
    if (/* 异步操作成功 */){
        resolve(value);
    } else {
        reject(error);
    }
})

promise 对象创建便会立即执行,但 promise 会保存状态。

基本用法

Promise 定义了一些原型方法来进行状态处理。最常用的有:
- Promise.prototype.then

const pm = new Promise(function(resolve) {
    setTimeout(function() {
        resolve(100)
    }, 2000)
})

pormise 对象通过内部 resolve 函数完成状态的 pending -> fulfilled。此后这个promise 将保留这个状态。可以通过 then 方法去处理状态。

pm.then((val) => {
    console.log(val); // 100
})

then 方法也可以用来处理 rejected 状态,then 方法可以接收 2 个函数参数。

new Promise(function( resolve, reject ) {
    setTimeout(() => {
        reject(new Error('err'))
    }, 2000)
}).then(null, (error) => {
    console.log(error); // error: err 
})

但用 then 的第二个参数去处理错误不是最好的选择。因为大多数情况下我们会用到链式调用。类似:promise.then().then().then()。所以在每个 then 方法去处理错误显得代码很多余,而且也真的没必要。

  • Promise.prototype.catch

catch 方法就是用来做错误统一处理。这样链式调用中我们只需要用 then 来处理 fulfilled 状态,在链的末尾加上 catch 来统一处理错误。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(100);
    }, 2000)
}).then(result => {
    return result * num // 这里模拟一个错误,num 未定义
}).then(result => {
    return result / 2;
}).catch(err => {
    console.log(err); // num is not defined
})

这里举得例子比较简单,在 then 方法里面没有去 return 新的 promise。可以看到第一个 then 发生了错误,最后的 catch 会捕捉这个错误。catch 实际上是.then(null, rejection)的别名。

  • Promise.prototype.finally()

这个 api 一般用来放在结尾,因为它不管前面的 promise 变为什么,它都会执行里面的回调函数。

new Promise((resolve, reject) => {
    setTimeout(() => {
        resolve(100);
    }, 2000)
}).then(result => {
    return result * num // 这里模拟一个错误,num 未定义
}).catch(err => {
    console.log(err); // num is not defined
}).finally(() => {
   console.log('complete'); // complete 
})

这便是一个完整的状态处理流程,then() 处理 fulfilled、catch() 处理 rejected、finally 两种都处理。但 finally 目前还只是提案阶段。

  • Promise.all

这个 api 在开发中也比较实用,如果 C 行为依赖于 A、B 行为,A、B 之间又没有依赖关系,而且只有当 A、B 的状态都为 fulfilled,或者有一个变为 rejected 时才会开始 C 行为。这时用 Promise.all() 就显得比较合适。

Promise.all([A, B]).then((resA, resB) => {
    ... // do something
}).catch(() => {
    ... // do something
})

如果不用 Promise.all 也能做到 A、B 完成之后再调用 C,一般会这么写:

promsiseA.then(() => {
  return promsiseB;
}).then(() => {
    ... // do something
}).catch(() => {
    // do something
})

这样就好比强行将 A、B 置为串行,所以对比 Promise.all() 显得效率不高。

  • Promise.race

用法和 Promise.all 一样,C 依赖于 A、B,但只要 A,B 的任一状态改变了,C 便开始执行。

Promise.race([A, B]).then((res) => {
    ... // do something
}).catch(() => {
    ... // do something
})

链式调用

之所以能在 promise 的使用中进行链式调用,是因为 then、catch、finally 方法都会返回新的 promise 对象。链式调用在 js 中也很常见。

[1, 2, 3].map(num => num*2).join('-')  // "2-4-6"

$('#box').find('.info').text('hello world') // jquery 链式

其实链式调用原理都一样,让方法始终返回新的对象就好了。
promise 中的 then、catch、finally 方法也是如此,总是返回新的 promise。这里都能理解,但有趣的是 promise 具有了状态。这让链式变得稍微复杂了些。

  • 状态变化

then、catch、finally 返回 promise 但这些 promise 的状态由谁决定呢?

答案是如果处理了状态那新得到的 promise 的状态由处理函数的具体内容决定,如果没处理状态那得到 promise 的状态直接继承前面 promise 的状态。

假设 promiseA 的状态为 resolved

const promiseB = promiseA.then(() => {
    return 1
}) // resolved 1

const promiseB = promiseA.then(() => {
    const num = 100 
}) // resolved undefined

const promiseB = promiseA.then(() => {
    throw 1
}) // rejected 1

const promiseB = promiseA.then(() => {
    return promiseC
}) // 状态由 promiseC 决定

假设 promiseA 的状态为 rejected

const promiseB = promiseA.then(() => {
    // do anything 
}) // 状态沿用 promiseA: rejected
  • 错误处理
const pm = new Promise((resolve, reject) => {
    reject('err') // 直接将 pm 对象的状态变为 rejected
})

var pm1 = pm.then(result => {
  console.log('hello, then 1');  // 不会执行
  return 100; // 不会执行
});

这里由于pm 的状态是 rejected, 所以 .then 继续将 rejected 状态向下传递,这样我们就能通过末尾的 catch 操作处理异常。

pm
.then(result => {
    return result * 2
}) // rejected
.then(result => {
    return result * 4
}) // rejected
.catch(err => {
    console.log(err)
})

配合 async await 使用

如果能比较熟练的使用 promsie 再过渡到 async、await 也没什么问题。

  • 怎么使用
async function f() {
    await 100;
}

f().then((num) => {
    console.log(num); // 100
})

简单的例子似乎看不出 async await 到底是干什么的,又有什么用。但可以知道 async 返回的是 promise, await 相当于改变状态的操作。

  • 对比 promise 优势在哪?

aysnc 、await 最大的好处是简化 promise 的 .then 操作,让代码更加同步。

pmA.then((dataA) => {
    ...
    return dataB
}).then((dataB) => {
    ...
    return dataC
}).then((dataC) => {
    ...
    console.log('end')
})

换做是 async、await 就可以写成

async f() {
    const dataA = await pmA;
    const dataB = await fb(dataA);
    const dataC = await fc(dataB);
    ...
}

所以不管 await 后面去接什么样的异步操作,整体上还是会保持同步的方式,这样看起来结构清晰。

总结

从 promsie 到 async、await,JS 提供的这些新的特性,都让 JS 的使用体验变得越来越灵活方便。从 ES6 的出现到现在的 ES7、ES8,也都能感受到 JS 在拥有更大的能力去做好更多的事情。关于 promise 的使用还是要多用多体会,不然用 promise 写出回调地狱一样的代码也不是没有可能。最后还是贴出经典的 promise 问答题!据说能回答正确这些写法区别的 promise 掌握的肯定没问题。

// 1
doSomething().then(function () {
  return doSomethingElse()
}).then(/* ... */)

// 2
doSomething().then(function () {
  doSomethingElse()
}).then(/* ... */)

// 3
doSomething().then(doSomethingElse()).then(/* ... */)

// 4
doSomething().then(doSomethingElse).then(/* ... */)
0

逻辑思维:理清思路,表达自己的技巧

为什么要讲逻辑思维

逻辑思维一直是职场的重要技能之一。当遇到某个问题时,你可以运用逻辑思维去梳理问题、分析问题,从而找到问题的本质与解决方案;当需要向他人陈述结论时,你可以运用逻辑思维去梳理即将要表达的内容,划分清晰沟通中的结论、背景、论点,从而将你的结论条理清晰的传达给对方。

几乎可以这么说,一个人的逻辑思维越强,他解决问题的能力就越强,沟通表达就越清晰。

接下来,文章将分别从逻辑思维如何运用在思考过程中、如何运用在沟通表达中去展开介绍。这两种情况的核心区别在于是已有结论对外陈述,还是未有结论对内分析挖掘。因为现在大多数逻辑思维的讲解都是更注重教给我们如何对外陈述,所以在这里我们也从沟通表达的场景开始介绍。

表达框架:金字塔图组织语言,快速表达

在问题被梳理清晰的前提下,运用逻辑思维去沟通表达,一般是遵循 论点/背景--->结论--->理由--->行动 的整体框架。接下来逐个介绍各个环节的关键点:

1. 论点/背景

论点,一般指接下来谈话的中心内容。论点阐述时经常包含背景介绍,它们往往不可分割。

  • 阐述论点,应尽量从对方了解的信息开始阐述;
  • 我们可以使用 5W2H 法来细化论点:When、Where、Who、What、Why、How、How much,通过 5W2H 方法将来各事件元素(时间、地点、人物、事件、原因、如何进展、进展如何等)梳理清楚。

举一个简单例子,你想找 Boss 聊聊员工加班的事情,你不能说 “Boss,关于加班我想找你聊一聊“ 。这样显然没有将事件元素陈述清晰,Boss 会无法判断你谈话的内容,他只能去猜测你将要表达内容是关于加班的哪个方面。正确的论点阐述应该是 “最近,年轻员工加班时间增加过多了,我们是不是应该做一些调整?”。这样论点就描述清楚了。

2. 结论

结论,指在接下来谈话中你最想陈述的内容,即便删除所有其他内容你也想保留的内容。结论的陈述需要注意以下三点:

  • 结论是解决问题,而不是阐述事实;
  • 结论和论点不能偏离,也就是说结论与论点间的联系要科学易被听众接受:尽可能做到是/否问题回答是/否;原因问题回答原因;怎么做问题回答怎么做。比如,Boss 问你销售为什么不输入商品的预估数据?你却回答,输入的方式应该设计的简单一些,就是有问题的。Boss 问的是原因,你回答的解决方案,跳跃太大,造成 Boss 难以理解。
  • 遵循金字塔原则,大多数时候结论先行,否则倾听者容易产生疲倦。当希望给倾听者以准备时间或者倾听者自行得出结论的时候,我们才将结论放后面。

3. 理由

理由的陈述关键点在于做到符合 MECE 分析法,筛选“合格”理由作为结论的支撑,避免将一个相似的理由分成3个理由来说。那么如何做到符合MECE原则?如何筛选“正确合格”理由?接下来我们详细介绍下:

MECE分析法(Mutually Exclusive Collectively Exhaustive),理由间相互独立,完全穷尽。

通俗地说,就是在没有重复没有遗漏的状态下进行思考或表达。当然,真实的情况往往是我们难以做到真正的穷尽,所以只是力求不遗漏,覆盖 8~9成 即可。

MECE 的分析方法,主要有以下三种:

  • 日常分类模式:投入 vs 产出、生产 vs 消费、人力 vs 物力 vs 财力、过去 vs 现在 vs 未来...
  • 通过公式推导思维解决遗漏:销售额 = 用户数 * 转化率 * 客单价,因子一目了然;
  • 套用经典商业/业务模型:
    • 战略模型:3C-Customer/Competior/Company、SWOT-Strength/Weakness/Opportunity/Threat
    • 营销模型:4Ps-Product/Price/Place/Promotion、4Cs-Customer/Cost/Communication/Convenient
    • 生产管理模型:QCD-Quantity/Cost/Delivery

常见分析方法为我们提供了 MECE 分析的切入点,那么我们要如何选择 MECE 的理由?一个是参考下面将介绍的筛选合适理由的原则,另一个是注意从反向思考去检查MECE分析所得理由的缺点。

选择“正确合格”的理由作为支撑,遵循以下几个原则:

  • 从对方角度设想,选择能让对方信服的理由,所以面对不同的听众,要有不同的侧重;
  • 对于认同感不强的理由,采用“理由的理由”去支持它。“理由的理由”可以是:数据证明、一般常识/规律、事例的累积、已决断的策略、公司规定/制度等;
  • 有大量事实,但没有结论的场景,我们可以先陈列大量理由,从理由中推导出结论;
  • 已经有结论,但理由支撑没有思路的时候,我们同样可以先陈列大量理由,再整理分类理由。

此外,在理由的陈述中还需注意理由与结论的关联。因为理由是结论的支撑,理由推导得到结论的逻辑被听众理解是你的结论被理解的重要前提。下面介绍两种主要的理由推导结论的逻辑:

  • 归纳并举型:列举理由,通过理由的共通点推测结论,这种推理方式的缺点是显得主观,所以要格外注意考虑例外的情况。
  • 演绎推理型:三段式,"大前提 + 小前提" -> 结论。如果大前提、小前提是错误的,那么演绎推理的结论也是无意义的,所以要注意确保大、小前提是正确的。

4. 行动

有结论还应该有行动,行动应该是明确自身和他人的具体任务,并加以Deadline限制。即人、事、时间都是划分清楚的,在此同样可以采用5W2H来确认行动计划是否清晰。

思考框架:梳理问题,逻辑推导解决问题

在还没有将梳理清晰问题时,我们运用逻辑思维去梳理问题,它一般遵循 Problem-->Why-->How 的框架,接下来分开阐述各环节(因为思考过程中运用的逻辑思维方法与沟通表达所运用的方法类似,所以这里不会再陈述的非常详细):

1. 明确问题

明确问题主要有两个方法:

  • 设定想要的状态,即设定目标或设定参照物。当问题很明确,那么设定“理所当然”的目标即可,如:公司连续两年赤字,那么设定目标为公司盈利即可;当问题不明确,那么需要设定理想目标,如:公司连续10年保持全国第四,那么设定目标可考虑5年内成为全国第一。
  • 把问题具体化到能够思考原因的大小,列举具体事例,从事例中归纳问题。如,年轻员工没有朝气,那么我们可以通过列举具体事例:打招呼有气无力、写资料错误率高、辞职率高等,通过这样的细化,我们就知道具体的问题是什么了。

2. 寻找原因

寻找原因的关键在于,不停地询问为什么,逐步深入挖掘。如何有逻辑地深入挖掘原因?这里仍旧建议 MECE 分析法,与表达框架中介绍的理由陈述相似,毕竟在表达中陈述的理由就是在思考中挖掘得到的原因。

挖掘原因的另外一个重要命题是,我们需要深挖到什么程度?各种行业书籍给出的答案是,追问5次,直到能找到具体的解决方案。对于没有可能性的部分中途就可以停止深挖,不必要刻意追问5次,只对有真正可能性的原因进行探究。

3. 检讨解决方案

深挖直到能想象具体怎么做为止,在探讨解决方案的过程中,先不要过多考虑方案的可行性。这里,对日常生活中常见的两类人给出总结与建议:

  • 对于不思考就行动的人,建议多思考在行动,采用根本原因分析、从零开始思考等方法;
  • 对于思考过多而不行动的人,建议一边行动一边思考,采用假设思考法。

总结

以上,为关于逻辑思维整理思路,表达自身技巧的总结与介绍。但仍有一点是需被注意的:逻辑思维虽然有用,但也并非所有的场合都可以用逻辑思维去解决问题。比如,女朋友大发雷霆和你大吵特吵,你如果试图用逻辑思维去解决,那估计是要凉凉了(手动微笑脸)。。。大多数人建议在需要寻找问题总结结论或者进行结论表达的时候才使用逻辑思维!

0

Actor 模型及 Akka 简介

前提

随着业务的发展,现代分布式系统对于垂直扩展、水平扩展、容错性的要求越来越高。常见的一些编程模式已经不能很好的解决这些问题。

解决并发问题核心是并发线程中的数据通讯问题,一般有两种策略:

  1. 共享数据
  2. 消息传递

共享数据

基于 JVM 内存模型的设计,需要通过加锁等同步机制保证共享数据的一致性。但其实使用锁对于高并发系统并不是一个很好的解决方案:

  1. 运行低效,代价昂贵,非常限制并发。
  2. 调用线程会被阻塞,以致于它不能去做其他有意义的任务。
  3. 很难实现,比较容易出现死锁等各种问题。

消息传递

与共享数据方式相比,消息传递机制的最大优点就是不会产生竞争。实现消息传递的两种常见形式:

  1. 基于 Channel 的消息传递
  2. 基于 Actor 模型的消息传递

常见的 RabbitMQ 等消息队列,都可以认为是基于 Channel 的消息传递模式,而本文主要会介绍 Actor 模型相关内容。

Actor 模型

Actor 的基础就是消息传递,一个 Actor 可以认为是一个基本的计算单元,它能接收消息并基于其执行运算,它也可以发送消息给其他 Actor。Actors 之间相互隔离,它们之间并不共享内存。

Actor 本身封装了状态和行为,在进行并发编程时,Actor 只需要关注消息和它本身。而消息是一个不可变对象,所以 Actor 不需要去关注锁和内存原子性等一系列多线程常见的问题。

所以 Actor 是由状态(State)、行为(Behavior)和邮箱(MailBox,可以认为是一个消息队列)三部分组成:

  1. 状态:Actor 中的状态指 Actor 对象的变量信息,状态由 Actor 自己管理,避免了并发环境下的锁和内存原子性等问题。
  2. 行为:Actor 中的计算逻辑,通过 Actor 接收到的消息来改变 Actor 的状态。
  3. 邮箱:邮箱是 Actor 和 Actor 之间的通信桥梁,邮箱内部通过 FIFO(先入先出)消息队列来存储发送方 Actor 消息,接受方 Actor 从邮箱队列中获取消息。

模型概念

Actor

可以看出按消息的流向,可以将 Actor 分为发送方和接收方,一个 Actor 既可以是发送方也可以是接受方。

另外我们可以了解到 Actor 是串行处理消息的,另外 Actor 中消息不可变。

Actor 模型特点

  1. 对并发模型进行了更高的抽象。
  2. 使用了异步、非阻塞、高性能的事件驱动编程模型。
  3. 轻量级事件处理(1 GB 内存可容纳百万级别 Actor)。

简单了解了 Actor 模型,我们来看一个基于其实现的框架。

Akka Actor

Akka 是一个构建在 JVM 上,基于 Actor 模型的的并发框架,为构建伸缩性强,有弹性的响应式并发应用提高更好的平台。

ActorSystem

ActorSystem 可以看做是 Actor 的系统工厂或管理者。主要有以下功能:

  • 管理调度服务
  • 配置相关参数
  • 日志功能

Actor 层次结构

Akka 官网展示的 Actor 层次结构示意图

Akka 有在系统中初始化三个 Actor:

  1. / 所谓的根监护人。这是系统中所有 Actor 的父亲,当系统被终止时,它也是最后一个被停止的。
  2. /user 这是所有用户创建的 Actor 的父亲。不要被 user 这个名字所迷惑,他与最终用户没有关系,也和用户处理无关。你使用 Akka 库所创建的所有 Actor 的路径都将以/user/开头
  3. /system系统监护人

我们可以使用 system.actorOf() 来创建一个在 /user 路径下的 Actor。尽管它只是在用户创建的层次的最高级 Actor,但是我们把它称作顶级 Actor。

Akka 里的 Actor 总是属于其父母。可以通过调用 context.actorOf() 创建一个 Actor。这种方式向现有的 Actor 树内加入了一个新的 Actor,这个 Actor 的创建者就成为了这个 Actor 的父 Actor。

Actor 生命周期

Akka Actor 生命周期示意图
生命周期

Actor 在被创建后存在,并且在用户请求关闭时消失。当 Actor 被关闭后,其所有的子Actor 都将被依次地关闭.

AKKA 为 Actor 生命周期的每个阶段都提供了钩子(hook)方法,我们可以通过重写这些方法来管理 Actor 的生命周期。

Actor 被定义为 trait,可以认为就是一个接口,其中一个典型的方法对是 preStart()postStop(),顾名思义,两个方法分别在启动和停止时被调用。

ActorRef

在使用 system.actorOf() 创建 Actor 时,其实返回的是一个 ActorRef 对象。

ActorRef 可以看做是 Actor 的引用,是一个 Actor 的不可变,可序列化的句柄(handle),它可能不在本地或同一个 ActorSystem 中,它是实现网络空间位置透明性的关键设计。

ActorRef 最重要功能是支持向它所代表的 Actor 发送消息:

ref ! message

Dispatcher 和 MailBox

ActorRef 将消息处理能力委派给 Dispatcher,实际上,当我们创建 ActorSystem 和 ActorRef 时,Dispatcher 和 MailBox 就已经被创建了。

Dispatcher 从 ActorRef 中获取消息并传递给 MailBox,Dispatcher 封装了一个线程池,之后在线程池中执行 MailBox。

因为 MailBox 实现了 Runnable 接口,所以可以通过 Java 的线程池调用。

流程

通过了解上面的一些概念,我们可以 Akka Actor 的处理流程归纳如下:

  1. 创建 ActorSystem
  2. 通过 ActorSystem 创建 ActorRef,并将消息发送到 ActorRef
  3. ActorRef 将消息传递到 Dispatcher中
  4. Dispatcher 依次的将消息发送到 Actor 邮箱中
  5. Dispatcher 将邮箱推送至一个线程中
  6. 邮箱取出一条消息并委派给 Actor 的 receive 方法

简略流程图如下:
Akka Actor 流程图

EventBus

接下来我们看一个 Actor 的应用:EventBus。在异步处理场景下,运用最为广泛的消息处理模式即是 Pub-Sub 模式。基于 Pub-Sub 模式,还可以根据不同的场景衍生出特殊的模式,例如针对一个 Publisher 和多个 Subscriber,演化为 Broadcast 模式和 Message Router 模式。

EventBus 则通过引入总线来彻底解除 Publisher 与 Subscriber 之间的耦合,类似设计模式中的 Mediator 模式。总线就是 Mediator,用以协调 Publisher 与 Subscriber 之间的关系。对于 Publisher 而言,只需要把消息发布给 EventBus 即可;对于 Subscriber 而言,只需要在 EventBus 注册需要处理的事件并实现处理流程即可。

在没有使用 EventBus 的时候,Publisher 必须显式的调用 Subscriber 的方法。例如订单支付成功后,必须在订单处理模块调用积分模块处理积分,调用服务号模块进行通知。而且这样的显示调用会越来越多,每次都要去修改订单模块加一个调用。这样订单处理模块和那些模块就都紧密耦合在一起了。我们看看 EventBus 怎么解决这个问题。

EventBus 定义

要使用 Akka EventBus, 首先要实现一个 EventBus 接口。

trait EventBus {
  type Event
  type Classifier
  type Subscriber

  //#event-bus-api
  def subscribe(subscriber: Subscriber, to: Classifier): Boolean

  def unsubscribe(subscriber: Subscriber, from: Classifier): Boolean

  def unsubscribe(subscriber: Subscriber): Unit

  def publish(event: Event): Unit
  //#event-bus-api
}

如上所示:

  1. Event 就是需要发布到总线上的事件
  2. Classifier 分类器用于对订阅者进行绑定和筛选
  3. Subscriber 注册到总线上的订阅者。

所幸的是,我们不需要要从头实现 EventBus 接口,Akka 提供了一个 LookupClassification 帮助我们实现 Pub-Sub 模式,我们要做的最主要就是实现 publish 方法。

class XrEventBus extends EventBus with LookupClassification {
  type Event = XrEvent
  type Classifier = XrEventType
  type Subscriber = ActorRef

  override protected def publish(event: Event, subscriber: Subscriber): Unit = {
    subscriber ! event
  }
  // 其他方法...
}

可以看到:

  1. Event 的类型是我们自己定义的 XrEvent。
  2. 分类起是基于 XrEventType,也就是事件类型的。我们系统中定义了很多时间类型,例如 XrEventType.ORDER_PAID 是订单支付事件,XrEventType.DOC_REGISTERED 是用户注册事件。
  3. Subscriber 其实就是一个 Actor。
  4. Publisher 只是简单的将 Event 作为一个消息发布给所有 Subscriber。

事件发布和订阅

Subscriber 这边则需要实现对事件的处理。

class ScoreEventHandler extends Actor with Logging {
  override def receive = {
    // 订单支付成功
    case XrEvent(XrEventType.ORDER_PAID, order: OrderResponse) =>
      // 处理订单支付成功事件

    // 处理其他事件
  }
}

然后我们通过调用 EventBus.subscribe 进行事件订阅。

  val eventBus = new XrEventBus

  // 积分事件处理模块
  val scoreEventHandler = XingrenSingletons.akkaSystem.actorOf(
    Props[ScoreEventHandler], name = "scoreEventHandler"))
  eventBus.subscribe(scoreEventHandler, XrEventType.ORDER_PAID)
  // 订阅其他事件..

  // 微信服务号事件处理模块
  val weixinXrEventHandler = XingrenSingletons.akkaSystem.actorOf(
    Props[WeixinXrMessageActor], name = "weixinXrEventHandler"))
  eventBus.subscribe(weixinXrEventHandler, XrEventType.ORDER_PAID)
  // 订阅其他事件..

最后,我们的订单处理模块只需要调用 EventBus.publish 发布订单支付事件就好了。至于那些需要处理该事件的模块,自然会去订阅这个事件。上面 XrEventBus 的实现里可以看到,发布其实就是用 Actor 的消息发送机制,将消息发布给了所有的 Subscriber。

XrEventBus.publish(XrEventType.ORDER_PAID, new OrderResponse(order, product))

至此,我们的订单处理模块和积分处理模块、微信服务号模块就安全解耦了,很漂亮不是吗?

总结

当然 Actor 还有其他很多应用场景。例如并发流式处理,甚至我们系统中的定时任务,也是通过 Actor 实现的。

总之,Actor 为我们提供了更高层次的并发抽象模型,让我们不必关心底层的实现,只需着重实现业务逻辑。对于一些并发的场景,是很值得尝试的一种方案。

0

中文房间之争-浅论到底什么是智能

中文房间问题

1980 年,美国哲学家 John Searle 提出了一个思维实验:中文房间(Chinese Room Argument),它是这样的:

假想在一个密闭的房间中,有一个完全不懂任何中文的美国人。他手上有这两样东西:1)所有的中文字符集(数据);2)如何处理这些中文字符的规则书(程序)。现在,门外有人在纸条上用中文写上一个问题,递进房间当中(输入)。房间里这个完全不懂中文的美国人,按照手头的规则书,把问题的正确回答拼凑出来后递出去(输出)。按照图灵测试的标准,房间里的人被认为具有理解中文的智能,然而,实际上他对中文一无所知。

要注意的是,这本规则书上仅仅只是基于语法(Syntax)的,而不涉及任何语义(Semantics)。也就是说,你可以理解成,这本规则书上,罗列了一切可能的中文问题,并给出了相应的中文回答,房间中的人,只需要按照对应的回答,拼凑出相应的中文字符递出去,但这个过程中,他对问题和答案是什么意思,一无所知。

这个思维实验提出后,长达三十余年的时间里,各方都提供了各种回复与反驳。Searle 最初只是希望借助于这个思维实验,来指出图灵测试在验证智能方面并不是完备的,即我们该如何辨别智能。不过,随着论战的升级,它实际上指向着一个历史更悠久的路线之争:智能是可计算的吗?

其实,所谓路线之争,本来就没有绝对的对错,关键在于你持有什么立场和信念。我们就来看看,中文房间面临的各种诘难和相应的回复。

系统论回复(System Reply)

最常见的回复,也是 Searle 在其论文中首先驳斥的,就是系统论。在这种观点下,房间中的那个人的确不理解中文,然而,如果你把房间当作一个整体系统,则可以说这个系统是理解中文的。这一派观点的人做出了一个类比:想像一下人类大脑中的神经元,单独拎出来一个神经元,它显然不理解中文,然而,人作为一个整体,是可以理解语言的。

Searle 对此的回复是:让我们更极端一点,现在房间中的这个美国人,背下来了所有的中文字符和整本规则书。现在,他离开房间出门和真正的中国人交谈。对面走来了一个人问他「你给王菊投票了吗?」,他根据大脑里内化的规则书回答「没有」。尽管他对「王菊」和「投票」是什么,一无所知。Searle 在 1984 年进一步指出,系统论一方的错误在于,误把这个问题当作整体论和还原论之争的一个分支,然而实际上,他驳斥的是单凭语法本身并不足以构建语义。

系统论的一个变体是「虚拟心智论(Virtual Mind Reply)」。和系统论一样,它同样不认为房间中的人具有智能,但也不认为这个系统整体具有智能。事实上,它完全不关心智能是通过谁或什么(Agent)展现了出来,而只在意在整个过程当中,是否有智能被创造了出来。许多人在这一观点上进行了进一步的演化,继而讨论到:中文房间之中,真正的智能体现在那本规则书上。正反双方对这本规则书是否是可穷尽的,以及意识和本体论的行为主义之间的关系展开了辩驳,不过讨论的内容过于学究,这里就不展开了。

英国物理学家和数学家 Roger Penrose 在 2002 年回应过这一系列相关的观点,把「中文房间」问题扩大化到「中文体育馆(Chinese Gym)」变体。现在,让全中国所有的人,每个人模拟一个大脑中的神经元来处理信息。难道中国这个国家,或者在这个过程当中,存在着任何形式的智能吗?好吧,虽然这个变体的思维实验,核心目的是驳斥智能不需要依附主体的这一看法,但如果你想到了《三体》一书中,三体星人全民模拟一台计算机的行径,很多人的确可能会觉得这个过程当中存在着智能。

机器人回复(Robot Reply)

机器人回复赞同 Searl 的观点,即中文房间中无论是那个人,还是房间整体,都不存在智能可言。但原因在于,仅仅语言是不足以构成智能的,按照这一方的观点,如果我们造出了一个机器人,它不仅仅可以根据语法处理中文,还可以识别图像、声音、运动,并给出相应的肢体、表情等反馈,这个机器人就具备了智能。按这样的标准,西部世界中的接待者(Hosts)显然是具备智能的。

Searl 和一众人并不认为这样的机器人真正具备了智能,反驳的主要切入点,是从意向性(Intentionality)和因果关系(Causal Connection)两点入手的。通俗一点来说,这样的智能只是一系列反应的组合,硅谷最新一季当中的 Fiona 就是典型代表,她的语言、面部表情、肢体动作只不过是按照程序例行公事,却没有自发的因果性。

v2-2b8d6ad7f7bcb3317a41f8ff13d2f730_r

模拟大脑回复(Brain Simulator Reply)

模拟大脑回复跳开了中文房间这个问题,而是直接向 Searl 质问:如果我们用非生物的方式,模拟出了一个和大脑神经元一模一样工作的装置,它拥有智能吗?想像一下西部世界中接待员脑中的白色装置,就可以认为是这个,显然西部世界里的机器人,比上面硅谷中的那个 Fiona 更像拥有智能的人类。

Searl 的回复依然是不能。模拟大脑本身并不意味着智能,他在这里给出了一个很类似于上文「中文体育馆(Chinese Gym)」的思维实验变体,目前来看,Searl 就是既不认同智能直接等同于大脑的电化学信号。不过,在这个方向上讨论下去,Searl 隐晦地表达出了他对于什么是智能的深层看法。

模拟大脑回复一方进一步质问:我们现在不模拟大脑了,而是逐步替换大脑。假设有一个人,我们把他大脑中的突触,一个接一个,替换成特制的晶体管,这些晶体管能十分逼真地模拟大脑突触。那么,当全部替换完成后,这个人还拥有智能吗?

Searl 一方的回答是:在我不知道他被替换前,我可能会认为他拥有智能。但一旦当我知道它是一台图灵机后,我便不再认为他具备智能。这个看似已经近乎于狡辩的回复,其实暗藏着 Searl 一方对智能更深层的看法:凡是可以被计算的,都不再是智能。其实,直到今天,也没有人可以给智能下一个令所有人满意的定义,而中文房间更深层次的分歧正在于:智能是一个可计算的问题吗?

他人心智回复(Other Minds Reply)和直觉回复(Intuition Reply)

还有许多人绕开了关于智能的定义,而尝试从如何辨别智能的角度出发。在他们看来,我们之所以认为中文房间中的人,或机器人不具备智能,纯粹是出于我们固有的偏见。按照同样的标准,我们一样也无法判定身边的人是否真的具备智能,或者仅仅只是表现出智能。这一系列的回复,都强调图灵测试的重要性,正在于摒弃我们固有的偏见,而单纯从结果出发来辨别智能是否存在。

美国哲学家 Daniel Dennett 提出了哲学僵尸(Philosophical Zombie),也可以作为一个补充回应。用一个非常简化但并不准确的版本来说,这里的僵尸不是美剧 Walking Dead 中的僵尸,而是指那些只表现出智能,却并不真的具备智能的人类,假设他们存在的话。Daniel Dennett 认为,首先智能显然是一个进化出来的产物。其次,中文房间提出的观点如果成立,即表现出智能的主体并不一定真正具备智能。那么,两个同样在行为上表现出智能的主体,前者不具备真正的智能(只是装得像),而后者具备真正的智能,通过他的一系列的论证(这里就简化不展开,不考虑他论证的正确性),最后得到的结论是:前者比后者更有生存优势,故而地球上大多数人,其实都是只表现出智能,却不真正具备智能的僵尸。

能看到这,说明你肯定不是一具僵尸了。

0

从零搭建一个基于Istio的服务网格

上篇文章从微服务1.0时代的三大痛点(技术门槛高,多语言支持不足和代码侵入性强)说起,由此引出服务网格的起源和演化历史。但古语有云纸上得来终觉浅,绝知此事要躬行,不亲自撸一遍命令,怎敢跟人提服务网格?本篇我将教大家如何在本地从零搭建一个基于Istio的服务网格,从而对服务网格有一个更直观的认识。

1 通关密码:上上下下左左右右ABAB

  • 原料:Mac一台,VPN账号一枚
  • 做法:依序安装和运行KubernetesMinikube,Istio

mario

2 穿墙大法:Shadowsocks

无论是Kubernetes、Minikube还是Istio,官方提供的安装文档都非常详尽,只要英文过关,依葫芦画瓢基本上都能跑通。但如果你在国内,还得加一个必要条件,学会如何突破网络审查,俗称fan墙。

Mac下的穿墙软件我首推Shadowsocks,同时支持Socks5代理和HTTP代理,最新版本可以从GitHub下载。

3 小Boss: kubectl!

3.1 安装

Kubernetes是Istio首推的运行平台,因此作为第一步,我们首先来安装kubectl,Kubernetes的命令行工具,用来控制Kubernetes集群。根据官方文档,Mac下安装kubectl只需要一行命令,brew install kubectl,这简单、极致的用户体验让你感动的想哭。But wait...

3.2 穿墙1: Brew

你敲完命令,踌躇满志的按下回车之后,可能会发现,屏幕迟迟没有输出,10秒,30秒,1分钟,3分钟,10分钟。。。恭喜你,你被墙了!

Brew默认的镜像源是GitHub,而GitHub时不时会被墙,即使不被墙访问速度有时也慢的令人发指,导致Brew命令也常常超时甚至失败。解决办法要么换源,要么给GitHub配上Socks5代理。对码农而言,我更推荐后一种,方法如下:

1) 打开~/.gitconfig文件,如果不存在则新建

2) 在文件末尾添加如下配置并保存:

[http "https://github.com"]
  proxy = socks5://127.0.0.1:1086
[https "https://github.com"]
  proxy = socks5://127.0.0.1:1086

注:socks5://127.0.0.1:1086是Shadowsocks默认开启的Socks5代理地址。

配上Socks5代理之后,一般就可以妥妥的运行Brew命令了。

3.3 验证

安装好kubectl之后,直接运行kubectl version查看版本号。完整的kubectl命令列表在这里可以找到。如果想进一步学习常见的kubectl命令,可以访问Kubernetes Playground完成在线练习。

4 中Boss: Minikube!

4.1 安装

安装完kubectl,接下来就是在本地搭建Kubernetes集群,Minikube是最简单的一种搭建方式,它通过VM模拟了一个单节点的Kubernetes集群。官方文档给出了Mac下的安装命令。

curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-darwin-amd64 && \
chmod +x minikube && \
sudo mv minikube /usr/local/bin/

Minikube默认使用的VM Driver是VirutalBox,因此启动Minikube之前,还要安装VirtualBox。

4.2 启动

安装好Minikube和VirutalBox之后,可运行如下命令第一次启动Minikube:

minikube start --docker-env HTTP_PROXY=http://<本机IP>:1087 --docker-env HTTPS_PROXY=http://<本机IP>:1087

注:官方文档给出的启动命令带有--vm-driver=xhyve,而事实上最新版本的Minikube已经废弃了xhyve driver,应去除。

4.3 穿墙2: Docker

你可能已经注意到,上面的启动命令中带了两个--docker-env参数,都指向了Shadowsocks开启的HTTP代理,为啥呢?还是因为墙。Minikube默认使用Docker作为容器运行时,并在VM中内置了一个Docker守护进程,使用的镜像源是DockerHub。如果你经常使用Docker,那你一定知道在国内使用Docker一般都要修改镜像源(比如阿里云的容器镜像服务)或者使用代理,否则拉取速度也是慢的令人发指。由于Minikube使用的是内置的Docker守护进程,使用代理更为方便,但要注意,开启Shadowsocks HTTP代理时,需要修改代理的侦听地址为本机IP,而不是默认的127.0.0.1,否则在VM中的Docker守护进程是无法访问到这个代理的。

注:--docker-env参数只有在第一次启动Minikube时需要,之后启动直接运行minikube start即可。如果需要修改代理地址,可编辑~/.minikube/machines/minikube/config.json文件。

4.4 验证

安装完Minikube之后,就可以试着创建第一个Kubernetes服务了,具体步骤参考官方文档

5 大Boss: Istio!

5.1 安装

拿到了kubectl和Minikube两大神器,搭建Istio可以说是水到渠成了。基本步骤如下,

1) 启动Minikube

minikube start \
  --extra-config=controller-manager.ClusterSigningCertFile="/var/lib/localkube/certs/ca.crt" \
  --extra-config=controller-manager.ClusterSigningKeyFile="/var/lib/localkube/certs/ca.key" \
  --extra-config=apiserver.Admission.PluginNames=NamespaceLifecycle,LimitRanger,ServiceAccount,PersistentVolumeLabel,DefaultStorageClass,DefaultTolerationSeconds,MutatingAdmissionWebhook,ValidatingAdmissionWebhook,ResourceQuota \
  --kubernetes-version=v1.9.0

2) 下载并解压Istio安装包

curl -L https://git.io/getLatestIstio | sh -

3) 进入安装目录(假设为istio-0.7),将bin/目录添加到PATH环境变量

cd istio-0.7
export PATH=$PWD/bin:$PATH

4) 部署Istio的核心组件(包括外部流量网关Ingress, 管理Envoy实例生命周期的Pilot以及执行访问控制和使用策略的Mixer)到Kubernetes

kubectl apply -f install/kubernetes/istio.yaml

注:如果你需要启用Istio的Mutual TLS Authentication(服务身份验证)功能,可以改为运行kubectl apply -f install/kubernetes/istio-auth.yaml

至此,一个基于Istio的服务网格就算安装完成了。One more thing,还记得上篇文章提到的服务网格所独有的边车模式吗?为了将一个具体的服务接入Istio,需要为每一个服务实例创建一个配套的边车进程。根据官方文档,Istio提供手动和自动两种方式来创建边车进程,前者发生于部署阶段,而后者发生于Pod创建阶段,推荐使用后者,具体步骤参考官方文档,限于篇幅,这里就不再赘述。

5.2 验证

安装完Istio之后,可运行kubectl get pods -n istio-system查看所有Istio相关的Pods,确保这些Pods都处于Running状态。然后,你就可以开始Istio的探索之旅了,建议从官方提供的Bookinginfo示例应用起步,这里就不再展开。

NAME                                      READY     STATUS    RESTARTS   AGE
istio-ca-59f6dcb7d9-5mll5                 1/1       Running   18         42d
istio-ingress-779649ff5b-x2qmn            1/1       Running   26         42d
istio-mixer-7f4fd7dff-6l5g5               3/3       Running   54         42d
istio-pilot-5f5f76ddc8-6867m              2/2       Running   36         42d
istio-sidecar-injector-7947777478-gzcfz   1/1       Running   9          41d

6 参考

0

容器管理利器:Web Terminal 简介

一. 前言

在微服务大行其道的今天,容器恰巧又是微服务的主要载体,所以我们操作的对象也由最开始的「物理机」到「虚拟机」再到今天的「容器」。由于这些载体的变更,我们的使用方式也需要随之发生一些改变。比如一个最常用的登入操作,「虚拟机」下我们可能通过 ssh 的方式 ,但如果是容器呢?ssh 的方式就需要在每个容器中都运行一个 sshd 进程,这种做法可行但略显繁琐,也不太符合一个容器只运行一个进程的思想。
那么有没有一个即方便快捷又安全的登入方式呢?

有,通过 Web Terminal 的方式,通过 Web 的方式即可以避免对客户端的依赖又能够实现用户权限控制。目前,有很多开源的 Web Terminal 的项目,基本上都是通过 ssh 代理的方式调用并返回一个 shell 的虚拟终端(pty)。

ssh_proxy.png

二. 实现容器的 Web Terminal

2.1 架构图

docker ws.png

2.2 前端 Web Termianl 页面

Linux 终端返回的内容会带很多特殊的字符,比如我输入一个 ls 指令,终端返回的结果如下:

'l'
's'
'\r\n'
'\x1b[0;0mRUNNING_PID\x1b[0m  \x1b[1;34mbin\x1b[0m          \x1b[1;34mconf\x1b[0m         \x1b[1;34mlib\x1b[0m\r\nbash-4.3# '

这样我们就需要自己做穷举处理了,这里推荐使用一款的模拟 Terminal 的 JavaScript 库 xterm.js。这个库已经帮我们做了这些复杂操作。

<script>
  var term = new Terminal();
  term.open(document.getElementById('terminal'));
  term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
</script>

可以看到它已经将 \x1B[1;3;31mxterm.js\x1B[0m 这些特殊字符变成了红色:

xterm-example.png

2.3 调用 Docker Daemon API 返回 Shell 虚拟终端

在平常的命令行操作下,我们经常会使用 docker exec -i -t <container_id> /bin/sh 来模拟一个 Shell 的伪终端。在 Web Terminal 实现里,我们需要通过 API 调用的方式来实现同样的操作。当然,我们首先要确保 Docker Daemon 的远程调用是开启的。

  1. 先调用 execCreate 来创建一个 Exec。在调用时,需要指定TtyAttachStdinAttachStdoutAttachStderr 参数均为 true,Cmd 参数为 bash,这样才能获得 bash 进程的标准输入输出和错误;

    request

    POST /v1.24/containers/e90e34656806/exec HTTP/1.1
    Content-Type: application/json
    
    {
      "AttachStdin": true,
      "AttachStdout": true,
      "AttachStderr": true,
      "Cmd": ["sh"],
      "DetachKeys": "ctrl-p,ctrl-q",
      "Tty": true,
      ...
    }
    
  2. 如果调用 execCreate 成功,调用请求会返回该 Exec 的 ID,根据这个 ID 继续调用execStart 接口。在调用时,需要指定 Detach 为 False,Tty 为 True,这样才会返回一个 HTTP 的 stream:

    request

    POST /v1.24/exec/e90e34656806/start HTTP/1.1
    Content-Type: application/json
    
    {
     "Detach": false,
     "Tty": true
    }
    

    response:

    HTTP/1.1 200 OK
    Content-Type: application/vnd.docker.raw-stream
    
    
    {{ STREAM }}
    

2.4 d-terminal

d-terminal 是这个系统的核心,它分成两个部分:

  1. 一部分用于处理用户端的输入和输出,以及存储和展示后端 Docker Dameon 主机的 IP 和 container_id。因为像 top 这样的监控命令需要服务端定时推送数据给客户端,所以使用了 WebSocket 协议以支持服务端推送。
  2. 另一部分用于调用 Docker Daemon 返回虚拟终端。对于终端来说,通常是你输入一个字符就会立马返回,直到你输入一个 "归位键" 终端才会把你输入的字符拼接成一个字符串并发送给 shell 解释器,并将 shell 解释器的结果返回。为了提升使用流畅性,新启用了一个线程去调用 Docker Daemon API,当然也可以使用像 epoll 这样的多路复用技术来实现。

main.png

d-terminal 是使用 Python 实现的 Web 应用,核心代码如下:

@sockets.route('/echo')
def echo_socket(ws):
    ...
    # 调用 Docker API 返回一个虚拟终端
    docker_daemon_sock = get_tty()._sock
    # 启动一个与 Docker Daemon 通讯的子线
    docker_daemon_sock_thd = DockerDaemonSock(ws, docker_daemon_sock)
    docker_daemon_sock_thd.start()

    while not ws.closed:
        message = ws.receive() # 接收 terminal 的输入
        # 将用户的输入发送那个 docker daemon
        docker_daemon_sock.send(bytes(message, encoding='utf-8'))


# 子线程 DockerDaemonSock 类
class DockerDaemonSock(threading.Thread):
    def __init__(self, ws, docker_daemon_sock):
        super(DockerDaemonSock, self).__init__()
        self.ws = ws
        self.docker_daemon_sock = docker_daemon_sock

    def run(self):
        while not self.ws.closed:
            try:
                # 接收 docker daemon 的返回
                resp = self.docker_daemon_sock.recv(2048)
                if resp:
                    # 将 docker daemon 的返回发送给前端 terminal
                    self.ws.send(str(resp, encoding='utf-8'))
                else:
                    print("docker socket closed.")
                    self.ws.close()
            except Exception as e:
                print("docker termial socket error: {}".format(e))
                self.ws.close()

三. 总结

上述仅仅是描述了一个最基本的实现,完全是为了抛砖引玉,后续可以通过在中间层添加一些扩展,比如,用户权限的分配,与自己环境中的容器编排引擎集成等,最终作为 Pass 平台的一个基础的组成部分。

最后,上述的 demo 可去 github 具体查看。效果如下:

image

四. 参考

0

单元测试——工程师 Style 的测试方法

什么是单元测试?

测试分类

Wikipedia 对单元测试的定义:

在计算机编程中,单元测试(Unit Testing)又称为模块测试,是针对程序模块(软件设计的最小单位)来进行正确性检验的测试工作。

在实际测试中,一个单元可以小到一个方法,也可以大到包含多个类。从定义上讲,单元测试和集成测试是有严格的区分的,但是在实际开发中它们可能并没有那么严格的界限。如果专门追求单元测试必须测试最小的单元,反而容易造成多余的测试并且不易维护。换句更严谨一点的说法,我们要考虑测试的场景再去选择不同粒度的测试。

单元测试和集成测试即可以手工执行,也可以是程序自动执行。但现在一般提到单元测试,都是指自动执行的测试。所以我们下面提到的单元测试,没有特别注明,都是泛指自动执行的单元测试或集成测试。

单元测试入门

下面我们先看两个案例,感受一下单元测试到底是什么样子的。

例子 1:生命游戏单元测试

我们先看一个很简单的例子,实现一个康威生命游戏。如果不了解康威生命游戏的话,可以看 Wikipedia 的介绍。假设我们实现时定义这样的接口:

public interface Game {
    void init(int[][] shape) ;      // 初始化游戏 board 
    void tick();                    // 行进到在一个回合
    int[][] get();                  // 获取当前游戏 board
}

生命游戏有好几条规则,为了测试我们的实现是否正确,我们可以针对生命游戏的每个规则,写一个单元测试。下面测试的是复活的规则。

@Test
public void testRelive() {
    int[][] shape = {{0, 0, 1}, {0, 0, 0}, {1, 0, 1}};
    Game g = new GameImplSample(shape);
    g.tick();
    // 自己死亡,周围3个存活状态,复活
    assertEquals(1, g.get()[1][1]);
}

例子 2:订单退款集成测试

我们在看一个稍微复杂一些的例子,测试的是订单退款的过程。

@Test
public void test300_doRefundItem() {
    // 创建订单、支付,然后退款
    Order order = createOrder(OrderSource.XR_DOCTOR);
    order = fullPay(order, PayType.WECHAT_JS);
    OrderItem item = _doItemRefund(order, 1, false);

    // 检查退款中状态
    OrderWhole orderWholeRefunding = findOrderWhole(order.getOrderNo());
    isTrue(orderWholeRefunding.getRefundStatus().equals(
        OrderRefundStatus.PARTIAL_REFUNDING));
    isTrue(orderWholeRefunding.getRefunds().get(0).getStatus().equals(
        RefundStatus.REFUNDING));
    isTrue(orderWholeRefunding.getRefunds().get(0).getItemId().get().equals(
        item.getId()));

    // 构建退款的回调信息
    List<Payment> payments = findPayments(order.getId());
    List<Refund> refunds = findRefunds(order.getId());
    wxRefundNotify(payments.get(0), refunds.get(0), WxRefundStatus.SUCCESS);

    // 检查退款后状态
    OrderWhole orderWholeFinish = assertRefund(order, FULL_PAID, 
        PARTIAL_REFUND_OK, RefundStatus.SUCCESS, RefundMode.ITEM, false);
    isTrue(orderWholeFinish.getRefundFee() == item.getPaidPrice());
    isTrue(orderWholeFinish.getIncomes().stream()
        .filter(i -> i.getAmount() < 0).count() == 1);
}

单元测试执行

单元测试有很多种执行方式:
- 在 IDE 中执行
- 通过 mvn 或者 gradle 运行
- 在 CI 中执行

不论什么方式,单元测试都应该很容易就能运行,并给出一个测试结果。当然,单元测试运行速度得快,一般是在秒级的,太慢的话就不能及时获得反馈了。

为什么要写单元测试?

单元测试的好处

  • 确保代码满足需求或者设计规格。 使用单元测试来测试代码,可以通过构造数据和前置条件,确保测试覆盖到需要测试的逻辑。而手工测试或 UI 测试则无法做到,并且往往更复杂。
  • 快速定位并解决问题。 单元测试因为测试范围比较小,可以比较容易的定位到问题;而手工测试,常常需要耗费不少时间去定位问题。
  • 确保代码永远满足需求规格。 一旦需要对实现进行修改,单元测试可以确保代码的正确性,极大的降低各种修改和重构的风险。特别是避免那些在意想不到之处出现的 BUG。
  • 简化系统集成。 单元测试确保了系统或模块本身的正确性,集成时更不容易出错。
  • 提高代码质量和可维护性。 不可测试的代码,其本身的抽象性、模块性、可维护性是有些问题的。例如不符合单一职责、接口隔离等设计原则,或者依赖了全局变量。可测试的代码,往往其质量相对会高一些。
  • 提供文档和说明。 单元测试本身就是接口使用方法的很好的案例。

持续集成和持续交付

2010 年前后,大部分互联网公司的系统部署还是通过手工的方式进行的,往往要在半夜上线系统。但是之后持续集成、持续交付的理念不断推广,部署过程越来越灵活、顺畅。而单元测试则是持续集成和持续交付里重要的一环。

持续集成就是 Continuous Integration(CI),也就是指从开发上传代码、自动构建和测试、最后反馈结果的过程。

持续集成

更进一步,如果自动构建和测试后,会自动发布到测试环境或预发布环境,执行更多测试(集成测试、自动化 UI 测试等),甚至最后直接发布,那这一过程就是持续交付(Continuous Delivery,CD)。业内有不少公司,比如亚马逊、Esty,可以做到每天几十甚至成百上千次生产环境部署,就是因为有比较完善的持续交付环境。

CI 已经是互联网行业必备标准,CD 也在互联网行业有了越来越多的实践,但是如果没有单元测试这一环节,CI 和 CD 的过程是有缺陷的。

怎么写单元测试?

JUnit 简介

基本上每种语言和框架都有不错的单元测试框架和工具,例如 Java 的 JUnit、Scala 的 ScalaTest、Python 的 unittest、JavaScript 的 Jest 等。上面的例子都是基于 JUnit 的,我们下面就简单介绍下 JUnit。

  • JUnit 里面每个 @Test 注解的方法,就是一个测试。@Ignore 可以忽略一个测试。@Before、@BeforeClass、@After、@AfterClass 可以在测试执行前后插入一些通用的操作,比如初始化和资源释放等等。
  • 除了 assertEquals,JUnit 也支持不少其他的 assert 方法。例如 assertNull、assertArrayEquals​、assertThrows、assertTimeout​ 等。另外也可以用第三方的 assert 库比如 Spring 的 Assert 或者 AssertJ。
  • 除了可以测试普通的代码逻辑,JUnit 也可以进行异常测试和时间测试。异常测试是测试某段代码必须抛指定的异常,时间测试则是测试代码执行时间在一定范围内。
  • 也可以对测试进行分组。例如可以分成 contractTest 、mockTest 和 unitTest,通过参数指定执行某个分组的测试。

这里就不做过多介绍了,想了解更多 JUnit 的可以去看 极客学院的 JUnit 教程 等资料。其他的单元测试框架,基本功能都是大同小异。

使用测试 Double

狭义的单元测试,我们是只测试单元本身。即使我们写的是广义的单元测试,它依然可能依赖其他模块,比如其他类的方法、第三方服务调用或者数据库查询等等,造成我们无法很方便的测试被测系统或模块。这时我们就需要使用测试 Double 了。

如果细究的话,测试 Double 分成好多种,比如什么 Dummies、Fakes 等等。但我认为我们只要弄清两类就可以了,也就是 Stub 和 Mock。

Stub

Stub 指那些包含了预定义好的数据并且在测试时返回给调用者的对象。Stub 常被用于我们不希望返回真实数据或者造成其他副作用的场景。

我们契约测试生成的、可以通过 spring cloud stubrunner 运行的 Stub Jar 就是一个 Stub。我们可以让 Stub 返回预设好的假数据,然后在单元测试里就可以依赖这些数据,对代码进行测试。例如,我们可以让用户查询 Stub 根据参数里的用户 ID 返回认证用户和未认证用户,然后我们就可以测试调用方在这两种情况下的处理逻辑了。

当然,Stub 也可以不是远程服务,而是另外一个类。所以我们经常说要针对接口编程,因为这样我们就可以很容易的创建一个接口的 Stub 实现,从而替换具体的类。

public class StubNameService implement NameService {
    public String get(String userId) {
        return "Mock user name";
    }
}

public class UserServiceTest {
    // UserService 依赖 NameService,会调用其 get 方法
    @Inject
    private UserService userService;    

    @Test
    public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
        userService.setNameService(new StubNameService());
        String testName = userService.getUserName("SomeId");
        Assert.assertEquals("Mock user name", testName);
    }
}

不过这样要实现很多 Stub 也是很麻烦的,现在我们已经不需要自己创建 Stub 了,因为有了各种 Mock 工具。

Mock

Mocks 指那些可以记录它们的调用信息的对象,在测试断言中我们可以验证 Mocks 被进行了符合期望的调用。

Mock 和 Stub 的区别在于,Stub 只是提供一些数据,它并不进行验证,或者只是基于状态做一些验证;而 Mock 除了可以做 Stub 的事情,也可以基于调用行为进行验证。比如说,Mock 可以验证 Mock 接口被调用了不多不少正好两次,并且调用的参数是期望的数值。

Java 里最常用的 Mock 工具就是 Mockito 了。我们来看一个简单的例子,下面的 UserService 依赖 NameService。当我们测试 UserService 的时候,我们希望隔离 NameService,那么就可以创建一个 Mock 的 NameService 注入到 UserService 中(在 Spring 里只需要用 @Mock 和 @InjectMocks 两个注解就可以完成了)。

public class UserServiceTest {
    @InjectMocks
    private UserService userService;
    @Mock
    private NameService nameService;

    @Test
    public void whenUserIdIsProvided_thenRetrievedNameIsCorrect() {
        Mockito.when(nameService.getUserName("SomeId")).thenReturn("Mock user name");
        String testName = userService.getUserName("SomeId");
        Assert.assertEquals("Mock user name", testName);
        Mockito.verify(nameService).getUserName("SomeId");
    }
}

注意上面最后一行,是验证 nameService 的 getUserName 被调用,并且参数为 "SomeId"。更多关于 Mockito 的内容,可以参考 Mockito 的文档

契约测试

契约测试会给每个服务生成一个 Stub,可以用于调用方的单元/集成测试。例如,我们需要测试预约服务的预约操作,而预约操作会调用用户服务,去验证用户的一些基本信息,比如医生是否认证等。

所以,我们可以通过传入不同的用户 ID,让契约 Stub 返回不同状态的用户数据,从而验证不同的处理流程。例如,正常的预约流程的测试用例可能是这样的。

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
@AutoConfigureStubRunner(repositoryRoot="http://<nexus_root>",
        ids = {"com.xingren.service:user-client-stubs:1.0.0:stubs:6565"})
public class BookingTest {
    // BookingService 会调用用户服务,获取医生认证状态后进行不同的处理
    @Inject 
    private BookingService bookingService;
    @Test
    public void testBooking() {
        BookingForm form = new BookingForm(
            1,                      // doctorId
            1,                      // scheduleId
            1001);                  // patientId
        BookVO res = bookingService.book(form);
        assertTrue(res.id > 0);
        assertTrue(res.payStatus == PayStatus.UN_PAY);
    }
}

注意上面的 AutoConfigureStubRunner 注解就是设置并启动了用户服务 Stub,当然在测试的时候,我们需要把服务调用接口的 baseUrl 设置为http://localhost:6565。关于契约测试的更多内容,请参考微服务环境下的集成测试探索一文。

TDD

简单说下 Test Driven Development,也就是 TDD。左耳朵耗子就写了一篇TDD并不是看上去的那么美,我就直接引用其介绍了。

其开发过程是从功能需求的test case开始,先添加一个test case,然后运行所有的test case看看有没有问题,再实现test case所要测试的功能,然后再运行test case,查看是否有case失败,然后重构代码,再重复以上步骤。

其实严格的 TDD 流程实用性并不高,左耳朵耗子本身也是持批判态度。但是对于接口定义比较明确的模块,先写单元测试再写实现代码还是有很大好处的。因为目标清晰,而且可以立刻得到反馈。

如何设计单元测试?

单元测试设计方法

单元测试用例,和普通测试用例的设计,没有太多不同,常见的就是等价类划分、边界值分析等。而测试用例的设计其实也是开发者应该掌握的基本技能。

等价类划分

把所有输入划分为若干分类,从每个分类中选取少数有代表性的数据做为测试用例。

例如,一个方法计算输入参数的绝对值的倒数,如果是输入是 0,则抛异常。那么对这个方法写测试的话,就应该有三个等价类,输入是负数、0 以及正数。所以我可以选取一个负数、一个正数以及 0 来设计三个测试用例。

再举个例子,某个方法是根据医生的认证状态,发送不同的消息。那么等价类可能有三种,未认证、普通认证但无权威认证、普通认证且权威认证,某些情况下可能还会包括无普通认证但有威认证。

边界值分析

边界值是指划分等价类后,在边界附近的一些输入数据,这些输入往往是最容易出错的。

例如,对于上面计算绝对值的倒数的例子,那么边界值就包括 Integer.min、-1、0、1、Integer.max 等。再举个例子,文本框输入范围为 1 - 255 个字符,那么等价类按输入长度划分有三类 0、1 到 255、大于 255,而边界值则包括 0、1、2、254、255、256 等。

其他类似于空数组、数组的第一个和最后一个、报表的第一行和最后一行等等,也是属于边界值,需要特别关注。

判定表法

当我们由多个输入数据时,可以将这些数据的等价类的组合以表格的形式列举出来,然后设计测试用例。下面是一个例子(没有完全列举)。

用例 医生是否设置需要确认 医生是否设置免费咨询 医生是否已经确认患者 患者是否已经完善信息 期望结果
用例A 患者可以咨询医生
用例B 患者不能咨询医生
用例C 患者可以咨询医生
用例D 患者不能咨询医生
用例E 患者不能咨询医生

其他方法

除了上面提到的几种,测试设计方法还有几种常用的:
- 场景法。场景法是根据模块实际使用的场景,例如 API 的实际调用方法、系统的实际需求场景和处理逻辑创建的用例。这种方法比较直观,并且用例贴近实际需求的,不可忽视。
- 错误推测。错误推测其实就是凭直觉,考虑最容易出错的情况来设计用例。例如,我们直到新用户、重复请求、并发、弱网、大数据量等情况都是非常容易出错的,那么可以针对性的设计用例。错误推测需要测试设计者比较熟悉业务逻辑,并且经验丰富。

其他还有因果图、正交法等方法,这里就不说了。

覆盖率

如果按照前面的用例设计方法,可能会设计出很多用例。我们不可能也没有必要把每一个用例都写成单元测试。

怎么确认用例是否足够呢?一个很重要的参考指标就是代码覆盖率。

覆盖率指标

常用的覆盖率指标有四种:
- 语句覆盖:每条语句至少执行一次。
- 分支覆盖:每个分支至少有一次为真、一次为假。
- 条件覆盖:每个分支的每个条件至少有一次为真、一次为假。
- 路径覆盖:对所有的分支、循环等可能的路径,至少都要覆盖一次。

我们以这个简单的代码为例,看看这四种覆盖率到底是什么意思。

if (a && b) {
    // X    
}
// Y
if (c || d) {
    // X
}
  • 语句覆盖。只需要一个测试用例,让 a && bc || d 都为真,系统会依次执行 X、Y、Z 三个的代码段,就能做到语句覆盖。
  • 分支覆盖。至少需要两个测试用例,让 a && bc || d 都各为真假,例如用例1 a && b 为真和 c || d 为假,用例2 则反过来,既可让两个条件分支都各为真一次,为假一次。
  • 条件覆盖。至少需要四个测试用例,条件 a 和 b 的四种组合都要执行一次,条件 c 和 d 的四种组合也都要执行一次。
  • 路径覆盖。至少需要八个测试用例,条件 a、b、c 和 d 的所有组合都要执行一次。

可以看到,要做到条件覆盖甚至路径覆盖,会需要非常多的测试用例。一般情况,对于复杂的逻辑,单元测试做到分支覆盖就不错了,必要的话再做更多完全的覆盖。

Jacoco 覆盖

Jacoco 的覆盖率略有不同,这里简单说一下。
- 指令覆盖(Instructions),覆盖所有的 Java 代码指令。
- 分支覆盖(Branches),和上面的分支覆盖基本是一样的。
- 圈复杂度覆盖(Cyclomatic Complexity),可以认为就是路径覆盖率。
- 语句覆盖(Lines),和上面的语句覆盖基本是一样的。
- 方法覆盖(Methods),覆盖所有的方法。
- 类覆盖(Classes),覆盖所有的类。

怎么写有效的单元测试?

到现在,相信大家对怎么写单元测试应该有一定概念了。但是很多人也会有疑问:
- 单元测试耗费太多时间,会不会降低生产效率?
- 单元测试会不会很难维护?比如修改代码时还总是需要修改单元测试。

关于第一个问题,相信大家应该都能理解,如果我们在开发时发现 BUG,那么解决它是很容易的;但是一旦到了集成、验收甚至上线之后,那么要解决它就要花费比较大的代价了。业界很早就有共识,并且有不少数据可以证明,有效的单元测试虽然要花费更多编码时间,但是可以很大的减少项目的集成、测试和维护成本。

注意上面提到很重要一点是,单元测试必须是有效的,如果我们发现单元测试很难维护,那往往是因为我们没有写出有效的单元测试。

不是所有的代码都需要单元测试

写单元测试我们也需要考虑投入产出比,例如下面这些情况,写单元测试的投入产出比可能会较差。
- 短期的或者一次性的项目,例如 Demo、数据更新脚本。
- 业务简单的,不含太多逻辑的模块。例如获取或者查找一个数据,或者没有分支条件的业务逻辑等。
- UI 层,相对而言比较难做单元测试,除非 UI 本身就有比较复杂的逻辑(其实某些 UI 框架也提供了单元测试工具)。

那么那些情况下要写单元测试呢?简单来说,就是两类。
- 逻辑复杂、不容易理解、容易出错的模块。例如,计算闰年的方法、订单下单等。
- 公共模块或者核心的业务模块。

即使对于需要写单元测试的模块,我们也应该关注最核心最重要的测试用例,而没必要单纯的追求覆盖率,或者追求条件覆盖甚至路径覆盖,一般做到分支覆盖就可以了。另外一个有效的方法是,对于出现的每一个 BUG,添加一个单元测试。

单元测试应该是稳定的

这里稳定的第一个含义是,单元测试不应该经常需要修改。如果单元测试经常因为底层实现逻辑的变动而需要修改,那一定不是好的单元测试。也就是说,被测单元的接口应该是稳定的、设计良好的、易于扩展的。

稳定的第二个含义是,单元测试的结果应该是稳定的。如果在不同的环境、不同的情况运行单元测试,会返回不同的结果,那就不是好的单元测试。如果测试需要依赖特定的数据、文件等,那需要有前置的初始化脚本确保依赖的数据、文件在所有环境都存在并且是一致的。

单元测试应该是灰盒测试

单元测试应该覆盖核心逻辑的各种分支、边界及异常,但是避免涉及易变的实现逻辑。也就是说,我们不应该把单元测试当成完全的白盒测试,但也不是黑盒测试,而应该把它当成介于白盒和黑盒之间的灰盒测试。

被测代码应该是抽象良好的

如果我们发现一段代码很难编写单元测试,常常是因为这段代码没有符合良好的抽象规范,比如没有使用 DI、不符合单一职责原则、或者依赖了全局的公共变量和方法等等。我们可以考虑优化这段代码,再来尝试单元单元测试。

谈谈到底什么是抽象,以及软件设计的抽象原则 介绍了软件抽象的原则,这里就不再重复了。

编码时就应该同时写好单元测试

这样我们才能在调试时就发挥单元测试的优势,对代码的任何修改都能得到即时反馈。如果是后面再补充单元测试,一方面对实现可能已经不太熟悉了,编写测试的代价更大了;另一方面,单元测试能发挥的作用也变小了。不过即使这样,对那些需要长远维护的项目,编写单元测试也还是很有用的。

单元测试的代码质量也很重要

单元测试也是代码,也是需要不断维护的。所以我们不应该随随便便的去写单元测试,而是要把他们也当成普通代码一样,要做到高质量、模块化、可维护。

为什么要写单元测试之终极原因

终极原因是,作为一名优秀的工程师,如果被 QA 和产品经理 Challenge 有 BUG,能忍吗?而我们工程师当然要用工程师 Style 的测试方法,那就是自动化的单元测试了,不是吗?

参考

Software Testing Anti-patterns

0

理解 RabbitMQ Exchange

AMQP 简介

在了解 RabbitMQ 的 Exchange(交换机)的概念之前,我们首先要对 RabbitMQ 相关的概念和名词有一个大概的了解,RabbitMQ 是 AMQP(高级消息队列协议)的标准实现:

从 AMQP 协议可以看出,Queue、Exchange 和 Binding 构成了 AMQP 协议的核心

  • Producer:消息生产者,即投递消息的程序。
  • Broker:消息队列服务器实体。
    • Exchange:消息交换机,它指定消息按什么规则,路由到哪个队列。
    • Binding:绑定,它的作用就是把 Exchange 和 Queue 按照路由规则绑定起来。
    • Queue:消息队列载体,每个消息都会被投入到一个或多个队列。
  • Consumer:消息消费者,即接受消息的程序。

Exchange 简介

那么为什么我们需要 Exchange 而不是直接将消息发送至队列呢?

AMQP 协议中的核心思想就是生产者和消费者的解耦,生产者从不直接将消息发送给队列。生产者通常不知道是否一个消息会被发送到队列中,只是将消息发送到一个交换机。先由 Exchange 来接收,然后 Exchange 按照特定的策略转发到 Queue 进行存储。Exchange 就类似于一个交换机,将各个消息分发到相应的队列中。

qq

在实际应用中我们只需要定义好 Exchange 的路由策略,而生产者则不需要关心消息会发送到哪个 Queue 或被哪些 Consumer 消费。在这种模式下生产者只面向 Exchange 发布消息,消费者只面向 Queue 消费消息,Exchange 定义了消息路由到 Queue 的规则,将各个层面的消息传递隔离开,使每一层只需要关心自己面向的下一层,降低了整体的耦合度。

理解 Exchange

Exchange 收到消息时,他是如何知道需要发送至哪些 Queue 呢?这里就需要了解 Binding 和 RoutingKey 的概念:

Binding 表示 Exchange 与 Queue 之间的关系,我们也可以简单的认为队列对该交换机上的消息感兴趣,绑定可以附带一个额外的参数 RoutingKey。Exchange 就是根据这个 RoutingKey 和当前 Exchange 所有绑定的 Binding 做匹配,如果满足匹配,就往 Exchange 所绑定的 Queue 发送消息,这样就解决了我们向 RabbitMQ 发送一次消息,可以分发到不同的 Queue。RoutingKey 的意义依赖于交换机的类型。

下面就来了解一下 Exchange 的三种主要类型:FanoutDirectTopic

Fanout Exchange

5

Fanout Exchange 会忽略 RoutingKey 的设置,直接将 Message 广播到所有绑定的 Queue 中。

应用场景

以日志系统为例:假设我们定义了一个 Exchange 来接收日志消息,同时定义了两个 Queue 来存储消息:一个记录将被打印到控制台的日志消息;另一个记录将被写入磁盘文件的日志消息。我们希望 Exchange 接收到的每一条消息都会同时被转发到两个 Queue,这种场景下就可以使用 Fanout Exchange 来广播消息到所有绑定的 Queue。

Direct Exchange

4

Direct Exchange 是 RabbitMQ 默认的 Exchange,完全根据 RoutingKey 来路由消息。设置 Exchange 和 Queue 的 Binding 时需指定 RoutingKey(一般为 Queue Name),发消息时也指定一样的 RoutingKey,消息就会被路由到对应的Queue。

应用场景

现在我们考虑只把重要的日志消息写入磁盘文件,例如只把 Error 级别的日志发送给负责记录写入磁盘文件的 Queue。这种场景下我们可以使用指定的 RoutingKey(例如 error)将写入磁盘文件的 Queue 绑定到 Direct Exchange 上。

Topic Exchange

6

Topic Exchange 和 Direct Exchange 类似,也需要通过 RoutingKey 来路由消息,区别在于Direct Exchange 对 RoutingKey 是精确匹配,而 Topic Exchange 支持模糊匹配。分别支持*#通配符,*表示匹配一个单词,#则表示匹配没有或者多个单词。

应用场景

假设我们的消息路由规则除了需要根据日志级别来分发之外还需要根据消息来源分发,可以将 RoutingKey 定义为 消息来源.级别order.infouser.error 等。处理所有来源为 user 的 Queue 就可以通过 user.* 绑定到 Topic Exchange 上,而处理所有日志级别为 info 的 Queue 可以通过 *.info 绑定到 Exchange上。

两种特殊的 Exchange

Headers Exchange

Headers Exchange 会忽略 RoutingKey 而根据消息中的 Headers 和创建绑定关系时指定的 Arguments 来匹配决定路由到哪些 Queue。

Headers Exchange 的性能比较差,而且 Direct Exchange 完全可以代替它,所以不建议使用。

Default Exchange

Default Exchange 是一种特殊的 Direct Exchange。当你手动创建一个队列时,后台会自动将这个队列绑定到一个名称为空的 Direct Exchange 上,绑定 RoutingKey 与队列名称相同。有了这个默认的交换机和绑定,使我们只关心队列这一层即可,这个比较适合做一些简单的应用。

总结

在 Exchange 的基础上我们可以通过比较简单的配置绑定关系来灵活的使用消息路由,在简单的应用中也可以直接使用 RabbitMQ 提供的 Default Exchange 而无需关心 Exchange 和绑定关系。Direct Exchange、Topic Exchange、Fanout Exchange 三种类型的交换机的使用方式也很简单,容易掌握。

0

iOS 下的图片处理与性能优化

移动开发中我们经常和多媒体数据打交道,对这些数据的解析往往需要耗费大量资源,属于常见的性能瓶颈。

本文针对多媒体数据的一种———图片,介绍下图片的常见格式,它们如何在移动平台上被传输、存储和展示,以及优化图片显示性能的一种方法:强制子线程解码。

图片在计算机世界中怎样被存储和表示?

图片和其他所有资源一样,在内存中本质上都是0和1的二进制数据,计算机需要将这些原始内容渲染成人眼能观察的图片,反过来,也需要将图片以合适的形式保存在存储器或者在网络上传送。

下面是一张图片在硬盘中的原始十六进制表示:

一张图片在计算机中的原始数据

这种将图片以某种规则进行二进制编码的方式,就是图片的格式。

常见的图片格式

图片的格式有很多种,除了我们熟知的 JPG、PNG、GIF,还有Webp,BMP,TIFF,CDR 等等几十种,用于不同的场景或平台。

图片的常见格式

这些格式可以分为两大类:有损压缩无损压缩

有损压缩:相较于颜色,人眼对光线亮度信息更为敏感,基于此,通过合并图片中的颜色信息,保留亮度信息,可以在尽量不影响图片观感的前提下减少存储体积。顾名思义,这样压缩后的图片将会永久损失一些细节。最典型的有损压缩格式是 jpg。

不同压缩比例的jpg图片

无损压缩:和有损压缩不同,无损压缩不会损失图片细节。它降低图片体积的方式是通过索引,对图片中不同的颜色特征建立索引表,减少了重复的颜色数据,从而达到压缩的效果。常见的无损压缩格式是 png,gif。

除了上述提到的格式,有必要再简单介绍下 webpbitmap这两种格式:

Webp:jpg 作为主流的网络图片标准可以向上追溯到九十年代初期,已经十分古老了。所以谷歌公司推出了Webp标准意图替代陈旧的jpg,以加快网络图片的加载速度,提高图片压缩质量。

webp 同时支持有损和无损两种压缩方式,压缩率也很高,无损压缩后的 webp 比 png 少了45%的体积,相同质量的 webp 和 jpg,前者也能节省一半的流量。同时 webp 还支持动图,可谓图片压缩格式的集大成者。

webp的体积对比

webp 的缺点是浏览器和移动端支持还不是很完善,我们需要引入谷歌的 libwebp 框架,编解码也会消耗相对更多的资源。

bitmap:bitmap 又叫位图文件,它是一种非压缩的图片格式,所以体积非常大。所谓的非压缩,就是图片每个像素的原始信息在存储器中依次排列,一张典型的1920*1080像素的 bitmap 图片,每个像素由 RGBA 四个字节表示颜色,那么它的体积就是 1920 * 1080 * 4 = 1012.5kb。

由于 bitmap 简单顺序存储图片的像素信息,它可以不经过解码就直接被渲染到 UI 上。实际上,其它格式的图片一般都需要先被首先解码为 bitmap,然后才能渲染到界面上。

如何判断图片的格式?

在一些场景中,我们需要手动去判断图片数据的格式,以进行不同的处理。一般来说,只要拿到原始的二进制数据,根据不同压缩格式的编码特征,就可以进行简单的分类了。以下是一些图片框架的常用实现,可以复制使用:

+ (XRImageFormat)imageFormatForImageData:(nullable NSData *)data {
    if (!data) {
        return XRImageFormatUndefined;
    }

    uint8_t c;
    [data getBytes:&c length:1];
    switch (c) {
        case 0xFF:
            return XRImageFormatJPEG;
        case 0x89:
            return XRImageFormatPNG;
        case 0x47:
            return XRImageFormatGIF;
        case 0x49:
        case 0x4D:
            return XRImageFormatTIFF;
        case 0x52:

            if (data.length < 12) {
                return XRImageFormatUndefined;
            }

            NSString *testString = [[NSString alloc] initWithData:[data subdataWithRange:NSMakeRange(0, 12)] encoding:NSASCIIStringEncoding];
            if ([testString hasPrefix:@"RIFF"] && [testString hasSuffix:@"WEBP"]) {
                return XRImageFormatWebP;
            }
    }
    return XRImageFormatUndefined;
}

UIImageView 的性能瓶颈

如上文所说,大部分格式的图片,都需要被首先解码为bitmap,然后才能渲染到UI上。

UIImageView 显示图片,也有类似的过程。实际上,一张图片从在文件系统中,到被显示到 UIImageView,会经历以下几个步骤:

  1. 分配内存缓冲区和其它资源。
  2. 从磁盘拷贝数据到内核缓冲区
  3. 从内核缓冲区复制数据到用户空间
  4. 生成UIImageView,把图像数据赋值给UIImageView
  5. 将压缩的图片数据,解码为位图数据(bitmap),如果数据没有字节对齐,Core Animation会再拷贝一份数据,进行字节对齐。
  6. CATransaction捕获到UIImageView layer树的变化,主线程Runloop提交CATransaction,开始进行图像渲染
  7. GPU处理位图数据,进行渲染。

由于 UIKit 的封装性,这些细节不会直接对开发者展示。实际上,当我们调用[UIImage imageNamed:@"xxx"]后,UIImage 中存储的是未解码的图片,而调用 [UIImageView setImage:image]后,会在主线程进行图片的解码工作并且将图片显示到 UI 上,这时候,UIImage 中存储的是解码后的 bitmap 数据。

而图片的解压缩是一个非常消耗 CPU 资源的工作,如果我们有大量的图片需要展示到列表中,将会大大拖慢系统的响应速度,降低运行帧率。这就是 UIImageView 的一个性能瓶颈。

解决性能瓶颈:强制解码

如果 UIImage 中存储的是已经解码后的数据,速度就会快很多,所以优化的思路就是:在子线程中对图片原始数据进行强制解码,再将解码后的图片抛回主线程继续使用,从而提高主线程的响应速度。

我们需要使用的工具是 Core Graphics 框架的 CGBitmapContextCreate 方法和相关的绘制函数。总体的步骤是:

A. 创建一个指定大小和格式的 bitmap context。
B. 将未解码图片写入到这个 context 中,这个过程包含了强制解码
C. 从这个 context 中创建新的 UIImage 对象,返回。

下面是 SDWebImage 实现的核心代码,编号对应的解析在下文中:

// 1.
CGImageRef imageRef = image.CGImage;

// 2.
CGColorSpaceRef colorspaceRef = [UIImage colorSpaceForImageRef:imageRef];

size_t width = CGImageGetWidth(imageRef);
size_t height = CGImageGetHeight(imageRef);

// 3.
size_t bytesPerRow = 4 * width;

// 4.
CGContextRef context = CGBitmapContextCreate(NULL,
                                             width,
                                             height,
                                             kBitsPerComponent,
                                             bytesPerRow,
                                             colorspaceRef,
                                             kCGBitmapByteOrderDefault|kCGImageAlphaNoneSkipLast);
if (context == NULL) {
    return image;
}

// 5.
CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);

// 6.
CGImageRef newImageRef = CGBitmapContextCreateImage(context);

// 7.
UIImage *newImage = [UIImage imageWithCGImage:newImageRef
                                        scale:image.scale
                                  orientation:image.imageOrientation];

CGContextRelease(context);
CGImageRelease(newImageRef);

return newImage;

对上述代码的解析:

1、从 UIImage 对象中获取 CGImageRef 的引用。这两个结构是苹果在不同层级上对图片的表示方式,UIImage 属于 UIKit,是 UI 层级图片的抽象,用于图片的展示;CGImageRef 是 QuartzCore 中的一个结构体指针,用C语言编写,用来创建像素位图,可以通过操作存储的像素位来编辑图片。这两种结构可以方便的互转:

// CGImageRef 转换成 UIImage
CGImageRef imageRef = CGBitmapContextCreateImage(context);
UIImage *image = [UIImage imageWithCGImage:imageRef];

// UIImage 转换成 CGImageRef
UIImage *image=[UIImage imageNamed:@"xxx"];
CGImageRef imageRef=loadImage.CGImage;

2、调用 UIImage 的 +colorSpaceForImageRef: 方法来获取原始图片的颜色空间参数。

什么叫颜色空间呢,就是对相同颜色数值的解释方式,比如说一个像素的数据是(FF0000FF),在 RGBA 颜色空间中,会被解释为红色,而在 BGRA 颜色空间中,则会被解释为蓝色。所以我们需要提取出这个参数,保证解码前后的图片颜色空间一致。

颜色空间的对比

CoreGraphic中支持的颜色空间类型:

颜色空间

3、计算图片解码后每行需要的比特数,由两个参数相乘得到:每行的像素数 width,和存储一个像素需要的比特数4。

这里的4,其实是由每张图片的像素格式像素组合来决定的,下表是苹果平台支持的像素组合方式。

像素组合

表中的bpp,表示每个像素需要多少位;bpc表示颜色的每个分量,需要多少位。具体的解释方式,可以看下面这张图:

像素组合

我们解码后的图片,默认采用 kCGImageAlphaNoneSkipLast RGB 的像素组合,没有 alpha 通道,每个像素32位4个字节,前三个字节代表红绿蓝三个通道,最后一个字节废弃不被解释。

4、最关键的函数:调用 CGBitmapContextCreate() 方法,生成一个空白的图片绘制上下文,我们传入了上述的一些参数,指定了图片的大小、颜色空间、像素排列等等属性。

5、调用 CGContextDrawImage() 方法,将未解码的 imageRef 指针内容,写入到我们创建的上下文中,这个步骤,完成了隐式的解码工作。

6、从 context 上下文中创建一个新的 imageRef,这是解码后的图片了。

7、从 imageRef 生成供UI层使用的 UIImage 对象,同时指定图片的 scaleorientation 两个参数。

scale 指的是图片被渲染时需要被压缩的倍数,为什么会存在这个参数呢,因为苹果为了节省安装包体积,允许开发者为同一张图片上传不同分辨率的版本,也就是我们熟悉的@2x,@3x后缀图片。不同屏幕素质的设备,会获取到对应的资源。为了绘制图片时统一,这些图片会被set自己的scale属性,比如@2x图片,scale 值就是2,虽然和1x图片的绘制宽高一样,但是实际的长是width * scale

orientation 很好理解,就是图片的旋转属性,告诉设备,以哪个方向作为图片的默认方向来渲染。

通过以上的步骤,我们成功在子线程中对图片进行了强制转码,回调给主线程使用,从而大大提高了图片的渲染效率。这也是现在主流 App 和大量三方库的最佳实践。

总结

总结一下本文内容:

  • 图片在计算机世界中被按照不同的封装格式进行压缩,以便存储和传输。
  • 手机会在主线程中将压缩的图片解压为可以进行渲染的位图格式,这个过程会消耗大量资源,影响App性能。
  • 我们使用 Core Graphics 的绘制方法,强制在子线程中先对 UIImage 进行转码工作,减少主线程的负担,从而提升App的响应速度。

和 UIImageView 类似,UIKit 隐藏了很多技术细节,降低开发者的学习门槛,但另一方面,却也限制了我们对一些底层技术的探究。文中提到的强制解码方法,其实也是 CGBitmapContextCreate 方法的一个『副作用』,属于比较hack方式,这也是iOS平台的一个局限:苹果过于封闭了。

用户对软件性能(帧率、响应速度、闪退率等等)其实非常敏感,作为开发者,必须不断探究性能瓶颈背后的原理,并且尝试解决,移动端开发的性能优化永无止境。

0