I code it

Code and Life

为什么优秀的程序员喜欢命令行

程序员的日常工作

The three chief virtues of a programmer are: Laziness, Impatience and Hubris. – Larry Wall

懒惰这个特点位于程序员的三大美德之首:唯有懒惰才会驱动程序员尽可能的将日常工作自动化起来,解放自己的双手,节省自己的时间。而GUI,不得不说,天然就是为了让自动化变得困难的一种设计。GUI更强调的是与人类的直接交互:通过视觉手段将信息以多层次的方式呈现,使用视觉元素进行指引,最后系统在后台进行实际的处理,并将最终结果以视觉手段展现出来。

这种更强调交互过程的设计初衷使得自动化变得非常困难。另一方面,由于GUI是为人类设计的,它的响应就不能太快,至少要留给操作者反应时间(甚至有些用户操作需要人为的加入一些延迟,以提升用户体验)。

程序员除了写代码之外,还有很多事情要做,比如自动化测试,基础设施的配置和管理,持续集成/持续发布环境,甚至有些团队好需要做一些与运维相关的事情(线上问题监控,环境监控等)。

  • 开发/测试
  • 基础设施管理
  • 持续集成/持续发布
  • 运维工作
  • 娱乐

UNIX编程哲学

关于UNIX哲学,其实坊间有多个版本,这里有一个比较详细的清单。虽然有不同的版本,但是有很多一致的地方:

  1. 小即是美
  2. 让程序只做好一件事
  3. 尽可能早地创建原型(然后逐步演进)
  4. 数据应该保存为文本文件
  5. 避免使用可定制性低下的用户界面

这里列举一些小的例子,我们来看看命令行工具是如何通过应用这些哲学来简化工作,提高效率的。一旦你熟练掌握这些技能,就再也无法摆脱它,也再也忍受不了低效而难用的各种GUI工具了。

命令行如何帮助程序员提升效率

一个高阶计算器

在我的编程生涯的早期,读过的最为振奋的一本书是《UNIX编程环境》,和其他基本UNIX世界的大部头比起来,这本书其实还是比较小众的。我读大二的时候这本书已经出版了差不多22年(中文版也已经有7年了),有一些内容已经过时了,比如没有返回值的main函数,外置的参数列表等等,不过在学习到HOC(High Order Calculator)的全部开发过程时,我依然被深深的震撼到了。

简而言之,这个HOC语言的开发过程需要这样几个组件:

  • 词法分析器lex
  • 语法分析器yacc
  • 标准数学库stdlib

另外还有一些自定义的函数等,最后通过make连接在一起。我跟着书上的讲解,对着书把所有代码都敲了一遍。所有的操作都是在一台很老的IBM的ThinkPad T20上完成的,而且全部都在命令行中进行(当然,还在命令行里听着歌)。

这也是我第一次彻底被UNIX的哲学所折服的体验:

  • 每个工具只做且做好一件事
  • 工具可以协作起来
  • 一切面向文本

下面是书中的Makefile脚本,通过简单的配置,就将一些各司其职的小工具协作起来,完成一个编程语言程序的预编译、编译、链接、二进制生成的动作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
YFLAGS = -d
OBJS = hoc.o code.o init.o math.o symbol.o

hoc5: $(OBJS)
  cc $(OBJS) -lm -o hoc5

hoc.o code.o init.o symbol.o: hoc.h

code.o init.o symbol.o: x.tab.h

x.tab.h: y.tab.h
  -cmp -s x.tab.h y.tab.h || cp y.tab.h x.tab.h

pr:   hoc.y hoc.h code.c init.c math.c symbol.c
  @pr $?
  @touch pr

clean:
  rm -f $(OBJS) [xy].tab.[ch]

虽然现在来看,这本书的很多内容已经过期(特别是离它第一次出版已经过去了近30年),有兴趣的朋友可以读一读。

基础设施自动化

开发过程中,工程师还需要关注的一个问题是:软件运行的环境。我在上学的时候,刚开始学习Linux的时候,会在Windows机器上装一个虚拟机软件VMWare,然后在VMWare中安装一个Redhat Linux 9。这样当我不小心把Linux玩坏了之后,只需要重装一下就行了,不影响我的其他数据(比如课程作业,文档之类)。不过每次重装也挺麻烦,需要找到iso镜像文件,再挂载到本地的虚拟光驱上,然后再用VMWare来安装。

而且这些动作都是在GUI里完成的,每次都要做很多重复的事情:找镜像文件,使用虚拟光驱软件挂载,启动VMWare,安装Linux,配置个人偏好,配置用户名/密码等等。熟练之后,我可以在30分钟 – 60分钟安装和配置好一个新的环境。

Vagrant

后来我就发现了Vagrant,它支持开发者通过配置的方式将机器描述出来,然后通过命令行的方式来安装并启动,比如下面这个配置:

1
2
3
4
5
6
VAGRANTFILE_API_VERSION = "2"

Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
  config.vm.box = "precise64"
  config.vm.network "private_network", :ip => "192.168.2.100"
end

它定义了一个虚拟机,使用Ubuntu Precise 64的镜像,然后为其配置一个网络地址192.168.2.100,定义好之后,我只需要执行:

1
$ vagrant up

我的机器就可以在几分钟内装好,因为这个动作是命令行里完成的,我可以在持续集成环境里做同样的事情 – 只需要一条命令。定义好的这个文件可以在团队内共享,可以放入版本管理,团队里的任何一个成员都可以在几分钟内得到一个和我一样的环境。

Ansible

一般而言,对于一个软件项目而言,一个全新的操作系统基本上没有任何用处。为了让应用跑起来,我们还需要很多东西。比如Web服务器,Java环境,cgi路径等,除了安装一些软件之外,还有大量的配置工作要做,比如apache httpd服务器的文档根路径,JAVA_HOME环境变量等等。

这些工作做好了,一个环境才算就绪。我记得在上一个项目上,不小心把测试环境的Tomcat目录给删除了,结果害的另外一位同事花了三四个小时才把环境恢复回来(包括重新安装Tomcat,配置一些JAVA_OPTS,应用的部署等)。

不过还在我们有很多工具可以帮助开发者完成环境的自动化准备,比如:Chef, Puppet, Ansible。只需要一些简单的配置,然后结合一个命令行应用,整个过程就可以自动化起来了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- name: setup custom repo
  apt: pkg=python-pycurl state=present

- name: enable carbon
  copy: dest=/etc/default/graphite-carbon content='CARBON_CACHE_ENABLED=true'

- name: install graphite and deps
  apt: name= state=present
  with_items: packages

- name: install graphite and deps
  pip: name= state=present
  with_items: python_packages

- name: setup apache
  copy: src=apache2-graphite.conf dest=/etc/apache2/sites-available/default
  notify: restart apache

- name: configure wsgi
  file: path=/etc/apache2/wsgi state=directory

上边的配置描述了安装graphite-carbon,配置apahce等很多手工的劳动,开发者现在只需要执行:

1
$ ansible

就可以将整个过程自动化起来。现在如果我不小心把Tomcat删了,只需要几分钟就可以重新配置一个全新的,当然整个过程还是自动的。这在GUI下完全无法想象,特别是有如此多的定制内容的场景下。

持续集成/持续发布

日常开发任务中,除了实际的编码和环境配置之外,另一大部分内容就是持续集成/持续发布了。借助于命令行,这个动作也可以非常高效和自动化。

Jenkins

持续集成/持续发布已经是很多企业IT的基本配置了。各个团队通过持续集成环境来编译代码、静态检查、执行单元测试、端到端测试、生成报告、打包、部署到测试环境等等。

比如在Jenkins环境中,在最前的版本中,要配置一个构建任务需要很多的GUI操作,不过在新版本中,大部分操作都已经可以写成脚本。

这样的方式,使得自动化变成了可能,要复制一个已有的pipline,或者要修改一些配置、命令、变量等等,再也不需要用鼠标点来点去了。而且这些代码可以纳入项目代码库中,和其他代码一起被管理,维护,变更历史也更容易追踪和回滚(在GUI上,特别是基于Web的,回滚操作基本上属于不可能)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
node {
   def mvnHome

   stage('Preparation') { // for display purposes
      git 'https://github.com/jglick/simple-maven-project-with-tests.git'
      mvnHome = tool 'M3'
   }

   stage('Build') {
      sh "'${mvnHome}/bin/mvn' -Dmaven.test.failure.ignore clean package"
   }

   stage('Results') {
      junit '**/target/surefire-reports/TEST-*.xml'
      archive 'target/*.jar'
   }
}

上面这段groovy脚本定义了三个Stage,每个Stage中分别有自己的命令,这种以代码来控制的方式显然比GUI编辑的方式更加高效,自动化也编程了可能。

运维工作

自动化监控

Graphite是一个功能强大的监控工具,不过其背后的理念倒是很简单:

  • 存储基于时间线的数据
  • 将数据渲染成图,并定期刷新

用户只需要将数据按照一定格式定期发送给Graphite,剩下的事情就交给Graphite了,比如它可以消费这样的数据:

1
2
3
instance.prod.cpu.load 40 1484638635
instance.prod.cpu.load 35 1484638754
instance.prod.cpu.load 23 1484638812

第一个字段表示数据的名称,比如此处instance.prod.cpu.load表示prod实例的CPU负载,第二个字段表示数据的,最后一个字段表示时间戳。

这样,Graphite就会将所有同一个名称下的值按照时间顺序画成图。

图片来源

默认地,Graphite会监听一个网络端口,用户通过网络将信息发送给这个端口,然后Graphite会将信息持久化起来,然后定期刷新。简而言之,只需要一条命令就可以做到发送数据:

1
echo "instance.prod.cpu.load 23 `date +%s`" | nc -q0 graphite.server 2003

date +%s会生成当前时间戳,然后通过echo命令将其拼成一个完整的字符串,比如:

1
instance.prod.cpu.load 23 1484638812

然后通过管道|将这个字符串通过网络发送给graphite.server这台机器的2003端口。这样数据就被记录在graphite.server上了。

定时任务

如果我们要自动的将数据每隔几秒就发送给graphite.server,只需要改造一下这行命令:

  1. 获取当前CPU的load
  2. 获取当前时间戳
  3. 拼成一个字符串
  4. 发送给graphite.server2003端口
  5. 每隔5分钟做重复一下1-4

