从 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

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

为什么要讲逻辑思维

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

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

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

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

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

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

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

中文房间问题

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

单元测试——工程师 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

不懂产品的研发,不是好 CTO

在做产品经理之前,我是传说中第三种人类:程序媛。曾经,被产品经理虐过,吐槽过产品经理;现在,也虐过研发同学,也被研发同学吐槽过。因为做过研发也做过产品,可谓知己知彼,深刻的体会到双方沟通中的痛点,希望本文能够帮助研发同学更好的和产品经理沟(si)通(bi)。

什么是产品?

产品是指能够供给市场,被人们使用和消费,并能满足人们某种需求的任何东西,包括有形的物品、无形的服务、组织、观念或它们的组合。

比如,手机满足人们通讯需求,是有形的硬件产品;微信满足人们社交需求,是无形的软件产品;家政服务满足人们清洁需求,是无形的服务产品……

其实,简单来说,产品就是能满足人们某种需求的东西。

什么是好产品?

从以下三组图片中,选择你认为好的产品。

幻灯片02

幻灯片03

幻灯片04

第一组,相信大家都会选择小米遥控器。因为相比传统的遥控器,小米遥控器设计简洁,按键功能一目了然,不用看说明书就知道如何使用。

第二组,每个人的选择都不一样。对于普通用户来说,美图秀秀是个好产品,好用、容易上手,而 PS 完全不知道从何下手。但对于专业设计师来说,美图秀秀就是个渣渣,根本满足不了工作需求。

第三组,大家可能没怎么接触过,通过这两款产品可以实时查询公交的位置,还有几分钟到达相应的站点。仅凭一个界面,有些人会觉得车来了是好产品,因为界面干净、整洁、交互也特别好,而上海公交查询体验特别差。但事实上,我把车来了这个 App 卸载了,却一直在使用上海公交查询,因为它提供的数据非常准确。对我来说,这两个产品都算不上是好产品。

所以,好的产品必须同时具有以下三点:

  • 有用
  • 能用
  • 好用

image

人人都是产品经理?伪命题!

每次看到这句话我就特别想翻白眼,甚至想学罗胖来一句,我呸!

这是大家眼中的产品经理:

幻灯片07

你是想卖一辈子糖水,还是跟着我们改变世界

在每个产品经理内心深处都有一个改变世界的梦想。

在父母眼中,产品经理就是公司的核心,不可或缺。

在研发眼中,产品经理就是一个打杂小妹,随叫随到。

在设计眼中,产品经理就是山顶洞人,完全没有审美眼光。

逻辑不如研发,文案不如运营,审美不如设计,视野不如老板。

为什么需要产品经理?

存在即是合理。

老板表示需要一个背锅的:遇到不好用的 App,大家第一反应都是什么 SB 功能,SB 产品经理,但是从来没有人骂 SB 老板;用户吐槽产品很难用,设计可以说这锅我不背,产品经理设计的流程就是这样的;项目进度延迟,研发可以说这锅我不背,产品经理需求变更频繁……

任何人都可以甩锅,唯独产品经理不可以,任何与产品相关的锅产品经理都要背,这些都是产品经理的责任,不可推卸。

研发、设计表示需要一个打杂的:缺文案,产品经理帮你写;缺数据,产品经理帮你整理;需求开发好了,产品经理还要帮忙验收测试……

市场、用户表示需要一个翻译:例如,如何向用户解释什么是 2G、3G、4G 网络。如果直接告诉用户 2G 是数字网络、3G 是高速 IP 数据网络、4G 是全 IP 数据网络,用户肯定一脸懵逼:什么鬼?但如果告诉用户 2G 可以看文字、3G 可以看图片、4G 可以看小电影,用户立马就明白了。

怎样才能成为产品经理?

在大部分人眼里,只要会吐槽就可以做产品经理。

吐槽吐的专业,可以成为评论家;吐槽吐的幽默,可以成为段子手;吐槽吐的群情激奋,可以成为意见领袖;但发现问题仅仅只是产品经理工作中的一小部分。一个优秀的产品经理,必须会分析并解决问题。

我们来看一下产品经理的关键词:

幻灯片11

是不是有种产品经理从入门到放弃的感觉?其实,将这些关键词整理一下,可以分成以下几个维度:

1.人

幻灯片13

实现一个产品功能,产品经理需要全程跟进,在这个过程中,就免不了在多个部门之间沟通协调。

例如,老板想要增加某个功能,那么产品经理首先需要和老板沟通,了解增加该功能的原因,然后简单评估一下需求的优先级,并给出一个合理的解决方案。找设计沟通UI设计及交互逻辑;拿着做好的设计稿找到研发,确定开发计划及上线时间;功能上线后,向市场、客服部门的同事做培训,推广产品功能;产品功能推向市场后,收集用户反馈,处理用户遇到的问题;功能稳定后,采集数据,分析数据,持续运营产品;最后,还需要向老板及相关部门汇报产品数据,复盘总结。

2.能力

幻灯片14

在图中列出的产品经理相关能力中,最核心的能力是学习能力、独立思考、沟通协调能力。这里的沟通能力,并不是指性格开朗、热情大方、幽默风趣这些一般人理解的沟通能力,而是把细节说清楚,专业。把细节描述清楚,没有歧义,双方能达成共识;工作态度专业认真,不过于情绪化。而协调指的是协调所有可用资源,让事情朝着你想要的方向前进。

3.工具&方法&输出

幻灯片16

工具、方法、输出都是手段,不是目的。学以致用才是学习的目的,学了不会用等于白学。

而在实际工作中,光会这些还不够,必须把所有这些都揉碎、重组、融入到工作的每个细节中。

左边是大家看到的路径,右边是产品经理考虑的路径。

幻灯片23

左边是大家看到的需求,右边是产品经理面对的需求。

幻灯片24

左边是大家的工作流程,右边是产品经理的工作流程。

幻灯片25

以杏仁医生App药品登记需求为例,下图是用户和研发看到的。

幻灯片26

而实际上,下图是我考虑的内容。

幻灯片27

看到这里,你还认为产品经理门槛很低吗?人人都是产品经理吗?

产品经理不生产需求,只是需求的搬运工

介绍完产品经理,我们要介绍产品的另一个关键词:需求。产品经理不生产需求,只是需求的搬运工。有人可能要说反对意见,乔布斯不就生产了需求吗?其实乔布斯也没有生产需求。

需求就像煤矿一样,有的随意散落在地表,大家都能看见;可绝大部分有价值的需求就像埋在地底下的煤矿一样,需要专业的人员去挖掘,去采集,去清洗。而产品经理就是这个挖煤的矿工,优秀的产品经理能挖掘到人性最深层的需求。

按照马斯洛需求层次理论,饿了么满足了用户的生理需求,在家也能吃上全国各地的饭菜;支付宝满足了用户的安全需求,给用户提供了资金安全保障;微信满足了用户的社交需求,让用户低成本的与朋友、亲人、同事交流;知乎满足了用户的尊重需求,让用户展示自己出众的知识技能,得到大家的赞赏;得到满足了用户自我实现需求,营造一种充实感,让用户感觉自己每天都在进步……

image__1_

需求需要符合这四个条件:

  • 特定的人
  • 特定的情况下
  • 特定的问题
  • 可以被解决

概括下来就两个字:场景

之前有同事提出,杏仁医生 App 中的药品登记模块需要增加一个批量登记的功能。咋一看是不是很合理?但仔细一想,什么场景下医生会需要批量登记呢?

事实上,并没有这样的场景。医生开药的场景大部分都是先搜索药品,如果没有搜索到合适的药品,就顺手登记一下。医生并不会用小本本记下缺哪些药品,然后批量登记。因此,批量登记功能是一个伪需求。

把问题放到一个具体的场景中可以过滤掉非常多的伪需求、不合理的需求。

需求从哪里来?

1.公司外部

公司外部的需求主要来自用户、合作伙伴、竞争对手。用户经常抱怨某个功能不好用,用户反馈其实就是需求;合作伙伴在和我们合作的过程中,肯定会产生一些新的想法,这些也是需求;竞争对手推出了一些好用的功能抢走了很多用户,为了抢夺市场,也会产生需求。

2.公司内部

公司内部的需求主要来自公司战略、产品规划、内部人员。每年公司都会制定公司一整年的战略计划,产品总监会根据这些战略制定每个季度的产品规划,产品经理再将这些产品规划分解成多个产品功能。在产品迭代的过程中,老板、市场、运营会提各种各样的需求,研发、产品经理也会从技术、产品层面提出需求。

怎么定优先级?

1.刚性

指这个需求是否迫切,实现了这个需求,是不是能立马解决困扰用户很久的问题。

2.广度&频率

广度指这个需求会影响多少用户,多少核心用户。频率指用户使用这个功能的频率。

3.替代方案

指解决这个需求,是否有替代方案。

按照以上原则,规划电商模块功能。第一个版本必须要实现商品展示、商品详情、购物车、下单结算、支付功能,因为这是用户购买过程中最核心的功能。而售后退款相关流程可以放到后续版本,因为在初期售后退款功能并不是很迫切,只影响部分用户,且存在替代方案——人工处理的方式。当用户规模达到一定程度后,再做相关功能的开发。

满足需求的方法?

相信大家都去海底捞吃过火锅,每次去总是人满为患,假设你是海底捞的店长,你怎么解决海底捞排队的问题?满足用户快速吃饭的需求。

1.改变现状

我们能想到最直接的办法,就是扩大门店,增加更多的桌子和服务员。但性价比很低,一是扩张需要成本,需要额外负担装修成本、服务员工资;二是在非吃饭高峰期造成资源浪费,大面积的区域没有使用,部分服务员无事可做。

2.降低期望

在海底捞排队取号时,通常服务员都会告诉你前面还有多少桌,并告诉你大概的排队时间。但这个预估的排队时间通常都比实际等待时间要长一倍到两倍,虽然会吓跑一部分顾客,但留下来排队的顾客体验会好很多。本来以为要排 1 个小时,实际上只排了半个小时,内心会有小小的惊喜:海底捞效率还挺高的。

3.转移需求

现在海底捞的做法就是在排队区域准备一些小零食、水果和小游戏,让顾客一边吃东西玩游戏,一边排队。一方面通过零食和小游戏分散顾客的注意力,缓解顾客排队等候的暴躁情绪;另一方面通过零食和小游戏将大部分顾客牢牢的锁在海底捞,提高订单转化率。

研发与产品的爱恨情仇,本是同根生相煎何太急

经常会在网上看到程序员吐槽产品经理的各种段子、漫画和文章,平时工作中也遇到不少。分析下来,矛盾的根源在于:

  • 频繁变更需求
  • 低估研发成本
  • 需求变更没有通知到位

产品经理防“群殴”指南

image__2_

需求频繁变更,其实说明产品经理在分析需求时缺乏思考,没有多想几个为什么。

不要用战术上的勤奋掩饰战略上的懒惰。

经常有朋友问我:做产品经理和做研发有什么区别?

最大的区别在于研发 80% 的时间用于执行,而产品 80% 的时间用于思考和沟通。

1.考虑未来三个月内的扩展性

在做一些比较大的功能模块时,不能只考虑第一个版本的规划,还需要考虑未来两到三个版本的功能。可以将版本规划提前告知研发团队,让他们了解未来产品要做成什么样子,哪些功能必须要做,哪些功能可能要优化。这样研发同学就可以提前做相关技术调研,规划技术架构,提高代码的可扩展性。

2.考虑常见的特殊情况

最常见的情况,调后台接口,展示列表数据。此时就要考虑很多种特殊情况:

  1. 没有网络
  2. 网络条件不好
  3. WiFi网络环境和非WiFi网络环境
  4. 没有数据
  5. 少量数据
  6. 大量数据

再比如,电商业务中最常见的订单,需要梳理整个过程中的状态,并画出各个状态之间的转化示意图。只有把所有状态都列清楚,才能确认逻辑是否完善,让研发能更好的实现订单流程。

3.不要想满足所有人的需求

相信大家也知道夫妻骑驴的故事,在这个故事中,无论这对夫妻采用何种方案,总会有批评的声音。

image__3_

做产品也是同样的道理。比如,针对患教资料是否显示原创医生的个人信息。部分医生认为应该显示,理由一:这是医生花费大量时间和精力编写的文章,要尊重医生的知识产权;理由二:通过分享患教资料扩大知名度,让更多的陌生患者添加自己;另一部分医生却强烈要求隐藏原创医生的个人信息,这部分医生认为自己分享了其他医生写的患教资料,相当于向自己的患者介绍其他医生,为其他医生做推广。

无论是否显示原创医生的个人信息,总会有部分医生不满。你不可能满足所有用户的需求,在这种情况,坚持做自己认为对的事情就好。

4.MVP( Minimal Viable Product 最小可行性产品)

产品迭代路径应该按照 MVP 原则,先向用户提供能用的产品,再根据用户需求,逐步提供更好用的产品。而不是一上来,就规划一个非常完美的方案,然后花上好几个月甚至一两年来实现,说不定等你实现所谓“完美”的产品,用户需求早已发生翻天覆地的变化。

image__4_

5.适当了解一些技术,适当看一些技术文档

在做患者端小程序前,先查看小程序接口文档,了解以下问题:

  1. 小程序能实现是否支持语音、视频、扫一扫功能
  2. 小程序和公众号如何相互跳转
  3. 小程序通知下发机制
  4. 如何配置 webview 业务域名
  5. webview 组件如何实现微信支付

这样才能规划小程序做哪些功能;如何实现小程序和公众号之间相互引流;如何通知用户;遇到非业务域名如何处理;如何在 webview 中调用小程序微信支付。

之前听过一句玩笑话。

产品经理懂技术等于流氓会武功?

流氓会武功的话,对付一般的小警察还是很有优势的;但如果流氓因为自己会武功,而无法无天,自以为是;等遇到狙击手的时候,直接一枪就被放到了好吗。

同样的道理,产品经理不能仗着自己懂一点技术,就对研发同学指手画脚。懂技术是为了排除需求中的隐患,更好的和研发沟通,提升工作效率。了解一些基础技术架构即可,不要沉迷于技术实现,否则,就是本末倒置。

6.重视文档更新

这个不解释。

研发防“入坑”指南

每次版本总结,总是会有研发表示被产品经理“坑”惨了:需求变更太频繁,需求不靠谱,需求解读错误,需求不明确……除去市场、产品本身存在的问题,大部分问题其实是沟通问题。

1.正确看待需求变更

一提到需求变更,一些研发会皱着眉头改代码,心里 mmp ,怎么又 TM 改需求啊;一些研发会让产品经理签字画押,作为日后打脸的呈堂证供;还有一些研发会直接拒绝,要改你来改啊,老子就不改……但事实上,业务稍微复杂一点的产品都会有需求变更,要求产品没有需求变更,相当于要求研发写的代码没有 bug 。我相信没有一个研发敢摸着良心保证自己的代码没有一个 bug 。

从另一个角度思考,需求变更其实是一件好事。正因为有需求变更,才能体现研发的价值:如果没有需求变更,80% 的程序员都要失业了。正因为有需求变更,研发的工作更具有挑战:如何设计更合理的架构,覆盖更多的业务场景,支持更复杂的业务流程。

2.先考虑需求是不是合理,是不是有更好的解决方案

看到这里,有部分研发同学认为,我为什么要考虑这些,这是产品经理的工作,不是研发的工作。这的确不是研发的工作,但实现这些需求却是研发的工作。为了避免自己被“坑”,如果有更好的解决方案,完全可以提出来,和产品经理一起讨论。

也许你们会讨论出一套性价比更高的方案:一方面满足产品需求,一方面减少自身的开发工作量。也许你们讨论过程中发现遗漏了某个分支的处理流程:一方面发现了产品设计的漏洞,一方面减少了 bug 数量提高代码质量。

3.有疑问,有困难一定要尽早提出,不要自己一个人默默的解决

例如,某个功能实现起来有技术难度,此时,研发和产品经理可以沟通讨论出更好的方案;某个模块功能太多研发时间太长,此时,产品经理可以根据实际项目情况,砍掉部分优先级低的需求,或者申请适当延长研发周期。千万不要自己一个人死扛,默默的处理遇到的所有问题,最终产品验收时,发现偏离了产品需求,吃力不讨好。

在每个版本开发前,产品经理都会先和各组研发 leader 进行小范围的需求评审,之后和后台研发同事进行第二轮需求评审,最后和所有相关研发同事进行最终需求评审。在这个过程中,产品经理逐步完善产品设计。前期沟通的越清楚,后期沟通的成本就越小,产品需求变更的情况也越少。

4.产品经理做的不好的地方,可以当面指出

在产品研发的过程中,产品经理接触最多的就是研发,听到最多的就是研发同事对产品经理的吐槽与抱怨。吐槽需求不靠谱,吐槽产品原型有漏洞,吐槽产品经理低估研发成本……吐槽产品经理也成为研发日常工作的一部分,我也相信这些槽点三天三夜也吐不完。

面对这些吐槽与抱怨,任何一个有职业素养的产品经理都会认真的听取意见。毕竟产品经理的部分工作就是收集用户建议、跟踪用户反馈、持续提高用户体验,而研发其实是产品经理最重要的用户。

没有什么事情,是一顿火锅解决不了的。如果有,那就两顿!

相信大家都知道瞎子和跛子的故事:一个瞎子和一个跛子,被困在火场中,眼看着要被烧死了,瞎子背起跛子,跛子指路,终于从大火中死里逃生。

如果说创业就像在火场中逃生,九死一生。那么产品经理就是一个跛子,能看见前进的方向,但没有行动能力;研发是一个瞎子,有行动能力,但看不见前进的方向;如果研发和产品经理之间相互猜忌,埋怨,最终可能是死路一条。只有两者相互信任,才有可能活着走出火场。

最后,希望研发同学和产品经理能够相互信任、和谐沟通,一起愉快的玩耍。

0

技术选型的艺术

什么是技术选型

技术选型对于广大程序员,特别是互联网公司的技术负责人或者架构师来说,一定不陌生。小到日常开发中的一个工具库的选择,大到整个系统语言、架构层面的选择,都是技术选型的范围。今天我们就简单聊聊技术选型。

一般而已,我们会碰到的技术选型,可以分为以下几类:

  • 基础设施选型:云平台或IDC、编程语言、数据库等。
  • 框架和库的选型:前后端的开发框架、核心类库等。
  • 中间件选型:负载平衡、消息中间件、缓存中间件等。
  • 第三方服务选择:第三方的推送、短信等。

实际上,我们常常面临的不是单个技术的选型,而是对于一个项目所涉及的一整套的技术、方案、规范或者产品的选型。这就需要我们更仔细的去权衡各种技术、各种组合的利弊,作出取舍。

技术选型,其实本质上就是一种架构决策。《系统架构》这本书里将架构决策总结成了六种模式,包括选项、筛选、指派、分区、排列和连接。而技术选型明显属于其中的选项(Decision-Option)模式,也即每个决策都是一套离散选项,从中选择一个合适的。

影响技术选型的因素

项目因素

首先考虑的是项目。

我们要明确项目本身的性质,包括项目的规模、重要程度、时间要求等等。如果是一个小规模的实验性项目,那么尝试一些新技术也未尝不可。如果时间要求非常紧,那可以考虑基于开源或商用的程序修改。淘宝当年上线时候,就是购买了一个商用程序后修改的。另外,项目的成本和预算也是必须要考虑的。如果预算足够,我们也可以考虑购买商用程序或者第三方服务。

项目的需求也会影响甚至限制技术的选型。特别要注意的是那些非功能性的需求,例如对并发性、实时性、可用性、数据一致性、安全性等方面的需求,往往对技术方案和选型有很大影响。例如支付相关的应用,对数据的一致性有非常高的要求,那核心的支付数据的存储,就会倾向选择 Oracle、PostgreSQL 这种强一致性的数据库,而不会去选 MongoDB(虽然据说马上也要支持事务了)。

团队因素

其次要考虑的是团队,也就是说人的因素。

技术选型一定要考虑当前团队人员的技术组成。对于一些比较基础的技术的选型,比如语言和框架、数据库等等,往往最合适的选择就是团队最熟悉的技术。如果打算选择不同的技术的话,那就要考虑下团队是否有人能够 Hold 住了。另一个必须要考虑的是招聘,如果使用的是比较小众的技术,那么当需要扩充团队的时候,招聘人员会比较困难。我们杏仁在这方面就取了一个巧,最开始我们用的是基于 JVM 的 Scala 语言,但是我们招聘的时候,都是打的 Java 工程师的幌子。

团队的发展时期对于技术选型也会有一定影响。对于早期团队,大多数员工都是喜欢创新、愿意承担风险,那么可以选择一些相对较新、有挑战的技术;团队发展到一定阶段,那就要开始考虑团队效率、开发规范等因素,此时往往会选择一些比较大众的、经过验证的技术。

我加入现在的公司时,我们正好开始转型。原有的产品是基于 Web 的网站,我们打算开发一个移动 APP。我们原先后端应用使用的是 Scala 语言和 Play 框架,当时面临的一个问题是,新的产品后端应该选择什么技术?虽然也考虑过 Java,但因为时间很紧,我们还是选择了团队最熟悉的 Scala。但后面我们开始服务化的时候,就选择了 Java。

还有一点就是,虽然技术选型需要考虑团队人员的喜好,但千万不要因为某几个人的个人喜好,来决定技术的选型。还是通过细致的分析和实验来进行选型。而决策者也需要看的更长远一些,推动团队技术向前发展。

技术因素

技术选型当然也必须考虑技术因素,例如技术特性(易用性、可维护性、可扩展性、性能等)、技术成熟度、社区活跃度、架构匹配和演化等等。

技术特性往往是大家都会去关注的,相信不必多说。但是注意不要只是浏览网上看到的各种分析,对于重要的特性,还是需要去亲自实验一下或者做一些测试,有第一手的感觉。另外,这些特性往往不可兼得,所以我们需要对照项目因素和团队因素来进行取舍。

技术的发展有其内在趋势,Gartner 每年都会发布一个 Hyper Cycle,标记各种技术的发展阶段;另外一个很好的参考是 ThoughtWorks 的技术雷达。我们可以根据项目、团队等因素去选择不同成熟度的技术和产品。但是对于核心项目,最好还是选择足够成熟的技术,切忌盲目追求新技术。

对于比较重要的应用,我们一般会选择比较大众的技术,因为使用的人多了,也从某种程度上说明这个产品是比较优秀的,并且招聘也会更容易些。这方面可以参考的有 Github 的 star 数、Google Trends、百度指数等等。社区活跃度也是必须要考虑的因素,你一定不会想用一个没有人维护,提了问题也没人回复的技术产品吧。

最后还需要考虑技术产品和当前架构的匹配程度。一个团队的技术栈太过散乱,对开发和运维会是很大的压力,甚至影响系统的稳定性。例如我们当前的数据库使用的是 MySQL,而一个技术产品如果要求必须引入 MongoDB,那我一定会多想一下,我们多维护一个 MongDB 是不是值得。另外,如果大家对自己架构的演化有一定规划,那也要考虑引入的新的技术产品和未来的架构是否能够匹配。

其他因素

最后,可能还会有一些额外的因素要考虑。比如许可协议的问题,前段时间闹得沸沸扬扬的 React Native 许可协议事件,相信大家记忆犹新。还有一些公司,可能对于使用第三方的程序或者服务,有一些原则甚至规范,那也一定要注意参考。另外,在稍大一些的公司里可能还会涉及一些派系之争,这里就不多说了。

如何进行技术选型

我们上面已经列出了很多技术选型需要考虑的因素,那么到底如何进行技术选型呢?大致上可以分为以下几个步骤:

  1. 首先要明确选型的需求和目的,最好能列出必须要考虑的各种因素以及评判标准。
  2. 然后就可以开始寻找候选技术或产品了。这时范围可以尽量广一些,搜集尽可能多的候选技术和产品。
  3. 其次就可以进行初步筛选。把一些由于各种限制无法选择的、或者明显不可能的技术或产品排除,筛选出 3 个左右备选方案。
  4. 再然后就要做一些详细的调查和分析了。可以列一个表格,把备选方案、以及需要考虑的因素放到表格的横向和纵向中去,一个个进行评估的分析。此时可能会需要做一些小 Demo 验证可行性,或者做一些性能测试、压力测试等等。必要的话可以在表格里给每一个因素打分。
  5. 最后,对分析结果进行评审,作出最后决定。

技术选型分析表,每一格里可以有具体的打分,也可以有优势劣势的评价。

候选A 候选B 候选C
团队
技术成熟度
性能
架构一致性
...

当然,小的不太重要的技术选型也不一定要这么麻烦,而重要的技术选型则可能要反复各个步骤多次。

技术选型的几个注意点

  • 一定要进行可行性分析,如果不太确定,做个 Demo 验证一下。如果项目进行到一半,发现原来设想的某个方案不可行,那会是非常痛苦和浪费时间的事情。
  • 不要有思维定势,习惯性的使用某些技术,比如服务化就用 Dubbo、缓存就用 Redis,具体问题要具体分析。也不要赶时髦,在重要的项目上使用太新的不够成熟的技术。
  • 随着业务发展,很多架构需要不断升级,所以一定要考虑未来如果要替换某项技术,是否会很麻烦。可以选择符合一些标准的技术或产品,或者在应用中部署一个适配层,方便未来适配其他技术。
  • 架构应该尽可能统一,一个领域避免引入太多相同功能的技术产品。

总结

最后,大家其实会发现,技术选型既是一种科学、又是一种艺术,有时候并没有对错之分。最后面临两难选择的时候,还是需要决策人拿出勇气,拍板决定,坚定的去推进。

0

服务网格:微服务进入2.0时代

微服务自2014年3月由Martin Fowler首次提出以来,在Spring CloudDubbo等各类微服务框架的帮助下,以燎原之势席卷了整个IT技术界,成为了最主流的分布式应用解决方案。但仍然还有很多问题没有得到根本性的解决,比如技术门槛高、多语言支持不足、代码侵入性强等。如何应对这些挑战成为了下一代微服务首要回答的问题。直到服务网格(Service Mesh)被提出,这一切都有了答案。

1 微服务之殇

时光回到2017年初,那时所有主流的微服务框架,不管是类库性质的FinagleHystrix,还是框架性质的Spring Cloud、Dubbo,本质上都归于应用内解决方案,都存在以下三个问题:

  • 技术门槛高:随着微服务实施水平的不断深化,除了基础的服务发现配置中心授权管理之外,团队将不可避免的在服务治理层面面临各类新的挑战,包括但不限于分布式跟踪、熔断降级、灰度发布、故障切换等,这对团队提出了非常高的技术要求。

service-governance

图片出处:Service Mesh:下一代微服务

  • 多语言支持不足:对于稍具规模的团队,尤其在高速成长的互联网创业公司,多语言的技术栈是常态,跨语言的服务调用也是常态,但目前开源社区上并没有一套统一的、跨语言的微服务技术栈。
  • 代码侵入性强:主流的微服务框架(比如Spring Cloud、Dubbo)或多或少都对业务代码有一定的侵入性,框架替换成本高,导致业务团队配合意愿低,微服务落地困难。

这些问题加起来导致的结果就是,在实施微服务的过程中,小团队Hold不住,大公司推不动。

2 另辟蹊径

如何解决上述三个问题呢?最容易想到的是代理模式,在LB层(比如NginxApache HTTP Server)处理所有的服务调用,以及部分服务治理问题(比如分布式跟踪、熔断降级)。但这个方案有两个显著的缺点,第一,中心化架构,代理端自身的性能和可用性将是整个系统的瓶颈;第二,运维复杂度高,业务团队笑了,运维团队哭了。

难道这就是桃园吗?

服务网格(Service Mesh)应运而生!自2016年9月Linkerd第一次公开使用之后,伴随着LinkerdEnvoyIstioNGINX Application PlatformConduit等新框架如雨后春笋般不断涌现,在微服务之后,服务网格和它的边车(Sidecar)模式引领了IT技术界2017一整年的走向。

3 服务网格

3.1 元定义

首先,我们来看一下服务网格的提出者William Morgan是如何描述它的。

A service mesh is a dedicated infrastructure layer for handling service-to-service communication. Consists of a control plane and data plane (service proxies act as "mesh"). - William Morgan, What's a Service Mesh? And Why Do I Need One?

上面这段话非常清晰的指明了服务网格的职责,即处理服务间通讯,这正是服务治理的核心所在。而a dedicated infrastructure layer这几个单词将服务网格和之前所有的微服务框架(framework)划清了界限,也即服务网格独立于具体的服务而存在,这从根本上解决了前文提到的老的微服务框架在多语言支持和代码侵入方面存在的问题。并且,由于服务网格的独立性,业务团队不再需要操心服务治理相关的复杂度,全权交给服务网格处理即可。

那你可能会问,这不跟之前提到的代理模式差不多吗?区别在于服务网格独创的边车模式。针对每一个服务实例,服务网格都会在同一主机上一对一并行部署一个边车进程,接管该服务实例所有对外的网络通讯(参见下图)。这样就去除了代理模式下中心化架构的瓶颈。同时,借助于良好的框架封装,运维成本也可以得到有效的控制。

linkerd-service-mesh-diagram

图片出处:What's a Service Mesh? And Why Do I Need One?

3.2 演化史

追本溯源,服务网格从无到有可分为三个演化阶段(参见下图)。第一个阶段,每个服务各显神通,自行处理对外通讯。第二个阶段,所有服务使用统一的类库进行通讯。第三个阶段,服务不再关心通讯细节,统统交给边车进程,就像在TCP/IP协议中,应用层只需把要传输的内容告诉TCP层,由TCP层负责将所有内容原封不动的送达目的端,整个过程中应用层并不需要关心实际传输过程中的任何细节。

pattern-network

pattern-library

pattern-sidecar

图片出处:Pattern: Service Mesh

3.3 时间线

最后,再来回看一下服务网格年轻的历史。虽然服务网格的正式提出是在2016年9月,但其实早在2013年,Airbnb就提出了类似的想法——SmartStack,只不过SmartStack局限于服务发现,并没有引起太多关注,类似的还有Netflix的Prana和唯品会的OSP Local Proxy。2016年服务网格提出之后,以Linkerd和Envoy为代表的框架开始崭露头角,并于2017年先后加入CNCF基金(Cloud Native Computing Foundation),最终促使了一代新贵Istio的诞生。2018年,Istio将发布1.0版本,这也许意味着微服务开始进入2.0时代。

history

图片出处:Service Mesh:下一代微服务

4 参考

0

如何写好产品中的提示文案

产品中的提示文案往往并不显眼,许多人投入精力优化大标题、Slogan,却忽视了产品中无处不在的提示文案。无论是点击按钮的反馈,还是针对一项功能的注释说明,这些文字反而是和用户沟通最频繁的存在,在潜移默化中输出着整体的品牌形象,影响了用户对产品的认知。

不同功能模块往往由不同的人负责,而每个人都有各自的语言风格,长期下来,就会在同一个产品中形成不同风格的提示文案,造成一定的割裂感。因此,有必要提前达成共识,以一个共同的准则去输出产品中的提示文案。

我们往往可以从发声者(Voice)、语言规范(Language)、语调情感(Tone)三个层面来制定一个完整的准则。发声者强调确认产品文案的主体人格,究竟是谁在和用户沟通;语言规范则帮助我们形成简单、易懂、明确的表达方式;而语调情感则是在主体人格确立的前提下,进一步探讨文案的情感表达,是偏活泼或沉稳,偏中立或引导。

一、发声者

发声者是谁

我们首先需要确定的是,在不同情景下,究竟是谁在为产品文案发声。往往我们有三种选择:

  • 一个没有存在感的客观形象:这可能是大多数产品在大多数情景下的选择,将产品认为是一个整体性的客观主体,它尽可能地降低自身的存在感。在这种情境下,产品中多使用「你」作为沟通语言,仿佛是有一个人在和你对话,但这个人并不显露身份,也因此尽可能地避免使用「我们」。
˟需要我们帮你撤回该消息吗?
✓你确认撤回该消息吗?
  • 一个有鲜明人格形象的客观形象:在人格化运营尝试中,我们往往赋予产品以一个鲜明的人格形象,如小管家、小帮手,以拉近和用户的距离感。我们往往会赋予这个形象以具体的姓名、语气、常用语,来进一步强化它的人格特征。
˟你的搜索结果已保存到收藏夹
✓小度已经把搜索结果塞进收藏夹啦
  • 将用户作为主体代入:也有部分产品,尤其是游戏类的应用,往往会舍弃客观形象的存在,而为了给用户营造更强的浸入感,完全使用用户作为表达的主体,仿佛每一句话,都是用户自己的心声。
˟你要攻打这座城池?
✓我要攻打这座城池

不同发声者,可以在一个产品中共存

根据场景不同,不同的发声者,是可以在一个产品中共存的。例如,一个选择客观形象作为主要发声者的产品,在一定情境下,也可以完全转换到第一人称的表达。在某些情境下,我们希望能引起用户强烈的认同感,或者明确操作的自主自愿性,都可以临时转换到第一人称「我」的表达。

˟你同意上传个人资料
✓我同意上传个人资料

不要在一个页面或流程中混入不同的发声者

在同一个表达、页面或流程中,混入不同的发声者,会造成强烈的割裂感,甚至增加理解的复杂度。因此,应该尽可能保持主体的一致性。

˟去「我的设置」修改你的主页可见性
✓去「我的设置」修改我的主页可见性

又比如,在上述同意上传个人资料的操作下,如果想要告知用户,会有专人来审核,下面一行的小字,应该避免如下表达:

˟我们将安排专人审核你的资料,并通知你结果

这样的表达,不仅用户的代入感从「我」又切换回了「你」,在理解中,还额外增加了发声者「我们」和第三方「专人」。我们可以尝试这样的提示:

✓资料经审核后,会通知我结果

二、语言规范

完整的语言规范涉及到了表义规范、写作规范、标点规范、用词规范等等,不一而足,在这里没法面面俱到,就挑几个最重要的说。

提高信息熵

我们常常说,用户没有耐心去仔细阅读我们精心放置的提示文字。很多时候,是我们写得太过啰嗦了。一般来说,在手机屏幕上,超过两行的提示文字,就已经让人不想阅读了。

在写作提示文字时,我们必须尽可能提高信息熵,即在表达信息量相同的情况下,尽可能减少内容量。要做到这一点,不必过于拘束,而可以先把你想表达的意思写下来,再开始精简。

你可能会发现,许多时候可以精简的内容量是惊人的。假设现在我们想告诉用户,他的消息已经发送成功了,我们可能会这么写:

˟你的消息已经发送成功

显然,「你的消息」过于啰嗦了,在实际操作中,用户刚刚发送完一条消息,自然能和这条提示文字对应上,我们可以精简成:

˟已经发送成功

现在,我们再来想一下,我们提示用户这条信息已经发送出去了,成功应该是大概率的情况,如果发送失败,显然我们需要更强烈的引导和提示。那么,「信息已经发送」在理解上和「信息已经发送成功」应该是无差别的。因此,它还可以进一步缩减成为:

˟已经发送

更进一步的,在这句话中,「已经」作为状语,缩写成「已」,不仅语义是一致的,在语感上也没有任何区别(「可以」缩写为「可」常常会产生语感上的区别)。因此,这个提示可以进一步变成:

✓已发送

你看,从最初的 10 个字,已经变成了最后的 3 个字。事实上,只要我们认真审视每句话里面的主谓宾定状补,就总能发现各种精简后并不影响理解和表达的成份。

从目的出发

向用户提示时,就像我们和人沟通一样,表达目的应该先行。用户不应该需要阅读大段的操作性说明,来了解最终可以实现什么目标。相反,应该是获知目标后,再决定是否需要进一步了解。

˟只要提现金额大于 1,000 元,就能当日到账;
✓若想当日到账,提现金额须大于 1,000 元;

使用用户的语言

使用用户的语言和他们沟通,避免使用内部代号、技术性描述等语言。

˟对方服务器没有响应,将重试 POST 该消息,请等待返回结果;
✓发送失败,将尝试重发,请等待;

使用主动语态

在写作提示文字时,如果不是出于特别强调的目的,尽量使用主动语态。简单一点来说,在你的提示文字中,少出现「被」字。

˟密码被输入后,将向对方转账
✓输入密码以完成转账

使用阿拉伯数字

阿拉伯数字在视觉上更加醒目,试试比较下面两句话:

˟若想当日到账,提现金额须大于一千元;
✓若想当日到账,提现金额须大于 1,000 元;

三、语调情感

在明确发声者和语言规范后,我们还应该进一步丰满其形象,通过设立语气、表达方式、常用语等特征,最终映射到品牌或产品形象上。感受一下,下面三种表达,在用户脑海中呈现出的形象,是截然不同的。

小度已经把搜索结果添加到收藏夹
小度已经把搜索结果添加到收藏夹啦
小度已经把搜索结果塞进收藏夹啦

显然,第一种给人的感觉是严肃、平淡的,第二个仅仅通过添加了语气助词「啦」,就让整体形象稍显活泼,第三个在使用了「塞进」这样的表达后,在活泼之余,又增添了一份趣味性。

语调情感并没有绝对的好坏,需要根据产品面向的用户群体和使用场景,来差异化地制定。不过,有一些基本通用的准则,可以供你参考:

产品和用户的关系定位

在产品中指代用户时,使用「你」还是「您」,实际上是产品和用户的关系定位决定的。

在杏仁医生中,由于我们的用户群体是医生,抱以最大的尊重,我们对用户的称谓,一律都是使用「您」的。而在服务患者的微信公众号上,由于平台和用户之间更多的是平等的关系,我们则使用「你」来称呼。

除了人称代词外,确立了关系定位后,还会影响谦词、敬词的使用。感受一下:

对不起,没有找到对应结果,请稍后再试
没有找到对应结果,请稍后再试
没有找到对应结果,你可以稍后再试

使用积极的表达方式

在语言规范中,我们强调多使用主动语态。而在语调情感中,我们则应该多使用积极的表达方式,从肯定视角进行描述。感受一下:

˟上传的头像不要超过 2MB;
✓上传的头像须在 2MB 以内;

除了避免使用否定视角,我们还应该尽可能地突出正面意义,避免恐吓用户。例如:

˟常时间开启高性能模式会加快电量消耗,直至关机
✓为了节省电量,你可以关闭高性能模式

慎用感叹号

感叹号实际上是一个非常强烈的情感表达,你可以想像着如果和用户面对面,会不会这么说话。比如,在针对新用户的提示中,我们使用如下表达来展现我们的热情洋溢,是可以接受的:

✓欢迎来到杏仁医生!

不过,如果是错误提示、操作引导,滥用感叹号,就不免让人觉得有喝斥责怪之感,感受一下:

˟页面已经到底了!
0

Facebook、Google、Amazon 是如何高效开会的

会议是工作中绕不开的一部分,许多人都听说过,在一项研究中发现,语言在我们的沟通中只占了 7% 的比例。虽然这个研究结果仅仅是面向单个字眼的沟通,在现实中比例不至于这么夸张,但不可否认的是,冰山下的许多信息,都是依靠语调、身体语言来传递的。这也是为什么,即使在线沟通如此多样和便捷,会议依旧不可被取代。

然而,许多人却会对会议有抵触情绪。冗长的节奏,即兴的跑题,敌对的氛围,模糊的结论……这些都让会议要不然变得漫长而无效,要不然变得沉闷而无趣。那么,如果才能开一场高效的会议呢?一起来看看科技巨头,如 Facebook、Google、Amazon 等这些公司,是如何高效开会的。

Facebook

  • 明确会议类型:在 Facebook,CEO Zuckerberg 要求所有人在开会前,都先想好一个最基础的问题:本次会议的目标是什么?在他看来,会议可以分为两大类:决策型会议、讨论型会议。前者必须输出一个明确的决定,如项目评审会、预算审批会,而后者则可能是召集所有人一起讨论问题,共享信息,如晨会、头脑风暴、项目沟通会等。
  • 为会议制作进度表:把会议议题制作成一张进度表,每讨论完一个议题,就打一个勾,这是 Facebook 的 COO Sheryl Sandberg 的建议。一方面,确保每一个议题都已经有了一个明确的结果;另一方面,如果你提前勾完了这份进度表,就让会议提前结束。
  • 在中午 12 点开站会:Facebook 的研发经理 Mark Tonkelowitz 往往会在中午开一个 15 分钟左右的站会,所有人一会都要去吃饭,自然不会愿意花时间跑题。

image

Google

  • 保持会议精简:Google 的历任高管都曾强调会议精简的重要性。想要让会议精简,首先必须控制人,然后是控制时间。2011 年 Larry Page 回归 Google 重新出任 CEO 时,甚至要求全公司每个会议最多不超过 10 个人。而曾在雅虎、Google 担任高管的 Marissa Mayer,她要求会议尽量保持在 5-10 分钟之间,快速讨论,快速结束。
  • 提前制定会议议程:Google 曾经的 CEO Eric Schmidt 在《How Google Works》一书中提到过,一定要提前 24 小时,将会议议程发给所有的参会人。议程应该包含会议目标、参会人、待讨论的事项等内容。
  • 每个会议都有一个 Leader:每一个会议上都需要有一个 Leader 负责做决定,并且在会后 48 小时内,将所有的结论总结成邮件发送给参会人与相关方。
  • 紧急决定不需要等待会议:在 Google,会议是为了得出结论的。不要让决定等待会议,如果你马上需要做一个紧急决定,要不就跳过会议直接做决定,要不就立即召集会议。
  • 用数据替代争论:在会议中难免会陷入分歧,不同的人持有不同的观点,这时候会议往往会陷入无休止的争吵和讨论。Google 强调在这类问题上,尽可能抛弃主观色彩,而使用数据说话。
  • 准备一个计时器:Google Ventures 的合伙人 Jake Knapp 推荐任何会议都应该有一个计时器,而且是实体的计时器。他特别推荐 Time Timer的计时器,表盘上红色的区域会随着时间的流逝而逐渐减少,更让参会人有时间的紧迫感。

image

Amazon

  • 只准备两份批萨:对于如何控制会议规模,Amazon 的 CEO Jeff Bezos 曾有一个非常有趣的衡量标准:一场会议的人数,订两份批萨就够了。如果超出这个规模,说明这个会议有太多的人参加了。
  • 永远准备一张空椅子:Jeff Bezos 还做过一个有趣的提议:永远在会议室准备一张空的椅子来代表顾客。Amazon 的核心文化理念之一,就是从各个方面不断完善和提升顾问体验。这张空椅子就是一个符号性的象征,提醒每个人不要忘记这一点。

xMentalHelpChat-iStock516454908-Gestalt-Therapy-The-Empty-Chair-Technique-1024x585.jpg.pagespeed.ic.KFNGU7vI9a

可以看到,无论是 Amazon、Facebook 还是 Google,这些公司对会议都有一些共同的要求,例如只邀请必要的人,控制会议时长,事先准备议程,事后有结论等等。你也可以为自己的工作环境制定一些会议准则,来更高效地开会。

参考文章:
1. http://www.businessinsider.com/googles-rules-for-a-great-meeting-2014-9
2. https://www.inc.com/larry-kim/jeff-bezos-surprising-meeting-strategy.html
3. http://www.businessinsider.com/this-is-how-larry-page-changed-meetings-at-google-after-taking-over-last-spring-2012-1
4. https://www.getminute.com/how-to-run-a-meeting-like-google-apple-amazon-and-facebook/
5. https://qz.com/94701/top-business-leaders-meeting-tips/

1+

谈谈到底什么是抽象,以及软件设计的抽象原则

我们在日常开发中,我们常常会提到抽象。但很多人常常搞不清楚,究竟什么是抽象,以及如何进行抽象。今天我们就来谈谈抽象。

什么是抽象?

首先,抽象这个词在中文里可以作为动词也可以作为名词。作为动词的抽象就是指一种行为,这种行为的结果,就是作为名词的抽象。Wikipedia 上是这么定义抽象的:

Conceptual abstractions may be formed by filtering the information content of a concept or an observable phenomenon, selecting only the aspects which are relevant for a particular subjectively valued purpose.

也就是说,抽象是指为了某种目的,对一个概念或一种现象包含的信息进行过滤,移除不相关的信息,只保留与某种最终目的相关的信息。例如,一个皮质的足球,我们可以过滤它的质料等信息,得到更一般性的概念,也就是。从另外一个角度看,抽象就是简化事物,抓住事物本质的过程。

需要注意的是,抽象是分层次的。还是用 Wikipedia 上的例子,以下是对一份报纸在多个不同层次的抽象:

  1. 我的 5 月 18 日的《旧金山纪事报》
  2. 5 月 18 日的《旧金山纪事报》
  3. 《旧金山纪事报》
  4. 一份报纸
  5. 一个出版品

可以看到,在不同层次的抽象,就是过滤掉了不同的信息。这里没有展现出来的是,我们需要确保最终留下来的信息,都是当前抽象层需要的信息。

生活中的抽象

其实我们生活中每时每刻都在接触或者进行各种抽象。接触最多的,应该就是数字了。其实原始人类并没有数字这个概念,他们可能能够理解三个苹果,也能够理解三只鸭子,但是对他们来说,是不存在数字“三”这个概念的。在他们的理解里,三个苹果和三只鸭子是没有任何联系的。直到某一天,某个原始人发现了这两者之间,有那么一个共性,也即是数字“三”,于是就有了数字这个概念。从那以后,人们就开始用数字对各类事物进行计数。

赫拉利在《人类简史》里说,人类之所以成为人类,是因为人类能够想象。这里的想象,我认为很大程度上也是指抽象。只有人类能够从具体的事物本身,抽象出各种概念。可以说,人类的几乎所有事情,包括政治(例如民族、国家)、经济(例如货币、证券)、文学、艺术、科学等等,都是建立在抽象的基础上的。绘画有一个流派叫抽象主义,很多人(包括我)都表示看不懂,但下面几幅毕加索画的牛,也许能够从直观上让我们更好的理解什么是抽象。

毕加索的牛

科学里的抽象就更广泛了,我们可以认为所有的科学理论和定理都是一种抽象。物体的质量是一种抽象,它不关注物体是什么以及它的形状或质地;牛顿定律是对物体运动规律的抽象,我们现在知道它不准确,但它在常规世界里,却依然是一个相当可靠的抽象。在科学和工程里,常常需要建立一些模型或者假设,比如量子力学的标准粒子模型、经济学的理性人假设,这些都是抽象。甚至包括现在 AI 里通过训练生成的模型,某种程度上说,也是一种抽象。

当然,哲学上对抽象有很多讨论,什么本体论、白马非马之类的,这些已经在本人的理解范围之外了,就不讨论了。

开发中的抽象

现在我们应该能大致理解抽象这个概念了,让我们回到软件开发领域。

在软件开发里面,最重要的抽象就可能是分层了。分层随处可见,例如我们的系统就是分层的。最早的程序是直接运行在硬件上的,开发成本非常高。然后慢慢开始有了操作系统,操作系统提供了资源管理、进程调度、输入输出等所有程序都需要的基础功能,开发程序时调用操作系统的接口就可以了。再后来发现操作系统也不够,于是又有了各种运行环境(如 JVM)。

编程语言也是一种分层的抽象。机器理解的其实是机器语言,即各种二进制的指令。但我们不可能直接用机器语言编程,于是我们发明了汇编语言、C 语言以及 Java 等各种高级语言,一直到 Ruby、Python 等动态语言。

开发中,我们应该也都听说过各种分层模型。例如经典的三层模型(展现层、业务逻辑层、数据层),还有 MVC 模型等。有一句名言:“软件领域的任何问题,都可以通过增加一个间接的中间层来解决”。分层架构的核心其实就是抽象的分层,每一层的抽象只需要而且只能关注本层相关的信息,从而简化整个系统的设计。

其实软件开发本身,就是一个不断抽象的过程。我们把业务需求抽象成数据模型、模块、服务和系统,面向对象开发时我们抽象出类和对象,面向过程开发时我们抽象出方法和函数。也即是说,上面提到的模型、模块、服务、系统、类、对象、方法、函数等,都是一种抽象。可想而知,设计一个好的抽象,对我们软件开发有多么重要。

抽象的原则

那么到底应如何做到好的抽象呢?在软件开发领域,其实早就有 SOLID 等原则,虽然很多人都听说过,但其实真正能理解这些原则的开发者并不多。那么我们就从抽象的角度,再来看下这些原则,也许会有更好的理解。

单一职责原则(Single Responsibility Principle, SRP)

单一职责是指一个模块应该只做一件事,并把这件事做好。其实对照应抽象的定义,可以发现这个原则本身就是抽象的核心体现。如果一个类包含了很多方法,或者一个方法特别长,就要引起我们的特别注意了。例如下面这个 Employee 类,既有业务逻辑(calculatePay)、又有数据库逻辑(saveToDb),那它其实至少做了两件事情,也就不符合单一职责原则,当然也就不是一个好的抽象。

class Employee {
  public Pay calculatePay() {...}
  public void saveToDb() {...}
}  

有些人觉得单一职责不太好理解,有时候很那分辨一个模块到底是不是单一职责。其实单一职责的概念,常常需要结合抽象的分层去理解。

在同一个抽象层里,如果一个类或者一个方法做了不止一件事,一般是比较容易分辨的。例如一个违反单一职责原则的典型征兆是,一个方法接受一个布尔类型或者枚举类型的参数,然后一个大大的 if/else 或者 switch/case,分支里也是大段的代码处理各种情况下的逻辑。这时我们可以用简单工厂模式、策略模式等设计模式去优化设计。

假如说我们用了简单工厂模式,改进了一段代码,重构后代码可能像是下面是这样的。

    public Instance getInstance(final int type){ 
        switch (type) {
            case 1: return new AAInstance;
            case 2: return new BBInstance;
            default: return new DefaultInstance();
        }
    }

有人可能会有疑问,代码里依然还是存在 if/else 或者 switch/case,这不还是做了不止一件事情么?其实不是的,使用了简单工厂模式,其实就是增加了一个抽象层。在这个抽象层里,getInstance 的职责很明确,就是创建对象。而原来分支里的逻辑处理,则下沉到了另外一个抽象层里去了,也就是 Instance 的实现所在的抽象层。

再看下面 Scala 实现的 updateOrder 方法,它似乎也只是做了一件事情:处理订单,那算不算单一职责呢?

protected def updateOrder(t: TransationEntity) = {
  // 1 获取订单
  ManagedRepo.find[Order]("orderNo" -> t.tradeNo).map { order =>
    // 2 检查订单是否已支付
    val ps = SQL("""select statue from Order where id ={id} for update""").on("id" -> order.id).as(scalar[Long].singleOpt).getOrElse(0l)
    if (ps == PAID) {
      throw ServiceException(ApiError.SUBSCRIPTION_UPDATE_FAIL)        
    } else {
      // 3 更新订单信息,标记为已支付            
      val updatedOrder = // 略...
      updatedOrder.saveOrUpdate()
      // 4 生成收入记录
      createIncome(updatedOrder)
    }
  }
}

答案当然是不算,因为很明显,这个方法里面既有业务逻辑的代码,又有数据库处理的代码,这两类应该是在不同的抽象层的。我们把数据库处理的代码抽取出来,下沉到数据层,它就能符合单一职责原则了。

protected def updateOrder(t: TransationEntity) = {
  findUnpaidOrder(rtent.tradeNo).map { order =>
    val updatedOrder = updateOrderForPayment(rtent)
    createIncome(updatedOrder)
  }
}  

开放封闭原则(Open/Closed Principle, OCP)

开放封闭原则是指对扩展开放,对修改封闭。当需求改变时,我们可以扩展模块以满足新的需求;但扩展时,不应该需要修改原模块的实现。

下面两段代码都实现了方形、矩形以及圆形的面积计算。第一种用的是面向过程的方法,第二种用的是面向对象的方法。那么,到底哪一种更符合开放封闭原则呢?

面向过程方法:

public class Square { 
    public double side;
}
public class Rectangle { 
    public double height;
    public double width;
}
public class Circle { 
    public double radius;
}
public class Geometry {
    public double area(Object shape) {
        if (shape instanceof Square) {
            Square s = (Square) shape;
            return s.side * s.side;
        } else if (shape instanceof Rectangle) {
            Rectangle r = (Rectangle) shape;
            return r.height * r.width;
        } else if (shape instanceof Square) {
            Circle c = (Circle) shape;
            return PI * c.radius * c.radius;
        } else {
            throw new NoSuchShareException();
        }
    }
}

面向对象方法:

public class Square implements Share { 
    public double side;
    public double area() {
        return side * side;
    }
}
public class Rectangle implements Share { 
    public double height;
    public double width;
    public double area() {
        return height * width;
    }
}
public class Circle implements Share { 
    public double radius;
    public double area() {
        return PI * radius * radius;
    }
}

估计很多人会觉得面向对象的方式更好,更符合开放封闭原则。但真相其实没那么简单。想象如果我们需要添加一个新的形状,比如说椭圆,那面向对象的实现肯定更方便,我们只需要实现一个椭圆的类以及它的 area 方法。这时候我们可以说面向对象的方法更符合开放封闭原则。

但如果我们需要添加一个新的方法呢?比如说,我们发现我们还需要计算形状的周长。这时候,面向对象的实现似乎就没那么方便了,要在每个类里面添加计算周长的方法。而面向过程的方法,则只需要添加一个方法就行了。这时候,我们反而发现面向过程的方法更符合开放封闭原则。

所以开放封闭其实是相对的,有时候,如何进行抽象,取决于我们对未来最有可能的扩展的预判。

依赖倒置原则(Dependency Inversion Principle, DIP)

依赖倒置原则是指高层模块不应该依赖于低层模块的实现,两者都应该依赖于抽象。抽象不应该依赖于细节,细节应该依赖与抽象。前面提到,“软件领域的任何问题,都可以通过增加一个间接的中间层来解决” ,DIP 就是最典型的增加中间层的方式,也是我们需要解耦两个模块的最重要的方法之一。

依赖倒置原则的一个例子是 Java 的 JDBC。如果没有 JDBC,那我们的系统就会严格依赖我们使用的那个数据库。这时如果我们想要切换到另外一个数据库,就需要修改大量代码。但 Java 提供了 JDBC 接口,而所有关系数据库的连接库都实现了这个接口,我们的系统也只需要调用 JDBC 即可完成数据库操作。这时我们的系统和数据库的依赖就解除了。除了 JDBC,其实 SQL 本身也是一种依赖倒置的实现。另外一个很典型的例子就是 Java 的日志接口 Slf4j。
依赖倒置JDBC

其实所有的协议和标准化都是 DIP 的一种实现。包括 TCP、HTTP 等网络协议、操作系统、JVM、Spring 框架的 IOC 等等。设计模式里有不少模式,也是典型的依赖倒置,例如状态模式、工厂模式、代理模式、策略模式等等,下图是策略模式的结构图。
Strategy

我们日常生活中也有很多依赖倒置的例子。比如电源插座,家庭的供电只需要提供符合国家标准的电源插座,我们购买电器产品时,就不用担心买回来无法接入电源。汽车和轮胎、铅笔和笔、USB/耳机接口等等,也都是同一思想的体现。

里氏替换原则(Liskov Substitution Principle, LSP)

里氏替换原则是指子类必须能够替换成它们的基类。例如下面这个最常见的例子,Square 可以是 Rectangle 的子类吗?

public class Rectangle { 
    public double height;
    public double width;

    public void setHeight(int height) { ... }
    public void setWidth(int width) { ... }
}

public class Square extends Rectangle { 
    ???
}

虽然几何上说,Square 是一个特殊的 Rectangle,但把 Square 作为 Rectangle 的子类,却未必合适,因为它已经不存在宽和高的概念了。如果一个抽象不能符合里氏替换原则,那我们就需要考虑下这个抽象是不是合适了。

接口隔离原则(Interface Segregation Principle, ISP)

接口隔离原则是指客户端不应该被迫依赖它们不使用的方法。例如下面的 Square 类如果继承了Shape 接口,该如何计算体积以实现volume方法?

interface Shape {
    public function area();
    public function volume();
}
public class Square extends Shape { 
    ???
}

同样,如果一个抽象不符合接口隔离原则,那可能就不是一个合适的抽象。

迪米特法则(Law of Demeter)

迪米特法则不属于 SOLID 原则,但我觉得也值得说一下。它是指模块不应该了解它所操作的对象的内部情况。想象一下,如果你想让你的狗狗快点跑的话,你会对狗狗说,还是对四条狗腿说?如果你去店里买东西,你会把钱交给店员,还是会把钱包交给店员让他自己拿?

下面是一段违反迪米特法则的典型代码。这样的代码把对象内部实现暴露了出来,应该考虑讲将功能直接暴露为接口,或者合理使用设计模式(如 Facade)。

final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();

总结

关于抽象,今天我们就说到这里。不过要注意的是,软件开发并不是仅仅只依靠抽象能力就能完成的,最终我们还是要把我们抽象出来的架构、模型等,落地到真正的代码层面,那就还需要逻辑思维能力、系统分析能力等。以后如果有机会,我们可以继续探讨。

我希望各位看完本文,对抽象的理解能够更加深入一点。我们以奥卡姆剃刀原则来结束吧:一个抽象应该足够简单,但又不至于过于简单。这其实就是抽象的真谛。

2+

喜欢该文章的用户:

  • avatar