DDD 就是把面向对象做好

在 MVC 架构下写代码一直有一个问题,业务逻辑应该写 C 呢,还是写 M。

写 C 就变成了事务脚本的代码,虽然比起不用 MVC 的都多入口架构好很多,但是业务逻辑还是会重复和混乱。写 M 很多逻辑又写不下去,业务逻辑往往需要跨模型处理,大量业务逻辑难以落到 M 中。于是大家就引入了一个 S (Service),变成了 MVCS 架构,这个架构有很好的群众基础,逻辑清晰容易使用,被广泛采用。

在 DDD 重新热起来之后,事情有一点微妙的变化。很多 DDD 的支持者,认为将业务逻辑写入 S 不符合 DDD 的充血模型的逻辑,应该将按照 DDD 的书将业务逻辑写到领域模型。

于是我在一个项目中见到一段意思的写法,将业务逻辑大部分写在模型中,其中有一个订单对象,有价格等相关属性,并提供了计算、结账等方法,大概如下:

class Order{
    private List<Product> products;
    ... 

    public void checkout(){
    ...
    }
    public void calculate(){
    ...
    }
}

有人觉得别扭,订单居然能自己结账了;有人看过 DDD 相关的书后,觉得很香,这个类有良好的“封装”。这就是由来已久的充血、贫血之争。

充血模型、贫血模型

面向对象在处理对象的存储时,有两种风格一直争论不断。将业务逻辑放到领域对象中,对象不仅需要承载数据也需要承载行为,这种编程逻辑被称作充血模型

例如,计算订单的价格

// 下单方法
public void order(){
    Order = new Order();
    order.setId("UUID");
    order.setPrice(100);
    ... 

    order.calculate();
}

将业务逻辑放到领域对象之外,领域对象只承载数据,以及一些 getter、setter 方法,业务逻辑被另外的类(service)来承载,这种编程模型被称作贫血模型。

// 下单方法
public void order(){
    Order = new Order();
    order.setId("UUID");
    order.setPrice(100);
    ... 
    order.setItemCount(countItems());
}

private int countItems(){
    ...
}

充血模型和贫血模型还有另外一层含义,那就是谁来负责持久化他们。

一个简单的例子,充血模型中 order 对象可以调用 order.save() 来保存它自己。在一些动态的语言 ORM 框架,借助器动态语言特性的优势,往往会这样设计。典型的是 ActiveRecord 模式,数据库的一行记录、XML 的一个 元素被读取到内存中后,可以对应一个活跃对象。代表的框架有 Ruby On Rails、Yii、CakePHP 等。

// OrderService
$order = new Order();
$order->setId("UUID")
$order->setPrice(100);
...
$order->save();

贫血模型的持久化依赖另外一个对象。在 Hibernate 中,可以使用 EntityManager 的 persist 方法来持久化,或者使用 JPA 的 Repository,使用 Mybatis 的话就需要使用 Mapper 来完成持久化。

// OrderService
Order = new Order();@PersistenceContext
protected EntityManager entityManager = null;

public void saveOrder(){
    Order = new Order();
    order.setId("UUID");
    order.setPrice(100);
    ... 
    entityManager.persist(order);
}

总结一下,动态语言更容易实现充血模型,静态语言更容易实现贫血模型,但是并不是说静态语言就不能使用充血模型和 ActiveRecord 只是稍显麻烦。

各自的局限性

充血模型的好处是可以封装一些业务逻辑,避免面向模型的开发退化为事务脚本化的代码。事务脚本化的代码在前面的文章中已经讨论过,这是一种面向功能的开发方法,而非模型的开发方法,会造成代码中业务逻辑重复,一致性差的问题。

因此在 DDD 的相关书籍以及 《企业应用架构》中被反复提及,编程大师们大多鼓励使用充血模型。面向对象开发的早期就是充血模型,当我们接触面向对象的第一堂课时,老师都是拿现实世界中事物作为例子。

一个汽车由四个轮子构成,并且能跑,我们定义它的类就是:

Class Car {
    private List<Wheel> wheels;

    public void run(){

    }
}

然后实现这个类就能描述我们的业务逻辑,充血模型可以将业务逻辑封装到领域模型中。

但是充血模型容易陷入一个困境,就是封装的层次难以维系。上面订单的例子,在实际开发过程中很难将很多业务逻辑落到模型层,例如订单计算可能需要商品、用户积分等其他模型。除了单个模型,批量业务逻辑也很难实现。

于是从 EJB2 开始倡导使用贫血模型,将业务逻辑封装到 Service 这类专门承载业务逻辑的对象,Order 这类的模型只需要承载数据结构。贫血模型,让面向对象变得非常轻量, Spring 大规模推广开之后尤为明显。

面向对象跳出 ”车有四个轮子,车能跑“ 的局限认识,换句话来说,”订单有多个订单项和总价,但是订单不能自己结账,应该由收营员结账“。贫血模型的本质是将不合理的行为从模型中抽离出去,订单模型负责承载数据,收银员对象负责承载行为。

// Cashier.java 
class Cashier{
    public final static Cashier INSTANCE = new Cashier(); 
    public Bill checkout(Order order){
       .. 结账逻辑
    }
}

// Order.java 
class Order{
    private List<OrderItem> items;
} 

Cashier 可以实现为单例,Order 作为 POJO 存在,这就是 Spring 这套开发模型的基本逻辑。贫血模型很容易将代码写成事务脚本,因此业界尤其是 Mtin Fowler 等人鼓励将更多的业务逻辑实现为充血模型。

主体、客体和面向对象

”车有四个轮子,车能跑“ ”订单有多个订单项和总价,但是订单不能自己结账,应该由收营员结账“。这两种面向对象的思维模型都没有错,关键的问题是在何种场景下合理使用。

我们使用餐厅作为例子,开启上帝视角,对事物有仔细的观察。一个典型的餐厅有什么呢?

  • 桌子
  • 菜品
  • 订单
  • 服务员
  • 账单
  • 菜单
  • 厨师
  • 服务员

假定需要实现下列需求:

  1. 对桌位进行预定
  2. 点菜下单
  3. 结账
  4. 开发票

我们用哲学认识世界的方法,把按照操作的主体、客体进行分类,主体和客体是认识世界很好的方式,也是真正理解面向对象的有效途径。

主体-客体问题是一个长期存在、关于人类经验分析的哲学论题,产生于这样一个前提:世界由客体(实体)组成,主体(观察者)知觉或假定客体作为实体存在。-- 维基百科

假定这个餐厅非常大,在断电的情况下,无法使用收银机和软件操作,需要不同的人各司其职,并用纸和笔完成正常运作:

场景 主体 客体
预定 负责预定的服务员 桌子
点菜下单 负责点菜的服务员 菜单、订单
结账 负责结账的收银员 订单、账单
开发票 负责开发票的服务员 账单、发票

我们用语言描述一下这个店的工作过程:

顾客打来电话需要预定桌位,负责预定的服务员在预定表格上预定了桌位。顾客到来后,负责点菜的服务员拿出点菜便笺,点了菜撕下复印的菜单给了后厨。顾客吃完饭后让收银员结账,收银员结算后,归档了小票。负责开发票的收银员,开具了发票。

现在我们需要信息化这个系统,如果按照一般的充血模型,桌子有预定方法、订单有结账方法,这样并不符合逻辑,其实也不符合面向对象的思想。正确的面向对象,应该对主体进行 ”拟人化“ ,对客体进行 ”拟物化“。

现实世界中的服务员、收银员变成了预订服务和订单服务。于是就写出了大家所熟悉的这种编码模式:

UseCase 主体 客体
book ReservationService Desk
order OrderService Product、Order
checkout OrderService Order、Bill
applyVoice OrderService Order、Voice

主体认识客体的过程,也是主体改造客体的过程。

领域模型是客体,领域服务是主体,应用程序的本质是认识世界(读),和改造世界(写)的过程。主体和客体是可以互相转换的,收银员能操作订单,另一方面如果需要,收银员会被商户管理员作为人员操作。

现实中的收银员反应在计算机系统中时:

  • 作为主体,就是可以管理订单的领域服务
  • 作为客体,就是被商户管理的人员和角色

面向对象就是计算机软件对现实世界的阐述,当我们能直观的描述业务场景,就能很好的编写代码。

通过这种认识论我们发现,充血模型和贫血模型并不矛盾。如果你在编写一个游戏或者前端的可视化工具,充血模型无疑非常好用;但是如果你是编写一个信息化系统,并将充血模型滥用到客体对象上,则十分痛苦。

充血模型和贫血模型的问题应该从如何选择演变成如何使用的问题,充谁的血,在哪里充血。

合适的 ”充血“

回到这个朴素的逻辑命题: 程序 = 数据结构+算法。

这个说法其实一直都不过时,领域服务也是领域的一部分,不应该强行让领域模型充血,充血的过程由领域服务完成。换句话说,领域模型,拟物化,体现了数据结构;领域服务,拟人化,体现了算法。

对象拟人化是一个非常好的实践,我在一个项目中做了一个非常小的功能,需要定时拉取服务器配置并切换应用中的数据库连接。

我设计了 4 个对象:

  • ConfigPoster 配置传输者
  • ConfigRefresher 配置刷新者
  • ConfigContainer 配置容器,用于承接本地配置
  • ConfigPackage 配置传输包

ConfigPoster、ConfigRefresher 是主体进行拟人化命名,ConfigContainer、ConfigPackage 是客体,进行拟物化命名。通过这种设计,避免了滥用的充血模型。

当然你可能说,在 CURD 项目中无法体现这类思想。那是因为一般的管理系统,业务很简单,无需过于进行面向对象设计,于是容易写出 Service 扮演所有的主体。

  • 主体就是一些管理者,ProductManager、UseManager
  • 客体就是一些简单的对象,Product、User

但是,很难说所有项目都是简单的 CURD 操作,这种情况往往是业务逻辑没有分析清楚,CRUD 项目带来的业务价值非常少,即便如此,也可以有意识的将对象拟物化、拟人化。

例如:操作订单的对象命名为 Cashier、XXXManager。开源项目尤其擅长此道,Spring Security 中TokenExtractor 用来从 HTTP 包中抽取 token; ETL 工具 kettle 用厨房中的事物 Spoon、Fork 代表各种模型,构建了非常容易理解的对象系统。

在实际使用 DDD 的过程中,领域服务可以和聚合成对出现。在避免了事务脚本的模式的同时,具有良好的封装性,能更好的对现实世界的阐述。

而通过主体和客体对模型进行分解,有很多好处:

  • 容易理解
  • 符合面向对象的直觉
  • 聚合根容易存
  • 批量问题被处理

通过对面向对象的进一步理解,这些问题也就迎刃而解了,当然学习 DDD 的过程也是重新认识这个世界的过程。

more >>

好领导,本来应是挖渠人

大多数谈论经管类的文章,作者都是企业高管,或者社会名人、公知。视角往往都是自上而下,先从战略规划,再到具体实践,高屋建瓴,视角宏远。作为一个长期一线的程序员,大多数情况下都是被管理者,我在想是不是也能从另外一个视角发出自己的声音,对我们日常团队协作有些许帮助。

手机配件送货员的故事

我比较幸运,无论是读书时期的兼职零工,还是毕业后的正式工作,和老板(直属领导)关系都还行,没有发生一些不愉快的事情。不过对身边发生的一些见闻感触很多。

高中毕业的时候在老家的小城市找了一份暑期兼职,工作的内容就是在手机零配件批发部给手机维修师傅送货。那个时候还是功能机时代,手机维修的需求很多,手机维修需要一些屏幕、听筒、排线等配件。城市不大,骑个自行车市区10 -20 分钟内大部分地方可以送到,所以老板喜欢招一些年轻小伙儿送货,人力便宜还身手敏捷。当然也有中年人做这个,临时混口饭吃。

我们店的老板姓陈,每天坐在店里接电话出货。大家都叫他老陈,为人很和气,不管是客户还是员工遇到的问题都尽力帮忙解决,跟他干还是比较开心。手机和配件这个行业在当时的小城市属于暴利行业,一块商务电池进价 20 元,给维修师傅的价格是 60 元,最终顾客需要支付 100 - 200 元不等。

隔壁陆陆续续开过几家新的批发铺子,都不太长久。有一次中午吃饭认识了一个隔壁的送货小哥,我叫他黄哥,给我说干啥都不容易,他们那个老板很烦,经常数落他们。客户(维修师傅)有时候摸不清需要更换什么配件,往往弄错,于是他们就来回跑。浪费时间不说,空跑也不挣钱,老板就责怪他们为啥不问清楚再送,如果维修师傅经常这样,就加钱或者不送了。

我感到很惊讶,这算啥事儿啊。我们之前也遇到这些问题,就给老陈吐槽这事儿。老陈说你们来回跑怪累的,我这就去买一些包,下次你就把常用的都带上,有个包放收据啥的也方便。工作中这样的例子很多,有啥事儿就给老陈说,一起想想如何改进也就解决了。

优秀的管理者,应是挖渠人。利用自己的位置和资源帮助解决问题,让员工工作流畅,就像春耕时期引水灌溉一样,及时疏通水渠中的石头和杂草,而不是怪水流的太慢。

ThoughtWorks 的一个同事

刚到 ThoughtWorks 的时候被分配到国内某项目,负责前端开发,构建一个报表页面。我们组就两个人,一个前端,也就是我,还有一个后端。

项目比较简单,我很快构建好了 React 的前端页面,因此在 Interview ++ 中的评价还行。后端那个兄弟使用 Spring boot 构建 API ,并需要连接 LDAP 获取公司的员工信息。他的工作并不顺利,花了大概两周才完成了 API 构建而且质量不佳。

后来我参加他的 Interview ++,得知他有10年以上的工作经验,之前一直都是写 C++ ,因为没有合适的项目来这个项目上写 Java。他的 Interview ++ 评价很不好,“技术能力一般,和工作经验不匹配” “不主动交流” “接受反馈的能力不好” 等。

因为项目上只有两个人,我们私下关系比较好,他虽然不是很健谈的人,但是具有非常好的沟通能力,理性和独立思考。我能理解他被给负面反馈的原因,无非是他工作在一个不适合他的位置,当技术能力无法满足项目需要时。在团队眼里,优点就会被弱化,缺点被放大。

很多人可能会说,写得好 C++ 的人写 Java 也会很快上手。但是客观事实是,计算机语言虽然很容易跨越,但是领域很难。长期重试嵌入式工作的人在没有人知道的情况下,在两周内上手 Spring boot 这种 “低级“ 技术很困难,因为需要很多服务器编程的背景知识。

在某个平行宇宙,他会不会上了一个他擅长的项目,被当做大神膜拜。“不主动交流” “接受反馈的能力不好” 这些反馈还算不算缺点呢?

优秀的管理者,应是引水人,把员工放到合适的地方,用合适的期望评价工作产出。需要遵从科学发展观,再好的水也不能逆势倒流

一个客户的故事

”我说过好多次了,提交代码前需要本地跑通,现在流水线又挂了“

”我昨天就强调,没用的代码要删除,不要留在项目里面“

”你这个代码能不能写的规范一点,这么乱“

晨会开了半个小时后,我无奈的放下耳机。在国内的一个项目上,我被派到一个团队和客户一起工作,这几乎是每天早上的状态。每天主持晨会的人是客户一个工作了很久的人,他似乎很恼怒,责备负责管理供应商的人为什么招聘了这么多能力差的人。

我入场后,分配的第一个个工作是改造一个旧项目,这个项目因为某些设计遗留问题,遇到了性能瓶颈,需要优化。一个负责相关业务的人来给我讲了一下背景,然后给我开通了一个代码仓库的权限,我就这么开始工作了。

当我开始下载好代码后,头皮发麻,这个项目没有任何文档介绍如何启动。甚至我不知道这个项目在整个系统中处于哪个位置,我开始四处求助。我找遍了整个办公室的人,得到了很多收获。

  • 想要把项目跑起来,本地需要安装 Redis、数据库等依赖,不过需要从同事那里先拷贝一些特别的配置,并注释几行代码。

  • 想要了解整个项目的背景,找了同事 A、B、C 终于拼凑出了这个项目的概况。

  • 项目没有任何规范,全凭自己发挥,至少有三种加解密的工具类,多种序列化方法。
  • 想要部署到 Dev 环境,发现每个人建有自己的流水线,部署方式各不相同。

最终这个项目做的非常头疼,技术经理没有打通整个工作流,整理相关的文档、规范、部署方法,让开发把主要力气用在开发上。每个人都需要摸索一套自己的做事方法,做东西很难说高效和”规范“ 。

优秀的管理者,应是治水人,像都江堰的鱼嘴、堤堰,疏、堵相适宜。设计工作流、协作体系,制定规范。

more >>

DDD 指导团队划分问题

前面提到过,促使公司将应用垂直拆分的一个原因是团队工作在一个项目上,团队过大无法展开。虽然将应用拆分的原因有很多,但是因为团队过大是所有原因中最被低估的一个。互联网应用往往是因为人多才拆成几个服务,而不是因为是几个服务所以才人多的。三五个人几条枪,被大公司忽悠,一个小项目拆成了十几个服务,这种技术很”吓人“的项目往往结局也很吓人。

刚入行带我的老板是从一家大公司出来创业的,随着团队扩大,他老挂在嘴边的一句话是,”一个50人以上的团队是没办法管理的“。当时不以为然,后来工作久了,参与过数百人一起开发的电商系统,经历过一个 Java 团队就有30 人的项目。

深以为然,甚至更为悲观,认为超过 15 人的团队都是难以被管理的。如果是敏捷团队,人数过多会很麻烦。敏捷有很多实践,有站会、codereview、计划会议、回顾会议等需要全体参与的会议。10 人以上的团队都难以让站会在 15 分钟内结束,codereview、回顾会议在 30 分钟内结束。超过这个时间,还要不要写代码修 bug 啦?

对于一个大型项目来说,服务化正是为了解决这个问题。通俗一点,团队划分的问题就是谁和谁坐在一起的问题,比较合适的人坐到一起工作效率更高不是吗,对于互联网公司而言,组织架构需要和技术架构适配才能取得最好的效果。回答这个问题,我们需要先看下各种公司的组织架构形式。

IT 行业企业组织架构类型

一些管理书籍将组织架构分为:金字塔式、扁平组织架构、矩阵制组织形式。对于互联网行业的公司来,我用更为通俗的说法,和现实中的经历,重新划分了一下:

  • 大部门式的组织架构。相同职责的人被收集到一起,形成了前端、后端、测试等大部门,典型的科层制的组织结构。
  • 矩阵制组织形式。大部门式的组织架构上根据项目再动态划分,行政管理和项目双线制。
  • 自由市场式的组织架构。这个公司就是一个大市场,各个单位按照一定形式混编,甚至管理团队也作为一个单位为公司提供服务。
  • 分形组织架构。基层由较全功能的小团队构成,多个小团队构成一个中层大部门,最后各个大部门构成整个公司。整个公司可以看做大型企业,每个大部门可以看做一个中型企业,每个基层团队可以视为一个创业公司。

image-20200517161135173

大部门式的组织架构

有一些公司是按照工种来划分的,客户端、测试、产品经理被分到不同的部门。我曾在一个这种类型的公司任职,客串过前端部门和后端开发部门。因为业务决定了后端开发的工作量远多于客户端和移动端开发,因此部门之间极不均衡。

image-20200517104005548

我们有 40 人的后端开发部门,10 人的产品经理部门,30 人的测试部门。这些人的工作部门领导分配,任务的流动方式是市场领导 -> 产品经理领导 -> 设计和交互部门领导 -> 各个开发部门(前端、后端、客户端、移动端) -> 测试部门领导 -> 运维部门领导 -> 市场部门领导。

这种工作模式本质还是一个大的单体团队 + 瀑布的工作流,其组织是一个金字塔形式,行动缓慢。这种组织架构带来了另外一个问题,团队扩张非常困难。这种困难和我们在技术上的容量瓶颈类似,技术上应用的水平扩展无法突破数据库的瓶颈,这种组织架构的瓶颈无法突破部门经理的负载。

矩阵式架构

大部门式的组织架构是从亚当斯密的社会化分工理论就开始了,制造业尤为明显。曾参观过富士康、自行车制造商捷安特的工厂,基本都是以大部门式的组织方式。银行、传统行业转行的 IT 公司很多也是大部门式。这种组织形式劳动密集型企业没有问题,但是对智力密集型企业缺点越来越明显。知识性工作,工作者之间需要大量的交流。相关的人,”坐到一起“ 永远是效率最快的交流方式。

随着软件工程的发展,项目管理在软件公司也像在制造业一样蓬勃发展。于是很多公司开始使用项目制,但是项目制的缺点和大部门式完全相反,项目组成员能有效沟通,但无法传达行政指令。行政管理总是不可或缺的,比如涉及绩效、涨薪等问题,项目制很难解决,几乎没有纯项目制的公司。

于是出现了项目、行政部门双轨的组织形式,这种形式能比较符合软件工程的需要。

img

传统的软件行业更像是来料加工的制造业,客户需要一个软件就启动一个项目,开发完成后释放掉这个项目。但是现代的互联网行业更像是一个服务业,软件开发需要源源不断的开发、修复问题和提供更新。于是矩阵式架构就留给了软件技术服务和解决方案公司了。

自由市场式的组织架构

互联网软件开发需要源源不断的开发、修复问题和提供更新,按照时间或者事件启动的软件项目越来越鸡肋。软件开发往平台化、服务化发展。互联网公司项目经理的话语权慢慢移交给了产品经理,软件开发变成了一个持续的行为。

相应的,互联网公司的组织架构开始调整为小型敏捷团队。响应需求和提供服务变成了其组织的目标,彻底区别于”来料加工“型的组织方式。这种组织形式需要很多灵活的小团队和一个中心来调度协调,于是变得越来越扁平。

很多公司推崇的扁平式组织架构,与其说扁平不如说是自由市场式。谁能为这个企业的内部产业链上提供服务就有机会拉起一个团队,如果能在公司外找到又好又便宜的供应商就外包出去,自由市场就是这么残酷。有人制作过一张苹果公司的组织架构图,非常有趣,围绕着 CEO 的是各个团队领导以及背后的团队。

image-20200517161736294

扁平的另外一个含义就是各个单位之间相对独立,每个单位直接向核心层汇报。每个团队的独立性使之可以让核心层快速调用,当然每个团队也更容易被替代。不过需要注意的一点是,组织架构扁平的出现可不是公司为了给雇员提供一个轻松而没有压迫的环境,而是由他们业务决定的。

因为这样比较省钱,充分体现了市场经济的哲学原理。

披着”市场经济下“外衣的扁平架构就是完美的组织架构了吗?如果是这样,经济危机就不会出现了,扁平组织架构的公司就能万世长存。扁平意味着权利下放到基层,权利结构分散和多样。团队之间虽然可以平等对话,但是会出现互相不正常竞争、KPI 至上的问题。团队之间在每个财年争抢预算,各自为政。

这种组织架构要求一个精神领袖,或者强大的平台,否则难以约束基层的权利。当缺乏精神领袖时,这种系统并不稳定,举个不恰当的例子是周天子衰败的春秋时期,以及失去向心力的民国时期。至于民国时期的状态,可以了解下《中国近代简史》,下面是一张军阀分布图。

img

分形组织架构

当人类有组织后,大部门式的组织架构就有了,这是一种朴素的组织架构方式;那么自由市场式的组织架构是IT 行业特有的吗?拿我们近代史中的故事。二战时期的步兵往往就是大部门式的组织架构,这符合那个时期的军队特征。不过日、美军已经有全功能小队作战的思想,小范围用于山地特种作战。这个和全功能的敏捷团队有一点类似,有一点自由市场式的味道。

幸运的是我军走了另外一条路,也就是我们要聊的分形组织架构。当时我军在装备、人员上都和日军差距巨大,但好在产生了”三三制“这种班组突击战术,后来推广全军的组织架构上。三人构成一个战斗小组呈三角进攻队形,每名士兵分工明确,进攻-掩护-支援。三个战斗小组组成一个班,班长、副班长、二组长,各带一个战斗小组行动。一个排由三个或多个班组成,一个连由三个排组成,全军依次类推。这种组织形式的好处是,分工明确但又有向上信息传递的通道,不那么依赖中心,能在战术穿插过程中快速重新组织起来,形成战斗力。

img

分形是数学上的概念,它的局部总是和全局相似,自然界无处不在:雪花、树干、晶体等等。这种组织形态类似数学上的分形,我们暂且把这种形态叫做分形组织架构。

img

当然”三“也不是绝对的,可以由三人或者多人构成,现在由于兵种更加多样,现在军队也不是完全”三三制“了。不过”三三制“这种思想还广泛存在,影响了很多机构、企业的组织形式。

image-20200517194703512

曾经很长一段时间里,对国内一些大型的互联网公司的组织架构感到疑惑。他们既不像大部门式的组织架构,也不像自由市场式的扁平架构。当一个公司大的可怕时,这些公司更像一个市场而不是一家公司,现在的理论仿佛不能完全描述他们。

三四个后端、一两个前端、一个 UI 设计师、两个测试外加一个产品经理,这就是一个互联网公司最基层的一个敏捷团队,负责一个大产品中的一个服务,也是很多初创团队的配置。

三四个敏捷团队大约几十人左右,就可以负责一个完整的产品,这也是一个起步成功的小公司的配置。

三四个产品构成一个事业部,能负责一个公司一条产品线,这也是一个中大型公司的配置。最后,多个事业部构成了最终的大公司,当然这些团队还有可能是市场、法务、生产等其他角色。

这种形态已经是目前大的互联网公司主流形态,金山、百度、阿里等等。这样来看的话,分形组织架构的局部就可以看做自由市场式的组织架构。

使用 DDD 指导团队划分

对于互联网企业而言,比较看好的是分形组织架构和自由市场式的组织架,因为我们基于 DDD 理论设计的分布式系统某种程度上也是分形结构。既然团队划分是为了服务技术架构,那我们又可以借助 DDD 的一些成果为团队划分帮一点忙了。

我们采用和《分布式授权设计》类似的套路,先把 DDD 的服务划分图做一些调整,然后再映射上团队划分图。再来解决一个一个细节问题。我拿最复杂的那张图做蓝本,并补充了分布式授权部分补充的授权服务。其实这张图还不够复杂,在真实的互联网公司往往还有支付、大数据、物流、售后服务等等。

image-20200516232757864

我们从 DDDD 得到应用的分层,然后划分出服务。根据康威定律,一个公司的组织架构和技术架构需要适配,当我们采用分布式系统划分了我们的应用后,传统的开发、测试大部门就不再适用了。

因此我们使用敏捷的工作方式和团队划分方式。这也是我们正在讨论的问题,根据我们服务的划分作为底图,初步得到我们的团队划分方式:

image-20200516234815771

作为敏捷团队,后端开发、客户端开发、移动端开发、前端开发、测试被打散到各个团队中。我将团队性质不同使用颜色区别了一下,以便于后面调整和阐述每种类型的团队成员组成部分。抱歉,因为这个图我还想保留 DDD 分层的信息,显得有点凌乱,我们会一步一步突出团队划分。

  • 绿色, Deveops 团队,负责基础设施的开发和运维,提供开发、部署平台。负责维护测试环境、部署、日志、监控、代码质量检查、堡垒机、安全审计、数据库管理等。
  • 红色,领域服务团队,负责领域服务开发、测试。可以由纯后端开发组成、QA为主。领域服务的 QA 负责 API 测试、集成测试、性能测试。
  • 青色,应用团队,负责应用层和端侧实现。负责整合后端 API 实现端到端应用,团队由前端、客户端、后端开发或者全栈工程师组成。QA 负责应用端到端测试,确保最终交付质量。
  • 藏青色,认证和授权团队,负责提供统一的认证和授权服务,也就是 SSO 服务。认证和授权团队一般都比较特殊和独立,因此单独一个团队。

这样一整理,我们发现每种颜色对人员的需求是不一样的,甚至需要自己独立招人。试试就是现在主流的互联网公司都是这样操作的,不仅需要独立招人,还有内部的人才市场。

那么,对于上面这个场景下的人员构成,我们再细化一下每个团队需要的具体角色。

image-20200517220726961

这种系统还有一个问题没有解决,每个团队相同的角色之间没有交流。每个团队有自己的 UI ,那么整个公司就没法统一;各个团队的工程师如果没有交流,整个公司的技术架构就是一团糟;BA 各自设计,业务逻辑只考虑自己的应用和服务,必然会造成逻辑上的矛盾。为了解决这个问题,有些公司创造了一个”委员会”的概念,由分形的上一级协调拉通。

这些委员会不是一个团队,而是由每个团队挑选出能“说的了话”的人共通参与,进行需求、设计、架构、测试评审的。

  • 架构委员会。由后端开发、devops、各端(前端、IOS、安卓、PC)等和技术选型、方案、架构相关的人员参与。职责为参与方案评审、技术选型、安全评估、规范制定、技术演进等。有一些公司会提供单独的架构师职位,其实更多的公司作为架构师的前提是一个优秀的一线工程师。
  • UI/UX委员会。由各个团队的 UI/X 组成,负责制定 UI 规范,设计系统。
  • 测试委员会。由各个团队测试组成,负责制定测试策略、测试用例编写规范。
  • 业务需求委员会。由各个团队业务组成,负责拉通业务需求和产品设计,保持各个应用的业务逻辑的一致性。

当然如果是在一家大的电商公司还会有自己的支付、物流、大数据、营销、财务平台,除了 IT 部门外还有市场、销售、法务,根据我们的分形理论,他们可能存在于另外一个平行世界里。

总结

聊完这些,对于一个大团队是否可以被管理又没有那么悲观了。当人类开始有能力成群结队用石头击败野兽的时候,管理就出现了。推动人类社会进步的是火吗,其实更应该是建立在语言之上的社会组织能力。800年周王朝建立在封建社会对奴隶社会的优越性上,资本主义的萌芽不仅仅是因为蒸汽机,更可能是社会化大生产的新型组织形式。

一个大的公司真的应该重新审视组织和技术两个方面,组织架构的创新也许比科学技术的创新更重要,团队划分的问题实际上是一个如何解放生产力的问题。

more >>

解决应用垂直拆分的分布式授权问题

应用垂直拆分后,面临的另外一个技术问题就是分布式授权问题。实际上 OAuth2 非常成熟,互联网的分布式授权体系基本都是基于 OAuth2的,使用分布式会话机制实现的 session 共享不能算严格分布式授权。讲解 OAuth2 的文章非常多,这里简单回顾一下,细节请参考相关资料 —— 之前的博客 细说API - 认证、授权和凭证

OAuth2 有四个角色:资源所有者、资源服务器、授权服务器、客户端。

  • 资源所有者(Resource Owner)。通俗来说就是需要授权的用户。
  • 资源服务器(Resource Server)。这个比较好理解就是提供资源或者业务能力的服务器。
  • 授权服务器(Authorization Server)。提供授权、发放凭证、检查凭证的服务器。
  • 客户端(Client)。发起授权请求的服务器。

OAuth 2 主要解决了两个问题:

  • 在不传递用户口令情况下让用户获得权限通行证。这个是通过服务间传递一次性授权码的方式代替密码传递实现的,基本原理比较简单:用户访问应用服务,应用服务去资源服务器拿资源,被要求授权。用户被跳转到授权服务,并使用密码请求授权服务器,授权服务器生成一次性授权码,跳转回应用服务。应用服务通过一次性授权码去授权服务器换取资源服务认可的凭证。
  • 让分布式系统中各个资源识别访问者是否有权限访问。资源服务器提前注册到授权服务器,通过凭证去授权服务器检查是否有效,并获取角色信息。

基本的逻辑如下:

oauth.png

实际上,OAuth2 有主要 4 种模式,解决不同场景下的问题

  • 授权码模式(authorization code)。通过一次性授权码,在无密码传递情况下获取授权。

  • 简化模式(implicit)。没有应用服务器,客户端充当应用服务的角色,授权服务器直接把权限给客户端。

  • 密码模式(resource owner password credentials)。应用服务器和授权服务器彼此信任,通俗来说密码模式就是走公司自己的授权服务,授权码模式走第三方授权。用户直接在应用服务输入密码,应用服务传递密码给授权服务器。

  • 客户端模式(client credentials)。和用户授权无关,解决服务之间访问的问题。也就是常说的 AK/SK 授权,识别来调用你服务的来源是否合法。

匹配 OAuth2 权限角色

在 OAuth2 实际应用中,有一个问题没有解决。应用垂直拆分后,那些服务分别充当 OAuth2 中的角色呢?我们回来之前的复杂问题,收银系统。当时为了简单,省略了用户服务和认证(IAM)服务,为了画下这张图,省略了一些元素,重点突出授权方面的内容。

image-20200516150054993

这个图还是看不出我们应用中那些是资源服务器、那些是应用服务器、认证服务器。我把图修改为半透明,并映射相关信息。

image-20200516151440735

通过 DDD 和 OAuth2 的结合,可以清晰地将 DDD 中的各种角色映射到 DDD 分层模型上。总结如下:

  • DDD 各种应用服务,需要实现为 OAuth 客户端。

  • DDD 各种领域服务一般设计为资源服务器。

  • 授权服务器需要既要提供一个 DDD 概念上的应用层和领域层,可以部署到一起发布。

  • 用户服务既要充当授权服务的资源提供者,实现用户登录。又要充当资源服务器,需要被权限管理。是一个比较特殊的互为依赖的关系,为了破解这个死循环,所以往往系统管理员都内置在数据库中。

  • 系统内部一般使用密码模式简化授权流程。

  • 如果需要提供一个开放的授权服务,授权服务器需要支持授权码模式。

  • 服务之间的信任问题使用客户端模式,通过 AK/SK 访问,在很多公司内部叫做 ”集成账号“ 调用。

  • 主流的实现上一般设计了认证服务和用户服务。用户服务负责用户、权限、部门等数据管理,认证服务负责 token 的分发,权限校验,绑定第三方登录等职责。

拦截器应该放到哪里?

除了搞明白 DDD 分层和 OAuth2 角色映射关系之外。DDD 思想还可以回答另一个问题:访问控制应该在哪里完成?用户获取到凭证后,授权服务器确实可以校验凭证,那么谁来负责访问控制(ACL)呢?接入层、领域层、还是应用层?

为了回答这个问题,我们需要将权限分为两种情况:

  • 方法级别的功能权限。解决的问题是,我能做什么。
  • 数据权限,角色和对象之间的权限。解决的问题是,我能对什么数据做什么。

我们发现,功能权限不正是应用层的职责吗?应用层定义某个角色能完成一个独立的业务职责,也就是 use case。像添加商品、删除商品,都是一些 use case。那么这些权限控制放到应用层非常合适,应用层可以简单地在方法前面加上注解就可以实现。

@PreAuthorize("hasRole('ROLE_ADMIN')")
public void addProduct(){
}

数据权限则没有那么简单,有两种声音:

  1. 所有的权限都应该集中控制,数据权限也不例外,不应该放到领域层,应该尽量前移。在网关中完成认证和授权,请求进入系统内部后无需再检查权限。
  2. 数据权限和业务相关,应该留给领域层决定,例如删除文章,需要有业务逻辑实现删除的是谁的文章。或者满足一定规则,比如删除共享给我编辑的文章。

在趟过很多坑,以及和大量资深工程师交流后,我是第二种声音的坚定支持者。原因有几个:

  1. 不是所有的数据权限都能容易的被统一拦截器拦截实现,这个想法很美好,但是很不现实。
  2. 用户对象的权限,本质上是一种业务规则,应该属于领域服务中来完成,否则大一统的数据权限成本高昂。非常简单的例子:有一个协作工作系统,一篇文文章可以由一个团队的成员编辑、删除。如果放到应用层,根本无法通过一个注解完成,因为有隐藏的业务规则存在。这种规则在 DDD 中可以使用 specification 模式来描述。

权限设计的经验

  1. 通过 HTTP 请求的方法来设计拦截器,因为很多权限不是以 HTTP 请求为粒度设计的。另外权限应该同应用层和领域层有关,接入层只起数据编解码、转换的作用,拦截器不要放到接入层。
  2. 权限设计过于复杂,例如用户对一个集合部分数据有权限,部分无权限。业务往往要求 ”无权限即不可见“。但是这样做成本非常高,如果设计成上面方案 1 之后几乎没有能力扭转局面。这种场景会带来分页、过滤、统计等各种问题,建议从业务上避免。
  3. 通过 HTTP header 传递权限信息。服务之间的权限信息不要侵入业务方法,与之类似的还有语言、版本、trace id 之类的信息。
  4. 用户密码错误,不要提示密码错误,应该提示”用户名或密码错误“。防止用户名被嗅探,被账户注册机等灰产盯上。

分布式授权的性能问题

分布式授权往往会出现性能问题,每一次请求资源服务器都会拿到 access_token 后去认证服务器校验是否有效。在 Spring Security 中默认使用 RemoteTokenService,字面意思就是通过远程访问进行校验,通过 HTTP client 访问认证服务器。OAuth2 RFC 规范文档叫做 Introspection,定义了相关规范。

每一次远程调用意味着性能的浪费,为了消除掉这次远程调用,可以使用两种方法:

  1. 直接访问 token 存储源,Spring Security 设计有 RedisTokenStore。不过这种方式违背了分布式系统的初衷,可以在内部系统酌情使用。
  2. 使用自包含凭证,也就是 JWT(Json Web Token),Spring Security 设计有 JwtTokenStore。通过使用 Token 自身携带的凭证进行鉴权,可以较好的提高效率。

使用 JWT token 有两个注意事项:

  1. JWT 因为自包含的特性无法做到撤回,解决的办法是通过 redis 设置一个黑名单,redis 过期时间和 token 比 token 的过期时间稍长即可。另外也需要给 access_token 设置一个合适的过期时间,一般在 5 - 10 分钟。
  2. JWT 本质上是签名而非加密,Json Web Token 是 Json Web Signature 的应用。JWT 由消息体 + 签名构成,实际上就是结构化的 HMAC 签名方法。因此不能将一些敏感信息放到 JWT 消息体中,消息体只存放一些必要信息,并用于验证签名是否有效。如果需要更多用户信息,需要从用户服务获取。

image-20200516221603716

总之,应用垂直拆分后解决授权问题主流的方法是 OAuth2 + OpenID(OpenID 是一种基于授权的认证机制,可以参考相关资料)。OAuth2 只提供了一个授权模型,DDD 可以指导那些服务可以设计为客户端、资源服务器、授权服务器,根据 DDD 对系统分层可以得到一个映射关系,从而梳理出合理的权限系统。最后,分布式授权也会带来性能问题,我们可以通过 JWT 来解决这个问题,JWT 只是一种凭证格式,不是授权机制,应注意避免滥用。

more >>

解决应用垂直拆分的联表问题

应用垂直拆分后面临的另外一个问题是实现表关联查询,例如某项业务在界面上有一个订单列表,订单列表中的一项是商品名称。按照一般的规律,商品和订单会被划分到两个服务中。

如何实现这个需求呢?

如果是单体、小项目,首选的方案就是订单表和商品表进行关联查询。不仅在分布式系统下,关联查询不可能实现,即使两张表在同一个库里,对于商品、订单这类大表联表的速度也非常慢。

使用实时查询

服务化后,为了解决这个问题,走进的一个误区是通过 ID 来查询一个列表。为了构造出带有商品信息的订单列表,很多开发者会在分页后通过订单上的商品 ID 构造一个请求发送到商品服务,以此获取商品数据。为了避免循环调用产生 N+1 的问题,商品服务的开发者”贴心的“提供了一个通过商品 ID 数组批量获取商品数据的接口。

当这种接口大量出现的时候,拆分出来的服务又耦合到一起了,系统性能受损,且容易造成”雪崩“。会有大量的请求打到基础服务上,用户服务、商品服务、基础配置服务等首当其冲,需要注意这是分布式应用下的一种反模式。

在不得已且场景合理的情况下可以采用这种依赖方式获取数据,例如:

订单列表上有实时的物流信息,物流信息由物流服务提供,并且变化频繁。业务要求用户能看到自己的订单列表,列表中有一列为物流信息,包括:物流状态、物流单号、物流公司。

这种场景下,不得不采用将订单分页后通过 ID 到物流服务中获取,然后返回给前端。

即使是使用这种方式,也需要遵守一些规则。获取两个服务的数据并编排到一起,显然这个工作应该放到应用层,并尽可能使用异步的方式获取数据并聚合到一起。正是这个原因,Nodejs 以其天然的异步特性,被大量使用在应用层和接入层。如果使用 Java 则可以考虑 RxJava 等异步库来实现。

image-20200516093818246

同时,这种调用也需要进行熔断和回退。当物流服务 不可用时,应该即使中断连接,并依然将可用的数据返回给前端。界面上的表现是订单数据显示完整,仅仅是物流数据暂无法获取,对用户来说应用基本可用。

image-20200516093947300

不过,大多数场景下可以通过数据副本冗余和搜索服务解决这个问题。

使用数据副本冗余和反范式

产生这个问题,实际上是受到数据库关系理论影响太深了,数据库关系理论可以有效解决数据一致性和冗余,但在分布式系统下不再适用。

我们来回顾下关系理论中最重要的数据库范式理论。范式理论在某种程度上代表着数据库设计冗余度,主流的范式理论有第一到第五范式,第三范式后面有一个巴斯-科德范式,可以看做 3.5 范式。随着满足范式增高,意味着数据库冗余降低,也就是表越 ”碎“,所以一般参考第三范式。

image-20200516133902761

  1. 第一范式(1NF)。列职责单一,即表中每一列都是不可分割的,这是数据库设计的基本条件。学生表(学生姓名,学生基本信息),用户基本信息中还有姓名。不满足第一范式,连关系数据库都不能算。
  2. 第二范式(2NF)。所有字段都需要依赖主键,表中不应该存放和主键无关的字段。选课表(学号,姓名,性别,课程名,成绩),学号与课程名称是主键。姓名依赖学号,但是和课程没有关系。应该拆分成学生表、选课表。不满足第二范式,本质是多个模式叠加一起了,会存在冗余以及部分记录中大量空字段。
  3. 第三范式(3NF)。所有的字段都应该直接依赖主键,而不是传递依赖主键。基于第二范式的选课表(选课ID,学号,课程号,课程名,成绩),选课ID是主键,学号、课程号、成绩决定了选课记录。课程名和选课 ID 实际上没有关系,课程名存在冗余。如果要满足第三范,需要将课程名拆分出去,通过课程号外键关联。

第三范式导致了学生和课程关联到一起,如果需要将学生和课程拆分成不同的服务,选课由课程服务提供,冗余其中一个服务的部分字段。在分布式应用系统下,可以通过反范式的思想来解决服务间的数据关联问题,单体系统我们一般将范式设计到第三或者巴斯-科德范式,分布式系统则应该考虑退化到第二范式,使用数据冗余和服务间同步。

image-20200516135408376

关于反范式应该注意两点:

  1. 反范式不是没有范式,而是将高范式退化大低范式,通过冗余换联表成本。
  2. 有些场景使用了冗余和快照是业务使然,例如订单中商品快照本来就不是商品,他们之间无需同步。即使在单体系统下也应该分开。这种冗余也不是反范式。

下面几个常见类似业务场景:

  1. 某电商系统将订单和商品拆分为不同的服务,但是业务要求订单列表中需要显示商品名称。
  2. 某大型网站提供了用户认证 SSO 服务,且拆分了单独的用户服,在某管理后台应用中,需要显示已经登录的用户列表和用户基本信息。
  3. 某电商系统将订单和支付拆分为不同的服务,但是业务要求订单列表中需要显示支付状态。

通过 DDD 思想来分析,业务场景 1,订单中的商品和商品服务中的商品明显不是同一个概念。关于二义性的问题,我们在之前的文章中也讨论过,在不同的上下文下会存在二义性,通过发现二义性能建立合理的模型。这里的商品应该作为订单服务中订单项上的商品快照存在,设计为数据冗余。充分分析实际的业务问题,自然就解决了冗余关联的问题。类似的场景还有收货地址、票据等,这些对象都可以使用 DDD 中的值对象来设计,利用不可变性,让代码更直观。

image-20200516100017610

对于业务场景 2 属于常见需求,但是领域建模比较难的部分。很多系统都会提供认证服务,在认证这个上下文中,模型的本质是认证后的用户而非用户本身。因此往往是 token + 用户快照,不过并不是持久化到数据库中(往往是 redis ),所以在线用户列表并不需要去关联用户表,数据的一致性在用户重新登录后完成。

image-20200516100927239

对于业务场景 3 不能简单套用业务场景 1、2 。订单中需要冗余支付状态信息用于在订单服务中使用,但是对于这类需要服务间更新的冗余数据来说,可以借助在《分布式事务设计》部分谈到的最终一致性完成。不需要实时的访问订单服务,而应该采用同步和推送的方式,将支付服务中的状态同步到订单服务。

image-20200516141244461

在充分对业务分析的场景下,这种方式优势明显:

  1. 查询性能高,不需要跨服务查询,缓存策略简单
  2. 不需要熔断,没有雪崩效应
  3. 便于业务统计分析,数据统计的成本低

当然,并不是所有的业务都能满足条件去实现这种设计,需要具备一些条件:

  1. 业务天然具有分离性,典型的就是商品快照、收货地址、发票、单据
  2. 业务上不完全分离,但能接受一定程度的延迟,对实时性要求不高,可以通过最终一致性同步过来。例如商品服务和供应链服务。

还有一些陷阱需要注意:

  1. 盲目冗余,被冗余数据和当前服务完全没有关系。例如用户是否有折扣由积分阈值决定,积分可以动态增加和减少。有可能业务需要用户列表中显示有无折扣,但是折扣信息和用户完全不相关,这种情况也不要冗余。
  2. 延迟要求较高,在前面的例子中物流信息变化十分频繁,并且业务上对延迟接受度低,也不要设计为冗余。

使用搜索引擎解决复杂查询

解决两个服务的问题,有时候可以引入第三个服务来解决。我们来看下面的场景:

一个社区应用,用户发帖时间达到一定可以领取荣誉勋章,该系统将用户信息和成长体系分为两个服务。现在有一个需求是管理员需要根据荣誉勋章或者用户名进行搜索,并显示到一个列表上。同时类似业务也有可能出现在用户侧。

这个场景有两个特点。第一,不满足冗余设计的条件,荣誉勋章输入成长体系的一部分,这个服务和用户服务不太搭边。第二,需要跨服务搜索,且搜索会很复杂。

这种场景下实时查询和冗余设计的都不是很方便解决这个问题,好在可以通过搜索服务来解决这个问题。

image-20200516103402240

开源成熟的搜索引擎 Elastic Search 在互联网公司广泛应用,用于解决服务垂直拆分后的查询问题非常实用。通过创建多个 Elastic Search 的索引,可以满足各种不同的查询需求。

使用搜索引擎会造成项目的运维成本上升,带来好处同时也需要考虑成本。另外真实项目中,服务间数据同步失败,最终一致性丢失这种问题始终存在。搜索引擎应该只拿来做搜索,不应该获取搜索引擎中的数据直接应用于交易业务。用户从搜索引擎获取的列表,然后根据该条目操作退款等业务,即使条目中含有相关数据,也应根据关联字段实时访问服务获取最新正事数据,保证强一致性。

总之解决表关联问题还是需要转换关系型数据理论和分布式系统下的两种思维方式,灵活应用。跨库联表等技术显得非常不自然,同时在性能、业务规则上都面临挑战。

more >>

DDD 指导应用垂直拆分后事务问题

服务被垂直拆分后,如何解决事务问题是一个业界难题。很多讲架构的书都会讲两阶段提交、三阶段提交、TCC模式、Saga 长时处理,然而并没有说明什么场景下该选用什么架构。

其实我们一直都在和分布式事务打交道,银行、典型业务有一个概念叫做”冲正“。意思是对错误、或者不一致的情况出现时进行纠正,和数据库事务的回滚类似。和第三方支付系统对接时,支付系统会同步或者异步回调,回调失败后会反复重试。

和外部系统对接,本质上就是构成了一个分布式系统,其一致性问题就是分布式系统。但这种一致性问题和我们在数据库事务不能完全等同,数据事务是强一致性,满足 ACID 的特性。分布式系统事务参考 BASE 思想,保证最终可用即可。

根据 CAP 定理,分布式系统中一致性、可用性、分区容忍性是矛盾的,只能三者取二。因此分布式事务的最佳解决方法是:服务间使用柔性事务(AP),服务内使用强一致性事务(CA)。 解决这个问题,需要了解几个基本理论:CAP 定理、ACID 原理、BASE 思想、幂等原理。

CAP 定理

数据库随着服务垂直拆分后,单机系统的事务规则不再满足。分布式系统的 CAP 定理包含三个元素:

  • C: Consistency,一致性。系统中所有的数据备份、分区,在同一时刻具有同样的值。
  • A: Availability,可用性。系统收到请求后,必须完成响应,否则就是不满足可用性。
  • P: Partition tolerance,分区容忍性。每个节点可以视为一个区,可以允许分区之间通信失败。通俗的来说,由于人类通信技术条件限制,如果是分布式系统,就具备分区容忍性。

CAP Theorem 2

CAP 定理的本质是阐述了网络通信的不可靠传输,技术上无法突破,但是可以从业务上取舍。关系型数据库都选择 CA,保证业务可用和一致性问题,不接受分区容忍,也就是说网络断开就不再工作。

因此服务内就是一个单体,可以从容的选择强一致性事务。而服务间无法突破 CAP 定理的限制,可以通过各种手段达到最终一致性,而最终一致性的各种方案非常成熟。

ACID

  • A:Atomicity,原子性是指一个事务是一个不可分割的工作单位,其中的操作要么都做,要么都不做。
  • C: Consistency,一致性是指事务执行前后,数据处于一种合法的状态,最终状态不会出现。
  • I:Isolation,隔离性是指多个事务并发执行的时候,事务内部的操作与其他事务是隔离的,相互之间不受影响。
  • D:Durability,持久性是指事务一旦提交,它对数据的改变就应该是永久性的,机器故障或断电都不会丢失数据。

ACID 约束了数据库具有一致性和可靠性优先,主流关系型数据都支持 ACID 特性,但是需要注意 MongoDB、Redis 等数据库是不支持的,不应该完全依赖它们存放交易数据。

BASE 事务原理

BASE 思想相对于 ACID 可以指导分布式系统中柔性事务设计, BASE 包含三个元素:

  • BA:BasicallyAvailable,基本可用。
  • Soft-state,软状态/柔性事务,允许系统在一定时间之内存在不一致的情况,这种状态叫做软状态。
  • Eventually Consistent,最终一致性,经过一段时间之后,更新的数据会到达系统中所有的节点,这段时间被称为最终一致性时间窗口。

BASE 思想更契合我们做服务拆分的目标,分而治之,实现弹性拓展。例如订单支付完成之后,允许一段时间后,订单状态最终被标记为被支付。BASE 事务原理在实际开发中最难的不是技术问题,而是让业务方能充分理解,这两种事务的关系,并在交互方式上做出调整,例如在界面上增加状态、进度等。

根据 BASE 的思想,服务之间数据更新的方法调用,最好采用异步、消息机制,保证可用性、性能,一只性留给时间。

服务内部调用性能指标使用 QPS、TPS,服务间调用的设计,有两个不同的性能指标:

  • 调用成功率,尽量不启用补偿机制
  • 最终一致性时间,同步时间尽量小

幂等

想要做到最终一致的方案有很多,例如可靠消息模式、重试等。其中一个重要概念是,支持柔性事务的方法都需要设计为幂等,建议服务间调用的方法都设计为幂等。

幂等是一个数学与计算机学概念,幂等操作的特点是其任意多次执行所产生的影响均与一次执行的影响相同。通俗来说就是一个方法多次执行不会产生副作用。数学公式表达如下:

image-20200505194248188

HTTP 协议中 GET、PUT、DELETE 等方法往往都是幂等的,除了 GET 这种天然具有幂等特性的方法外,分布式系统中对数据更新的调用也需要设计为幂等,用于实现最终一致性。

解决服务间一致性问题的方法总结:

  1. 业务上做出取舍,或者划分服务时考虑,强一致性事务在服务内完成

  2. 服务间使用柔性事务

  3. 服务间有状态的方法调用设计为幂等

  4. 服务间有状态的方法调用尽量走消息队列

more >>

在分布式系统中使用 DDD

在分布式系统中使用 DDD

在使用 DDD 的思想时,最让人迷惑的就是如何组织代码,也就是通常所说的系统架构的问题。在前面提到 DDD 可以很好地指导代码组织,其中举了两个例子,单体和微服务架构下 DDD 如何指导代码的组织方式。令人沮丧的是,大部分应用系统既不是完全的单体系统,也不是纯粹的微服务架构,而是出于某种中间状态。

无论我们使用单体、SOA、微服务、中台或者其他架构,都需要解决如何组织代码这个问题,DDD 并不是一个技术,而是指导我们组织代码的一种思想,这种思想也并不是凭空出现的。

就代码组织这个问题,看起来没有什么技术含量,但实际上非常重要,软件工程发展过程中出现过三次危机,软件危机泛指在计算机软件的开发和维护过程中所遇到的一系列严重问题,代码的组织和大规模协作是其重要的组成部分。

  1. 结构化程序设计解决了第一次软件危机。60年代~70年代计算机刚刚投入商业使用,主要的编程方式还是汇编语言在特定的机器上编写程序。当软件规模较小,基本上处于计算机科学家个人编码设计、使用的方式。随着软件规模扩大,复杂度增加,依赖特定机器、无结构化的编程方式无法应对软件的发展,带来了第一次软件危机。为了克服这个问题,业界提出了”软件工程“的概念,1972 年 C 语言的出现,解决了代码结构化、抽象性、可移植的问题。

  2. 面向对象解决了第二次软件危机。随着软件在商业中大规模使用,软件变得原来越复杂,即使结构化的 C 语言也无法满足业界对可维护性、可拓展性的需求。标志性的事件是 IBM 公司开发的 OS/360 系统失败,该系统有 4000 多个模块,约 100 万条指令,以及大量的 bug。面向对象的编程语言,Java、C#、C++ 出现,面向对象带来了更自然地代码组织方式,软件开发变得越像建筑业。
  3. 第三次软件危机。第三次软件危机还没有一个明确定义,通常来说就是互联网行业兴起,软件变得越来越复杂,需求越来越多变。软件开发从建筑业变成了服务业,需要随时响应变化,在软件行业表现为瀑布开发越来越不可行,敏捷开发越来越重要。从技术上表现为单机开发越来越不可行,分布式系统是必然的趋势。

每一次危机的解决,都是建立在前一次的基础之上的。面向对象是建立在结构化程序设计之上的,敏捷也是建立在瀑布之上的,而不是推翻前者。DDD 还停留在面向对象这个阶段,可以用来指导分布式系统设计,应对越来越复杂的应用系统,DDD 也不是面向对象思想的替代者。

DDD 的代码组织形式众说纷纭,并没有一个标准的代码架构。为什么会这样呢?实践中我们发现,不同公司、项目的业务背景不一致,架构不一致,架构的演化层次不一样(查看另外一篇文章《架构的演进》),标准的代码架构并不适合每一个公司。

当我们的系统架构从单体往 SOA、微服务、中台演变,无论名称如何变化,实际上都是分布式系统,只不过分布式的程度不一致而已。所以我们需要将问题拓展到分布式系统这个更大的概念上,再来谈 DDD 的代码组织形式才有意义。

我们看一下分布式系统下一个定义:

分布式系统是一组电脑,透过网络相互连接传递消息与通信后并协调它们的行为而形成的系统。——维基百科

从广义的分布式系统定义上来看,现在的互联网应用基本上没有不是分布式的了。分布式系统不是软件工程师主动选择的结构,而是业务逼得这样选择。阿里巴巴带动的去 IOE (去掉IBM的小型机、Oracle数据库、EMC存储设备,代之以自己在开源软件基础上开发的系统)就是一个很好的体现。

在这样的一个思维方式下,单体系统是只有一个计算节点的分布式系统,那么 DDD 在单体应用下的经验也可以应用起来。我没有找到一个专业术语描述分布式系统程度,这里请允许我创造一个新词,分布式级别。

分布式级别

为了解决业务上的问题,用户量大、业务规模大,当用户量增长到无法被容忍时,我们引入分库分表(分布式数据库)、垂直拆分业务(微服务)。

我们会将系统变得越来越复杂,然后不得不解决各种分布式系统下的新问题,业务上面临的问题被转移到技术上,从而业务才有可能持续性的发展。我们面临的问题不会消失,只会从一个地方转移到另外一个地方,转移到我们能容忍的地方,比如转移到云上,然后通过购买服务解决。

系统中节点角色越少,需要解决的分布式问题则越少,可以认为这是低级别的分布式系统。低级别的分布式系统 架构基本上没有什么分布式问题存在,目前主流的小项目通过 Nginx 让应用水平拓展 + 主从数据库的架构可以看做低级别的分布式系统。

系统中节点的角色越多,应用垂直拆分,需要解决的分布式问题就越多,遇到的技术挑战也越多,我们可以认为这是高级别的的系统。应用系统的例子就是微服务架构,另外一个例子就是大数据平台。

我把分布式级别做了如下划分,基本上可以囊括目前互联网应用系统的主流架构:

  • 准单体系统
  • 低级别分布式系统
  • 高级别分布式系统
  • 复杂分布式系统
分布式级别 案例 架构参考 业务价值 分布式系统问题
准单体系统 博客系统
内网 OA 软件
LAMP
Java单体
简单、成本低 无分布式问题
低级别分布式系统 小型互联网产品 Nginx 负载均衡
Redis 分布式会话
读写分离
RDS
应用水平拓展
存储水平拓展
高可用问题
动态负载均衡问题
监控问题
高级别分布式系统 中型互联网产品 SOA
微服务
应用垂直拓展
存储垂直拓展
上一级别所有问题
分布式事务
关联查询
服务发现
熔断降级
调用链跟踪
复杂分布式系统 大型互联网产品
大数据平台
中台 各个服务独立演进
业务复用
上一级别所有问题
版本化问题
团队协作问题
性能开销

在微服务项目中经历过痛苦的开发者应该所有体会,全世界开发者贡献了大量的开源软件尝试解决这些问题,后面详细介绍每一个问题如何具体解决。

清醒的使用 DDD

上面这些分布式系统的问题,DDD 都解决不了。DDD 的作用只有一个:在单体中划分模块,在分布式系统中划分服务。 服务划分的良好,关联查询、授权、分布式一致性等问题可以被很好的解决,也就是我们常常说的解耦

但是就这一个作用,对于做应用开发的业务系统来说至关重要,虽然对于专门解决技术复杂度问题的云厂商来说用处不大,所以最好让 DDD 在合适的地方发挥作用。高级别的微服务系统的修改成本如此之高,以至于服务划分错误几乎没有能力调整回来,甚至导致很多互联网公司就此走向失败。

因此,如何划分服务,这是 DDD 非常有价值的一个地方,在分布式系统中,DDD 起到的作用实际上就是指导垂直拓展。值得庆幸的是,应用系统分布式级别增加带来很多技术挑战,但是逻辑上的架构变化却不大。

在每一个不同的演化层次下,谈 DDD 的代码架构才有意义。例如单体系统没有必要过多分层,避免样板代码大量出现;微服务系统则需要小心分层,并严格执行,否则修改成本非常高。另外也需要解决该层次下的技术问题,微服务需要解决分布式事务问题、分布式授权问题、分布式缓存问题、性能问题等。

DDD 分层和职责

在 DDD 指导代码设计部分,我们提到了三层架构和 DDD 的四层架构的区别,DDD 的四层架构被越来越多的认可,但是每层具体的职责很少有文章谈到。根据实践经验,我把四层模型中具体的职责整理出来,用于团队在做架构设计中能有共同的认识。

前面的 DDD 四层模型的图为了表达每层中的元素,丢失了一个重要的角度,每一层的组件可能有多个。还是以收银机系统为例,架构会是像下面这样,业界大多数互联网架构图也是这样画的,只是使用术语略有不同。

image-20200505112134088

实践中我们发现,接入层是由应用场景解决的,因此接入层需要在特定应用场景下使用。收银机应用下,接入层是 Restful API 以及 socket 连接实现的实时通信,商户管理和平台管理无需使用这些接入方法,在不前后端分离的情况下,模板引擎也足够使用。

同样的,基础设施层是和领域层绑定到一起用于实现业务逻辑和规则,底层基础设施的选择由领域层决定。商品服务主要是和数据库打交道,需要使用 Mybatis,但是用户认证服务(图上未体现)可能只需要 Redis 做分布式会话即可。

接入层和技术设施层,更应该看做两个亚层。结合 DDD 术语将示例图调整如下:

image-20200505111729710

应用层

餐饮系统是一个非常复杂,具有多端、多租户的系统,往往有收银机应用、手机点餐应用、商户管理、平台管理等应用,从而组合成一个系统。在有些公司的语境里,应用层往往是根据用户角色划分的,被称为”业务面“。

应用层的特点:

  • 关心处理完一个完整的业务

  • 该层只负责业务编排,对象转换,实际业务逻辑由领域层完成

  • 不关心请求从何处来,但是关心谁来、做什么、有没有权限做

  • 集成不同的领域服务解决问题

  • 最终一致性(最终一致性对业务有侵入)事务放到这层

  • 对应到分布式系统中的中台等概念

  • 方法级别的功能权限控制放到这层

  • 只产应用异常,对应 HTTP 状态码 403、401

  • 准单体系统下,按照应用划分模块

接入层

对接入层来说,我们可以看到,实际上接入层是依附于应用层存在的,随着前后端分离,Restful API 成了主流,对简单的系统来说这一层越来越弱化。对于有终端接入的系统来说,接入层并不简单,需要处理各种协议适配:XMPP、websocket、MQTT 等。在复杂度不高的情况下,我们往往把接入层和应用层合并部署,这里往往凭经验来决定。如果对分布式级别有了认识,可以更为科学的选择是否要将接入层和应用部署到一起。

接入层的特点:

  • 关心视图和对外的服务,Restful、页面渲染、websocket、XMPP 连接等
  • 如果没有多种接入方式,可以和应用层合并
  • 对应到分布式系统中的网关、BFF、前台等概念
  • 只产生接入异常,例如数据校验,对应 HTTP 状态码 400、415 等
  • 一个应用可以有多个接入层
  • 接入层做和业务规则无关的 bean validation 验证
  • 准单体系统下,按照连接方式分包

领域层

对于领域层来说,很多互联网公司没有这个概念,将这些实现混合在应用层隐藏实现了,造成业务规则不一致。随着前后端分离的发展,2013 年左右我也开始前后端分离实践,接入层剥离出去后,后端开发者开始审视是否需要抽象出一层来复用业务逻辑。当时大部分互联网公司称为服务,也就是 SOA 架构,大量使用 XML 和 SOAP 技术。

领域层的特点:

  • 不关心场景,关心模型完整性和业务规则

  • 不关心谁来,不关心场景完整的业务,关心当前上下文的业务完整

  • 强一致性事务放到这层,聚合的事务是 "理所当然的"

  • 对应到分布式系统中的 domain service、后台等概念

  • 领域层做业务规则验证

  • 产生业务规则异常,例如用户退款条件不满足,对应状态码 412、419 等

  • 数据权限放到这层(比如只允许删除自己创建的商品),因为数据权限涉及业务规则

  • 准单体系统下按照上下文分包,上下文之间调用必须走领域 domain service,目的就是解耦

  • 上下文中分聚合,聚合根要足够小,只允许聚合根拥有对应的 domain service

  • 根据业务情况,参考反范式理论,跨上下文使用值对象做必要的数据冗余

基础设施层

对于基础设施层来说,技术设施层并不是指 MySQL、Redis 等外部组件,而是外部组件的适配器,Hibernate、Mybatis、Redis Template 等,因此在 DDD 中适配器模式被多次提到,基础设施层往往不能单独存在,还是要依附于领域层。技术设施层的适配器还包括了外部系统的适配,互联网产品系统的外部系统非常多,常见的有活体监测、风控系统、税务发票等。

技术设施层的特点:

  • 关心存储、通知、第三方系统等外部设施(防腐层隔离)

  • 如果使用自动化的 ORM,这层可以在一定程度上省略

  • 基础设施异常,应丢出内部异常,对应状态码 500

  • 准单体系统下按照 adapter 分包

  • 基础设施的权限由配置到应用的凭证控制,例如数据库、对象存储的凭证,技术设施层不涉及用户的权限

DDD 分层的注意事项

DDD 分层架构需要认识到一点是,有时候我们在项目中找不到每层之间的明显的界限,那是因为我们使用的框架帮我们完成某一层。MVC 框架,Spring MVC、Jersey 帮我们搞定了接入层的事情,Hibernate、Redis Template 让我们感觉不到基础设施层。四层模型并不是一个刻板的教条,应该和你选用的框架做出调整,DDD 的作者也多次强调这一点。

另外,基础设施层和接入层需要注意两点:

  • 接入层指的是服务端用于适配端侧的部分,而非端侧本身。因为接入层本来就依赖应用层,没有人使用接口在这里做依赖倒置,所有又被称作主动适配。
  • 基础设施层指的是适配基础设施的部分,而非基础设施本身。开发者往往希望数据访问的接口有应用来定义,避免和基础设施绑定,提供替换的可能,因此这里往往大量使用接口,会有一些依赖倒置的实现,所以又被称作为被动适配。关于依赖倒置的知识,可以了解面向对象的一些基础概念。

DDD 分层到四种架构的映射

我们把这四层合到一起部署就是准单体系统,分开部署就是微服务、SOA。

更加有意思的是,在准单体系统中,如果我们严格限定领域层中模块之间的耦合关系,应用层访问领域层是通过本地方法调用的。当我们想改造成微服务实现时,只需要简单的抽象一个接口,然后通过远程调用实现它,无论是 RPC、还是 Restful 访问都不是大问题。

当然我们得解决远程调用后的一系列问题,以及领域层是解耦良好的。

准单体系统

准单体系统架构下,所有的代码在一个代码仓库,四层架构依然,往往通过多模块组织代码。应用层通过不同的模块实现,然后将领域服务抽出来一个公用模块。很多小型项目依然保持这种形态,每层能保持良好的依赖关系非常重要。 每层之间最好依次向下调用,DDD 的书中有一个不好的示例,上层可以跳过中间层直接调用下层。

image-20200505123131815

很多内网部署的传统项目单机就能满足,小型公司的 OA 软件、餐饮软件、会员管理系统的单机版就是通过这种方式部署。

低级别分布式系统

image-20200505124048034

将应用水平拓展,数据库进行主从拆分,Redis 使用主从或哨兵模式,本质上和准单体系统没有区别,应用没有垂直拓展复杂性不会有特别大的提升。

还有一种折中的方式,应用层各个模块单独部署,领域层的业务逻辑单独部署或者通过 Jar 包的方式加载应用中,实现应用层的解耦,并且不会带来分布式的问题。

image-20200505130402741

基于上面这种模式的变体,下面这种部署方式也有很多,通过这种部署方式,领域服务使用严谨的 Java 实现,接入层和应用层使用 PHP、Nodejs 等动态语言实现。

image-20200505130840667

高级别分布式系统

如果我们把应用和领域层都独立部署,就得到了现在主流的微服务架构。只不过在微服务的语境下,应用层 + 接入层被称为 BFF (Backend for Frontend),领域层负责实现业务逻辑,应用层用于各种业务场景下的适配。

image-20200505151228368

然而这种设计会受到一些批评,他们认为这不是正宗的微服务,而像现在所说的中台。部分微服务的工程师倡导使用 API Gateway 的方式将领域服务的 API 直接暴露给端侧。

实际上这种做法应用层并没有消失,编排领域服务 API 的职责被下放到端侧,在一些特殊的业务场景下没有问题,但是大多数场景下并不合适。业务逻辑容易造成碎片化,存在调用次数多,服务间最终一致性事务难以实现等问题。下面这张图表达了这种设计方式,但大多数情况下并不推荐。

image-20200505151818133

到此,领域层被垂直拆分,随之而来的就是我们熟知的各种分布式问题了,熔断、负载均衡等问题属于技术复杂度可以在业务无感知的情况下被解决,但下面几个问题需要侵入业务才能被良好的解决,因此还需要 DDD 的帮助。

  • 领域层模块之间的事务怎么处理?
  • 领域层模块之间需要表关联怎么办?
  • 领域层是无状态的,怎么做权限控制?
  • 领域层模块之间的依赖关系怎么处理?

我们在后面的 《DDD 指导应用垂直拆分后的问题》部分回答。

复杂分布式系统

高级别的分布式系统已经是业界大的互联网公司的主流做法,不过在一些极端复杂的系统中,依然不能满足业务需要。倒不是技术上一定要拆的非常细,主要是参与开发的人数多、代码量大,团队协作、版本构建有很多问题。

一个最佳的敏捷团队为 10 到 15人,除去测试、业务分析师,开发者一般在 10 人左右。因此在非常复杂的系统中尽可能把能拆分的都拆出去。继续拆分往往有两个方向:

  1. 变得复杂的接入层,在应用层里面兜不住了。例如 socket 连接相当费资源,可以剥离出去单独建立连接,然后和收银机应用通信。
  2. 一些外部系统的适配层,例如短信网关、税务系统适配服务。

image-20200505161853912

某大型 lot 平台将对接端侧的服务根据接入协议拆分,HTTP、MQTT、XMPP 然后转换数据格式后统一送入。不过,这种场景已经比较少见。

more >>

软件项目规范化指南

对于一个软件开发团队,可以通过哪些代码质量指标和扫描方法让团队产出规范、安全、高质量的代码?让开发团队运行的安全、透明、可靠?

最近参与了某著名通信设备公司的软件研发合规运动,虽然对于一线开发团队来说是一件非常痛苦的事情,但是不得不被该公司的执行力和决心所折服。本文总结了其中一些实践和工具,包含常见代码质量扫描工具、代码质量指标、第三方依赖管理、安全运维等几个方面,主要适用于 Java/JavaScript 技术栈的 web 项目,希望对于想要规范化自己的项目的 Tech Lead 有所帮助。

代码扫描和常见质量指标

“祸患常积于忽微”,往往一些奇怪的 bug 都是一些不规范的小问题造成的。这里整理了一些常见的扫描工具和代码质量指标,可以在搭建项目基础设施时引入,用于自动化的检查代码中潜在的问题,达到控制代码产出质量的目的,

扫描工具

checkstyle

checkstyle 是常用于 java 项目的扫描工具,检查源代码是否与代码规范相符,检查项目主要包括:Javadoc 注释、imports、过长的类和方法、空格、重复文件、圈复杂度等,默认使用 sun 的代码规则,也可以配置自定义的代码规则,例如阿里就发布了相应的检查规则。

findbugs

通过 Bug Patterns 的概念,寻找代码中可能出现的 bug,检查项目主要包括:不良编程习惯导致的问题、性能问题、安全问题、线程问题等。例如,应使用 equals 判断相等,而不是 “ =” 操作符、流需要关闭、线程资源需要释放等问题。findbugs 的模式库对编程经验也有较好的提升作用。还可以导入和编写自己的 Bug Patterns 完善检查机制。

simian

simian 是一个用于检查重复和相似代码的工具,它的重复检查类似于论文查重,会提示一定的相似度。可以单独运行,也可以作为 checkstyle 插件来使用,相对来来说比较小众。

pmd

pmd 是一款跨语言的通用静态扫描工具,具备一部分 checkstyle、findbugs 的功能,不再赘述。

ESlint/TSlint

前端界的 checkstyle , TSlint 设计用来做 TypeScript 类型检查,ESlint 作为代码风格检查工具。不过现在 ESlint 也提供了TypeScript 类型检查功能,基本上 ESlint 能整合这两个功能。由于性能问题, TypeScript 也采用了 ESLint 作为 TSlint替代的检查工具。

SonarQube

SonarQube 是一款用于代码质量管理的开源工具,它主要用于管理源代码的质量。 SonarQube 和上面的工具不太一样,SonarQube 设计目的是提供一个平台,通过插件的方式提供对各个语言进行支持,也可以和 checkstyle、pmd、simian 等工具进行集成。SonarQube 一般需要单独部署成一个服务,提供数据库,可以记录扫描结果等信息。

npm audit

npm audit 是 npm 6 之后的版本 自带的一个前端安全扫描工具,可以扫描 npm 依赖中的潜在的漏洞威胁。这些引入的漏洞可能威胁用户开发的机,另外也可能被带入 bundle 文件发布到线上,带来安全问题。目前 npm audit 会在 npm install 完成后自动执行,需要留意安全威胁报告

Fortify SCA

Fortify SCA(Source Code Analyzer) 是一款非常优秀的代码安全扫描工具,用于分析代码中潜在的安全问题。通过调用语言的编译器或者解释器把代码(Java、C、C++等源代码)转换成一种中间媒体文件 NST(Normal Syntax Trcc),然后通过模式匹配相关的方式抓取存在于漏洞库中的漏洞。例如,上传的文件没有做检查等 XSS 攻击。

OWASP Dependency-Track

开放式 Web 应用程序安全项目(OWASP)是一个非营利组织,提供了很多安全标准、数据库、社区和培训。其中一个工具就是 OWASP Dependency-Track,可以对第三方依赖包中的知名漏洞进行检查,扫描结果受到漏洞数据库的更新影响。

archunit 架构规范检查

前面的检查是代码层面,archunit 可以用于代码架构检查,可以定义规则检查每个包中的实现是否符合规范。例如,controller 包中的类不能实现 service 的接口,repository 下的类必须实现 Repository 接口。通过 archunit 可以减少 codereview 的工作量,避免项目的结构被破坏。

统计工具

sloccount、sourcemointor 这两个工具可以用于统计代码数量,包括行数、文件数、注释等。除了在项目中扫描 bug 之外,配置代码统计工具可以对项目有一个整体的认知。

其他的扫描工具还很多,例如 coverity、codemars、binscope、synk、appscan、retire.js 等工具,不再一一列举。

最佳搭配

这几款工具之间的功能有所重叠,在实际工作中,我们可以根据上面推荐的关注的点,重点清除这些问题。这些扫描工具全部用上除了会带来团队压力和维护成本之外,代码质量不会随着引入的插件增多。除开有质量团队的大厂提供这些扫描平台外,敏捷团队往往不会太大,团队持续关注一个精简的扫描组合更好。

Java 后端:

  1. checkstyle Java 代码风格守护,Java 项目至少应该配置一个默认的 checkstyle 规则。至少让项目干净,没有无用、重复的代码,以及超大的类和方法。建议做到每次提交代码前检查。
  2. findbugs 常见不规范的代码检查,一些空指针、equals 检查非常有用,而且 IDE 的插件也很好用。

前端:

  1. eslint 守护 JavaScript 代码风格,eslint 搭配一个 .editorconfig ,可以方便的让编辑器保持同 eslint 一致的代码风格。
  2. npm audit 项目中第三方包的威胁扫描,npm 自带无需额外安装,npm 6 以后自运行,需要关注并修复报出的安全问题。

安全:

  1. fortify 扫描代码中的漏洞,用它检查出来的大部分安全问题都是注入攻击、XSS 等攻击,这些问题明显可以在开发过程中避免。可以作为 Jenkins 插件配置,和单元测试作为同一阶段运行。
  2. OWASP 插件 用来扫描第三方依赖漏洞,因为项目中的依赖不会像源代码一样频繁变化,推荐使用 Jekins 插件,定期执行即可。

为什么不用 SonarQube 呢,SonarQube 是一个非常优秀的代码质量开放平台,需要单独的配置安装,需要花费额外的时间维护,对于小团队来说成本较高,如果有专门的质量团队可以考虑维护一套。

常用代码质量指标参考

  1. 编译告警数,大部分程序员基本上忽略 warning,但是编译器出现了告警是一种不好的体现,意味着软件可能工作,但是存在不好的实践,而这种不确定性,会带来不确定的 bug 最终让人一头雾水。编译过程中的告警,尽量消除掉,编译告警的值推荐消除到 0。
  2. 平均函数代码行数,过大的函数会导致阅读困难,而且往往过大的函数职责不够单一,一般将一个方法代码行数控制到 30 - 50 行。
  3. 平均文件代码行,和平均函数代码行一样,过长的文件一样难以维护,一般一个文件10多个方法,因此文件的代码行数一般控制到 300 - 500 行。
  4. 冗余代码,有时候我们代码中可能存在未使用的方法、变量等代码,这让维护者一头雾水,通常需要清零。
  5. 总文件重复率,出现重复文件的次数。除了编写单元测试的情况下,业务代码不应该出现重复代码,推荐值为 0。
  6. 总代码重复度,代码的重复度检查,限于扫描工具的识别模式,需要有一定的容忍度,推荐值在 5% - 10%
  7. 平均函数圈复杂度,圈复杂度用来衡量一个模块判定结构的复杂程度。如果一个方法内部有大量的 if 语句嵌套,意味着这个方法的实现质量低下,且程序复杂度高不利于维护,推荐值小于 5%。
  8. 安全告警,如果配置了安全扫描工具,例如 Fortify,安全威胁应该被清零。
  9. 代码缺陷,如果配置了缺陷扫描工具,例如 Findbus,需要清零。
检查项 建议的扫描工具 推荐值
代码风格检查 checkstyle/eslint 0
编译告警数 checkstyle 0
平均函数代码行 checkstyle 30-50
平均文件代码行 checkstyle 300-500
冗余代码 checkstyle 0
总文件重复率 checkstyle 0
总代码重复度 checkstyle 5% - 10%
平均函数圈复杂度 checkstyle <=5%
安全告警 sonarqube/fortify 0
代码缺陷 findbugs 0

第三方依赖规范化

软件开发过程中,不可避免的需要引入第三方或者开源软件包作为库或者框架引入。“第三方” 其实不是一个软件工程术语,现今在软件行业里面的理解是:第一方为自研的软件,第二方为内部发布的软件,第三方为从社区或者外部商业途径引入的软件包。

对于个人开发者而言,面向“搜索引擎”编程往往将来源不明的代码片段和程序包引入到项目中。对于企业来说,考虑到的不仅仅是功能是否能实现,还要考虑引入时带来的成本和问题,例如是否需要授权、开源协议是否合理、是否会带来安全威胁。

企业对于第三方依赖的引入分为几种情况:

  1. 作为开发工具引入,例如 gcc、Jenkins,基本没有开源协议问题,但是需要注意开发机、CI 会有安全风险。Jenkins 曾出现过漏洞,CI 服务器被当做远程矿机使用。
  2. 作为服务部署使用(SaaS),部分开源协议会限制这种使用方式,第三方依赖的安全问题会威胁服务器。
  3. 通过软件包再发布,大部分开源软件对这种使用方式有较多要求,例如 GPL 开源协议具有传染性,要求使用了 GPL 的项目也要开源。
  4. 拷贝源代码引入项目,非常不推荐这种方式,尽量通过包管理的方式引入。

引入第三方依赖需要充分考虑,尽可能最小成本的引入。在一个 React 的前端项目中,有不熟悉的工程师,为了使用一个简单的手风琴效果,引入了整套 bootstrap。不仅破坏了使用 React 的最佳实践,而且让输出的 bundle 文件大小激增数倍,造成首屏加载的性能问题。

常见商业友好的开源协议

商业用户常用的开源协议实际上只有6种左右,即 LGPL、Mozilla、GPL、BSD、MIT、Apache,另外还有极其宽松的 The Unlicense,但采用的开源软件不多。

GitHub 提供了一个 license 清单的列表 https://choosealicense.com/licenses/,我根据开源协议的宽松程度,整理了一个列表,方便查看

开源协议 简介 主要权利 主要条件
MIT License 名称来源于麻省理工学院的许可协议 可商业使用,并用于盈利可再次发布允许修改源代码包含专利使用权利 保留版权信息
BSD 伯克利软件分发许可协议 可商业使用,并用于盈利可再次发布允许修改源代码包含专利使用权利 保留版权信息不可以用开源项目名字和作者用于商业推广
Apache License 2.0 Apache许可协议 可商业使用,并用于盈利可再次发布允许修改源代码包含专利使用权利 保留版权信息对源代码的修改需要提供文档说明
Mozilla Public License 2.0 Mozilla小组设计的许可协议 可商业使用,并用于盈利可再次发布允许修改源代码包含专利使用权利 保留版权信息基于该协议之上的项目需要采用相同的协议继续开源,但是开源范围只是改变的文件级别,无需整个项目开源
GNU LGPLv3 GUN 宽通用许可协议 可商业使用,并用于盈利可再次发布允许修改源代码包含专利使用权利 保留版权信息对源代码的修改需要提供文档说明基于该协议之上的项目需要采用相同的协议继续开源,如果使用library的方式可以不用开源
GNU GPLv3 GUN 通用许可协议 可商业使用,并用于盈利可再次发布允许修改源代码包含专利使用权利 保留版权信息基于该协议之上的项目需要采用相同的协议继续开源

几乎所有的开源协议有一个共同的注意事项:采用该开源协议的软件项目,不提供任何责任转移和质量保证。也就是说采用开源软件造成的法律问题和开源项目无关,另外需要使用者承担因质量问题造成的所有后果。另外,除了引入的程序包之外,字体、图片、特效音、手册等媒体资源也算广义上的“软件”需要考虑开源协议和使用场景。

第三方依赖管理

从这次合规运动中印象最深刻的是对项目中出现的任何第三方依赖有效的管理,通过扫描工具,识别出项目中是否有源码、jar包、二进制文件是否来源于某个开源项目。

任何的第三方软件需要申请入库管理(内部其他团队申请通过可以直接使用),质量团队对申请的软件进行评估:

  1. 是否有开源义务需要履行
  2. 引入的第三方依赖是否有 CVEs等漏洞
  3. 第三方开源软件是否仍然在维护

质量团队根据上面的一些条件,决定出申请的软件能否在项目中使用,允许被采用的软件会定义出优选级别,优先推荐团队使用较为优选的软件,并对项目整体的优选率有一定要求。如果项目中出现了无法识别的二进制文件、非约定目录下的代码片段,需要报备。通过良好的依赖管理和规范化,能减少不良第三方依赖的引入,让软件项目透明、可信。

一些商业公司提供这些完整的服务,例如 fossid、blackduck、code-climate 等。

运维安全

大的软件公司,往往有一堆流程和要求,在合规运动期间,也经历了一些运维方面的改造。虽然一线开发对堡垒机、防火墙、各种安全规范显得不耐烦,但这些安全措施也在保护开发者。

防火墙用于环境隔离

往往开发者理解的防火墙用于防止网络入侵、审计、入侵检测等功能,除此之外,防火墙还可以用于各个环境的隔离。一般来说,企业对于生产环境的数据控制比较严格,不会将生产环境的权限交给团队所有开发者,但网络连接有可能疏漏。

曾经出现过一次线上事故,由于配置文件错误,将原本应该连接到测试的数据库连接到了生产环境,造成大量脏数据写入。如果通过防火墙规则对各个环境进行隔离,这类问题将不会出现。

另外也可以设计 DMZ 区,将面向用户侧的网关部署到 DMZ 区,仅仅开放必要的端口给网关,实现内外网的物理隔离。同时,对整个系统的防火墙策略应该清晰地记录,否则在做大的基础设施更新时,梳理出所有的防火墙策略,是一件比较困难的事情。

凭据管理

项目中会用到大量的凭据,例如数据库、第三方系统对接的 key,使用明文不是一件好事。理想的情况下,对项目中所有的密码信息进行掩盖(mask),避免 CI、日志中敏感信息的泄露。

有很多种方法可以掩盖项目中的密码信息:

  1. 使用环境变量对密码信息进行覆盖
  2. 使用Spring boot 的项目可以配置 jasypt,使用 jasypt 将密码加密,将生成的加密串配置 ENC(加密串) 到工程的配置文件中。加密过程可以加盐作为解密的凭据,“盐” 可以不存放到工程中,在工程部署的时候注入即可
  3. 如果使用 Jenkins 等 CI/CD 工具,可以使用构建平台提供的凭证管理工具
  4. 如果使用 Spring cloud,可以使用 spring cloud vault 组件部署一个凭证管理服务

另外,建议不要用任何个人凭据用作系统对接,应该使用一个公共的应用凭据。

堡垒机

在合规运动的过程中,我们管理有数十台服务器,所有的运维操作需要通过堡垒机进行操作。而开放 22 等高危端口,允许开发者直接登录到服务器是一种不安全的做法。

堡垒机,通俗的来说是跳板机 + 监控。行业最初使用的跳板机配置了两张网卡,用于连接开发环境和生产环境,并没有监控功能。在此基础上,堡垒机增加了统一运维管理的功能,往往需要两步验证(SMS 或 Email),并对所有的操作进行记录和监控。

在需要团队参与运维工作的场景中,非常有必要部署一套堡垒机服务,并使用 LDAP 对接到团队成员的 ID 上,便于集中运维管理。

定期对系统软件扫描

Linux 系统往往有云厂商推送安全补丁和风险提示,但是安装到服务器上的软件,例如 JDK、nodejs,需要自己检查安全问题。因此需要在系统中安装并定期运行 CVEs 检查并及时更新。有一款 cvechecker 可以帮助运维人员,编写一个脚本定期运行 cvechecker 检查系统中已知的软件是否存在 CVEs 漏洞,并提醒开发者及时更新。

写在后面

刚开始工作时候,喜欢动态的、灵活的编程语言,讨厌的死板的、套路化的编程语言,然而需要很长一段时间,才能意识到 “约束是程序员的朋友”。对一些安全知识了解的来源大多来自修复 SonarQube 的经历,使用 findbugs 也让我对 Java 基础认识的更加深刻。

类似的,在使用一些框架、平台的时候往往存在大量的限制,有时候开发者难以意识到 “限制” 正是框架、平台的作者 “保护” 应用开发者的一种方式。有一些开发者以 Hack 框架、平台为乐,但是这样会带来潜在的隐患,在用户量上来之后负面效应表现的尤为明显。

项目的规范化对于 Tech Lead来说可以减少程序的运行事故和 codereview 时间,对于团队来说也许可以少加班吧。

more >>

几种性能测试工具总结

我们经常会谈论性能、并发等问题,但是衡量性能不是说写段代码循环几百次这么简单。最近从项目上的同事了解到了代码化的测试性能测试工具 k6,以及结合之前用过的Java 微基准测试 (JMH)、AB (Apache Benchmark) 测试、Jmeter 做一下总结。

谈性能,实际上结合实际的业务背景、网络条件、测试数据的选择等因素影响非常大,单纯的谈 QPS 等数据意义不大。

这里介绍的几个工具刚好能满足平时开发工作中不同场景下衡量性能的需求,因此整理出来。

  • Java 微基准测试 (JMH) 可以用于衡量一段 Java 代码到底性能如何,例如我们平时总是谈 StringBuilder 比 new String() 快很多。我们有一个很好地量化方法,就可以很直观的展示出一段代码的性能优劣。
  • AB (Apache Benchmark) 测试是 Apache 服务器内置的一个 http web 压测工具,非常简单易用。Mac 预装了 Apache,因此可以随手使用来测试一个页面或者 API 的性能。贵在简单易用,无需额外安装。
  • k6 一款使用 go 语言编写,支持用户编写测试脚本的测试套件。弥补了 ab 测试功能不足,以及 jemeter 不容易代码化的缺点。也是项目上需要使用,从同事那里了解到的。
  • Jmeter 老牌的性能测试工具,有大量专门讲 jmeter 的资料,本文不再赘述。

那我们从 JMH 开始从来看下这几个工具的特点和使用吧。

Java 微基准测试

StringBuilder 到底比 new String() 快多少呢?

我们可以使用 JMH 来测试一下。JMH 是一个用于构建、运行和分析 Java 方法运行性能工具,可以做到 nano/micro/mili/macro 时间粒度。JMH 不仅可以分析 Java 语言,基于 JVM 的语言都可以使用。

JMH 由 OpenJDK 团队开发,由一次下载 OpenJDK 时注意到官网还有这么一个东西。

OpenJdk 官方运行 JMH 测试推的方法是使用 Maven 构建一个单独的项目,然后把需要测试的项目作为 Jar 包引入。这样能排除项目代码的干扰,得到比较可靠地测试效果。当然也可以使用 IDE 或者 Gradle 配置到自己项目中,便于和已有项目集成,代价是配置比较麻烦并且结果没那么可靠。

使用 Maven 构建基准测试

根据官网的例子,我们可以使用官网的一个模板项目。

mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=org.sample \
-DartifactId=test \
-Dversion=1.0

创建一个项目,导入 IDE,Maven 会帮我们生成一个测试类,但是这个测试类没有任何内容,这个测试也是可以运行的。

先编译成 jar

mvn clean install

然后使用 javar -jar 来运行测试

java -jar target/benchmarks.jar

运行后可以看到输出信息中包含 JDK、JVM 等信息,以及一些用于测试的配置信息。

# JMH version: 1.22
# VM version: JDK 1.8.0_181, Java HotSpot(TM) 64-Bit Server VM, 25.181-b13
# VM invoker: /Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/jre/bin/java
# VM options: <none>
# Warmup: 5 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Throughput, ops/time
# Benchmark: org.sample.MyBenchmark.testSimpleString

下面是一些配置信息说明

  • Warmup 因为 JVM 即时编译的存在,所以为了更加准确有一个预热环节,这里是预热 5,每轮 10s。
  • Measurement 是真实的性能测量参数,这里是 5轮,每轮10s。
  • Timeout 每轮测试,JMH 会进行 GC 然后暂停一段时间,默认是 10 分钟。
  • Threads 使用多少个线程来运行,一个线程会同步阻塞执行。
  • Benchmark mode 输出的运行模式,常用的有下面几个。
    • Throughput 吞吐量,即每单位运行多少次操作。
    • AverageTime 调用的平均时间,每次调用耗费多少时间。
    • SingleShotTime 运行一次的时间,如果把预热关闭可以测试代码冷启动时间
  • Benchmark 测试的目标类

实际上还有很多配置,可以通过 -h 参数查看

java -jar target/benchmarks.jar -h

由于默认的配置停顿的时间太长,我们通过注解修改配置,并增加了 Java 中最基本的字符串操作性能对比。

@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3)
@Measurement(iterations = 5, time = 5, timeUnit = TimeUnit.SECONDS)
@Threads(8)
@Fork(1)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class MyBenchmark {

    @Benchmark
    public void testSimpleString() {
        String s = "Hello world!";
        for (int i = 0; i < 10; i++) {
            s += s;
        }
    }

    @Benchmark
    public void testStringBuilder() {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < 10; i++) {
            sb.append(i);
        }
    }
}

在控制台可以看到输出的测试报告,我们直接看最后一部分即可。

Benchmark                       Mode  Cnt      Score      Error   Units
MyBenchmark.testSimpleString   thrpt   10    226.930 ±   16.621  ops/ms
MyBenchmark.testStringBuilder  thrpt   10  80369.037 ± 3058.280  ops/ms

Score 这列的意思是每毫秒完成了多少次操作,可见 StringBuilder 确实比普通的 String 构造器性能高很多。

更多有趣的测试

实际上平时 Java 开发中一些细节对性能有明显的影响,虽然对系统整体来说影响比较小,但是注意这些细节可以低成本的避免性能问题堆积。

其中一个非常有意思细节是自动包装类型的使用,即使是一个简单的 for 循环,如果不小心讲 int 使用成 Integer 也会造成性能浪费。

我们来编写一个简单的基准测试

    @Benchmark
    public void primaryDataType() {
        int sum = 0;
        for (int i = 0; i < 10; i++) {
            sum += i;
        }
    }

    @Benchmark
    public void boxDataType() {
        int sum = 0;
        for (Integer i = 0; i < 10; i++) {
            sum += i;
        }
    }

运行测试后,得到下面的测试结果

AutoBoxBenchmark.boxDataType       thrpt    5   312779.633 ±   26761.457  ops/ms
AutoBoxBenchmark.primaryDataType   thrpt    5  8522641.543 ± 2500518.440  ops/ms

基本类型的性能高出了一个数量级。当然你可能会说基本类型这种性能问题比较微小,但是性能往往就是这种从细微处提高的。另外编写 JMH 测试也会让团队看待性能问题更为直观。

一份直观的 Java 基础性能报告

下面是我写的常见场景的性能测试,例如 StringBuilder 比 new String() 速度快几个数量级。

Test Mode OPS Unit
"cn.printf.jmhreports.AutoBoxBenchmark.boxDataType" "thrpt" 323693300.862712 ops/s
"cn.printf.jmhreports.AutoBoxBenchmark.primaryDataType" "thrpt" 9421830157.195677 ops/s
"cn.printf.jmhreports.CacheValueBenchmark.test" "thrpt" 204814.611974 ops/s
"cn.printf.jmhreports.CacheValueBenchmark.testStringBuilder" "thrpt" 80039810.903665 ops/s
"cn.printf.jmhreports.StringBenchmark.constructStringByAssignment" "thrpt" 197815.644537 ops/s
"cn.printf.jmhreports.StringBenchmark.constructStringByConstructor" "thrpt" 205494.677150 ops/s
"cn.printf.jmhreports.StringBenchmark.constructStringByStringBuilder" "thrpt" 66162972.690813 ops/s

代码仓库和持续更新的基准测试可以看下面的仓库。

https://github.com/linksgo2011/jmh-reports

Apache Benchmark 测试

我想用命令行快速简单的压测一下网站该怎么办呢?

Apache Benchmark (简称 ab,不同于产品领域的 A/B 测试) 是 Apache web 服务器自带的性能测试工具,在 windows 或者 linux 上安装了 Apache 服务器就可以在其安装位置的 bin 目录中找到 ab 这个程序。

ab 使用起来非常简单,一般只需要 -n 参数指明发出请求的总数,以及 -c 参数指明测试期间的并发数。

例如对 ThoughtWorks 官网首页发出 100 个请求,模拟并发数为 10:

ab -n 100 -c 10 https://thoughtworks.com/

需要注意的是 ab 工具接收一个 url 作为参数,仅仅是一个域名是不合法的,需要增加 / 表示首页。稍等片刻后就可以看到测试报告:

Server Software:        nginx/1.15.6
Server Hostname:        thoughtworks.com
Server Port:            443
SSL/TLS Protocol:       TLSv1.2,ECDHE-RSA-AES256-GCM-SHA384,2048,256
Server Temp Key:        ECDH P-256 256 bits
TLS Server Name:        thoughtworks.com

Document Path:          /
Document Length:        162 bytes

Concurrency Level:      10
Time taken for tests:   42.079 seconds
Complete requests:      100
Failed requests:        0
Non-2xx responses:      100
Total transferred:      42500 bytes
HTML transferred:       16200 bytes
Requests per second:    2.38 [#/sec] (mean)
Time per request:       4207.888 [ms] (mean)
Time per request:       420.789 [ms] (mean, across all concurrent requests)
Transfer rate:          0.99 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:     1056 2474 3006.1   1144   23032
Processing:   349  740 1003.5    379    8461
Waiting:      349  461 290.9    377    2265
Total:       1411 3214 3273.9   1674   23424

Percentage of the requests served within a certain time (ms)
  50%   1674
  66%   2954
  75%   3951
  80%   4397
  90%   6713
  95%   9400
  98%  14973
  99%  23424
 100%  23424 (longest request)

从这个报告中可以看到服务器的一些基本信息,以及请求的统计信息。比较重要的指标是 Requests per second 每秒钟完成的请求数量,不严格的说也就是我们的平时说的 QPS。

ab 测试是专为 http 请求设计的,因此 ab 的其他参数和 curl 的参数比较类似,也可以指定 http method 以及 cookies 等参数。

K6 测试套件

我需要编写复杂的测试脚本,并保留压测的脚本、参数、数据,以及版本化该怎么做呢?

k6 是一个压力测试套件,使用 golang 编写。主要特性有:

  • 提供了友好的 CLI 工具
  • 使用 JavaScript 代码编写测试用例
  • 可以根据性能条件设置阈值,表明成功还是失败

k6 没有使用 nodejs 而是 golang 程序,通过包裹了一个 JavaScript 运行时来运行 JavaScript 脚本,因此不能直接使用 npm 包以及 Nodejs 提供的一些 API。

同时,k6 在运行测试时,没有启动浏览器,主要用于测试页面以及 API 加载速度。k6 提供了通过网络请求(HAR)生成测试脚本的方法,实现更简便的测试脚本编写,以及 session 的维护。

使用

在 Mac 上比较简单,直接使用 HomeBrew 即可安装:

brew install k6

其他平台官网也提供了相应的安装方式,比较特别的是提供了 Docker 的方式运行。

直接使用 k6 的命令运行测试,官网提供了一个例子:

k6 run github.com/loadimpact/k6/samples/http_get.js

也可以编写自己的测试脚本:

import http from "k6/http";
import { sleep } from "k6";

export default function() {
  http.get("https://www.thoughtworks.com/");
  sleep(1);
};

保存文件 script.js 后运行 k6 命令

k6 run script.js

然后可以看到 http 请求的各项指标

        /\      |‾‾|  /‾‾/  /‾/   
     /\  /  \     |  |_/  /  / /    
    /  \/    \    |      |  /  ‾‾\  
   /          \   |  |‾\  \ | (_) | 
  / __________ \  |__|  \__\ \___/ .io

  execution: local
     output: -
     script: k6.js

    duration: -,  iterations: 1
         vus: 1, max: 1

    done [==========================================================] 1 / 1

    data_received..............: 108 kB 27 kB/s
    data_sent..................: 1.0 kB 252 B/s
    http_req_blocked...........: avg=2.35s    min=2.35s    med=2.35s    max=2.35s    p(90)=2.35s    p(95)=2.35s   
    http_req_connecting........: avg=79.18ms  min=79.18ms  med=79.18ms  max=79.18ms  p(90)=79.18ms  p(95)=79.18ms 
    http_req_duration..........: avg=639.03ms min=639.03ms med=639.03ms max=639.03ms p(90)=639.03ms p(95)=639.03ms
    http_req_receiving.........: avg=358.12ms min=358.12ms med=358.12ms max=358.12ms p(90)=358.12ms p(95)=358.12ms
    http_req_sending...........: avg=1.79ms   min=1.79ms   med=1.79ms   max=1.79ms   p(90)=1.79ms   p(95)=1.79ms  
    http_req_tls_handshaking...: avg=701.46ms min=701.46ms med=701.46ms max=701.46ms p(90)=701.46ms p(95)=701.46ms
    http_req_waiting...........: avg=279.12ms min=279.12ms med=279.12ms max=279.12ms p(90)=279.12ms p(95)=279.12ms
    http_reqs..................: 1      0.249921/s
    iteration_duration.........: avg=4s       min=4s       med=4s       max=4s       p(90)=4s       p(95)=4s      
    iterations.................: 1      0.249921/s
    vus........................: 1      min=1 max=1
    vus_max....................: 1      min=1 max=1

k6 提供的性能指标相对 ab 工具多很多,也可以通过脚本自己计算性能指标。和 ab 工具中表明每秒钟处理完的请求数是 http_reqs,上面的测试默认只有一个用户的一次请求,如果通过参数增加更多请求,可以看到和 ab 工具得到的结果比较接近。

运行压力测试时,需要增加更多的虚拟用户(VU),vus 参数和持续时间的参数:

k6 run --vus 10 --duration 30s script.js

编写测试脚本的一些规则

default 方法是用于给每个 VU 以及每次迭代重复运行的,因此需要把真正的测试代码放到这个方法中,例如访问某个页面。

为了保证测试的准确性,一些初始化的代码不应该放到 default 方法中。尤其是文件的读取等依赖环境上下文的操作不能放到 default 方法中执行,这样做也会丢失 k6 分布式运行的能力。

前面提到的命令行参数,例如指定虚拟用户数量 --vus 10,这些参数也可以放到脚本代码中。通过暴露一个 options 对象即可。

export let options = {
  vus: 10,
  duration: "30s"
};

为了更为真实的模拟用户访问的场景,k6 提供了在整个测试期间让用户数量和访问时间呈阶段性变化的能力。只需要在 options 中增加 stages 参数即可:

export let options = {
 stages: [
    { duration: "30s", target: 20 },
    { duration: "1m30s", target: 10  },
    { duration: "20s", target: 0 },
  ]
};

在测试过程中需要检查网络请求是否成功,返回的状态码是否正确,以及响应时间是否符合某个阈值。在脚本中可以通过调用 check() 方法编写检查语句,以便 k6 能收集到报告。

import http from "k6/http";
import { check, sleep } from "k6";

export let options = {
  vus: 10,
  duration: "30s"
};

export default function() {
  let res = http.get("https://www.thoughtworks.com/");
  check(res, {
    "status was 200": (r) => r.status == 200,
    "transaction time OK": (r) => r.timings.duration < 200
  });
  sleep(1);
};

报告输出

k6 默认将报告输出到 stdout 控制台,同时也提供了多种格式报告输出,包括:

  • JSON
  • CSV
  • InfluxDB
  • Apache Kafka
  • StatsD
  • Datadog
  • Load Impact cloud platform

当然,我们在编写测试的时候不可能只有一个用例,对多个场景可以在脚本中通过 group 进行分组,分组后输出的报告会按照分组排列。同时,也可以使用对一个组整体性能衡量的指标 group_duration

import { group } from "k6";

export default function() {
  group("user flow: returning user", function() {
    group("visit homepage", function() {
      // load homepage resources
    });
    group("login", function() {
      // perform login
    });
  });
};

InfluxDB 等外部数据收集平台时,还可以打上标签,供过滤和检索使用。k6 提供了一些内置的标签,并允许用户自定义标签。

总结

实际上用于性能测试的工具还有很多,也有一些专门的工具针对网络质量(iperf) 、数据库(sysbench)、前端页面(PageSpeed)等专门方面进行性能测试。

写本文的初衷是想说评价性能,以及做性能优化的第一步应该是寻找到合适工具做一次基准测试,这样的优化往往才有意义。我在使用 JMH 后不仅在工作中使用它对一些代码片段进行测试以及优化,同时更重要的是,在codereview 中对某些操作关于性能的讨论不再基于经验,而是事实。

more >>

程序语言中接口的深层次含义

计算机科学本应该是一个实践和具体的科学,但是随着面向对象等思想的发展,大量的概念、原则、思想含糊不清,“编程思想” 则更加玄之又玄。

接口是面向对象中最重要的一个概念之一,接口这个词被用的太过于广泛,以至于成为软件工程师们最容易争吵的地方。

我们说的接口在不同上下文中略微不同,我猜测这是容易引发争论的原因,尝试分解为几个不同的场景,同时给出一些例子说明接口的不同含义。

强类型语言中的 interface

以 Java 为例,在强类型的语言中,天然就拥有 interface 的特性。当我们对一个功能要求多个实现的时候,我们可以先通过接口定义出需要的方法,然后使用不同的对象实现。

举个例子,现实生活中,我们想要通过一台计算机把文档或者图片投影到幕布上,同时也需要能通过打印机打印出来,那么投影仪和打印机两台输出设备必须具备支持信息输出的接口。

interface-1.jpg

如果使用 Java 来实现这个业务场景,可以通过定义一个 output 方法的 interface。打印机类( Printer )和 投影仪类(Projector)实现这个 interface,并实现该 interface 中的 output 方法。

interface-2.jpg
通过这个例子,我们可以认识到某个类为了实现某个功能必须提供一些方法,从而和系统中其他部分交互,在 Java 中通过 interface 来定义。在一些资料中,interface 被当做了一种特殊的抽象类,被认为抽象类,实际上不是特别准确。我们来拓展一下上面的例子,说明一下接口和抽象类的细微差别。

假设我们原来的打印机只能打印黑白色,现在想要实现彩色打印,我们可以增加一台彩色打印机(ColorPrinter),并把原来的打印机命名为黑白打印机(WBPrinter)。

interface-3.jpg

然后我们发现彩色打印机和黑白打印机都有一些共同的东西,例如纸张、油墨等。于是我们使用了抽象类(或者一个普通父类)来归纳这些属性,就像继承财产一样。这个抽象类同时也可以实现接口,并继承给子类。

interface4.jpg

通过这个例子,在 Java 中 interface 只是定义了实现这个接口的类是否能按照具体输入(参数)实现某些功能(方法)的能力,实现接口的过程中并没有传递任何状态和属性,这一点和抽象类有本质的区别。

因此可以说在 Java 世界里的接口是一组方法定义的集合。

JavaScript 中的接口

到了我们 JavaScript 中,不再有语言层面上的 interface,还能不能实现类似的功能呢?

答案是肯定的,我们在日常开发中也印证了这一点,JavaScript 是一门图灵完备的语言,java 能完成的任务,只要提供同样的运行环境 理论上 JavaScript 一样可以完成。只不过在实现 Projecto 和 Printer 类时,没有 interface 可以使用,开发者只能在大脑里存在一个意识: “我需要让这两个类提供命名一样、参数一样的 output 方法”。否则在运行时程序会因为找不到 output 方法而报错。

实际上,在开发 JavaScript 应用程序时,我们的 “interface” 存在于开发者的大脑和团队的约定中,下图的 interface 我使用虚线标出。

interface-5.jpg

在JavaScript的世界里,缺乏语法层面上的 interface,因此只能通过口头约定来实现接口。因为缺乏语言支持,这个约定是不清晰的,可以由第三方工具或者文档来支持。

随着越来越多的开发者意识到 “interface” 其实一直都存在,但 JavaScript 没有编译这个过程,无法对 “interface” 强制约束。于是 Flow 和 TypeScript 被开发出来帮我们完成这件事,在开发时期提供了 interface 这样一种特性。

如果我们尝试对比 TypeScript 代码和编译出来的 JavaScript 代码,我们会发现 TypeScript 中的interface 会彻底消失,就像根本不存在一样。

interface-6.jpg

通过编译TypeScript可以进一步证明,接口只是一个约定,编译完成后就不再需要了。实际上,对比 Java 源代码和 class字节码也能得到同样的结论。

跨语言的接口

当我们理解到弱类型语言中实际上也有接口存在,但是处于一个非常隐晦的概念中,我们可以把这种视角拓展的更远。下面我们再来看计算机世界中更多的例子。

其中一个有趣的例子是数据库(特指关系型数据库)。在现实开发中,各种编程语言都可以和不同的数据库通信,这再正常不过,通过 Node.js 平台 JavaScript 都可以连接 MySQL了。

回想一下我们编程语言都是怎么和数据库交互的呢?

interface-7.jpg

计算机中的差异化问题都可以通过增加分层的方式解决。所以对于不同的数据库和编程语言之间,我们有一个SQL语言。SQL 在这个场景下,充当了一个接口的角色。SQL 抽象了 DBMS 应该具备的能力,数据库相关的操作就可以都使用 SQL 来完成了。

interface-8.jpg

同样的例子还有很多,当我们在编写 JavaScript 脚本操作浏览器中的 DOM 时,在一定程度上不必考虑浏览器是 Chrome 还是 Firefox。DOM 充当了浏览器和JavaScript引擎的接口。甚至当 DOM 不是有浏览器来构建时,应用程序也有能力解析 HTML 并操作其中的属性。

所以在跨语言的交互中,往往存在一个容易被我们遗忘的中间层,充当着接口这一个角色。这个时候的接口是一种抽象。

无处不在的接口

我们还能找出更多的接口的例子吗?

当我刚刚开始学习 Java 编程时,servlet-api 依赖的引入让我非常困惑。servlet-api 是一个只包含接口的 Java 包,为什么需要引入它到项目中,而且必不可少。随着对 Java 生态的逐步了解,才知道 servlet 本身就是一个 web 容器的规范,使得我们的编写的 Java 代码能被 Tomcat、Jboss 等 web 服务器正确的运行。servlet-api 这个包也仅仅是用来在开发和编译期间去检查我们的代码是否符合 servlet 的规范。

Artur Ventura 使用 JavaScript 编写了一个 Java 虚拟机 BicaVM,理论上讲 JavaScript 也可以编译成 Java 的字节码,JavaScript 的源代码遵守 servlet-api 的规范的情况下也可以编写出能运行在 Tomcat 中的服务端应用程序。

如果我们把接口的含义拓展的更广一点的话,接口就是协议或者规范。

当我接触到越来越多的行业规范、软件标准、网络协议的时候,发现他们和我们在编程中谈到的接口并无二致。IP 协议让各种终端设备能接入互联网,TCP 让路由器和交换机传输数据,OSPF 能让所有的路由器共享路由表实现数据转发,而这些协议又被一种约定规范到TCP/IP 协议族中。

只有接口的开源项目

开源世界也正在发生变化,厂商在发布一个开源项目时,已经不在局限于开源某个软件,而是先开源一套接口或者规范。

至于是开源社区的实现,或者是其他公司的商业实现都能接入到这个生态中来。过去的开源软件往往先贡献了多种实现,然后再从中抽象出约定和规范,实现生态的完善。人们已经意识到,是不是应该先利用开源的优势,接口先行。接口一旦被定义,自然会有多个平台、不同的语言提供相应的实现,甚至非开源的商业实现也可以无缝接入。

著名的例子越来越多,GraphQL 是一个结构化的API查询语言,社区中已经有很多的实现可以做到无痛替换;Swagger 不满足于作为API文档工具,而演化出OpenAPI; 实现了 EditorConfig 插件的编辑器和IDE可以通过.editorConfig文件统一代码风格。

那么未来的接口和实现的关系会是什么样子呢?

more >>

为什么需要敏捷的7个问题

在一次敏捷课程上,学员问了我大量关于敏捷的问题,例如 “通过敏捷会让项目开发进度更快吗?”。其中一些也是几年前我想问的,并带着这些问题加入 ThoughtWorks。终于经过各种海内外敏捷项目,在一线开发有了对敏捷更为深刻的认识,现在回过头来聊一聊这些问题。

为什么要敏捷?

敏捷作为一种软件开发方法,或者项目管理方法,很容易被说的玄乎。软件开发一定要敏捷才行吗?实际上很多项目是可以不采用敏捷开发方法的。

在很多年前的典型、银行领域,银行的软件主要是给行内内部使用的。那个时候的软件开发采用瀑布模型,把软件开发过程划分为需求、分析、设计、开发、测试等不同阶段。这种开发方式同样也能完成所有的任务,甚至这种开发方式延续至今。瀑布模型从出现到持续到现在已经有很长时间,包括目前大学的软件工程专业的课程内容也主要采用这种方式。

另外一种开发方式被大家所忽略,就是一些创业公司或者小团队的开发模式是 “伪敏捷” 的开发方式。实际上,这种状况是既不 “敏捷” 又不“瀑布”,是一种混乱或者无序的开发模式。用CMMI成熟度来描述可能还存在于 “初始级”,其软件开发过程是无序的,对过程没有定义,成功取决于个人努力或偶然。

几十年前,随着软件复杂性日益增高,无序的这种开发方式不能满足需要,于是有了瀑布模型;但是到了今天随着互联网发展,软件的需求来源变得越来越不稳定,原来的瀑布模型的一个过程太长了,于是敏捷开发方式出现了。

敏捷开发方式和瀑布都有过程上的定义和管理,并不是说“响应变化”就瞎变化。而是通过迭代将瀑布模型分隔成更小的周期,从而实现迭代。

agile.png

在敏捷开发过程中,每一次迭代时间可能是2周。每个迭代都能都有交付的工件,如果交付物不能满足市场或客户需求。可以在下一个迭代再一次进行分析、调整和开发,从而响应变化。

敏捷会让项目更快吗?

答案是又不是。

先说不是的情况。项目更快不是那种开发方式来决定的,项目进展快的另外一个意思是时间用的更少。项目管理有一个共识,就是项目的成本(投入资源的数量)、时间、范围和质量,构成了一个矛盾的三角。

在相同的资源投入下,项目进度由范围、质量影响。通俗的来说,当一个项目人力资源匮乏时,一个人被当做两人使的时候,敏捷解决不了任何问题。

project-angle.jpg
甚至更糟糕的是,由于把一个长周期的开发过程,分割成了多个迭代,敏捷这种开发方法还要付出额外的开销。本来只需要整个周期开一次的会议,变成了每个迭代都需要开一次;迭代与迭代之间的融合也有额外的开销。如果适应了瀑布方法的团队,切换到敏捷工作方式后,开会的习惯还是和原来一样冗长就会是一个灾难。因为瀑布模型是一个从一而终的方法,所以必须要有完善的文档和详细的设计防止返工。但是敏捷团队按照同样的方式做的话,就变成了 “白天搞敏捷,晚上修 bug”,敏捷不仅不能让项目进展更快,而让拖慢项目,甚至导致项目失败。

再来说是的情况,敏捷这种开发方法确实能加快项目进度。其中一个重要的原因是加快了团队互动的频率和资源调度。怎么讲呢,在瀑布模型下,进度的安排会依赖一种叫做甘特图的工具。使用甘特图来排期,有两个问题:一个是某个人效率低下或请假会较大的影响整体进度,另外一个是下游工作等待时间长。

gant.jpg

在一个非常长的周期内,瀑布这种模型会产生严重的资源闲置和浪费。比如按照计划1个月完成开发工作,然后1周完成测试工作。那么测试团队需要一个月后才能接手测试工作,有很长的空窗期。敏捷方法把周期缩短了,那么测试团队就可以在迭代内相应的缩短空窗期,另外这个空窗期也可以拿来做一些准备工作(比如测试用例的编写)。

敏捷会让项目提高效率吗?

在资源投入不变的情况下其实上面的问题已经反映了效率。另外在补充一点敏捷方法关于效率的讨论,根据上面的聊到的,敏捷实际上不能平白无故的让效率变高,也不能让一个人干出两个人的活儿。

在实际工作中我们发现,敏捷方法实际上是在看不见的地方提高了效率。最大的地方就是避免返工。

几年前在一个比较大的上市传统企业研发中心待过,我们尝试过把一些工作进行外包。实际上效果并不好,因为管理外包带来的成本有时候还不如自己做。其中一次,我们把前端页面外包给一个团队,我们负责出图,他们负责写出HTML+CSS。这个团队隔一周就会给我们反馈进度,一切都很正常。但是最终交付的时候我们拿到代码傻眼了,这份代码全是用Table布局的,对我们来说根本没用,于是只能返工。

对于较大型的软件项目来说可能更糟糕,一个大的瀑布模型,即使做了充分的设计、讨论,最终返工的概率非常大。因为瀑布模型是从传统行业,例如建筑业吸收而来,建筑行业的变化并不大、并且是一个可重复过程的行业,同时并不具备重构和修改的能力。但是软件行业完全不同,或者说以前的软件行业可以按照这种方式做,但现在互联网化的产品便不再适应了。

敏捷软件开发的核心逻辑是快速迭代,同时也具备了快速试错。那么敏捷能避免返工吗?

当然还是不能,但让返工的影响降低到最低,就已经是巨大的成功了。

创业公司适合敏捷吗?

先说结论,创业公司更适合敏捷开发。

创业团队最大的优势是什么?船小好调头,人少好沟通。那么创业团队的弱势是什么呢?缺少战略基础,大部分时间在打游击战。因此创业公司适合一个灵活、轻量级的软件开发方法。

敏捷是一种轻量级的开发方法和理念,轻文档重合作,适合规模不大的团队,充分利用沟通成本低的优势 。通过迭代开发响应变化,每一个迭代能快速上线 验证产品设计是否合理。

所有项目都适合敏捷吗?

不是所有的项目都适合使用敏捷开发,但趋势是越来越多的项目适合使用敏捷,甚至不得不敏捷。

《大教堂与集市》中谈到,我们以为软件行业是制造业,实际上是服务业。和软件行业中很像的行业有两类,一种是能被重复的、不容易改变的、过程可控的,例如建筑行业。另一种是不能被重复的、可以改变的、过程 不可控的,例如医药研发。

我们把这两种行业概括为预定义过程控制和经验性过程控制。而过去我们认识的软件可以适合,预定义过程控制,也就是说,立项的第一天就能规划到项目结束,例如ERP系统、薪资系统等成熟的行业方案。而另外一些软件例如微信、京东等互联网产品,毫无疑问是需要随着经验不断调整的,这种是经验性过程控制。

显而易见,互联网产品大多无法采用预定义过程控制,更加适合经验性过程控制。敏捷开发方法是基于经验性过程控制的,因此更加适合变化性强的,过程不可控的软件开发项目。

软件行业是一个新行业吗?如果是的话,我们可以把行业划分为金融业、电信业、软件业。然而现在不是这样的,软件业充当了所有行业信息化的角色,也就是说,未来所有的公司都是软件公司。5年以前的银行可能一个项目需要10名工程师花费1年的时间,然后5名测试人员花费2个月的时间测试,然后等待领导审批最终交付给运维上线。然而目前这种情况变得不能接受,所以越来越多的项目还是转向到敏捷开发上来。

敏捷开发有什么缺点和不足吗?

敏捷开发方法一点问题和缺点都没有吗?《两个凡是》的教训告诉我们,任何优秀的的思想和理论都不能迷信。实践是检验真理的唯一标准,敏捷这种方法在实际软件开发过程中也会暴露一些问题,但是可以想办法优化流程,尽量降低这些问题的影响。

敏捷软件开发的特征是增量的,因此每个迭代都会有新的业务分析,新的开发工作在进行。这带来的一个问题是,不会有一个统一的 PRD 文档出现,最后在项目结束时候,交付物中没有好的文档。所以敏捷往往强调可交付的软件更为重要,在代码质量上下功夫,做到代码即文档。

敏捷中参与人员都是根据团队划分的,例如独立的PM、BA、DEV,不再存在管理部分、研发部门。带来的矛盾是对个体的要求变高了,有时候往往一个团队中只有一个BA或者UI,对新人挑战较大。

上面提到的敏捷是根据团队来划分的,其组织架构和传统的公司部门形式提出了挑战。如果企业的组织架构没有改变,敏捷团队的存在可能会出现组织架构上的矛盾。

敏捷实践中这么多会议怎么办?

对敏捷这种方法论最大的质疑就是会变多了,整体效率被拉低了。所以网友调侃 “白天搞敏捷,晚上写代码”。

会议变多的主要原因在前文已经分析过了,迭代变多,原来只需要开一次的会议,现在每个迭代都需要开一次。我待过得一些敏捷团队中,确实有一些会议花费的时间比较,敏捷开发中常见的会议有:每日站会、迭代计划、项目回顾会议、产品展示会议等。

scrum meeting.jpg

需要特别说明的是,看似这些会比较多,实际上我们使用瀑布的时候,项目初期甚至拿了全天的时间来开会。因此在敏捷中,我们每个迭代的会议是不是也要像迭代一样被摊薄呢。

如果我们设定一个迭代的时间是两周,那么迭代计划开了一整天就没有时间进行开发和干活了。

因此各个会议的推荐时间是:

  • 站会应该咋15分钟内完成
  • 迭代计划会议不超过2小时
  • 回顾会议不应该超过1小时
  • 产品展示会议不应该超过30分钟

如何做到把会议开到如此高效呢?这个就是对会议主持人的要求了,一些要点如下:

  • 站会的目的是更新进度和暴露开发中遇到的问题,不应该讨论具体的问题。
  • 在开迭代计划会议之前应该准备好所有的需求分析,如果遇到需求不合理应该及时跳过,不应该在会议中寻求解决方案。
  • 回顾会议应该把重点放到上一次行动是否明确执行以及需要改进点上。可以通过投票讨论优先级高的改进点。
  • 参会人员应该准时到达。对迟到的容忍就是对准时到的人惩罚。
  • 每个会议必须有主持人、会议目标、会议准备,否则会议是冗长而无效的。

有了看板、站会、回顾会议就敏捷吗?

敏捷是一种理念和价值观,具体的软件开发方法主要是 Scrum,那么采用了 Scrum 中的实践就敏捷了吗?

第一个问题中谈到,敏捷的关键是迭代和响应变化。那么我们把瀑布模型,拆分成多个迭代进行,是不是也是一种敏捷方式了。这种“朴素”的敏捷方式可以没有看板、站会、回顾。只有需求、分析、开发、测试这些过程,那么不能说不算敏捷吧。

如果我们没了迭代,再看看我们的看板、站会、回顾会议还是什么:

  • 看板,最早是出现于工业企业中。是一种在工业企业的工序管理中,以卡片为凭证,定时定点交货的管理制度。在没有迭代的情况下,看板只是一种过程可视化工具,对工作过程并没有任何变化。
  • 站会,大多数公司都有晨会的概念,用于向团队内部同步前一天和当天的信息。至于是否是站着开,并不重要。站着开只是为了让会议更快结束。
  • 回顾会议(Retro),回顾会议在英文中又叫 Retro,作用就是在一个工作阶段后进行反思和回顾的会议。这种回顾会议很多公司都会进行,比较著名的形式有从美国陆军流传而来的 ARR (After Action Review)。

敏捷的实践很重要,是团队重要的活动,但并不意味着采纳了一些敏捷实践,团队就是按照敏捷的方式运作。

more >>

怎样讲好一个故事

ted.jpeg

每个能张嘴说话的人都是演讲高手,如果台下的人愿意认真听的话。

如果你是马云,一句话不说都有人在台下陪着坐一晚上。如果你是雷军,即使天天被吐槽仙桃口音,也会有无数人坚持把发布会看完。对于普通人而言,能想办法让听众注意到你、自主的听完整段讲话,就已经是莫大的成功。讲好一个故事,成为演讲高手的开始。

在一次毕业生培训中,客户要求我们在毕业答辩前夕安排一些额外的课程。我们考虑到答辩前夕置入太多新的内容会影响他们答辩,于是我们设计了和答辩有相关的一次演讲课。

这次演讲课非常朴素,设计的思路和常规课程略微不同。课程的设计思路是让学员先忘记演讲技巧,通过讲一个自己的故事或者印象最深的经历,尽可能让听众记住自己演讲的关键信息。听众记住演讲的内容越多,越被演讲者吸引,演讲则越成功。

人的天性是喜欢听故事,而非听人说教;喜欢看电影,而非上培训课。利用讲故事的方式,让学员在一个安全、舒适的空间找到自信和适合自己的演讲方式,并通过听众的反馈提炼演讲技巧。

在课程开始前给予所有学员10分钟准备时间,并做了一些要求:

对演讲者而言,在演讲期间需要注意下面几点:

  1. 忘掉所有演讲技巧,不必给自己增加负担
  2. 提前组织一下语言,不要求即兴演讲
  3. 尽可能让听众记住你

对听众的要求,在听完演讲后,需要回答下面几个问题:

  1. 记住了那些关键信息点,为什么我记住了这些信息?
  2. 为什么记住了这些信息?
  3. 回答如果XXX做,我会记住更多的信息?

关于变态杀人狂的故事

这是第一个分享的故事,讲述了一段演讲者自己印象最深的观影体验。演讲中电影的名字叫 《杀人回忆》,是讲一宗连环奸杀案,被害人有十几岁的少女,也有71岁的老人。最终凶手被抓住,凶手是一个17岁的少年,并且极其普通,看似与凶悍的杀人犯毫不相关。发人深省的是变态杀人狂和外表并没有什么关系,这个和一般在影视作品中的认知差异极大。

演讲完成后,随着演讲的进行,被吸引的同学越来越多,听的同学表示记住的最多的信息是:

  1. 71岁老奶奶被17岁少年奸杀
  2. 态杀人狂和外表没什么关系

我问大家为什么会愿意听,并记住了这样的信息呢?

回答是人们喜欢故事,并且越是离奇的故事越愿意听,这是其一;第二是人们会对有认知差异的内容警觉,例如 “71岁老奶奶被17岁少年奸杀” 这种事情和平时的认知差异巨大。

从这个故事中,我抛出了一个演讲理论:制造冲突。把一个普通人的日常拍成电视剧,恐怕收视率为0,得亏的倾家荡产。但是讲述一个普通人突然中了彩票,又染上赌博,进而亏得倾家荡产,就能算稍微好一点的故事了。

冲突表现在几个方面:

  1. 认知冲突,在给学员播放过的 TED 视频中,有一个老奶奶的教学法,大意是通过引导自学就能得到和学校一样的学习效果。这就是一种典型的认知冲突,利用大众对教学认知差异,制造冲突。
  2. 主题冲突,例如想要讲一个帝王将相的故事,往往都是先讲这个人在尚未飞黄腾达的时期的故事,然后要讲的主题才能有对比和冲突。
  3. 情节冲突,每一部好的电影和小说都是情节冲突的好例子,《杜十娘怒沉百宝箱》中杜十娘和李甲相识到投江就是强烈的情节冲突。

关于刚毕业漂泊的故事

这是一个几乎全场所有人都认真聆听的故事。

演讲者讲述了毕业之后离开自己大学所在城市,只身前往北京工作的经历。在北京每月工资只有 4000 元,除去房租、吃饭、通勤费用过后几乎没有剩余。工作期间内,因为担心丢失工作,在公司待的战战兢兢,不敢得罪任何一个人;因为手上没有积蓄,不敢出去玩,不敢生病;因为工作压力大,没有多余的时间,不敢恋爱。虽然在朋友和家人的支持下,在北京能勉强生存,但是还是选择了回到郑州老家。没有像电视中,到大城市打拼并最终走上人生巅峰的结局,而大部分人都选择或者被迫了接受现实。

这一次的故事,演讲者和部分听的同学哭了,这一次大家记住的信息是:

  1. 刚毕业的时间
  2. 4000 收入的北漂生活
  3. 最终回到家乡工作的大学生

为什么这次所有人都认真听了,并记住了这些关键信息呢?

因为我们的听众全都是刚毕业的大学生,演讲者说的每一个场景都好像发生在自己身上一样,每一句话都感同身受。

从这次演讲,我们能得到另外一个有力的演讲理论:大众认同和共情

每一场激烈的争论,是想要别人站在自己的角度思考问题;每一次看完电影后被打动,是因为电影表达了自己也想要的表达的东西;每一次朋友圈转发的文章,是因为文章写了自己也想说的话。在心理上,人们乐于接受和自己相同的观点,以及认同和自己相同的经历。

站在大多数听众的角度讲故事,讲出听众看到的、听到的、想到的内容,得到所有听众的认同和共情,这就是演讲中的的核武器。

回顾演讲技巧

还有很多很有意思的故事,也从这些演讲中得到更多的演讲经验,限于篇幅,无法一一列举。在课程结束前,和同学们一起总结了一些演讲技巧,如果做到这些,他们表示作为听众会更愿意记住更多信息:

有故事、有主题

一次只讲一个完整的故事,每个故事都有一个独立的主题。人们喜欢听故事,而非听人讲道理。如果我们观察 TED 的演讲方式和领导的讲话,非常明显的区别就是 TED 的演讲更像是一个朋友在分享自己的经历,而领导讲话站的姿态则完全不同。讲故事的过程中切记不要好为人师,多使用 “我们” 这样的拉近关系的词汇,不要使用 “我” “你们” 这类明显强调个人和对立的词汇。

有准备

作为普通人很少能完全做到即兴演讲,如果有充足的准备工作,可以让演讲做的更好。可以在卡片或者报事贴上快速组织演讲内容,“总-分-总” 是一种的较好的组织演讲稿的思维方式,这种组织表达的方式也叫 “钻石表达法”,两头小,中间大。

声音够大、声音够有力、气息足、语速慢

让在场的所有人都能听得清楚,大部分人演讲时犯得第一个错误就是声音太小。避免语速过快,当语速够慢的时候,每一字会变得更清楚,可以通过和语音助手对话练习,如果语音助手能识别,说明语速适中、吐字清晰、容易识别。让讲话变得清晰、有力,说话时胸腔吸取足量的空气,说一句话用完,然后再深吸一口气,开始下一句。

有场景,有细节

人们都是感官动物,容易理解具体的、有场景、可触摸的内容,而非抽象的内容。例如在讲在北京租的房子破的时候,可以举个例子说搬家的时候,磕了一下墙壁,然后一大块墙皮掉落下来。画面感可以通过补充细节实现,多使用感官词汇,例如能看到的、摸到的、听到的、闻到的。

制造冲突

人们喜欢听和自己认知不同的、前后有起伏、情节有变化的内容。制造冲突不仅可以用于演讲,还可以用于写作、营销。明朝文学家徐渭就是制造冲突的高手:

明朝文学家徐渭曾应邀去参加一位老婆婆的生日宴会,其四个儿子请徐渭为其母题词。徐渭提笔挥毫:"这个婆婆不是人。"满堂宾客大失神色。而徐渭不慌不忙继续写到:"九天神女下凡尘。"四个儿子与众宾客顿时眉开眼笑,齐声喝彩称颂。殊不知徐渭又接着写出一句:"生的儿子都是贼。"此时连老太太也勃然大怒了。可徐渭笔峰一转写下最后一句:"偷来仙桃孝母亲。"引得宾客无不拍手叫绝。

寻求共鸣,感同身受

站在和大多数听众的背景下讲故事,例如项目管理方面的内容显然不适合毕业生,而毕业漂泊的经历,则能打动大部分有过类似经历的过来人。

记忆点

找到一些记忆点,这个在心理学中称为心锚,属于条件反射的一种形式。我分享了一个故事,地点在河北,但是我没有用河北,而是选择了 “白洋淀”,因为听众大多在中学学过孙犁对 “白洋淀” 的描写。人们很容易去寻找记忆中的画面和当前听到的词建立联系,进而被记住。

避免过多肢体动作

身体无意义的晃动,表达了不安和焦虑,容易分散听众的注意力。注意区别讲课时刻意的走动。

富有感情

很多感恩课、激励课,都是通过极其富有感情的方法演讲,但这些感情是通过刻意调动听众得来的。例如感恩演讲往往利用父母的付出、老师的辛苦,来实现感情操控。而如果我们自己所演讲的故事是来源真实经历,往往会带来意想不到的效果。

缀词过多

缀词过多,就像大脑在放屁,避免无意义的缀词。例如很多明星喜欢说“然后”、“呃”、“这个” 等无意义的词汇,在演讲课上,我观察到很多人都有这个问题(我自己也有这个问题),自己基本上察觉不到。这是因为大脑的思考跟不上说话,解决的方法是说慢一点,给大脑一些时间。或者,做更多的准备。

寻求观点认同,但也要有自己的立场

故事的结尾,应该有自己的看法和态度,才能让演讲不仅仅是一个故事,达到升华。但需要注意,人们更容易接受自己想要接受的观点,所以结束的观点请符合主流价值观。人们愿意接受相同的观点的同时也会被不同的观点吸引注意力,这就是我们开始说的冲突,合理运用冲突和认同是演讲的利器。

所以常见的模式是把冲突放到开头,共情用到中间,认同放到最后:

  • 通过问题制造冲突
  • 讲述故事,尽量利用共情产生共鸣
  • 陈述观点和看法,匹配主流价值观,得到认同

总结

课后讲北京漂泊那个故事的同学找到我说,老师,原来很多人和我的经历类似,这些话我从来没在公开场合说过,其实开始挺不好意思的,其他同学都那么优秀,不会像我这样窘迫。但是经过这次演讲,原来大部分人和我一样都是普通人,并且都不是一帆风顺的。

我想总结的第一点是,通过讲故事的方式练习演讲,确实增强了他们的自信,如果选用自己背景相关的故事,演讲非常自然。

另外一点是,我在鼓励其中一个想要上台,但非常害羞的一个同学时说的话。“其实没有那么多人关注你,你试试明天不要洗头上班就好了,你会发现同事都那么忙并没人在乎你是否出丑”。

最后一点,从这个课程本身我也学到很多,有一些人自然而然的就能运用演讲的技巧,但会在其他地方做的不足。例如其中一个同学演讲非常富有感情,但是没有面向和关注听众。想把其他人的技巧变成自己的能力,唯一的方法就是刻意练习。刻意练习必须形成肌肉记忆,就像学习英语,当我们想要表达一句话,但还需要在自己脑子里套用语法模式组织句子,这是远远不够的。

附录 - 演讲技巧

  • 故事都有主题
  • 有准备
  • 声音够大、声音够有力、气息足
  • 有场景,有细节
  • 制造冲突
  • 先讲故事,自然有结论
  • 注意语速
  • 寻求共鸣,感同身受
  • 观点认同
  • 记忆点
  • 避免过多肢体动作
  • 富有感情
  • 缀词过多,就像大脑在放屁
  • 说服力
  • 有自己的立场

more >>