获取CPU的load在大多数系统中都很容易:

1
ps -A -o %cpu

这里的参数:

  • -A表示统计所有当前进程
  • -o %cpu表示仅显示%cpu列的数值

这样可以得到每个进程占用CPU复杂的数字:

1
2
3
4
5
%CPU
  12.0
  8.2
  1.2
  ...

下一步是将这些数字加起来。通过awk命令,可以很容易做到这一点:

1
$ awk '{s+=$1} END {print s}'

比如要计算1 2 3的和:

1
2
$ echo "1\n2\n3" | awk '{s+=$1} END {print s}'
6

通过管道可以讲两者连起来:

1
$ ps -A -o %cpu | awk '{s+=$1} END {print s}'

我们测试一下效果:

1
2
$ ps -A -o %cpu | awk '{s+=$1} END {print s}'
28.6

看来还不错,有个这个脚本,通过crontab来定期调用即可:

1
2
3
4
5
6
#!/bin/bash
SERVER=graphite.server
PORT=2003
LOAD=`ps -A -o %cpu | awk '{s+=$1} END {print s}'`

echo "instance.prod.cpu.load ${LOAD} `date +%s`" | nc -q0 ${SERVER} ${PORT}

当然,如果使用Grafana等强调UI的工具,可以很容易的做的更加酷炫:

图片来源

想想用GUI应用如何做到这些工作。

娱乐

命令行的MP3播放器

最早的时候,有一个叫做mpg123的命令行工具,用来播放MP3文件。不过这个工具是商用的,于是就有人写了一个工具,叫mpg321,基本上是mpg123的开源克隆。不过后来mpg123自己也开源了,这是后话不提。

将我的所有mp3文件的路径保存成一个文件,相当于我的歌单:

1
2
3
4
5
6
7
$ ls /Users/jtqiu/Music/*.mp3 > favorites.list
$ cat favorites.list
...
/Users/jtqiu/Music/Rolling In The Deep-Adele.mp3
/Users/jtqiu/Music/Wavin' Flag-K'Naan.mp3
/Users/jtqiu/Music/蓝莲花-许巍.mp3
...

然后我将这个歌单交给mpg321去在后台播放:

1
2
$ mpg321 -q --list favorites.list &
[1] 10268

这样我就可以一边写代码一边听音乐,如果听烦了,只需要将这个后台任务切换到前台fg,然后就可以关掉了:

1
2
$ fg
[1]  + 10268 running    mpg321 -q --list favorites.list

小结

综上,优秀的程序员借助命令行的特性,可以成倍(有时候是跨越数量级的)地提高工作效率,从而有更多的时间进行思考、学习新的技能,或者开发新的工具帮助某项工作的自动化。这也是优秀的程序员之所以优秀的原因。而面向手工的,原始的图形界面会拖慢这一过程,很多原本可以自动化起来的工作被淹没在“简单的GUI”之中。

最后补充一点,本文的关键在于强调优秀的程序员与命令行的关系,而不在GUI应用和命令行应用的优劣。GUI应用当然有其使用场景,比如做3D建模,GIS系统,设计师的创作,图文并茂的字处理软件,电影播放器,网页浏览器等等。

实施领域驱动设计的正确姿势

DDD为什么很难实施

领域驱动设计(Domain Driven Design)的概念已经被发明了十多年,而且也不乏相关著作,但是业界宣称自己应用了DDD原则的项目,软件却鲜有耳闻。随着微服务架构的流行,DDD在边界识别,服务划分等方面不断被提及,作为一种应对复杂软件的方法论,似乎又被重视起来了。

那么,为什么这个听起来很靠谱的方法论实际上很难实施呢?我们以DDD创始人Eric Evans的经典书籍《领域驱动设计:软件核心复杂性应对之道》为例,分析一下,可能的原因是这样的:

  • 新概念数量比较多
  • 战术/战略都有所涉及
  • 不同层次的概念混杂(除了设计模式之外,还有架构风格的讨论)在一起

繁复而混杂的概念

领域,子域,核心子域,通用子域,实体,值对象,领域服务,应用服务,领域事件,统一语言,衔接上下文,遵循者等等。DDD中有着大量的新概念,而且这些概念有些是技术相关的,有些是问题相关的,交织在一起之后,很难理清头绪。

DDD Full

DDD中的模式又分为战术的和战略的两大部分,有很多团队应用了战术相关的,比如实体,值对象,领域服务,仓库等模式,代码看似DDD,实则与DDD强调的以领域为中心相去甚远,陷入了开发者太过于关注技术本身的老路上,这种现象还有个专门的名词,叫DDD-Lite

不同的思维方式要求

DDD要求读者既有具体Coding的技能,有需要跳出圈外,以架构师的角度来审视整个系统。DDD需要开发者以一个全新的视角去认识软件开发,强调对业务流程的熟悉,强调与领域专家一起协作,强调软件的表达能力和进化能力。而这些思维方式的转变,都有很大阻力的(想想从面向对象世界切换到函数式编程,再到响应式函数编程的切换)。

It should be noted that no ethically-trained software engineer would ever consent to write a DestroyBaghdad procedure. Basic professional ethics would instead require him to write a DestroyCity procedure, to which Baghdad could be given as a parameter. – Nathaniel Borenstein

我自己在工作的前4年,非常反感业务,认为case by case的业务流程会严重影响我的代码的通用性。然而事实是,纯粹通用的代码是不存在的。毕竟,诸如IoC容器,Web等基础设施已经相当完善,完全无需我们重复发明轮子。而作为应用开发人员,更重要的是在充分理解业务的前提下,写出易于维护,易于扩展,可以更快速响应业务变化的代码。

正因为如此,DDD在一定程度上会让程序员感到不适,它太强调领域了,而领域又是独一无二的,每个公司的每个系统都有其独立性,这就要求你的代码可能没法做到纯粹

正确姿势

要解决上面提到的这些问题,正确的实施DDD来指导实际的开发工作,需要至少做到这样几个事情。首先,明确分清问题方案(这是初学DDD者最容易犯的错误);其次,转变思维方式,将软件开发的重心放在梳理并明确业务需求上,而不是代码逻辑上;最后,需要应用一些合理的工程实践来促成DDD-Lite的落地。

分清问题和解决方案

我们说领域(子域,通用子域,支撑子域等)的时候,是在讨论问题域,即定义我们要解决的问题是什么。而说到限界上下文,聚合,实体,仓库则是在讨论解决方案部分,人们很容易将两者搞混。

在电商的领域中,一些典型的问题是:

  • 如何获得更多的客户
  • 如何让客户更快速的找到自己想要的商品
  • 如何准确的推荐相关产品给客户
  • 用户如何付费

要解决这些问题,人们可能会开发出来一个软件系统,也可能会用手工的流程,也可能是混合模式。同样,一些具体的解决方案的例子是:

  • 商品促销子系统
  • 全文搜索系统
  • 推荐子系统
  • 支付平台

显然,解决方案是在问题定义之后才产生的,而定义问题本身是一件开发人员不擅长的工作,这也是为什么DDD特别强调领域专家的原因。实施DDD需要从领域专家,技术专家的深入合作中,得出一个模型。其实,DDD的核心就是要解决一个问题:建立领域问题的软件模型,这个模型需要满足这样几个条件:

  • 能准确表达领域概念,业务流程等(无需经过翻译)
  • 容易演进
  • 每个领域有自己的解决方案

DDD战略模式

这是Vaughn Vernon的著作《Implement Domain Driven Design》中的一张图,整个外边的大圈是业务对应的领域(问题域),这个大的圈被虚线划分成了很多小的子域(依然是问题域,不过每个子域要解决的问题都不相同),子域有很多类型,比如核心子域,支撑子域,通用子域等。对应的,每个域都可能有自己的限界上下文,上下文之间有不同类型的上下文映射模式。

子域的边界由通用语言来划分,也就是说,每个子域有自己区别于其他子域的语言(也就是自己的业务概念,业务规则)。比如财务系统中,提及的概念如毛利,净利率,投入回报比,发票,报销等等,与招聘系统中的候选人,面试,宣讲,校招等等都大不相同。

正确的应用DDD的战略模式来帮助问题的识别和理解,比单纯的应用DDD-Lite要重要得多。

  • 通用语言
  • 限界上下文
  • 上下文映射

思维方式转变

开发者要将思维方式转变成以业务规则优先是一件非常困难的事儿。毕竟经过了多年的训练,特别是抽象思维的训练,开发者很喜欢通用的技巧,比如管道-过滤器,线程池,解析器等等。然而业务规则往往是具体的,而且很多时候,需求变化的方向会破坏掉开发者精心构筑的抽象体系。

然而个思维方式的转变正是成功实施DDD关键所在:愿意和业务人员一起理解沟通,理解业务是实施DDD的第一步。其实每个行业的业务都有很多有意思的地方,我在ThoughtWorks,由于工作性质的原因,可以接触到很多不同的客户,不同的业务。每个项目上,我都很乐于去向业务人员学习,业务在现实世界中是如何运作的:房产中介如何打广告,房东如何付费,房地产广告平台如何从中盈利;无线基站如何建设,工人如何去施工,甚至基站铁塔如何避雷,如何防雨;保单的类型,付费年限,如何分红等等。

每个不同的业务都可以学到很多人们在解决问题时发明出来的新思路,新方法。这些思路和方法未尝不可以反过来应用在软件开发上。

工程实践

  • 敏捷方法
  • 自动化测试

敏捷方法

其实早在敏捷宣言产生的时代,人们就已经发现了客户合作胜过合同谈判。与客户保持高度的合作(最好是业务人员就坐在开发人员旁边),实时的反馈收集并根据反馈进行方向调整都是敏捷方法中倡导的。而DDD更进一步,需要和业务人员一起,定义出领域模型的原型,包括一些白板上的图和代码原型,并根据敏捷方法进行持续的演进。

比如这里提出的一些实践可以比较好的帮助你来实施DDD:

  • 需求的Kickoff(BA,开发,QA一起来明确需求的含义)
  • 结对编程(不限于开发之间的结对,也可能是不同角色间的结对)
  • 代码审视
  • 代码重构
  • Mini Showcase
  • 回顾会议

通过敏捷实践,我们可以建立起快速的反馈机制,和对变化的响应能力,冗长的流程和不透明的价值流向会导致很多问题。

如果我们承认自己不是全知全能的,需要不断地通过学习来不断的理解业务,那么重构就编程了一件自然而言地、必须做的事情了。通过重构,我们将之前模糊的业务概念清晰起来,将缺失的概念补齐,将代码中的华为到消除。

Mini Showcase强调开发者在完成需求的过程中,不定期的与BA,QA等角色交流、确认。如果有理解不正确的地方,可以尽快发现,然后解决。

自动化测试

另一方面,要保证模型的可理解性,除了Clean Code的一些原则之外,自动化测试也是一个必不可少的工程实践。领域模型在意图表现上需要做到极致,而单元测试/功能测试则以用例的方式将模型用起来。这要求开发者使用实例化需求行为驱动开发等方法编写实践作支撑。

测试不是为了覆盖率,也不能仅仅只是为了自动化一些手工的工作。在DDD的上下文中,自动化测试更多的是完整表达业务意图的用例。

实例化需求

测试需要以实际用户的角度来编写,并以实例的方式描述。这样即使自动化做不了,这个实例依然是一个很好的文档。当然,为了保证这个文档不过期(和代码实现不匹配),还是强烈建议可以将其自动化起来。

比如这里有个例子,人事经理想要找一些开发人员来分配到项目上,这个找人的过程有两条很简单的规则:

  • 开发人员的技能要和项目的要求匹配
  • 开发人员当前不在项目上(比如当前项目为Beach即为空闲状态)

Cucumber写的测试看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Feature: Find employee by skills
  As a staffing manager
  I want to find employee by skills
  So that I know whether we have the staff or we need to hire new ones

  Background: persona
    Given we have the following employees:
      | name   | currentProject | role | skills    |
      | Juntao | Consulting     | Dev  | Java,Ruby |
      | Yanyu  | Beach          | Dev  | Ruby      |
      | Jiawei | Beach          | Dev  | Java,Ruby |
      | Momo   | Beach          | Dev  | Python    |

  Scenario: Search by skills
    Given I have a project which require "Ruby" as language
    When I search staff by skill
    Then I should get the following names:
      | Yanyu  |
      | Jiawei |

迭代开发

软件开发本身就具有很高的复杂度,正如我在上一篇博客里提到的,在项目启动之初,无论是业务专家还是开发者,对软件系统的知识都是非常有限的。迭代式的开发方式更加契合这种复杂度很高的活动。业务人员和开发一起,在建模、实现的过程中会对领域进行深入的学习,并产生新的知识。反过来这些新的知识又会影响模型的进一步进化。

迭代开发的方式可以帮助我们将这个复杂度很高的过程变得平缓一些,而且从一个个小的迭代中学习,并根据反馈来指导新的方向,可以快速的帮助团队建立信心,同时对业务的理解更为深入,使得整个开发过程越来越平顺。

小结

简而言之,要顺利实施DDD,需要至少做到:

  • 明确概念,分清问题域解决方案域,应用DDD的战略模式
  • 转变思维方式,为业务梳理和理解赋予最高的优先级
  • 通过工程实践来确保落地

当然,如何来实施战略模式(通用语言,限界上下文,上下文映射)也需要一些工程实践的帮助,我会在另外一篇文章中详细讨论。

软件开发为什么很难

问题的分类

最初在1999年被Dave Snowden开发出来的Cynefin框架尝试把世界上的问题划分到了5个域中(大类):

  • 简单(Simple)问题,该域中的因果关系非常明显,解决这些问题的方法是 感知-分类-响应(Sense-Categorise-Respond),有对应的最佳实践
  • 复合(Complicated)问题,该域中的因果关系需要分析,或者需要一些其他形式的调查和/或专业知识的应用,解决这些问题的方法是感知-分析-响应(Sense-Analyze-Respond),有对应的好的实践
  • 复杂(Complex)问题,该域中的因果关系仅能够从回顾中发现,解决这些问题的方法是探索-感知-响应(Probe-Sense-Respond),我们能够感知涌现实践(emergent practice)
  • 混乱(Chaotic)问题,该域中没有系统级别的因果关系,方法是行动-感知-响应(Act-Sense-Respond),我们能够发现新颖实践(novel practice)
  • 失序(Disorder)问题,该域中没有因果关系,不可感知,其中的问题也无法被解决

显然,软件开发过程更多地是一个复杂(Complex)问题。在一个产品被开发出来之前,不确定性非常高,团队(包括业务人员和技术人员)对产品的知识也是最少的,而且需要大量的学习和尝试才可以明确下一步可能的方向。不幸的是,很多时候我们需要在一开始(不确定性最高的时候)就为项目做计划。这种从传统行业中非常适合的方法在软件开发领域不再适用,这也是敏捷开发、精益等方法论在软件开发中更加适合的原因。

来源:http://alistair.cockburn.us/Disciplined+Learning

正因为软件开发事实上是一个学习的过程,我们学习到的新知识反过来会帮助我们对问题的定义,从而带来变化。这里的变化可能来自两个方向:

  • 功能性
  • 非功能性

功能性的变化指随着对业务的深入理解、或者已有业务规则为了匹配市场而产生的变化。比如支付方式由传统的货到付款变成了网银付款,又变成了微信支付、支付宝扫码等等。一个原始的电商平台仅仅提供基本的购物服务,但是后来可以根据已有数据产生推荐商品,从来带来更大的流量。这些变化需要体现在已有的代码中,而对代码的修改往往是牵一发而动全身。

非功能性的变化是指随着业务的发展,用户规模的增加,数据量的变化,安全认知的变化等产生的新的需求。比如100个用户的时候无需考虑性能问题,但是100万用户的时候,性能就变成了必须重视的问题。天气预报应用的数据安全性和网络银行的数据安全性要求也大不相同。

而在业务提出一个需求的时候,往往只是一个简化过的版本。

非功能性复杂性

来源:https://d13yacurqjgara.cloudfront.net/users/749341/screenshots/2228676/uielements_day021_dribbbleinvites.jpg

这是一个经过设计师精确设计的界面,在它被设计出来之前,用户事实上无法准确的描述出它。设计过程中经历了很多的诸如:

  • 线框图
  • 颜色的确定
  • 交互的动画
  • 信息层次

往复多次之后,界面确定了。在没有仔细思考使用场景的时候,开发会误以为这个功能非常简单。但是如果你是一个有经验的开发者,很快会想到的一些问题是:

  • 在宽屏下如何展示
  • 在平板上如何展示
  • 在手机上如何展示
  • 即使仅仅支持桌面版,跨浏览器要考虑吗?支持哪些版本?
  • 有些UI效果在低版本的浏览器上不工作,需要Shim技术

除此之外,依然有大量的其他细节需要考虑:

  • 性能要求是什么样的?
  • 安全性要考虑吗?
  • 在网络环境不好的时候,要不要fallback到基础视图?
  • 既然涉及发送邀请函,送达率如何保证
  • 与外部邮件服务提供商集成时的工作量

等等。这些隐含的信息需要被充分挖掘出来,然后开发者才能做一个合理的评估,而且这还只是开始。一旦进入开发阶段,很多之前没有考虑到的细节开始涌现:字体的选用,字号,字体颜色,元素间的间距等等,如何测试邮件是否发送成功,多个角色之间的conversation又会消耗很多时间。

需求的变化方向

作为程序员,有一天你被要求写一段代码,这段代码需要完成一件很简单的事:

  1. 打印”Hello, world”5次

很容易嘛,你想,然后顺手就写下了下面这几行代码:

1
2
3
4
5
print("Hello, world")
print("Hello, world")
print("Hello, world")
print("Hello, world")
print("Hello, world")

不过,拷贝粘贴看起来有点低端,你做了一个微小的改动:

1
2
3
for(var i = 0; i < 5; i++) {
  print("Hello, world")
}

看起来还不错,老板的需求又变成了打印”Goodbye, world”5次。既然是打印不同的消息,那何不把消息作为参数呢?

1
2
3
4
5
6
7
8
function printMessage(message) {
  for(i = 0; i < 5; i++) {
      print(message);
  }
}

printMessage("Hello, world")
printMessage("Goodbye, world")

有了这个函数,你可以打印任意消息5次了。老板又一次改变了需求:打印”Hello, world”13次(没人知道为什么是13)。既然次数也变化了,那么一个可能是将次数作为参数传入:

1
2
3
4
5
6
7
8
function printMessage(count, message) {
  for(i = 0; i < count; i++) {
      print(message);
  }
}

printMessage(13, "Hello, world");
printMessage(5, "Goodbye, world");

完美,这就是抽象的魅力。有了这个函数,你可以将任意消息打印任意次数。不过老板是永远无法满足的,就在这次需求变化之后的第二天,他的需求又变了:不但要将”Hello, world”打印到控制台,还要将其计入日志。

没办法,通过搜索JavaScript的文档,你发现了一个叫做高阶函数的东东:函数可以作为参数传入另一个参数!

1
2
3
4
5
6
7
8
9
10
11
12
function log(message) {
  system.log(message);
}

function doMessage(count, message, action) {
  for(i = 0; i < count; i++) {
      action(message);
  }
}

doMessage(5, "Hello, world", print);
doMessage(5, "Hello, world", log);

这下厉害了,我们可以对任意消息,做任意次的任意动作!再回过头来看看那个最开始的需求:

  1. 打印”Hello, world”5次

稍微分割一下这句话:打印,”Hello, world”,5次,可以看到,这三个元素最后都变成了可以变化的点,软件开发很多时候正是如此,需求可能在任意可能变化的方向上变化。这也是各种软件开发原则尝试解决的问题:如何写出更容易扩展,更容易响应变化的代码来。

小节

软件的复杂性来自于大量的不确定性,而这个不确定性事实上是无法避免的,而且每个软件都是独一无二的。另一方面,软件的需求会以各种方式来变化,而且往往会以开发者没有预料到的方向。比如上面这个小例子中看到的,最后的需求可能会变成将消息以短信的方式发送给手机号以185开头的用户手机上。

我的2016

大事记

把女儿心心哄睡着之后,我躺在她旁边,听着她平稳的呼吸声,心里充满了幸福和感激。女儿降生的那种感动,是无以伦比的,之前的很多事情变得无所谓起来,这当然是我今年最大的收获。

心心

当然,初为人父,有好多好多东西需要学习,她还在月子里的时候,我整理过一次思维导图:

新生儿

今年也有一些其他的里程碑:

  • 骑行了第一个100公里
  • 学会了驾驶(已经行驶了近6000公里)
  • 学会了抱娃,换尿布,给娃洗澡,试水温等等
  • 《轻量级Web应用开发》被翻译成繁体中文版在台湾发售
  • Hiking到了秦岭最高点(大爷海,海拔3500m)

学习

今年的大部分时间都不在办公室,在客户现场出差有8-9个月。最大的感受是归属感的缺乏,另外就是大部分时候再输出,会导致积累的存货快要被掏空的感觉。很多知识需要花时间来补充上,咨询工作本身有很大一部分是需要咨询师在非咨询项目上做积累,然后再去输出的。

这一点希望在2017年可以做的更好一些:减少一些出差,多在办公室的项目上工作,然后能有更多的时间来积累自己的知识库。

上半年花了一些时间和设计师唐婉莹合写的《在迭代1之前》,后来出差网络比较封闭的时候,就只有唐婉莹在做更新(频率和质量都很高),我自己的开发部分很久没有改动了,算是一个比较遗憾的地方。2017年希望可以完成其中的烂尾部分。

总体来说,2016年很多时间是做咨询工作,内容比较杂乱。我梳理了一下,大致如下:

一些微小的练习

我的第一个React Natvie应用,感谢傅若愚:

闪电计划的页面局部,感谢张小虫:

Graphviz一个例子:

闪电计划

7月,和另外两个同事组织了一个系列的活动,这个活动旨在通过一系列的刻意练习,包括但不限于:

  • TDD,Tasking,构建,环境自动化
  • 自动化测试(集成测试,UI测试)
  • 项目中的常见场景(多表关联,异常处理,RESTful API设计)
  • 常见静态页面
  • 一些具体而微的端到端Web项目

闪电计划的另一个副作用是找出了一些前端的Kata,可以供后续的前端们做练习用。我已经找张小虫,李彤欣做过了第一次的实验,证明是一个比较合理的方向。在2017年,可以作为新的Workshop进行。

项目经历

元月份经历了一个史上最奇特的咨询项目:Staffing好的3个人中,有两个离职了……。不过还好,通过两个月的微小工作,客户最终还是比较满意的,也很顺利的有了项目的第二期(虽然由于其他原因,第二期只做了一半)。

其他的经历比较平淡:

  • 在回南天的3月去了一趟广州出差,做了一些前端性能相关的测试。
  • 在最热的7月份去了一趟上海出差,莫名其妙的做了一些前端性能相关的测试,以及React Native的一些预研。
  • 最后在寒冷的12月去了一趟深圳出差,做了一些安全测试(基于OWASP)、依赖关系图(基于Doxygen)等的Spike。

不过总体来看,2016年经历的项目是最多的,而且多样性也很高:

  • 3个咨询项目
  • 3个交付项目
  • 3个售前

其他

应王欢要求,除了一起吃过几次饭之外,今年跟王欢基本上没有交集。倒是多亏了他的脸,在楼下的来福士和他喝了不少次咖啡。

这是九月份去付莹家的黄河时和同事们的合影,第一次开车走高速去那么远的地方,后座还有一个澳洲的客户。

骑行了一半儿,有一群开着车的同事追了上来,跟我们一起吃了个饭,我们骑车回去,他们则开车/坐车回去了。

微服务场景下的自动化测试

新的挑战

微服务和传统的单块应用相比,在测试策略上,会有一些不太一样的地方。简单来说,在微服务架构中,测试的层次变得更多,而且对环境的搭建要求更高。比如对单块应用,在一个机器上就可以setup出所有的依赖,但是在微服务场景下,由于依赖的服务往往很多,要搭建一个完整的环境非常困难,这对团队的DevOps的能力也有比较高的要求。

相对于单块来说,微服务架构具有以下特点:

  • 每个微服务在物理上分属不同进程
  • 服务间往往通过RESTful来集成
  • 多语言,多数据库,多运行时
  • 网络的不可靠特性
  • 不同的团队和交付周期

上述的这些微服务环境的特点,决定了在微服务场景中进行测试自然会面临的一些挑战:

  • 服务间依赖关系复杂
  • 需要为每个不同语言,不同数据库的服务搭建各自的环境
  • 端到端测试时,环境准备复杂
  • 网络的不可靠会导致测试套件的不稳定
  • 团队之间的沟通成本

测试的分层

相比于常见的三层测试金字塔,在微服务场景下,这个层次可以被扩展为5层(如果将UI测试单独抽取出来,可以分为六层)。

  • 单元测试
  • 集成测试
  • 组件测试
  • 契约测试
  • 端到端测试

Test layers

和测试金字塔的基本原则相同:

  1. 越往上,越接近业务/最终用户;越往下,越接近开发
  2. 越往上,测试用例越少
  3. 越往上,测试成本越高(越耗时,失败时的信息越模糊,越难跟踪)

单元测试

单元测试,即每个微服务内部,对于领域对象,领域逻辑的测试。它的隔离性比较高,无需其他依赖,执行速度较快。

对于业务规则:

  1. 商用软件需要License才可以使用,License有时间限制
  2. 需要License的软件在到期之前,系统需要发出告警
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void license_should_expire_after_the_evaluation_period() {
    LocalDate fixed = getDateFrom("2015-09-03");
    License license = new License(fixed.toDate(), 1);

    boolean isExpiredOn = license.isExpiredOn(fixed.plusYears(1).plusDays(1).toDate());
    assertTrue(isExpiredOn);
}

@Test
public void license_should_not_expire_before_the_evaluation_period() {
    LocalDate fixed = getDateFrom("2015-09-05");
    License license = new License(fixed.toDate(), 1);

    boolean isExpiredOn = license.isExpiredOn(fixed.plusYears(1).minusDays(1).toDate());
    assertFalse(isExpiredOn);
}

上面这个例子就是一个非常典型的单元测试,它和其他组件基本上没有依赖。即使要测试的对象对其他类有依赖,我们会Stub/Mock的手段来将这些依赖消除,比如使用mockito/PowerMock

集成测试

系统内模块(一个模块对其周边的依赖项)间的集成,系统间的集成都可以归类为集成测试。比如

  • 数据库访问模块与数据库的集成
  • 对外部service依赖的测试,比如对第三方支付,通知等服务的集成

集成测试强调模块和外部的交互的验证,在集成测试时,通常会涉及到外部的组件,比如数据库,第三方服务。这时候需要尽可能真实的去与外部组件进行交互,比如使用和真实环境相同类型的数据库,采用独立模式(Standalone)的WireMock来启动外部依赖的RESTful系统。

通常会用来做模拟外部依赖工具包括:

其中,mountbank还支持Socket级别的Mock,可以在非HTTP协议的场景中使用。

Integration Test

组件测试

贯穿应用层和领域层的测试。不过通常来说,这部分的测试不会访问真实的外部数据源,而是使用同schema的内存数据库,而且对外部service的访问也会使用Stub的方式:

比如使用h2来做内存数据库,并且自动生成schema。使用WireMock来Stub外部的服务等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
public void should_create_user() {
    given().contentType(ContentType.JSON).body(prepareUser()).
            when().post("/users").
            then().statusCode(201).
            body("id", notNullValue()).
            body("name", is("Juntao Qiu")).
            body("email", is("juntao.qiu@gmail.com"));
}

private User prepareUser() {
    User user = new User();
    user.setName("Juntao Qiu");
    user.setEmail("juntao.qiu@gmail.com");
    user.setPassword("password");
    return user;
}

如果使用Spring,还可以通过profile来切换不同的数据库。比如下面这个例子中,默认的profile会连接数据库jigsaw,而integration的profile会连接jigsaw_test数据库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jigsaw
    driver-class-title: com.mysql.jdbc.Driver
    username: root
    password: password

---

spring:
  profiles: integration

  datasource:
    url: jdbc:mysql://localhost:3306/jigsaw_test
    driver-class-title: com.mysql.jdbc.Driver
    username: root
    password: password

组件测试会涉及到的组件包括:

  • URL路由
  • 序列化与反序列化
  • 应用对领域层的访问
  • 领域层对数据的访问
  • 数据库访问层
前后端分离

除了后端的测试之外,在目前的前后端分离场景下,前端的应用越来越复杂,在这种情况下,前端的组件测试也是一个测试的重点。

一个前端应用至少包括了这样一些组件:

  • 前端路由
  • 模板
  • 前端的MVVM
  • 拦截器
  • 事件的响应

要确保这些组件组合起来还能如预期的执行,相关测试必不可少。这篇文章详细讨论了前后端分离之后的测试及开发实践。

契约测试

在微服务场景中,服务之间会有很多依赖关系。根据消费者驱动契约,我们可以将服务分为消费者端和生产者端,通常消费者自己会定义需要的数据格式以及交互细节,并生成一个契约文件。然后生产者根据自己的契约来实现自己的逻辑,并在持续集成环境中持续验证。

Pact已经基本上是消费者驱动契约(Consumer Driven Contract)的事实标准了。它已经有多种语言的实现,Java平台的可以使用pact-jvm及相应的maven/gradle插件进行开发。

pact example

(图片来源:Why you should use Consumer-Driven Contracts for Microservice integration tests)

通常在工程实践上,当消费者根据需要生成了契约之后,我们会将契约上传至一个公共可访问的地址,然后生产者在执行时会访问这个地址,并获得最新版本的契约,然后对着这些契约来执行相应的验证过程。

一个典型的契约的片段是这样的(使用pact):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
"interactions": [
    {
        "description": "Project Service",
        "request": {
            "method": "GET",
            "path": "/projects/11046"
        },
        "response": {
            "status": 200,
            "headers": {
                "Content-Type": "application/json; charset=UTF-8"
            },
            "body": {
                "project-id": "004c97"
            },
            "matchingRules": {
                "$.body.project-id": {
                    "match": "type"
                }
            }
        },
        "providerState": "project service"
    }
]

端到端测试

端到端测试是整个微服务测试中最困难的,一个完整的环境的创建于维护可能需要花费很大的经历,特别是当有多个不同的团队在独立开发的场景下。

另一方面,从传统的测试金字塔来看,端到端测试应该覆盖那些业务价值最高的Happy Path。也就是说,端到端测试并不关注异常场景,甚至大部分的业务场景都不考虑。要做到这一点,需要在设计测试时,从最终用户的角度来考虑,通过用户画像User Journey来确定测试场景。

在端到端测试中,最重要的反而不是测试本身,而是环境的自动化能力。比如可以通过一键就可以将整个环境provision出来:

  • 安装和配置相关依赖
  • 自动将测试数据Feed到数据库
  • 自动部署
  • 服务的自动重启

随着容器技术和容器的编排技术的成熟,这部分工作已经可以比较好的自动化,依赖的工具包括:

一个典型的流程是:

  1. 搭建持续发布流水线
  2. 应用代码的每一次提交都可以构建出docker镜像
  3. 将docker镜像发布在内部的docker-hub上
  4. 触发部署任务,通过rancher的upgrade命令将新的镜像发布
  5. 执行端到端测试套件

端到端测试还可以细分为两个不同的场景:

  • 没有用户交互的场景,如一系列的微服务组成了一个业务API
  • 有用户交互的场景
UI测试

最顶层的UI测试跟传统方式的UI测试并无二致。我们可以使用BDD与实例化需求(Specification By Example)的概念,从用户使用的角度来描述需求,以及相关的验收条件。这里我们会使用WebDriver来驱动浏览器,并通过诸如Capybara等工具来模拟用户的操作。

扩展阅读

测试自动化后,我们还需要QA吗?

QA的职责

我们先讨论一下传统的瀑布模型下QA是如何工作的,其中最主要的问题是什么;然后作为对比,我们来看看在敏捷团队里QA又是如何工作的,工作重点又是什么;最后,我们详细看一看在新的职责下,QA应该如何做。

瀑布开发模型

即使在今天,在很多企业中,瀑布模型仍然是主流。每一个需求都需要经过分析,设计,开发,测试,上线部署,运维等阶段。虽然一些企业已经在实施敏捷开发,比如项目/产品以迭代的方式运作,也有诸如每日站会,代码检视等敏捷实践,但是如果仔细审视,你会发现其实开发模式骨子里还是瀑布:按照软件组件划分的部门结构(详见康威定律),按照职能划分的团队(开发和测试分属不同部门),过长的反馈周期,永远无法摆脱的集成难题等等。

随着软件变得越来越复杂,团队里没有任何一个人可以说出系统是如何运作的,也不知道最终用户是谁,以及最终用户会以何种方式来使用最终的软件。

更糟糕的是,按照职能划分的团队在物理上都是隔离的,比如独立的测试部门,独立的运维部门,整日忙碌而难以预约到档期的业务人员,当然还有经常疲于交付,无处吐槽的苦逼开发。由于这些隔离,信息的反馈周期会非常长,一个本来很容易修复的缺陷可能在4周之后才可能被另一个部门的测试发现,然后通过复杂的工作流(比如某种形式的缺陷追踪系统)流到开发那里,而开发可能还在拼命的完成早就应该交付的功能,从而形成恶性循环。

瀑布模式中的QA

在这样的环境中,QA们能做的事情非常有限。在需求开始时会他们参加需求澄清的会议,制定一些测试计划,然后进行测试用例的设计。有的企业会用诸如Excel之类的工具来记录这些用例。这些写在Excel里的,的用例用处非常有限。而最大的问题在于:它们无法自动化执行。另外,在实际软件开发中,需求总是会经常发生变化,需求的优先级也会有调整,然后这些记录在Excel中的的用例会很快过期,变得无人问津。

除此之外,QA中的有些成员会使用工具来录制一些UI测试的场景,然后在每个新版本出来之后进行回放即可。然而,当UI发生一点变化之后,这些自动化的用例就会失效:比如HTML片段中元素位置的调整,JavaScript的异步调用超时等等。

显然,这种单纯以黑盒的形式来检查功能点的测试方式是不工作的,要真正有效的提升软件质量,仅仅通过事后检查是远远不够的,软件的质量也应该内建于软件之中。QA的工作也应该是一个贯穿软件生命周期的活动,从商业想法,到真实上线,这其中的所有环节,都应该有QA的参与。

系统思考

如果不从一个系统的角度来思考软件质量,就无法真正构建出健壮的、让业务和团队都有信心的软件系统。质量从来都不只是QA的职责,而是整个团队的职责。

关于软件质量,一个根深蒂固的误解是:缺陷在开发过程中被引入,然后在测试阶段被发现,最后在QA和开发的来来回回的撕扯中被解决(或者数量被大规模降低),最后在生产环境中,就只会有很少的,优先级很低的缺陷。

然而事实上,很多需求就没有仔细分析,业务价值不很确定,验收条件模糊,流入开发后又会引入一些代码级别的错误,以及业务规则上的缺陷,测试阶段会漏掉一些功能点,上线之后更是问题百出(网络故障,缓存失效,黑客攻击,操作系统补丁,甚至内存溢出,log文件将磁盘写满等等)。

在一个敏捷团队中,每个个人都应该对质量负责,而QA则以自己的丰富经验和独特视角来发掘系统中可能的质量隐患,并帮助团队将这些隐患消除。

测试职责

我在ThoughtWorks的同事Anand Bagmar在他的演讲What is Agile testing- How does automation help?中详细讨论过这部分内容。

QA到底应该干什么?

本质上来说,任何软件项目的目标都应该是:更快地将高质量的软件从想法变成产品

将这个大目标细分一下,会得到这样几个子项,即企业需要:

  • 更多的商业回报(发掘业务价值)
  • 更快的上线时间(做最简单,直接的版本)
  • 更好的软件质量(质量内嵌)
  • 更少的资源投入(减少浪费)

其实就是传说中的多、快、好、省。如果说这是每一个软件项目的目标的话,那么团队里的每一个个人都应该向着这个目标而努力,任何其他形式的工作都可以归类为浪费。用Excel记录那些经常会失效,而且无法自动执行的测试用例是浪费,会因为页面布局变化而大面积失效的UI测试也是浪费,一个容易修复的缺陷要等到数周之后才被发现也是浪费。

在这个大前提下,我们再来思考QA在团队里应该做什么以及怎么做。

QA的职责

Lisa Crispin在《敏捷软件测试》中提到过一个很著名的模型:敏捷测试四象限。这个模型是QA制定测试策略时的一个重要参考:

敏捷软件测试

如果按照纵向划分的话,图中的活动,越向上越面向业务;越向下越面向技术。横向划分的话,往左是支撑团队;往右是评价产品。

其实简化一下,QA在团队里的工作,可以分为两大类:

  • 确保我们在正确的交付产品
  • 确保我们交付了正确的产品

根据这个四象限的划分,大部分团队可能都会从Q2起步:QA会和BA,甚至UX一起,从需求分析入手,进行需求分析,业务场景梳理,这时候没有具体的可以被测试的软件代码。不过这并不妨碍测试活动,比如一些纸上原型的设计(感谢刘海生供图):

通过这一阶段之后,我们已经有了用户故事,这时候QA需要和开发一起编写用户故事的自动化验收测试。当开发交付一部分功能之后,QA就可以做常规的用户故事测试,几个迭代之后,QA开始进行跨功能需求测试和探索性测试等。根据探索性测试的结果,QA可能会调整测试策略,调整测试优先级,完善测试用例等等。

根据项目的不同,团队可以从不同的象限开始测试策略的制定。事实上,Q1-Q4仅仅是一个编号,与时间、阶段并无关系,Lisa Crispin还专门撰文解释过。

关于QA如何在软件分析的上游就介入,然后通过BDD的方式与业务分析师一起产出软件的各种规格描述,并通过实例的方式来帮助整个团队对需求的理解,ThoughtWorks的林冰玉有一篇文章很好的介绍了BDD的正确做法。如果将QA的外延扩展到在线的生产环境,制定合理的测量指标,调整测试策略,强烈推荐林冰玉写的另一篇文章产品环境中的QA

其他职责

事实上,软件生命周期中有很多的活动,有很多处于灰色地段。既可以说是应该开发做,又可以说应该QA做,甚至可以推给其他角色(比如OPs)。不过我们知道,一旦涉及角色,人们就再也不会按照全局优化的思路来应对问题了。这种灰色的活动包括:

  • 持续集成的搭建
  • 测试环境的创建于维护
  • UAT上的数据准备
  • 代码中的测试代码的维护
  • 测试代码的重构

在团队实践中,这些活动我们通常会让QA和开发或者OPs同事一起结对来完成。一方面避免知识孤岛的形成,另一方面在跨角色的工作中,也可以激发出更多不同的思路。

万能的QA?

虽然在这些活动中,QA都会参与,但是并不是说团队里只要有一个QA就可以了。QA在参与这些活动时,侧重点还是有很大不同的。

比如需求分析阶段,如果有QA的加入,一些从QA角度可以发现的有明显缺陷的场景,则可以在分析阶段就得到很好的处理。另一方面,尽早介入可以设计出更合理的测试计划(比如哪些功能的优先级比较高,用户更会频繁使用,那么对应的测试比重也会更高)。在Story分析与书写阶段,QA可以帮助写出更加合理的验收条件,既满足业务需求,又可以很好的指导开发。

在和开发一起编写澄清需求时,主要是编写自动化验收测试,而不是实际编写业务逻辑的实现(虽然QA应该参与Code Reivew环节,学习并分享自己的观点);甚至在上线运维阶段,QA还需要和OPs一起来设计用户数据的采集指标(比如用户访问的关键路径,浏览器版本,地区的区分等),从而制定出新的测试策略。

扩展阅读

P.S. 感谢林冰玉对本文的Review和指导。

如何设计一次培训

培训元模式

最近在帮客户设计一个微服务进阶版培训的材料,整理的过程中我意识到这类事情我已经做过好多次了。比如在ThoughtWorks的P2能力建设项目,3周3页面工作坊等等,我觉得应该将设计课程/设计培训中的模式、原则和实践都提取一下,形成一个元模式(即关于培训的培训)。

一个培训中的活动,按照时间顺序可以分为三个步骤:

  1. 设计培训内容
  2. 培训前期准备
  3. 培训中的一些实践

设计培训内容

根据经验,只有那些正好处于瓶颈阶段,需要别人给予一些具体指导的人,培训是最有效的。比如我最近在学习iOS开发,那么用Swift开发一个Todo List应用就特别合适,而另一个基于Objective-C的自动化测试则对我来说一点用也没有(即使这个可能更高级,讲师更牛)。

在任何一个培训可能的设计之前,首先需要回答几个问题:

  1. 培训目的是什么?
  2. 参与者对主题的了解程度如何?
  3. 参与者的组成比例(比如junior占比,senior占比等)
  4. 结果如何检验?

一个例子

例子是人类的好朋友,这里我们来看一个例子:

客户要为负责开发的同事做一次培训,培训的目的是要帮助他们建立微服务架构下的常见实践的知识框架。从结果的长期效果来看,这次培训要能指导实际的开发工作。参与者对微服务的一些概念有初步了解,也做过小的练习,但是诸如如何划分服务边界,如何拆分微服务等,都不了解,也比较迫切的想要了解。

根据这个上下文,客户希望在培训中可以传递这样一些内容:

  • 如何拆分微服务
  • 常见的微服务设计原则
  • 拆分微服务的时机
  • 如何做微服务的测试

为了确保信息传递的准确性,我问了一遍上边都提到的列表,并且得到了答案:

  • 听众是开发经验相对丰富的开发(3-5年)
  • 学习拆分为了将大的应用拆分,以方便维护
  • 自动化测试能力和意识都较为薄弱
  • 听众自己的期望是可以有一些切实可以指导实际开发的收获

在有了这些输入的情况下,我做出了这样一些调整:

  • 不专门讲拆分微服务
  • 主要精力讲DDD(Domain Driven Design)
  • 设计迭代式的,逐步变得复杂的场景来练习DDD
  • 讲解和练习自动化测试(Consumer Driven Contract)
  • Session+Workshop+讨论的形式

看了这个计划之后,客户开始觉得挺困惑,说这个怎么跟我们梳理的课程诉求不一致?对于这个疑惑,我的建议是这样的:

  • 之前的例子不能落地(找不到足够复杂的,又适合在培训中拆分的场景)
  • 微服务的核心不是基础设施,而是设计原则,或者说如何在开发中找出边界
  • 有了DDD的指导,划分本身并非难事儿
  • 自动化测试(集成测试和契约测试)的能力和认识必须建立

然后我把整理好的课件,实例分解,课程安排给客户讲了一遍之后,他觉得很满意。客户自己也是懂技术的,在分析了现状之后,后来又专门要求给部门内做一些DDD培训(而不是微服务本身)。

培训方式

同样,方式上也需要一些问题的解答才能有效进行:

  1. 培训总时长
  2. 更偏重练习还是偏重讲解(工作坊还是Session,以及各自的占比)
  3. 参与者如何投入(比如是工作时间,还是晚上等)

根据经验来看,不论是TWU还是对客户的培训,工作坊和Session结合的方式效果最好。 Workshop至少需要包含这样几部分:

  • 明确要做的练习(多长时间,达到什么目的等)
  • Showcase(参与感,如果有多轮的话,要保证每个人都有Show的机会,而不是每次同一个人)
  • 讨论环节(点评,这个时机可以做一些小结,将要传递的信息润物细无声的传递出去)

workshop

应该避免的做法是:

  • 一个无法完成的任务
  • 过长的时间
  • 没有讨论,没有点评(特别是在中国文化中,Trainer有指出对错的义务

Session则简单一些,交互比较少,需要讲师之前做足功课

  • 有清晰的Agenda(时长,要传递的大致内容)
  • 如果是讲方法论(DDD,BDD等等),最好结合实践
  • 不要讲代码(没有人能看清你的编辑器,也没有人有耐心看超过1行的代码)
  • 一次不要传递太多信息(只讲三点)

两者穿插起来,可以收到很好的效果,既不至于让人觉得没有内容,也不会感觉只说不练。

预演(Dry Run)

在进行实际的实施之前,可以从参与者中找出一些典型的人进行交流,dry run一两次。不过交流也需要平衡一些事实:

  1. 侧重点(做对的事情),比如从设计的层面来讲,DDD,重构等方法的掌握比服务的可用性要重要很多
  2. 参与者想要听的和主题的相关性(比如培训目的是Story拆分,那么测试驱动开发的需求就要果断放弃)

持续建立关注点,引导发散,归纳问题,归类并给出见解和可能的方案。

如何回答问题

有两种教学方式:苏格拉底式和填鸭式。苏格拉底式讲究只问不答,通过讲师不断提问的方式让学员自己思考,琢磨,并期望领悟;填鸭式则恰恰相反,假设学员没有任何能动性,讲师纯粹将自己知道的全部告诉学员。

这其实两者在时间中都不合适。有一个度可以把握的是:

善待问者如撞钟,叩之以小者则小鸣,叩之以大者则大鸣,待其从容,然后尽其声。

即根据提问的深度来反馈,比如JavaScript培训,学生问如何声明一个数组,讲师讲词法作用域,则费劲而没有效果。经过一段时间的练习和积累之后,学生会问出更深入的问题,这时候再逐步提高答案的深度。

实践

Energize

如果培训在白天的话,在午饭后,人们自然会感到困倦,这时候需要一些提神的小游戏帮助人们清醒。适度的紧张(比如简单的7Up游戏)或者肢体动作(做几个广播体操的动作)都会帮助人们振奋精神。

  • 围成一个圈的丢球自我介绍
  • A和B向对方互相介绍自己,然后A反过来向整个Group介绍B,B向整个Group介绍A
  • 7Up
  • 丢空气球

工作坊基本准则

  1. 按时参加
  2. 保持One convensition
  3. 手机静音

分组进行

分组讨论是一个很好的实践,既可以避免只说不练的现象,又可以让参与者有充分的参与感,还可以让培训的时间长度更灵活,更容易控制。

分组可以用报数的方法,通常人们习惯和自己熟悉的人坐在一起。这会促进他们交头接耳的几率,分组时可以通过报数的方式,比如目标是分为3组,就依次报数1、2、3,然后所有报1的人为1组,报2的人为2组,以此类推。

一个我自己常用的伎俩是自由调整时间。比如在一个练习开始前,你告诉参与者他们有10分钟来完成一个Task,然后在5分钟后,你发现大家都基本已经做完了,这时候可以直接说:时间到!(相信我,没有人真正看表的,他们通常会被手头的任务搞得焦头烂额)。反过来,你可以说:再延长5分钟!以督促那些比较慢的小组动作更快一些,而事实上他们还没有用完限定的10分钟。

grouping

Parking lot

培训当然需要参与者和trainer的积极互动,但是有时候参与者提出的问题不太适合在课堂上展开:

  • 与培训主题关联并不太大
  • 比较个例的问题
  • 争议较大,难以在短时间内讨论清楚的

这些问题可以记录下来,在正式课程时间结束后单点讨论,或者作为下一次备课的关键点,加入到课件中。

Retro

和我们倡导的一切活动一样,培训也需要有始有终。我们需要收集参与者的反馈,包括positive的和constructive的,这样经过几轮迭代之后,培训会成熟而具有一定的可复制性。

可以分成三类:做得好的/有待提高的/一些疑问/建议

将问题分组讨论之后,分为三组,然后讨论出一些解决方法。

retro

其他

  • 二维码+金数据表单
  • 白板+白板笔
  • Flipchart

总结

无他,但手熟尔

高效幻象

通过对自己的行为观察,我发现在很多时候,我以为我掌握了的知识和技能其实并不牢靠。我引以为豪的高效其实犹如一个彩色的肥皂泡,轻轻一碰就会破碎,散落一地。

你可能只是精通搜索

我们现在所处的时代,信息爆炸,每个人每天都会接触,阅读很多的信息,快速消费,快速遗忘。那种每天早上起来如同皇帝批阅奏折的、虚假的误以为掌握知识的错觉,驱动我们进入一个恶性循环。

即使在我们真的打算解决问题,进行主动学习时,更多的也只是在熟练使用搜索引擎而已(在一个领域待久了,你所使用的关键字准确度自然要比新人高一些,仅此而已)。精通了高效率搜索之后,你会产生一种你精通搜索到的知识本身错觉

stack overflow

如何写一个Shell脚本

在写博客的时候,我通常会在文章中配图。图片一般会放在一个有固定格式的目录中,比如现在是2016年5月,我本地就会有一个名为$BLOG_HOME/images/2016/05的目录。由于使用的是markdown,在插入图片时我就不得不输入完整的图片路径,如:/images/2016/05/stack-overflow.png。但是我又不太记得路径中的images是单数(image)还是复数(images),而且图片格式又可能是jpg,jpeg,gif或者png,我也经常会搞错,这会导致图片无法正确显示。另外,放入该目录的原始文件尺寸有可能比较大,我通常需要将其缩放成800像素宽(长度无所谓,因为文章总是要从上往下阅读)。

为了自动化这个步骤,我写了一个小的Shell脚本。当你输入一个文件名如:stack-overflow.png后,它会缩放这个文件到800像素宽,结果是一个新的图片文件,命名为stack-overflow-resized.png,另外它将符合markdown语法的文件路径拷贝到剪贴板里:/images/2016/05/stack-overflow-resized.png,这样我在文章正文中只需要用Command+V粘贴就可以了。

有了思路,写起来就很容易了。缩放图片的命令我是知道的:

1
$ convert -resize 800 stack-overflow.png stack-overflow-resized.png

但是要在文件明上加入-resized,需要分割文件名和文件扩展名,在Bash里如何做到这一点呢?Google一下:

1
2
3
4
5
6
7
FULLFILE=$1

FILENAME=$(basename "$FULLFILE")
EXTENSION="${FILENAME##*.}"
FILENAME="${FILENAME%.*}"

convert -resize 800 $FULLFILE $FILENAME-resized.EXTENSION

难看是有点难看,不过还是可以工作的。接下来是按照当前日期生成完整路径,date命令我是知道的,而且我知道它的format格式很复杂,而且跟JavaScript里Date对象的format又不太一样(事实上,世界上有多少种日期工具,基本上就有多少种格式)。再Google一下:

1
$ date +"/images/%Y/%m/"

最后一步将路径拷贝到剪贴板也容易,Mac下的pbcopy我也会用:echo一下字符串变量,再管道到pbcopy即可:

1
2
PREFIX=`date +"/images/%Y/%m/"`
echo "$PREFIX$FILENAME-resized.EXTENSION" | pbcopy

但是将内容粘贴到markdown里之后,我发现这个脚本多了一个换行。我想这个应该是echo自己的行为吧,会给字符串自动加上一个换行符。Google一下,发现加上-n参数就可以解决这个问题。

好了,完整的脚本写好了:

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
FULLFILE=$1

FILENAME=$(basename "$FULLFILE")
EXTENSION="${FILENAME##*.}"
FILENAME="${FILENAME%.*}"

convert -resize 800 $FULLFILE $FILENAME-resized.EXTENSION

PREFIX=`date +"/images/%Y/%m/"`
echo -n "$PREFIX$FILENAME-resized.EXTENSION" | pbcopy

嗯,还不错,整个过程中就用了我十几分钟时间而已,以后我在写博客时插入图片就方便多了!

不过等等,好像有点不对劲儿,我回过头来看了看这段脚本:7行代码只有1行是我独立写的!没有Google的话,查看man dateman echo也可以解决其中一部分问题,不过文件扩展名部分估计又得花较长时间。

仔细分析一下,之前的成就感荡然无存。

更多的例子

我相信,过几周我再来写这样一个简单的脚本时,上面那一幕还是会出现。开发者的IDE的外延已经将GoogleStack Overflow集成了。很难想象没有这两个IDE的插件我要怎样工作。

其实除此之外,日常工作中这样的事情每时每刻都在发生:

  1. Ansible里如何创建一个给用户robot读写权限的目录?
  2. Python 3中启动简单HTTPServer的命令是?
  3. Spring Boot的Gradle String是?
  4. Mongodb中如何为用户robot授权?
  5. Gulp里一个Task依赖另一个Task怎么写?

等等等等,这个列表可以根据你的技术栈,偏向前端/后端的不同而不同,但是相同的是在GoogleStack Overflow上搜索,阅读会浪费很多时间,而这些本来都是可以避免的。

肌肉记忆

大脑在对信息存储上有很高级的设计,如果某件事情不值得记忆,大脑会自动过滤掉(比如我们很容易获得的搜索结果)。而对于那些频繁发生,计算结果又不会变化的信息,大脑会将其下放到“更低级别”的神经去记忆。比如各种运动中的肌肉记忆,习武之人梦寐以求的“拳拳服膺”,“不期然而然,莫知之而至”。

这里也有两个小例子:

一个C语言的小程序

上周末我买了一个茶轴的机械键盘,打开包装之后我很兴奋,赶紧插在我的笔记本上,打开一个编辑器,心说敲一些代码体验一下。几秒钟后,我发现敲出来的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
# include <stdio.h>
# include <stdlib.h>

int main(int argc, char *argv[]) {
  if(argc != 3) {
      fprintf(stderr, "Usage: %s ip port\n", argv[0]);
      return -1;
  }

  fprintf(stdout, "Connecting to %s %d\n", argv[1], atoi(argv[2]));

  return 0;
}

然后在命令行里

1
2
3
4
5
6
$ gcc -o hello hello.c
$ ./hello
Usage: ./hello ip port

$ ./hello 10.180.1.1 9999
Connecting to 10.180.1.1 9999

整个过程极为流畅,上一次开发C代码已经是4年多前了。也就是说,我的手指已经记下了所有的这些命令:

  1. Linux下main函数的convention
  2. fprintf的签名
  3. stderr/stdout用法的区分
  4. main函数不同场景的返回值
  5. gcc命令的用法

另外一个小例子是vim编辑器。我使用vim已经有很多年了,现在在任何一个Linux服务器上,编辑那些/etc/nginx/nginx.conf之类的配置文件时,手指就会自动的找到快捷键,自动的完成搜索,替换,跳转等等操作。

刻意练习

对比这两个例子,一方面我惊讶于自己目前对搜索引擎、Stack Overflow的依赖;一方面惊讶于肌肉记忆力的深远和神奇。结合一下两者,我发现自己的开发效率有望得到很大的提升。

比如上面列出的那些略显尴尬的问题,如果我的手指可以自动的敲出这些答案,那么节省下的搜索、等待、阅读的时间就可以用来干别的事情,比如跑步啊,骑车啊,去驾校学车被教练骂啊等等,总之,去过自己的生活。

这方面的书籍,博客都已经有很多,比如我们在ThoughtWorks University里实践的Code KataJavaScript DojoTDD Dojo之类,都已经证明其有效性。

如果你打算做一些相关的练习,从Kata开始是一个不错的选择。每个Kata都包含一个简单的编程问题,你需要不断的去练习它(同一个题目做20遍,50遍等)。前几次你是在解决问题本身,慢慢就会变成在审视自己的编程习惯,发现并改进(比如快捷键的使用,语法的熟悉程度等等),这样在实际工作中你会以外的发现自己的速度变快了,而且对于重构的信心会变大很多。其实道理也很简单:如果你总是赶着deadline来完成任务,怎么会有时间来做优化呢?

这里有一些参考资料和Kata的题目,可供参考:

为故障和失败做设计

为故障和失败做设计

先来看一个笑话:

QA工程师走进酒吧,要了一杯啤酒,要了0杯啤酒,要了999999999杯啤酒,要了一只蜥蜴,要了-1杯啤酒,要了一个sfdeljknesv,酒保从容应对,QA工程师 很满意。接下来,一名顾客来到了同一个酒吧,问厕所在哪,酒吧顿时起了大火,然后整个建筑坍塌了。

事实上,无论我们事先做多么详尽的计划,我们还是会失败。而且大多数时候,失败、故障都会从一个我们无法预期的角度发生,令人猝不及防。

如果没有办法避免失败(事先要考虑到这么多的异常情况不太现实,而且会投入过多的精力),那么就需要设计某种机制,使得当发生这种失败时系统可以将损失降低到最小。

另一方面,系统需要具备从灾难中回复的能力。如果由于某种原因,服务进程意外终止了,那么一个watchdog机制就会非常有用,比如supervisord就可以用来保证进程意外终止之后的自动启动。

1
2
3
4
5
6
7
8
[program:jigsaw]
command=java -jar /app/jigsaw.jar
startsecs=0
stopwaitsecs=0
autostart=true
autorestart=true
stdout_logfile=/var/log/jigsaw/app.log
stderr_logfile=/var/log/jigsaw/app.err

在现实世界中,设计一个无缺陷的系统显然是不可能的,但是通过努力,我们还是有可能设计出具有弹性,能够快速失败,从失败中恢复的系统来。

错误无可避免

令人担心的错误处理

我们先来看两个代码片段,两段代码都是在实现一个典型的Linux下的Socket服务器:

1
2
3
4
5
6
7
8
9
10
int main (int argc, char *argv[]) {
  int serversock;
  struct sockaddr_in server;

  serversock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)
  setup_sockaddr(&server);
  bind(serversock, (struct sockaddr *) &server, sizeof(server));
  listen(serversock, MAXPENDING)
  //...
}

如果加上现实中可能出现的各种的处理,代码会变长一些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int main (int argc, char *argv[]) {
  int serversock;
  struct sockaddr_in server;

  if (argc != 2) {
    fprintf(stderr, "USAGE: server <port>\n");
    exit(-1);
  }

  if ((serversock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
    fprintf(stderr, "Failed to create socket\n");
    exit(-1);
  }

  setup_sockaddr(&server);
  if (bind(serversock, (struct sockaddr *) &server,
                               sizeof(server)) < 0) {
    fprintf(stderr, "Failed to bind the server socket\n");
    exit(-1);
  }

  if (listen(serversock, MAXPENDING) < 0) {
    fprintf(stderr, "Failed to listen on server socket\n");
    exit(-1);
  }

  //...
}

早在上学的时候,我在编写程序时就非常害怕处理错误情况。一方面加入错误处理会导致代码变长、变难看,另一方面是担心有遗漏掉的点,更多的则是对复杂多变的现实环境中不确定性的担忧。

每当写这样的代码时,我都会陷入深深的焦虑:如果真的出错了怎么办?事实上我也经常会遇到错误,比如命令行参数没有写对,绑定一个 已经被占用的端口,磁盘空间不足等等。工作之后,这些烦人的问题其实也并不经常出现。偶尔出现时我们也有很好的日志来帮助定位,最后问题总会解决,不过那种对不确定性的担心仍然深藏心底。

UDP协议

早在大学网络课上,我就已经对不靠谱的UDP协议非常不满了:作为一个网络协议,竟然不能保证数据可靠的传送到网络的另一端,如果数据没有丢失,也无法保证次序。这种有点不负责任的协议我从来不用,甚至在做练习时都会将其自动过滤,不管那种编程语言,我都会优先考虑TCP

不过在学习网络视频传输的时候,我发现很多时候人们都会采用UDP。另外很多场景下,比如最早的QQ也都使用了UDP来作为内网穿透等设计者可能都没有考虑到的功能。

事实上,这种看似不靠谱的协议在很多IM软件中都在采用(混合模式),比如Skype:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
lsof -i -n | grep -i skype | awk '{print $1, $8, $9}'
Skype TCP 192.168.0.101:52093->91.190.219.39:12350
Skype UDP 127.0.0.1:50511
Skype TCP 192.168.0.101:53090->40.113.87.220:https
Skype UDP *:*
Skype TCP 192.168.0.101:52240->64.4.61.220:https
Skype TCP 192.168.0.101:14214
Skype UDP *:63639
Skype UDP 192.168.0.101:14214
Skype TCP 192.168.0.101:52544->168.63.205.106:https
Skype TCP 192.168.0.101:52094->157.55.56.145:40032
Skype TCP 192.168.0.101:52938->40.113.87.220:https
Skype TCP 192.168.0.101:53091->40.113.87.220:https
Skype TCP 192.168.0.101:53092->40.113.87.220:https

这种简单,不保证可靠性的协议有强大的适应性,在大部分网络环境都是适用的。在工程中,人们会将它和TCP混合适用,在诸如视频,语音的传输中,小规模的丢包,失序都是可以接受的,毕竟还有人类大脑这样的高级处理器负责纠正那些网络错误。

处理失败的模式

超时机制

对于网络上的第三方依赖,你无法预料它的响应延迟是什么样子的,它可能每秒钟可以处理10000次请求而游刃有余,也可能在处理100个并发时就会无限期阻塞,你需要为这种情况有所准备。

nginx通常被用作一个网关,它总是处于请求的最前端,因此其中有很多关于超时的设置:

1
2
3
4
5
6
7
8
9
10
location /api {
  proxy_pass http://api.backend;
  proxy_connect_timeout 500s;
  proxy_read_timeout 500s;
  proxy_send_timeout 500s;

  proxy_set_header        X-Real-IP $remote_addr;
  proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header        Host $http_host;
 }

比如上面/api这个虚拟host中就有连接超时,读超时,后端写超时等设置。在实际环境中,Fail Fast是对无法预料错误的最好处理方法。缓慢的处理会阻塞其他请求,并很快堆积,然后耗尽系统资源。

系统超时配置只是一部分,在你自己的代码中也应该为所有网络依赖加上合适的超时机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Observable<Staff> fetchUserByName(String name) {
    String url = JIGSAW_ENDPOINT + name;

    Client client = ClientBuilder.newClient(new ClientConfig());
    client.property(ClientProperties.CONNECT_TIMEOUT, 10);
    client.property(ClientProperties.READ_TIMEOUT,    10);

    Invocation.Builder request = client.target(url).request(MediaType.APPLICATION_JSON);

    Observable<Staff> staff;

    try {
        staff = Observable.just(request.get(Staff.class));
    } catch (Exception ex) {
        staff = Observable.just(null);
    }

    return staff;
}

回退机制

如果应用程序无法获得正常的响应,那么提供优雅的回退机制在大多数情况下是必须的,而且这样做通常也不会很复杂。以Netflix的Hystrix库为例,如果一个异步命令失败(比如网络异常,超时等),它提供Fallback机制来返回客户端一个默认实现(或者一份本地缓存中的数据)。

1
2
3
4
5
6
7
8
@HystrixCommand(fallbackMethod = "getDefaultStaffInfo")
public Staff getStaffInfo(String loginName) {
  //fetch from remote server
}

public Staff getDefaultStaffInfo(String loginName) {
  return new Staff();
}

熔断器

Circuit Breaker

熔断器模式指当应用在依赖方响应过慢或者出现很多超时时,调用方主动熔断,这样可以防止对依赖方造成更严重的伤害。过一段时间之后,调用方会以较慢的速度开始重试,如果依赖方已经恢复,则逐步加大负载,直到恢复正常调用。如果依赖方还是没有就绪,那就延长等待时间,然后重试。这种模式使得系统在某种程度上显现出动态性和智能。

Netflix的Hystrix库已经提供了这种能力,事实上,如果你使用Spring Cloud Netfilx,这个功能是内置在系统中的,你只需要一些注解就可以让系统具备这样的能力:

1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableCircuitBreaker
public class Application {

    public static void main(String[] args) {
      SpringApplication.run(Application.class, args);
    }

}

如果5秒内连续失败了20次,Hystrix会进入熔断模式,后续的请求不会再发送。过一段时间之后,Hystrix又会逐步尝试恢复负载。

后记

扩展阅读:

技术文章:

保护你的API(下)

前后端分离之后

前后端分离之后,在部署上通过一个反向代理就可以实现动静态分离,跨域问题的解决等。但是一旦引入鉴权,则又会产生新的问题。通常来说,鉴权是对于后台API/API背后的资源的保护,即未经授权的用户不能访问受保护资源

要实现这个功能有很多种方式,在应用程序之外设置完善的安全拦截器是最常见的方式。不过有点不够优雅的是,一些不太纯粹的、非功能性的代码和业务代码混在同一个代码库中。

另一方面,各个业务系统都可能需要某种机制的鉴权,所以很多企业都会搭建SSO机制,即Single Sign-On。这样可以避免人们在多个系统创建不同账号,设置不同密码,不同的超时时间等等。如果SSO系统已经先于系统存在了很久,那么新开发的系统完全不需要自己再配置一套用户管理机制了(一般SSO只会完成鉴权鉴别的部分,授权还是需要各个业务系统自行处理)。

本文中,我们使用基础设施(反向代理)的一些配置,来完成保护未授权资源的目的。在这个例子中,我们假设系统由这样几个服务器组成:

系统组成

这个实例中,我们的系统分为三部分

  1. kanban.com:8000(业务系统前端)
  2. api.kanban.com:9000(业务系统后端API)
  3. sso.kanban.com:8100 (单点登录系统,登陆界面)

前端包含了HTML/JS/CSS等资源,是一个纯静态资源,所以本地磁盘即可。后端API则是一组需要被保护的API(比如查询工资详情,查询工作经历等)。最后,单点登录系统是一个简单的表单,用户填入用户名和密码后,如果登录成功,单点登录会将用户重定向到登录前的位置。

我们举一个具体场景的例子:

  1. 未登录用户访问http://kanba.com:8000/index.html
  2. 系统会重定向用户到http://sso.kanban.com:8100/sso?return=http://kanba.com:8000/index.html
  3. 用户看到登录页面,输入用户名、密码登录
  4. 用户被重定向回http://kanba.com:8000/index.html
  5. 此外,index.html页面上的app.jsapi.kanban.com:9000的访问也得到了授权

环境设置

简单起见,可以通过修改/etc/hosts文件来设置服务器环境:

1
2
3
127.0.0.1 sso.kanban.com
127.0.0.1 api.kanban.com
127.0.0.1 kanban.com

nginx及auth_request

反向代理nginx有一个auth_request的模块。在一个虚拟host中,每个请求会先发往一个内部location,这个内部的location可以指向一个可以做鉴权的Endpoint。如果这个请求得到的结果是200,那么nginx会返回用户本来请求的内容,如果返回401,则将用户重定向到一个预定义的地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
server {
    listen 8000;
    server_name kanban.com;

    root /usr/local/var/www/kanban/;

    error_page 401 = @error401;

    location @error401 {
        return 302 http://sso.kanban.com:8100/sso?return=$scheme://$http_host$request_uri;
    }

    auth_request /api/auth;

    location = /api/auth {
        internal;

        proxy_pass http://api.kanban.com:9000;

        proxy_pass_request_body     off;

        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        if ($http_cookie ~* "w3=(\w+)") {
            set $token "$1";
        }

        proxy_set_header X-KANBAN-TOKEN $token;
    }
}

比如上面这个例子中,auth_request的URL为/api/auth,它是一个内部的location,外部无法访问。在这个locaiton中,请求会被转发到http://api.kanban.com:9000,根据nginx的正则语法,请求将会被转发到http://api.kanban.com:9000/api/auth(我们随后可以看到这个Endpoint的定义)。

我们设置了请求的原始头信息,并禁用了request_body,如果cookie中包含了w3=(\w+)字样,则将这个w3的值抽取出来,并赋值给一个X-KANBAN-TOKEN的HTTP头。

权限Endpoint

对应的/api/auth的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/auth")
public class AuthController {

    @RequestMapping
    public ResponseEntity<String> simpleAuth(@RequestHeader(value="X-KANBAN-TOKEN", defaultValue = "") String token) {
        if(StringUtils.isEmpty(token)) {
            return new ResponseEntity<>("Unauthorized", HttpStatus.UNAUTHORIZED);
        } else {
            return new ResponseEntity<>("Authorized", HttpStatus.OK);
        }
    }
}

如果HTTP头上有X-KANBAN-TOKEN且值不为空,则返回200,否则返回401。

当这个请求得到401之后,用户被重定向到http://sso.kanban.com:8100/sso

1
2
3
4
5
error_page 401 = @error401;

location @error401 {
    return 302 http://sso.kanban.com:8100/sso?return=$scheme://$http_host$request_uri;
}

SSO组件(简化版)

这里用sinatra定义了一个简单的SSO服务器(去除了实际的校验部分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
require 'sinatra'
require 'uri'

set :return_url, ''
set :bind, '0.0.0.0'

get '/sso' do
    settings.return_url = params[:return]
    send_file 'public/index.html'
end

post '/login' do
  credential = params[:credential]
  # check credential against database
  uri = URI.parse(settings.return_url)
  response.set_cookie("w3", {
      :domain => ".#{uri.host}",
      :expires => Time.now + 2400,
      :value => "#{credential['name']}",
      :path => '/'
      })
  redirect settings.return_url, 302
end

/sso对应的Login Form是:

1
2
3
4
5
<form action="/login" method="post">
  <input type="text" name="credential[name]" />
  <input type="password" name="credential[password]" />
  <input type="submit">
</form>

当用户提交表单之后,我们只是简单的设置了cookie,并重定向用户到跳转前的URL。

前端页面

这个应用的前端应用非常简单,我们只需要将这些静态文件放到/usr/local/var/www/kanban目录下:

1
2
3
4
5
6
$ tree /usr/local/var/www/kanban

├── index.html
└── scripts
    ├── app.js
    └── jquery.min.js

其中index.html中引用的app.js会请求一个受保护的资源:

1
2
3
4
5
$(function() {
  $.get('/api/protected/1').done(function(message) {
      $('#message').text(message.content);
  });
});

从下图中的网络请求可以看到重定向的流程:

redirection

总结

本文我们通过配置反向代理,将多个Endpoint组织起来。这个过程可以在应用程序中通过代码实现,也可以在基础设施中通过配置实现,通常来讲,如果可以通过配置来实现的,就尽量将其与负责业务逻辑的代码隔离出来。这样可以保证各个组件的独立性,也可以使得优化和定位问题更加容易。

完整的代码可以在这里下载: