I code it

Code and Life

敏捷团队里的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可能会调整测试策略,调整测试优先级,完善测试用例等等。

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

万能的QA?

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

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

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

扩展阅读

如何设计一次培训

培训元模式

最近在帮客户设计一个微服务进阶版培训的材料,整理的过程中我意识到这类事情我已经做过好多次了。比如在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组织起来。这个过程可以在应用程序中通过代码实现,也可以在基础设施中通过配置实现,通常来讲,如果可以通过配置来实现的,就尽量将其与负责业务逻辑的代码隔离出来。这样可以保证各个组件的独立性,也可以使得优化和定位问题更加容易。

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

保护你的API(上)

保护你的API

在大部分时候,我们讨论API的设计时,会从功能的角度出发定义出完善的,易用的API。而很多时候,非功能需求如安全需求则会在很晚才加入考虑。而往往这部分会涉及很多额外的工作量,比如与外部的SSO集成,Token机制等等。

这篇文章会以一个简单的例子,从应用程序和部署架构上分别讨论几种常见的模型。这篇文章是这个系列的第一篇,会讨论两个简单的主题:

  • 基于Session的用户认证
  • 基于Token的RESTful API(使用Spring Security)

使用Session

由于HTTP协议本身是无状态的,服务器需要某种机制来区分每个请求。比如在返回给客户的响应中加入一些ID,客户端再次请求时带上这个ID,这样服务器就可以区分出来每个请求,并完成事务性的操作(完成订单的创建,更新,商品派送等等)。

在多数Web容器中,这种机制通过Session来实现。Web容器会为每个首次请求创建一个Session,并将Session的ID以浏览器Cookie的方式返回给客户端。客户端(常常是浏览器)在后续的请求中带上这个Session的ID来表明自己的身份。这种机制同样被用在了鉴权方面,用户登录系统之后,系统分配一个Session ID给他。

除非Session过期,或者用户从客户端的Cookie中主动删了Session ID,否则在服务器端来看,用户的信息会和这个Session绑定起来。后台系统也可以随时知道请求某个资源的真实用户是谁,并以此来判断该用户时候真的有权限这么做。

1
2
3
4
5
6
HttpSession session = request.getSession();
String user = (String)session.getAttribute("user");

if(user != null) {
    //
}

Session的问题

这种做法在小规模应用中工作良好,随着用户的增多,企业往往需要部署多台服务器形成集群来对外提供服务。在集群模式下,当某个节点挂掉之后,由于Session默认是保存在部署Web容器中的,用户会被误判为未登录,后续的请求会被重定向到登陆页面,影响用户体验。

这种将应用程序状态内置的方法已经完全无法满足应用的扩展,因此在工程实践中,我们会采用将Session外置的方式来解决这个问题。即集群中的所有节点都将Session保存在一个公用的键值数据库中:

1
2
3
4
@Configuration
@EnableRedisHttpSession
public class HttpSessionConfig {
}

上面这个例子是在Spring Boot中使用Redis来外置Session。Spring会拦截所有对HTTPSession对象的操作,后续的对Session的操作,Spring都会自动转换为与后台的Redis服务器的交互,从而避免节点挂掉之后Session丢失的问题。

1
2
3
spring.redis.host=192.168.99.100
spring.redis.password=
spring.redis.port=6379

如果你跟我一样懒的话,直接启动一个redis的docker container就可以:

1
$ docker run --name redis-server -d redis

这样,多个应用共享这一个实例,任何一个节点的终止、异常都不会产生Session的问题。

基于Token的安全机制

上面说到的场景中,使用Session需要额外部署一个组件(或者引入更加复杂的Session同步机制),这会带来另外的问题,比如如何保证这个节点的高可用,除了Production环境之外,Staging和QA环境也需要这个组件的配置、测试和维护。

很多项目现在会采用另外一种更加简单的方式:基于Token的安全机制。即不使用Session,用户在登陆之后,会获得一个Token,这个Token会以HTTP Header的方式发送给客户,同样,客户再后续的资源请求中也需要带着这个Token。通常这个Token还会有过期时间的限制(比如只能使用1周,一周之后需要重新获取)。

基于Token的机制更加简单,和RESTful风格的API一起使用更加自然,相较于传统的Web应用,RESTful的消费者可能是人,也可能是Mobile App,也可能是系统中另外的Service。也就是说,并不是所有的消费者都可以处理一个登陆表单!

Restful API

我们通过一个实例来看使用Spring Security保护受限制访问资源的场景。

对于Controller:

1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/protected")
public class ProtectedResourceController {

    @RequestMapping("/{id}")
    public Message getOne(@PathVariable("id") String id) {
        return new Message("Protected resource "+id);
    }
}

我们需要所有请求上都带有一个X-Auth-Token的Header,简单起见,如果这个Header有值,我们就认为这个请求已经被授权了。我们在Spring Security中定义这样的一个配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.
            csrf().disable().
            sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).
            and().
            authorizeRequests().
            anyRequest().
            authenticated().
            and().
            exceptionHandling().
            authenticationEntryPoint(new RestAuthenticationEntryPoint());
}

我们使用SessionCreationPolicy.STATELESS无状态的Session机制(即Spring不使用HTTPSession),对于所有的请求都做权限校验,这样Spring Security的拦截器会判断所有请求的Header上有没有”X-Auth-Token”。对于异常情况(即当Spring Security发现没有),Spring会启用一个认证入口:new RestAuthenticationEntryPoint。在我们这个场景下,这个入口只是简单的返回一个401即可:

1
2
3
4
5
6
7
8
9
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException ) throws IOException {
        response.sendError( HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized" );
    }
}

这时候,如果我们请求这个受限制的资源:

1
2
3
4
5
6
7
8
$ curl http://api.kanban.com:9000/api/protected/1 -s | jq .
{
  "timestamp": 1462621552738,
  "status": 401,
  "error": "Unauthorized",
  "message": "Unauthorized",
  "path": "/api/protected/1"
}

过滤器(Filter)及预认证(PreAuthentication)

为了让Spring Security可以处理用户登录的case,我们需要提供一个Filter。当然,Spring Security提供了丰富的Filter机制,我们这里使用一个预认证的Filter(即假设用户已经在别的外部系统如SSO中登录了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class KanBanPreAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {
    public static final String SSO_TOKEN = "X-Auth-Token";
    public static final String SSO_CREDENTIALS = "N/A";

    public KanBanPreAuthenticationFilter(AuthenticationManager authenticationManager) {
        setAuthenticationManager(authenticationManager);
    }

    @Override
    protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        return request.getHeader(SSO_TOKEN);
    }

    @Override
    protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        return SSO_CREDENTIALS;
    }
}

过滤器在获得Header中的Token后,Spring Security会尝试去认证用户:

1
2
3
4
5
6
7
8
9
10
11
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
    builder.authenticationProvider(preAuthenticationProvider());
}

private AuthenticationProvider preAuthenticationProvider() {
    PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
    provider.setPreAuthenticatedUserDetailsService(new KanBanAuthenticationUserDetailsService());

    return provider;
}

这里的KanBanAuthenticationUserDetailsService是一个实现了Spring Security的UserDetailsService的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class KanBanAuthenticationUserDetailsService
        implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {

    @Override
    public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {
        String principal = (String) token.getPrincipal();

        if(!StringUtils.isEmpty(principal)) {
            return new KanBanUserDetails(new KanBanUser(principal));
        }

        return null;
    }
}

这个类的职责是,查看从KanBanPreAuthenticationFilter返回的PreAuthenticatedAuthenticationToken,如果不为空,则表示该用户在系统中存在,并正常加载用户。如果返回null,则表示该认证失败,这时根据配置,Spring Security会重定向到认证入口RestAuthenticationEntryPoint

加上这个过滤器的配置之后:

1
2
3
4
5
6
7
8
9
10
@Override
protected void configure(HttpSecurity http) throws Exception {
    //...
    http.addFilter(headerAuthenticationFilter());
}

@Bean
public KanBanPreAuthenticationFilter headerAuthenticationFilter() throws Exception {
    return new KanBanPreAuthenticationFilter(authenticationManager());
}

这样,当我们在Header上加上X-Auth-Token之后,就会访问到受限的资源了:

1
2
3
4
$ curl -H "X-Auth-Token: juntao" http://api.kanban.com:9000/api/protected/1 -s | jq .
{
  "content": "Protected resource for 1"
}

总结

下一篇文章会以另外一个方式来完成鉴权机制和系统的集成问题。我们会在反向代理中做一些配置,将多个Endpoint组织起来。要完成这样的功能,使用Spring Security也可以做到,不过可能会为应用程序本身引入额外的复杂性。

CI上的Web前端性能测试

Web站点的响应速度

雅虎在2006年就发布了一份提升Web站点响应速度的最佳实践指南。该指南包含了35条规则,分为7个类别。这些规则已经被广泛使用,并指导人们在新的站点设计时更有针对性的考虑问题。这份指南已经成了Web前端性能度量的一个事实标准了。

YSlow是一个基于这份指南的测试工具,它可以测试一个站点是否“慢”,以及为什么“慢”?你可以通过很多方式来使用YSlow,比如Firefox,Chrome的插件,命令行工具,甚至PhantomJS这样的无头(Headless)浏览器。YSlow会检测你的站点中的资源是否没有压缩,是否缺失了超时设置,更进一步,它还会检测你的JS/CSS是否已经压缩/精简化,图片的尺寸,是否使用了CDN等等很多的维度。它还可以生成很多格式的报告,比如打分信息,TAP协议的输出,以及junit测试报告的格式。

我们这里讨论如何在持续集成服务器上设置一个YSlow任务,这个任务会在每次构建之后,测试你应用的性能指标,以帮助你更快的发现和定位问题。当然,我推荐你在staging环境,很多开发者在测试环境,本地开发环境都会启动很多对Debug友好的设置,比如未压缩的JS/CSS,没有超时设置的响应等,这会导致该构建任务的打分不够准确。

jenkins failure

搭建CI环境

按照传统方式,如果要搭建一个这样的CI任务,我们需要至少做这样一些事情:

然后设置环境变量,在Jenkins上创建任务,并运行YSlow.js脚本。这个任务很简单,只需要设置好参数,然后将结果输出为Jenkins上的报告即可。比如:

1
2
$ phantomjs /var/share/yslow.js -i grade -threshold "B" -f junit \
http://bookmarks-frontend.s3-website-us-west-2.amazonaws.com/ > yslow.xml
  • -i grade 展示打分(grade)信息(还可以是basic/stats/all)等
  • -threshold "B" 指定失败的阈值为B
  • -f junit 输出为junit识别的XML格式

这里的阈值可以是数字(0-100分),字母(A-F级别)或者一个JSON字符串(混合使用)

1
-threshold '{"overall": "B", "ycdn": "F", "yexpires": 85}'

上面的命令会测试站点http://bookmarks-frontend.s3-website-us-west-2.amazonaws.com/的各项指标,并应用雅虎的那35条规则,并最终生成一个junit测试报告格式的文件:yslow.xml

但是维护CI环境是一个比较麻烦的事情,而且既然每个项目都可能会用到这样的基础设施,一种好的做法就是将其做成一个镜像保存起来,以方便其他项目的复用!这里我们使用docker来安装和配置我们的CI环境,配置完成之后,我们可以将docker的镜像分享给其他团队,也可以供我们在下一个项目中使用。

基于docker/docker-compose的环境搭建

docker出现之前,我们要搭建一个测试或者staging环境,往往需要很多个不同角色的机器:有专门的数据库服务器,文件服务器,缓存服务器,Web服务器,反向代理等等。这样在成本上显然是个不小的开销,如果将所有不同的组件部署在同一台机器上,则又可能互相干扰,只需要一个小小的失误,整个系统就需要重新配置。更可怕的是,这个环境和生产系统不一致,那么很可能真实的错误要等到系统上线之后才会被发现。

比如在2012年,我所在的一个项目中,客户的系统采用传统的J2EE架构。本地开发中,我们使用了Jetty作为容器,而测试Staging环境使用了Tomcat。由于Tomcat对空格的处理和Jetty有所不同,我们在本地测试通过,并且运行良好的代码,在Staging变得完全不能工作。这个问题花费了团队很多时间来排查错误。

docker出现之后,我们可以在一台物理机器上运行多个互不干涉的容器,每个容器可以是一个组件(比如运行数据库的容器,Web服务器容器等等)。这样不但缩减了成本,而且可以让我们的环境尽可能和生产环境一致(有的项目甚至直接将CI出来的镜像应用到生产中)。不过对多个容器的管理是一个很麻烦的事情,好在docker提供了docker-compose工具来解决这个问题。使用docker-compose可以定义一组互相独立,但是又可以协作在一起的容器,这样我们可以很容易的搭建一个仿生产环境。

比如我们可以定义个docker-compse.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
app:
  build: .
  links:
    - db:postgres
  ports:
    - 8000:8000
  volumes:
    - .:/app
  working_dir: /app
  entrypoint: /app/start.sh
  environment:
    JDBC_DATABASE_URL: jdbc:postgresql://postgres:5432/bookmarks
    DATABASE_USER: bookmarks-user
    DATABASE_PASS: bookmarks-password

db:
  image: postgres:9.3
  ports:
    - 5432:5432
  environment:
    POSTGRES_DB: bookmarks
    POSTGRES_USER: bookmarks-user
    POSTGRES_PASSWORD: bookmarks-password

这个docker-compose定义了两个组件,appdbdb使用了postgres:9.3镜像,并设置了自己的环境变量。app则从当前目录.构建一个新的镜像,appdb通过links属性连接起来。

如果在当前目录执行docker-compose build命令,docker-compose会找到本地的Dockerfile,然后构建出一个docker的镜像,并启动该容器,同时,它还会启动postgres:9.3容器作为数据库组件。这样我们的环境就被完整的搭建好了。

搭建CI环境

1
2
3
4
5
6
7
app:
  build: .
  ports:
    - 8080:8080
    - 50000:50000
  volumes:
    - ./data:/var/jenkins_home

这个配置,表明我们会根据当前目录的Dockerfile来构建一个镜像。

通过命令volumns,我们将本地目录./data映射为jenkins_home,这样我们定义的job信息,以及插件的安装都会放到本地的目录中,方便管理。配置完成之后,构建并启动该容器:

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
FROM jenkins:latest

# Env
ENV PHANTOMJS_VERSION 1.9.6
ENV PHANTOMJS_YSLOW_VERSION 3.1.8
ENV SHARE_BIN /var/share

# Install stuff by using root
USER root
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y git wget libfreetype6 libfontconfig bzip2

RUN mkdir -p $SHARE_BIN

RUN wget -q --no-check-certificate -O /tmp/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 \
https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2
RUN tar -xjf /tmp/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 -C /tmp
RUN rm -f /tmp/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2
RUN mv /tmp/phantomjs-$PHANTOMJS_VERSION-linux-x86_64/ $SHARE_BIN/phantomjs
RUN ln -s $SHARE_BIN/phantomjs/bin/phantomjs /usr/bin/phantomjs

RUN wget -q --no-check-certificate -O /tmp/yslow-phantomjs-$PHANTOMJS_YSLOW_VERSION.zip \
http://yslow.org/yslow-phantomjs-$PHANTOMJS_YSLOW_VERSION.zip
RUN unzip /tmp/yslow-phantomjs-$PHANTOMJS_YSLOW_VERSION.zip -d $SHARE_BIN/
USER jenkins

执行下面的命令来设置并启动CI服务器:

1
docker-compose up

jenkins in docker

创建新任务,并指定该任务执行的命令为:

1
2
$ phantomjs /var/share/yslow.js -i grade -threshold "B" -f junit \
http://bookmarks-frontend.s3-website-us-west-2.amazonaws.com/ > yslow.xml

由于此时phantomjs已经被安装到了容器中,我们可以直接在jenkins中使用。运行结束之后,这个命令会生成一个报告:

jenkins failure

  • 没有压缩内容
  • 没有添加过期的头信息

在产品环境,我们需要使用反向代理来添加这些头信息,以保证最终用户在使用Web站点时的体验。

总结

我们只需要很少的配置就可以设置好一个工作良好的CI任务,这样在程序员某天引入了未经压缩的JS/CSS或者UX不小心提供了巨大而未经处理的图片时,你可以尽快的得到通知,并在正是上线之前发现这些问题。

代码配置在这里。

技术的执念

知识漩涡

只需稍加留意,我们就会发现自己被各种技术,工具包围。ThoughtWorks的技术雷达差不多每隔半年就会更新一次,而项目中更是会遇到很多已经从技术雷达上消失的技术,项目上的旧技术/旧框架还在服役,新的技术/工具/语言/框架又在迅速的出现,有些昙花一现,迅速被新的后来者取代,有的留下来了,不过也都在不断的演化,改变(不兼容的API,不同的版本等等随处可见)。

如果你不幸是一个前端工程师,那么这个更新速度还要更加迅速。三年前Backbone是主流,两年前是Angular.js,去年是React,紧接着FluxReflux等作为React的扩展而成为了新的主流;Grunt流行过一段时间,很快被Gulp替代,而新的Webpack又依稀有大一统的趋势。每周几乎都能看到新的框架涌现,双向绑定,虚拟DOM,事件代理,同构,后端渲染,更友好的语法糖,更快的执行速度等等等等,几乎任何一个方向都有无穷无尽的变化。

full stack js

而后端也好不到哪里去,容器技术,Web框架,ORM,构建脚本,自动化测试工具,依赖管理,应用服务器等等,你总有很多的选项,却又无法在事先区分到底哪个技术/工具更靠谱,更适合项目。

置身其中,往往有眼花缭乱,应接不暇的感觉。知识工作者当然需要终身不断的学习,但是像目前这种节奏,我很怀疑这是一种健康的状态。周围经常有人抱怨,好不容易上手了一个前端的MVC框架,一看周围的项目,大家已经在spike另外的框架/工具了(这意味着你在项目上无法使用该框架了……)。仅仅从学习的速度上来讲,我们已经远远无法跟上科技演化的节奏了,这是人类自身的一种限制。

知识的陷阱

假设你在一个Ruby项目上,学习了Rails/ActiveRecord/RSpec/MySQL。如果下一个项目还是Ruby,同样的技术站,你会觉得这是一种重复,因为除了业务逻辑、业务对象变化了之外,并没有新的内容,还是同样的技术。如果下一个项目是Python,技术栈变成了Django/nose/PostgreSQL,你可能会觉得有所提升,因为学到了不同的技术,框架,共建工具,测试工具等等,其实仔细观察,这还是一种重复,古人云:“换汤不换药”者,是也。

在目前我们所处的时代,信息以远远超过人们能接受的速度不断的被创造出来,一方面信息传播的速度大大提升了,另一方面是信息传播的渠道也极具多样化。我们无时无刻不被过载的信息包围着,即使你不主动的去尝试获取新的信息,手机App里的微信,微博,Flipboard,Pocket,知乎,开发者头条,Feedly,果壳,丁香园等等的推送已经足以提供给你足够的信息(大部分甚至都来不及消费就变成了历史信息而被忽略)。

以我自己为例,从2015年10月到现在(2016年2月),我学习了很多东西,看一下下面这张图:

tech tree

图中的灰色方框中的内容是项目要求的知识,另外的则是我根据自己的兴趣学习的(两者基本上各占一半)。事实上有很多内容(尤其是根据自己兴趣学习的)在真正要使用时,可能还需要学一遍。这些内容可能让我产生了我学到了好多东西的错觉。其实这个在另一个角度显现了技术人员的一个误区:以为自己可以掌握所有软件开发相关的知识(或者说太过于纵容自己的好奇心和兴趣)。

过载的信息

身处这样的信息过载环境,我们很难不为自己对信息的缺乏而感到不安,担心自己错过了什么重要的信息,这种担心和焦虑会促使我们进一步将时间消耗在对信息的获取上,从而更无暇思考什么是真正重要的。

《如何阅读一本书》将书分为两类:一种是提供资讯/信息(known)的,一种是帮助你理解(understand)信息的。相对于理解来讲,资讯本身其实并不那么重要。我们大部分人目前采用的碎片化的阅读方式无法提供给我们足够的“理解力”。我们都有这样的体验,有些书特别耗费脑力,读起来很累,而另一些书则非常轻松,易于消费。碎片话的阅读方式易于消费,只需要很少的思考就可以读懂,但是危害严重,它们并不会让帮助你提升理解力。

但是直觉上我们会选择容易的事情来做,虽然这种浅层次的阅读只对扩展信息/资讯有帮助,对提升理解力则几乎无用。而我们在处理日常工作中的问题时,能真正帮助的,只有理解了的那部分知识。我在2014年,曾经有几个月屏蔽了所有微信,微博,内容聚合类的应用,也尽量少的去技术论坛,每天就是写代码,读纸质书,除了最初几天的忐忑之外,整个过程的收获非常大(而且也没有漏掉任何重要的信息)。

知识框架

技术人员有时候会有一种想要把所有技术都掌握的执念,这在局外人来看是一种荒诞不经的想法,但是置身其中,你很难看出这一点。毕竟,有意思的东西是在太多了,各种范式的编程语言,编译器技术,人工智能,数据可视化,地理信息系统,嵌入式设备,软硬件结合,大数据,自动化测试等等,每一个方向都有无穷无尽的有意思的东西。

但是在知识规模如此巨大的今天,一个人是无法掌握所有技术的(更不用说新的技术还在不断的涌现出来)!这就要求我们有节制的来聚焦在某些技术上,而视其他技术如无物。当然这需要很大的勇气和魄力,不过唯有如此,技术人员才有可能有真正的长进和成就。

我基于自己的经验,绘制了一个Web开发方面的知识框架,这个框架上包含了一个比较全的技能/知识集合,也是我认为一个Web开发人员应该掌握的一些知识点。

knowledge framework

在成为一个专家之前,你需要先对要学习的领域有一个全面的认识。也就是说,做Web开发,需要尽可能覆盖到这个框架上的所有点。一旦完成了这棵树上的所有节点,就不用再去做第二次了,这时候你可以尝试找到树上的某一个分支,深入下去。这个听起来好像和我之前文章中的观点有所矛盾,其实不然。我在《我们真的缺前端工程师》一文中提到过,工程师不应该将自己束缚在前端开发上,要了解整个软件开发的全生命周期。这里的观点其实是一致的,即首先要了解软件开发全生命周期中的所有节点,然后再有所侧重的去找自己的兴趣点来发展,即:先建立广度,再建立深度。

应对方法

对于知识的陷阱

当因自己的兴趣(而不是项目驱动,也就是没有实际的土壤来验证)而想要学习一个新的知识时,对照知识框架,如果发现你已经在历史上学过它了,那就强迫自己放弃这个念头。比如如果你很熟悉用rspec来编写测试,忽然有一天心血来潮,想要学习JUnit,正确的做法就是泡杯茶,等这种冲动自己过去。相信我,一旦有了Java项目,你可以非常快速的掌握JUnit,而且很快会找到对应的feature,就像一个长期工作在Java技术栈上的同事那样!

对于过载的信息

实践中,首先要令自己相信:你无法掌握所有的知识,即使仅仅在软件开发领域。有了这个大前提之后,你只需要采取先建立广度,再建立深度的原则即可:

  • 做减法(在建立了知识框架之后,有针对性的学习)
  • 主动,深度阅读经典
  • 为那些有趣但非自己关注方向的知识赋予较低的优先级

另外,还可以尝试将微信,微博关闭一段时间,或者至少可以不去点那些朋友圈里的《老X聊微服务》或者《12个你不知道的Sublime技巧》文章,保持专注,保持简单。

不想当UX的开发不是好咨询师

成为咨询师

本文旨在帮助开发完成向咨询师的转变,内容不但涉及向UX学习,还包括思维方式的转变。我尽量采用一些亲历的例子来说明该如何做,也会适当的解释为什么需要这样做。不过在展开详细讨论之前,首先来澄清这里提到的三种角色。

开发(Developer)角色

开发是指那些喜欢写代码,享受写代码,喜欢纯粹,讨厌办公室政治,永远穿T恤的有些偏执的程序员。跟他们打交道,有这样一些注意事项:

  • 不要让他们帮你盗QQ
  • 不要让他们帮你修电脑或者装Windows系统
  • 不要跟他们讨论人文/政治类的问题

开发往往还单纯的可爱,除此之外,他们还有这样一些特点:

  • 逻辑清晰
  • 与人争辩时往往可以通过清晰的逻辑而获胜
  • 单身

业界已经有很多关于开发的描述了,我这里也有一个描述开发的列表:

当然,要严格界定一个人是不是开发是非常困难的,大多数情况下,他们沉默寡言,遇到程序中的bug或者在调试某些库的问题时眼神呆滞,口中念念有词,他们不太喜欢和陌生人说话,在晚上精神充沛,白天则显得有些呆滞,喜欢喝咖啡,相信世界上有绝对的正确和错误,往往会带着非黑即白的二分法来看待事物,生活很难自理,喜欢机械键盘/电子设备,周末宁愿宅在家里写代码也不去做社交……

用户体验设计师(UX)

UX是指用户体验设计师,在本文的上下文中,更偏向与非视觉设计的那些设计师(产品设计师)。在项目中,他们会做用户调研,竞品分析,信息架构简历,交互设计(纸上原型,低保真)等活动,并负责开发纸上原型,验证这些原型等。

UX打交道,也有一些应该注意的点,比如:

  • 不要叫他们美工
  • 不要对他们说诸如:“帮我美化一下这个页面”,“这个颜色得再亮一些”之类的话
  • 不要跟他们讲关于程序员的笑话

事实上,人们对UX的误解很深。提到UX人们的第一反应是PhotoShop,P图/切图。这仅仅是他们日常工作中很小的一部分。大部分UX还要做很多用户研究,信息架构整理的事情。老实说,我在去年5月之前的对UX的认识和大部分开发的认识是一样的,但是在后来的项目上和多个UX合作过之后,我彻底改变了原先那种偏见,开始敬佩他们,并向他们学习。

设计工作可以细分为这样一些不同的方面(图片来源网络):

JJG

UX的一项特别的技能在于能从复杂的现实世界中抽象出清晰的信息(用户画像,体验地图甚至最后的用户故事)。这项技能不但重要,而且还很牛逼。

知识的诅咒

《反脆弱》里有个有意思的例子:人们仅仅创造了非常有限的词汇来描述颜色,比如蓝色,红色,而任何一个视觉正常的人都可以轻松的识别出数百种不同的颜色。也就是说,人们可以很轻松的理解相当复杂的事物,但是很难向别人描述该事物(想象一下向别人描述一只章鱼的颜色)。

人们对于现实世界中的事情(特别是复杂的业务场景)往往只能意会而很难言传,再加上知识的诅咒(我在《如何写一本书》里,详细讨论了这种常见的陷阱)的存在,当用户在描述A的时候,在没有上下文的人听来,很可能是B或者C。这种情况在软件开发中非常常见,也是很多项目之所以延期的原因(大量并无必要的返工,需求澄清等)。

在项目前期,UX需要和客户坐在一起,将客户的需求分析清晰。分析细节包括业务场景,用户画像生成,信息架构,体验地图等等,这些信息并不是天然就显现的,恰恰相反,它们需要UX经过很多轮的辛苦引导,从用户的脑海里提取出来的。

这里需要UX的核心能力是:

  • 有目的的抛出问题,引导客户进行发散
  • 有节奏的收敛,形成共识
  • 不断修正过程中的错误
  • 可视化能力(这可能是大部分人觉得唯一和UX相关的点)

咨询师

咨询师是指那些根据自己的丰富经验来帮助客户解决具体问题的人。这些问题并不一定局限在技术上 —— 比如架构的设计,具体前端/后端技术的选定,还包括一些流程的改善。比如引入新的工程实践来缩减项目的周期时间,帮助团队发现问题,建设团队的能力,作为各个团队间的润滑剂帮助项目成功等等。

咨询师工作中的一个常见的场景是:

  • 列出目前遇到的问题
  • 确定各个问题的优先级(和各个利益方)
  • 制定方案
  • 给方案加上时间,形成计划
  • 细化计划中的条目,并促成它

引导/启发

我在印度的某一期TWU当教练的时候,发现了一个很有意思的现象,国外的同事在组织培训时更强调用引导/启发的方式,让学生们自己得出结论,并在课堂上进行讨论,以期教学相长。只有在过程中有启而不发的情况出现时,教练才会适当抛出自己的开发,并再次启动讨论。

TWU 33

与我一直的认识不同的是,这种方式效果很好。通过一些适当的启发,学生很容易自己讨论出一些有趣的看法,然后教练在这个基础上做一些总结,并帮助他们分析不同看法/想法之间的优劣。

我非常认同这种模式,后来自己组织的其他培训/workshop也都尽量采取这种方式。咨询师在客户现场,也应该采取这种引导的方式帮助团队来完成能力建设,而不是事必躬亲。

角色转化

开发者视角切换到咨询师的第一要诀就是:让团队解决自己遇到的问题!乍听起来,咨询师好像变成一个多余的角色了:既然团队自己可以搞定,还要咨询师干什么呢?咨询师的职责是让团队意识到问题,理清思路,制定解决方案,并逐步实施。

使能/赋能

我们来看一个简单的例子:在客户现场,你发现团队往往在集成时会花费很多额外的时间和返工,开发过程中大家各自为政,没有人知道一次commit会给软件包造成什么影响。

如果你是一个咨询师,应该如何解决这个问题?一个常犯的错误是,直接上手帮助团队搭建持续集成(CI)环境,并设置CI纪律(比如build红了不许过夜,红的时候其他人都不许commit等)。

一种更好的做法是:做为咨询师,首先需要帮助团队认识到这个问题,你需要让所有人都知道,我们现在的问题是什么。在所有人都清楚了这一点之后,你需要提出(或者引导出)持续集成的概念(因为根据经验,这是一种可以很好的解决集成时额外的返工现象的好办法)。

但是对于不熟悉持续集成的团队来说,搭建一个持续集成环境是一个非常复杂的任务。因此你需要分解这个任务为一些更小的,可以被解决的问题。

  • 申请虚拟机资源
  • 安装jenkins(包括安装JVM,创建用户等)
  • 配置本地构建脚本到jenkins(构建脚本,自动化测试等)
  • 申请显示器资源(作为CI Monitor)
  • 将结果显式在CI Monitor上

有了任务之后,你需要分别为这些子任务分配owner。对比搭建持续集成环境这样的大任务,这些小的任务已经非常具体,更重要的是,他可以被团队中任何人理解并解决。

学习做引导

除了思维方式的转变,以及自身过硬的专业技能(比如clean code/重构能力,自动化测试,DevOps,持续交付经验等)之外,开发者需要从UX那里学习如何发现问题,并将问题可视化出来的技能。

当你发现团队面临某个问题是,可以通过组织一个类似头脑风暴的会议来帮助团队梳理:

  • 提出问题
  • 维护会议纪律,保证所有人都贡献自己的想法
  • 将想法/问题归类
  • 找出问题的解决方案
  • 制定计划(包括时间点和owner)

关于如何做引导的详细信息,还可以参考我的上一篇文章

进一步的阅读

除了上边提到的

  1. 思维方式的转变
  2. UX学习引导的技巧

之外,事实上还有很多技巧和内容需要学习:

当我们谈论引导时,我们谈些什么?

什么是引导(facilitation)

引导(facilitation)的词根来源与拉丁语“facil”,意思是“让……更容易”。而负责引导的引导师(facilitator)的核心职责是,通过一系列的活动、技巧,保证引导会议顺畅的进行,并解决整个过程中的问题,使得参与者就问题产生一个共识,达成一个结论。

其中可能涉及很多具体的问题,比如几乎在每个会议中都可能看到的:

  • 如果有人尝试将会议变成一言堂,如何处理?
  • 如果参与者不愿意分享自己的观点,如何处理?
  • 过程中,两个参与者产生了争执,如何处理?
  • 如何把握节奏,刺激与会者发散?
  • 如何在收集到足够信息后,进行收敛?

显然,这是一个技术活儿。一次好的引导可以将与会者的众多想法,信息聚合起来,形成对团队下一步要做什么有极强指导意义的方案

日常的引导活动

在平时的工作中,我们其实已经在频繁的使用引导活动,但是很少有人将其作为体系来关注,也很少有人能将这个能力应用在其他方面(比如在客户现场咨询,或者参加售前等)。引导是如此的常见,以至于我们对其视而不见。比如在interview完成之后,所有面试官和HR一起做的well/less well的列举;各种社区活动(Open Party,CDConf等)之后的回顾;每个项目在一个迭代结束后的Retro;对于某个问题的头脑风暴等等。

项目回顾会议

在开始前,引导师需要保证团队:

  • 每个人都有开发的态度
  • 整个过程需要在一个足够安全的环境中进行(Safe Check)

有时候,有Team Lead在场,新人可能不愿意对某事(比如最近加班有点过分)发表自己的看法等。这时候需要有Safe Check,比如分为1到5档,大家用不记名投票的方式来表述自己是否觉得安全。如果投票结果显示大部分人都觉得不安全,则需要与会的人中,职位最高的那个人离开会议,然后再做一次Safe Check,直到大家都觉得足够安全。不过,对于已经进行过多轮回顾的团队,我们往往会忽略掉这一步。

Retro过程是,团队坐在一起,回顾上一个迭代(通常是两个星期)做过的事情,有哪些做的比较好,哪些有待改进,有哪些疑惑等等。Retro可以有很多的形式,比如简单的Well/Less Well/Questions,更聚焦在产生Action的海星式等等。

pens

通常的顺序是:

  1. 引导者请大家用纸笔将想法写在便签(stick)上
  2. Time box这个过程(通常是5分钟)
  3. 大家将这些stick贴在墙上
  4. 引导者和团队一起过一遍所有的stick
  5. 归类相似的stick
  6. 引导者促进团队交流,讨论stick上的问题,并形成一些改进点(Action)

Action一定要足够具体,并且需要一个所有者,所有者负责确保该Action一定会发生。比如团队发现上一个迭代中Code review做的不够好,一个Action就是每天下午5点有人来提醒大家来进行Code review

如果这时候发现有太多的问题,团队可以用投票的方式选出本次Retro要讨论的数个stick。

引导会议

在日常工作中,我们几乎每天都有会议,而且越来越多的团队已经意识到冗长,无聊的会议有多大的杀伤力了。在很多会议上,与会者要么在刷新朋友圈,要么在对着笔记本电脑写代码或者读新闻,即使强制要求不许带电脑和手机的情况下,也无法限制参加者神游太虚。

根据《引导的秘诀》这本书里的定义,引导会议是

引导会议是一个高度结构化的会议,会议中的领导者(引导者)引导参会人通过预先设定好的步骤达成所有参会人产生,理解并接受的结果。

引导会议需要充分调用参与者的积极性,每个人都需要足够聚焦,这要求引导者可以有能力使得团队振奋(比如幽默的风格,或者具有挑战性的问题等)。另外,每个人的idea都需要被充分重视(一个细节就是不要随意篡改你听到的内容,这是没有经验的引导者常犯的错误之一)。一旦所有参与者都积极起来,引导者就可以稍微退后一些,将舞台交给团队。

而有时候,情景则相反,大家都不发言,也没有看到明显的发言的趋势,这时候需要一些方法来激励。如果是团队都比较茫然,引导者需要列出一些简单而容易理解的步骤,帮助团队按照预设的节奏来逐步前进。比如,在一开始的时候就将agenda板书在墙上,并通过头脑风暴的方式,鼓励参与者来将自己的idea可视化出来。

一个典型的误区是,引导会议的最后结论是本来就存在与引导者脑海中的想法。如果仅从结果来看,这种情况可能发生,但是只能说是碰巧而已。一个好的引导者需要帮助与会者自己产生,并得出一个可行的,被广泛认可的方案,而不是强加一个自己的给团队。

我们最为专业的引导活动是UX团队在客户现场的inceptioninception由一系列相互关联,环环相扣的工作坊组成,这些工作坊基本上都需要采用很多引导的技巧,帮助客户团队将自己的问题描述清楚,并形成一个所有参与者都达成一直的可行方案。

sticks

如果你不知道如何开始一个引导会议,一个简单而通用的模式是:

  1. 我们的现状是
  2. 我们的目标是
  3. 我们如何到达目标
  4. 在行进中,如何度量

《引导的秘诀》里还提到了一种5P模式:目的(Purpose),产出(Product),与会人(Participant),可能的问题(Probable issues)以及流程(Process)。

5P提示你在准备会议之前,需要尝试回答这几个问题

  • 为什么要开这次会议?主要目的是什么?
  • 会议后的产出是什么?
  • 谁需要参与会议?
  • 在会议中,我们可能遇到什么问题?
  • 遇到这些问题是,我们如何解决?

引导中的常用技巧

在引导活动中,有一些基本的规则,可以保证引导会议的顺畅性,比如

  • 引导师需要有足够的权威(可以打断那些长篇大论,保证过程的流畅)
  • 如果人数太多,可以使用token(比如一个玩具考拉,或者一个澳式橄榄球,只有持有token的人可以说话)
  • 保持one conversation(不要交头接耳)
  • 每张stick上只写一条问题/想法

引导师必须有控制会话何时结束的能力,否则引导活动将会变成一发不可收拾的冗长会议。坚持one conversation可以保证参与者足够聚焦,也保证所有人都在同一个频道上。如果发现有交头接耳的,引导者可以直接打断并提醒之。

每张便签上只写一条想法,首先可以保证多样性,便于讨论,也便于后续的分类。另外,简洁的描述在一定程度上可以促进与会者进行讨论,而一个冗长的描述则会让人丧失兴趣。

另外还有一些比较基础的技巧:

  • 所有讨论都应该对事不对人(特别是一些负面的总结)
  • 如果有人提出与议题并不特别相关,但是又特别重要的点时,可以将这些点记下来(不要轻易打击发言者的积极性)
  • 不定时的总结,以确保参与者都在同一频道,并且有助于大家对进度的了解(是不是快结束了)