I code it

Code and Life

生活中的数据可视化之 – 换尿布

数据来源

从女儿心心出生开始,我们就通过各种方式记录她的各种信息:睡眠记录,吃药记录,体温记录,换尿布记录,哺乳记录等等。毕竟,处于忙乱状态的人们是很难精确地回忆各种数字的,特别是在体检时面对医生的询问时。大部分父母无法准确回答小孩上周平均的睡眠时间,或者平均的小便次数,这在很多时候会影响医生的判断。

我和我老婆的手机上都安装了宝宝生活记录(Baby Tracker)(这里强烈推荐一下,免费版就很好用,不过界面下方有个讨厌的广告,我自己买了无广告的Pro版本),这样心心的每次活动我们都会记录下来,很有意思的是这个APP的数据可以以CSV格式导出(这个太棒了!),而且它自身就可以生成各种的报告,报告还可以以PDF格式导出并发送给其他应用。

有了现实世界中的一组数据 – 我们记录的差不多100天的数据,而且正好我最近在复习D3相关的知识,正好可以用来做一些有趣的练习。

数据准备

Baby Tracker导出的数据是一些CSV文件组成是压缩包,解压之后大致结果是这样的:

  • 哺乳记录
  • 睡眠记录
  • 换尿布记录
  • 喂药/体温记录
  • 里程碑记录

我就从最简单换尿布数据记录开始吧。我们首先需要将数据做一些清洗和归一化,这样方便前端页面的计算和渲染。数据处理我一般会选择Python+Pandas的组合,只需要写很少的代码就可以完成任务。

python + pandas

原始数据看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name,date,status,note
心心,2016/11/13 17:00,嘘嘘
心心,2016/11/13 19:48,嘘嘘+便便
心心,2016/11/13 22:23,便便
心心,2016/11/14 00:19,便便,一点点,感觉很稀,穿厚点
心心,2016/11/14 04:33,嘘嘘
心心,2016/11/14 09:20,便便
心心,2016/11/14 11:33,便便
心心,2016/11/14 16:14,便便
心心,2016/11/14 21:12,嘘嘘+便便
心心,2016/11/14 23:12,嘘嘘+便便
心心,2016/11/15 00:32,嘘嘘+便便,有点稀
心心,2016/11/15 03:45,干爽
心心,2016/11/15 07:06,嘘嘘
心心,2016/11/15 10:30,嘘嘘+便便

为了方便展示,我需要将数据统计成这样:

1
2
3
4
date,urinate,stool
2016-11-13,2,2
2016-11-14,3,6
2016-11-15,6,8

我不关心每一天不同时刻换尿布的事件本身,只关心每天中,大小便的次数分布,也就是说,我需要这三项数据:日期当天的小便次数当天的大便次数。这个用pandas很容易就可以整理出来了,status字段的做一个微小的函数转换(当然可以写的更漂亮,不过在这里不是重点,暂时跳过):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import numpy as np
import pandas as pd

diaper = pd.read_csv('data/diaper_data.csv', usecols=['date', 'status'])
diaper['date'] = pd.to_datetime(diaper['date'], format='%Y/%m/%d %H:%M')
diaper.index=diaper['date']

def mapper(key):
    if key == '嘘嘘':
        return pd.Series([1, 0], index=['urinate', 'stool'])
    elif key == '便便':
        return pd.Series([0, 1], index=['urinate', 'stool'])
    else:
        return pd.Series([1, 1], index=['urinate', 'stool'])

converted = diaper['status'].apply(mapper)
diaper = pd.concat([diaper, converted], axis=1)


grouped = diaper.groupby(pd.TimeGrouper('D'))

result = grouped.aggregate(np.sum)
result.to_csv('data/diaper_normolized.csv')

这里的pd.TimeGrouper('D')表示按天分组。好了,存起来的diaper_normolized.csv文件就是我们想要的了,接下来就看如何可视化了。

可视化

仔细看一下数据,自然的想法是横坐标为日期,纵坐标为嘘嘘/便便的次数,然后分别将嘘嘘和便便的绘制成曲线即可。这个例子我使用D3来做可视化的工具,D3本身的API层次比较偏底层,这点和jQuery有点类似。

尝试1 - 曲线图

最简单的情况,只需要定义两条线条函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var valueline = d3.svg.line()
  .x(function(d) {
      return x(d.date);
  })
  .y(function(d) {
      return y(d.urinate);
  });

var stoolline = d3.svg.line()
  .x(function(d) {
      return x(d.date);
  })
  .y(function(d) {
      return y(d.stool);
  });

可以看到,直接将点连接起来,线条的拐点看起来会非常的尖锐。这个可以通过使用D3提供的插值函数来解决,比如采用basis方式插值:

1
2
3
4
5
6
7
8
var valueline = d3.svg.line()
  .interpolate('basis')
  .x(function(d) {
      return x(d.date);
  })
  .y(function(d) {
      return y(d.urinate);
  });

曲线图倒是看起来比较简单,可以看出基本的走势。比如新生儿阶段,大小便的次数都比较多,随着月龄的增长,呈现出了下降的趋势,而且便便次数降低了很多。

尝试2 - 散点图(气泡图)

曲线图看起来并不是太直观,我们接下来尝试一下其他的图表类型。比如散点图是一个比较好的选择:

1
2
3
4
5
6
7
8
9
svg.selectAll("dot")
    .data(data)
  .enter().append("circle")
      .attr('stroke', '#FD8D3C')
      .attr('fill', '#FD8D3C')
      .attr('opacity', ".7")
    .attr("r", function(d) {return 3;})
    .attr("cx", function(d) { return x(d.date); })
    .attr("cy", function(d) { return y(d.urinate); });

这里还使用了不同的颜色来区分嘘嘘和便便,但是信息强调的也不够充分。这时候可以通过尺寸的不同,色彩饱和度的差异再次强调各个点之间的对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
svg.selectAll("dot")
    .data(data)
  .enter().append("circle")
      .attr('stroke', '#FD8D3C')
      .attr('fill', '#FD8D3C')
      .attr('opacity', function(d) {return d.urinate * 0.099})
    .attr("r", function(d) {return d.urinate;})
    .attr("cx", function(d) { return x(d.date); })
    .attr("cy", function(d) { return y(d.urinate); })
    .on("mouseover", function(d) {
      div.html(formatTime(d.date) + ", 嘘嘘: "  + d.urinate + "次")
      .style("left", (d3.event.pageX) + "px")
      .style("top", (d3.event.pageY - 28) + "px")
      .style("opacity", .9)
      .style("background", "#FD8D3C");
  })
  .on("mouseout", function(d) {
      div.style("opacity", 0);
  });

此处的圆的半径与嘘嘘次数相关,圆的透明度也和嘘嘘的次数相关,这样从不同的视觉编码上重复强调数据的差异,效果比单纯的曲线图和散点图会更好一些。

一点理论

数据可视化过程可以分为这样几个步骤:

  1. 明确可视化的目的
  2. 数据获取
  3. 数据预处理
  4. 选择合适的图表
  5. 结果呈现

当然,可视化本身就是一个需要不断迭代的过程,步骤的2-5可能会经过多次迭代和修正:比如在呈现之后发现有信息没有充分展现,则需要回退到图表选择上,而不同的图表需要的数据又可能会有不同,我们可能需要又回到数据预处理、甚至数据获取阶段。

选择合适的图表

对于新手而言,图表的选择是非常困难的。同样的数据集,用不同的图表展现,效果会有很大差异。另一方面,对于手头的数据集,做何种预处理并以不同的角度来展现也同样充满挑战。

不过好在已经有人做过相关的研究,并形成了一个非常简便可行的Matrix:

通过这个Martix,你可以根据变量的数量,变量的类型很方便的选取合适的图表格式。这个表可以保证你不出大的差错,最起码可以很清晰的将结果展现出来。

视觉编码(Visual Encoding)

视觉编码,简而言之就是将数据映射为可视化的元素,这些元素可以帮助人们很快的区分出数据的差异。比如通过颜色的不同来区分分类型元素(亚太区收入,亚洲区收入),通过长度的不同来表示数量的不同等等。视觉编码有很多不同的元素:

  1. 位置
  2. 形状(体积)
  3. 纹理
  4. 角度
  5. 长度
  6. 色彩
  7. 色彩饱和度

Semiology of Graphics

Within the plane a mark can be at the top or the bottom, to the right or the left. The eye perceives two independent dimensions along X and Y, which are distinguished orthogonally. A variation in light energy produces a third dimension in Z, which is independent of X and Y…

The eye is sensitive, along the Z dimension, to 6 independent visual variables, which can be superimposed on the planar figures: the size of the marks, their value, texture, color, orientation, and shape. They can represent differences (≠), similarities (≡), a quantified order (Q), or a nonquantified order (O), and can express groups, hierarchies, or vertical movements.

来源:Semiology of Graphics

而且,人类对这些不同元素的认知是不同的,比如人们更容易辨识位置的不同,而比较难以区分饱和度的差异。这也是为什么大部分图表都会有坐标轴和标尺的原因(当然,还有网格),而像饼图这样的图形则很难让读者从形状上看出不同部分的差异。

Jock Mackinlay发表过关于不同视觉编码优先级的研究:

Mackinlay

越靠近上面的元素,在人眼所能识别的精度就越高。在图表类型的选取上,也需要充分考虑这些研究的成果,选取合理的视觉编码。

正如前面所说,可视化是一个不断迭代的过程,要将同样的信息展现出来,可能需要尝试不同的视觉编码的组合,比如:

上面几个图表对应的代码在这里,下一篇我们来看看数据可视化的其他知识。

如何提升页面渲染效率

Web页面的性能

我们每天都会浏览很多的Web页面,使用很多基于Web的应用。这些站点看起来既不一样,用途也都各有不同,有在线视频,Social Media,新闻,邮件客户端,在线存储,甚至图形编辑,地理信息系统等等。虽然有着各种各样的不同,但是相同的是,他们背后的工作原理都是一样的:

  • 用户输入网址
  • 浏览器加载HTML/CSS/JS,图片资源等
  • 浏览器将结果绘制成图形
  • 用户通过鼠标,键盘等与页面交互

这些种类繁多的页面,在用户体验方面也有很大差异:有的响应很快,用户很容易就可以完成自己想要做的事情;有的则慢慢吞吞,让焦躁的用户在受挫之后拂袖而去。毫无疑问,性能是影响用户体验的一个非常重要的因素,而影响性能的因素非常非常多,从用户输入网址,到用户最终看到结果,需要有很多的参与方共同努力。这些参与方中任何一个环节的性能都会影响到用户体验。

  • 宽带网速
  • DNS服务器的响应速度
  • 服务器的处理能力
  • 数据库性能
  • 路由转发
  • 浏览器处理能力

早在2006年,雅虎就发布了提升站点性能的指南,Google也发布了类似的指南。而且有很多工具可以和浏览器一起工作,对你的Web页面的加载速度进行评估:分析页面中资源的数量,传输是否采用了压缩,JS、CSS是否进行了精简,有没有合理的使用缓存等等。

如果你需要将这个过程与CI集成在一起,来对应用的性能进行监控,我去年写过一篇相关的博客

本文打算从另一个角度来尝试加速页面的渲染:浏览器是如何工作的,要将一个页面渲染成用户可以看到的图形,浏览器都需要做什么,哪些过程比较耗时,以及如何避免这些过程(或者至少以更高效的方式)。

页面是如何被渲染的

说到性能优化,规则一就是:

If you can’t measure it, you can’t improve it. - Peter Drucker

根据浏览器的工作原理,我们可以分别对各个阶段进行度量。

图片来源:http://dietjs.com/tutorials/host#backend

像素渲染流水线

  1. 下载HTML文档
  2. 解析HTML文档,生成DOM
  3. 下载文档中引用的CSS、JS
  4. 解析CSS样式表,生成CSSOM
  5. 将JS代码交给JS引擎执行
  6. 合并DOM和CSSOM,生成Render Tree
  7. 根据Render Tree进行布局layout(为每个元素计算尺寸和位置信息)
  8. 绘制(Paint)每个层中的元素(绘制每个瓦片,瓦片这个词与GIS中的瓦片含义相同)
  9. 执行图层合并(Composite Layers)

使用Chrome的DevTools - Timing,可以很容易的获取一个页面的渲染情况,比如在Event Log页签上,我们可以看到每个阶段的耗时细节(清晰起见,我没有显示LoadingScripting的耗时):

Timeline

上图中的Activity中,Recalculate Style就是上面的构建CSSOM的过程,其余Activity都分别于上述的过程匹配。

应该注意的是,浏览器可能会将Render Tree分成好几个层来分别绘制,最后再合并起来形成最终的结果,这个过程一般发生在GPU中。

Devtools中有一个选项:Rendering - Layers Borders,打开这个选项之后,你可以看到每个层,每个瓦片的边界。浏览器可能会启动多个线程来绘制不同的层/瓦片。

Layers and Tiles

Chrome还提供一个Paint Profiler的高级功能,在Event Log中选择一个Paint,然后点击右侧的Paint Profiler就可以看到其中绘制的全过程:

Paint in detail

你可以拖动滑块来看到随着时间的前进,页面上元素被逐步绘制出来了。我录制了一个我的知乎活动页面的视频,不过需要翻墙。

常规策略

为了尽快的让用户看到页面内容,我们需要快速的完成DOM+CSSOM - Layout - Paint - Composite Layers的整个过程。一切会阻塞DOM生成,阻塞CSSOM生成的动作都应该尽可能消除,或者延迟。

在这个前提下,常见的做法有两种:

分割CSS

对于不同的浏览终端,同一终端的不同模式,我们可能会提供不同的规则集:

1
2
3
4
5
6
7
8
9
10
@media print {
  html {
      font-family: 'Open Sans';
      font-size: 12px;
  }
}

@media orientation:landscape {
  //
}

如果将这些内容写到统一个文件中,浏览器需要下载并解析这些内容(虽然不会实际应用这些规则)。更好的做法是,将这些内容通过对link元素的media属性来指定:

1
2
<link href="print.css" rel="stylesheet" media="print">
<link href="landscape.css" rel="stylesheet" media="orientation:landscape">

这样,print.csslandscape.css的内容不会阻塞Render Tree的建立,用户可以更快的看到页面,从而获得更好的体验。

高效的CSS规则

CSS规则的优先级

很多使用SASS/LESS的开发人员,太过分的喜爱嵌套规则的特性,这可能会导致复杂的、无必要深层次的规则,比如:

1
2
3
4
5
6
7
8
9
#container {
  p {
      .title {
          span {
              color: #f3f3f3;
          }
      }
  }
}

在生成的CSS中,可以看到:

1
2
3
#container p .title span {
  color: #f3f3f3;
}

而这个层次可能并非必要。CSS规则越复杂,在构建Render Tree时,浏览器花费的时间越长。CSS规则有自己的优先级,不同的写法对效率也会有影响,特别是当规则很多的时候。这里有一篇关于CSS规则优先级的文章可供参考。

使用GPU加速

很多动画都会定时执行,每次执行都可能会导致浏览器的重新布局,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
@keyframes my {
  20% {
      top: 10px;
  }
  
  50% {
      top: 120px;
  }
  
  80% {
      top: 10px;
  }
}

这些内容可以放到GPU加速执行(GPU是专门设计来进行图形处理的,在图形处理上,比CPU要高效很多)。可以通过使用transform来启动这一特性:

1
2
3
4
5
6
7
8
9
10
11
12
13
@keyframes my {
  20% {
      transform: translateY(10px);
  }

  50% {
      transform: translateY(120px);
  }
      
  80% {
      transform: translateY(10px);
  }
}

异步JavaScript

我们知道,JavaScript的执行会阻塞DOM的构建过程,这是因为JavaScript中可能会有DOM操作:

1
2
3
4
5
var element = document.createElement('div');
element.style.width = '200px';
element.style.color = 'blue';

body.appendChild(element);

因此浏览器会等等待JS引擎的执行,执行结束之后,再恢复DOM的构建。但是并不是所有的JavaScript都会设计DOM操作,比如审计信息,WebWorker等,对于这些脚本,我们可以显式地指定该脚本是不阻塞DOM渲染的。

1
<script src="worker.js" async></script>

带有async标记的脚本,浏览器仍然会下载它,并在合适的时机执行,但是不会影响DOM树的构建过程。

首次渲染之后

在首次渲染之后,页面上的元素还可能被不断的重新布局,重新绘制。如果处理不当,这些动作可能会产生性能问题,产生不好的用户体验。

  • 访问元素的某些属性
  • 通过JavaScript修改元素的CSS属性
  • onScroll中做耗时任务
  • 图片的预处理(事先裁剪图片,而不是依赖浏览器在布局时的缩放)
  • 在其他Event Handler中做耗时任务
  • 过多的动画
  • 过多的数据处理(可以考虑放入WebWorker内执行)

强制同步布局/回流

元素的一些属性和方法,当在被访问或者被调用的时候,会触发浏览器的布局动作(以及后续的Paint动作),而布局基本上都会波及页面上的所有元素。当页面元素比较多的时候,布局和绘制都会花费比较大。

通过Timeline,有时候你会看到这样的警告:

比如访问一个元素的offsetWidth(布局宽度)属性时,浏览器需要重新计算(重新布局),然后才能返回最新的值。如果这个动作发生在一个很大的循环中,那么浏览器就不得不进行多次的重新布局,这可能会产生严重的性能问题:

1
2
3
for(var i = 0; i < list.length; i++) {
  list[i].style.width = parent.offsetWidth + 'px';
}

正确的做法是,先将这个值读出来,然后缓存在一个变量上(触发一次重新布局),以便后续使用:

1
2
3
4
var parentWidth = parent.offsetWidth;
for(var i = 0; i < list.length; i++) {
  list[i].style.width = parentWidth + 'px';
}

这里有一个完整的列表触发布局

CSS样式修改

布局相关属性修改

修改布局相关属性,会触发Layout - Paint - Composite Layers,比如对位置,尺寸信息的修改:

1
2
3
4
5
6
var element = document.querySelector('#id');
element.style.width = '100px';
element.style.height = '100px';

element.style.top = '20px';
element.style.left = '20px';
绘制相关属性修改

修改绘制相关属性,不会触发Layout,但是会触发后续的Paint - Composite Layers,比如对背景色,前景色的修改:

1
2
var element = document.querySelector('#id');
element.style.backgroundColor = 'red';
其他属性

除了上边的两种之外,有一些特别的属性可以在不同的层中单独绘制,然后再合并图层。对这种属性的访问(如果正确使用了CSS)不会触发Layout - Paint,而是直接进行Compsite Layers:

  • transform
  • opacity

transform展开的话又分为: translate, scale, rotate等,这些层应该放入单独的渲染层中,为了对这个元素创建一个独立的渲染层,你必须提升该元素。

可以通过这样的方式来提升该元素:

1
2
3
.element {
  will-change: transform;
}

CSS 属性 will-change 为web开发者提供了一种告知浏览器该元素会有哪些变化的方法,这样浏览器可以在元素属性真正发生变化之前提前做好对应的优化准备工作。

当然,额外的层次并不是没有代价的。太多的独立渲染层,虽然缩减了Paint的时间,但是增加了Composite Layers的时间,因此需要仔细权衡。在作调整之前,需要Timeline的运行结果来做支持。

还记得性能优化的规则一吗?

If you can’t measure it, you can’t improve it. - Peter Drucker

下面这个视频里可以看到,当鼠标挪动到特定元素时,由于CSS样式的变化,元素会被重新绘制:

CSS Triggers是一个完整的CSS属性列表,其中包含了会影响布局或者绘制的CSS属性,以及在不同的浏览器上的不同表现。

总结

了解浏览器的工作方式,对我们做前端页面渲染性能的分析和优化都非常有帮助。为了高效而智能的完成渲染,浏览器也在不断的进行优化,比如资源的预加载,更好的利用GPU(启用更多的线程来渲染)等等。

另一方面,我们在编写前端的HTML、JS、CSS时,也需要考虑浏览器的现状:如何减少DOM、CSSOM的构建时间,如何将耗时任务放在单独的线程中(通过WebWorker)。

参考资料

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

优秀的程序员

要给优秀的程序员下一个明确的定义无疑是一件非常困难的事情。擅长抽象思维,动手能力强,追求效率,喜欢自动化,愿意持续学习,对代码质量有很高的追求等等,这些维度都有其合理性,不过又都略显抽象和主观。

我对于一个程序员是否优秀,也有自己的标准,那就是TA对命令行的熟悉/喜爱程度。这个特点可以很好的看出TA是否是一个优秀的(或者潜在优秀的)程序员。我周围就有很多非常牛的程序员,无一例外的都非常擅长在命令行中工作。那什么叫熟悉命令行呢?简单来说,就是90%的日常工作内容可以在命令行完成。

当然,喜欢/习惯使用命令行可能只是表象,其背后包含的实质才是优秀的程序员之所以优秀的原因。

自动化

Perl语言的发明之Larry Wall有一句名言:

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

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

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

程序员的日常工作

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

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

而这一系列的工作背后,都隐含了一个自动化的需求。在做上述的工作时,优秀的程序员会努力的将其自动化,如果有工具就使用工具;如果没有,就开发一个新的工具。这种努力让一切都尽可能自动化起来的哲学起源于UNIX世界。

而UNIX哲学的实际体现则是通过命令行来完成的。

Where there is a shell, there is a way.

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年),有兴趣的朋友可以读一读。这里有一个Lex/Yacc的小例子,有情趣的朋友可以看看。

当然,如果你使用现在最先进的IDE(典型的GUI工具),其背后做的事情也是同样的原理:生成一个Makefile,然后在幕后调用它。

基础设施自动化

开发过程中,工程师还需要关注的一个问题是:软件运行的环境。我在上学的时候,刚开始学习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的题目,可供参考: