I code it

Code and Life

ThoughtWorks洞见讲什么?

ThoughtWorks洞见

ThoughtWorks洞见是ThoughtWorks的一个媒体渠道,汇集了来自ThoughtWorks最优秀的经验和思考,并分享给真正对软件有意愿思考和不断改进的人(修改自官方版本)。

截至目前为止,ThoughtWorks洞见已经汇集了50余位作者的300+篇文章(就在刚才,又有一篇更新)。那么这些文章中都在讨论什么样的话题呢?这篇文章将通过一些技术手段,提取出洞见中的关键字,然后采用可视化的方式呈现出来。

数据获取

本来我打算从RSS上读feed,解析出文章的link,再将所有文章爬一遍,最后保存到本地。不过写了几行代码后发现Wordpress(ThoughtWorks洞见目前托管在一个Wordpress上)默认地只输出最新的feed,这对于关键字提取来说数量远远不够。众所周知,语料库越大,效果越好。

既然是洞见本质上来说就是一个静态站点,那么最简单、最暴力的方式就是直接把站点克隆到本地。这一步通过使用wget可以很容易做到:

1
2
wget --mirror -p --html-extension --convert-links -e robots=off -P . \
http://insights.thoughtworkers.org/

默认地,wget会以站点的完整域名为目录名,然后保存整个站点到本地。我大概看了一下,其实不需要所有的目录,只需要一个层次即可,所以这里用find来做一个过滤,然后将文件名写到一个本地文件filepaths中。

1
find insights.thoughtworkers.org/ -name index.html -depth 2 > filepaths

这个文件的内容是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
insights.thoughtworkers.org/10-common-questions-of-ba/index.html
insights.thoughtworkers.org/10-tips-for-good-offshore-ba/index.html
insights.thoughtworkers.org/10-ways-improve-your-pairing-experience/index.html
insights.thoughtworkers.org/100-years-computer-science/index.html
insights.thoughtworkers.org/1000-cars-improve-beijing-transportation/index.html
insights.thoughtworkers.org/3d-printing/index.html
insights.thoughtworkers.org/4-advices-for-aid/index.html
insights.thoughtworkers.org/5-appointments-with-agile-team/index.html
insights.thoughtworkers.org/5-ways-exercise-visual-design/index.html
insights.thoughtworkers.org/7-step-agenda-effective-retrospective/index.html
insights.thoughtworkers.org/a-decade/index.html
insights.thoughtworkers.org/about-team-culture/index.html
insights.thoughtworkers.org/about-tw-insights/index.html
insights.thoughtworkers.org/agile-coach/index.html
insights.thoughtworkers.org/agile-communication/index.html
insights.thoughtworkers.org/agile-craftman/index.html
...

数据处理

这样我就可以很容易在python脚本中读取各个文件并做处理了。有了文件之后,需要做这样一些事情:

  1. 抽取HTML中的文本信息
  2. 将文本分词成列表
  3. 计算列表中所有词的TFIDF
  4. 计算每个词出现的频率
  5. 将结果持久化到本地

这里需要用到这样一些pyhton库:

  1. BeautifulSoap 解析HTML文档并抽取文本
  2. jieba 分词
  3. sk-learn 计算单词出现频率
  4. pandas 其他数据处理
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def extract_post_content(file):
    soup = BeautifulSoup(open(file).read(), "html.parser")
    return soup.find('div', attrs={'class': 'entry-content'}).text

def extract_all_text():
    with open('filepaths') as f:
        content = f.readlines()

    file_list = [x.strip() for x in content]
    return map(extract_post_content, file_list)

def extract_segments(data):
    seg_list = jieba.cut(data, cut_all=False)
    return [seg.strip() for seg in seg_list if len(seg) > 1]


def keywords_calc():
    corpus = [" ".join(item) for item in map(extract_segments, extract_all_text())]

keywords_calc()

extract_post_content函数用来打开一篇博客的HTML文件,并提取其中的div.entry-content中的文本内容。extract_all_text函数用来对文件filepaths中的每一行(一篇洞见文章的本地文件路径)都调用一次extract_post_content。而函数extract_segments会使用jieba来对每篇文章进行分词,并生成一个单词组成给的列表。最后,在函数keywords_calc中,通过一个列表推导式来生成语料库。

有了语料库之后,很容易使用sk-learn来进行计算:

1
2
3
4
5
6
7
8
9
10
11
def keywords_calc():
    corpus = [" ".join(item) for item in map(extract_segments, extract_all_text())]

    with open('stopwords-utf8.txt') as f:
        content = f.readlines()

    content.extend(['来说', '事情', '提供', '带来', '发现'])
    stopwords = [x.strip().decode('utf-8') for x in content]

    vectorizer = CountVectorizer(stop_words=stopwords)
    fit = vectorizer.fit_transform(corpus)

当然,由于处理的是中文,我们需要提供停止词来避免对无意义的词的统计(这个那个然后等等基本上每篇都会出现多次的词)。在经过transform之后,我们就得到了一个稀疏矩阵和词汇表,以及对应的tdidf的值,我们使用pandas提供的DataFrame来进行排序和存储即可:

1
2
3
4
5
6
7
def keywords_calc():
  
  #...   
    data = dict(zip(vectorizer.get_feature_names(), fit.toarray().sum(axis=0)))
    top_100 = DataFrame(data.items(), columns=['word', 'freq'])
        .sort_values('freq', ascending=False).head(100)
    top_100.to_csv('top-100-words-most-used-in-tw-insights.csv')

运行结果如下:

1
2
3
4
5
6
7
8
9
10
11
index,word,freq
18761,团队,1922
12479,测试,1851
4226,开发,1291
1910,服务,1288
10531,技术,1248
7081,用户,1145
17517,代码,1078
12712,项目,1062
4957,需求,1049
...

可视化

单词云

1
2
3
4
5
6
7
8
9
10
11
12
13
d3.csv('top-20-words-in-tw-insight.csv', function(err, data) {
    data.forEach(function(d) {
        d.freq = +d.freq
    });

    d3.layout.cloud().size([1600, 900])
        .words(data)
        .rotate(0)
        .fontSize(function(d) { return Math.round(d.freq/10); })
        .on("end", draw)
        .start();

});

这里我直接使用了一个第三方的单词云插件d3.layout.cloud,提供一个callback函数draw,当布局结束之后,插件会调用这个回调:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function draw(words) {
    d3.select("body").append("svg")
        .attr("width", width)
        .attr("height", height)
        .attr("class", "wordcloud")
        .append("g")
        .attr("transform", "translate(" + width/2 + "," + height/2 +")")
        .selectAll("text")
        .data(words)
        .enter().append("text")
        .style("font-size", function(d) { return Math.round(d.freq/10) + "px"; })
        .style("fill", function(d, i) { return color(i); })
        .attr("transform", function(d) {
            return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
        })
        .text(function(d) { return d.word; });
}

背景图制作

1
2
3
4
mkdir -p authors/ && cp wp-content/authors/* authors/
cd authors
mogrify -format png *.jpg
rm *.jpg

将作者的头像制作成一张9x6的大蒙太奇图:

1
2
montage *.png  -geometry +0+0 -resize 128x128^ -gravity center -crop 128x128+0+0 -tile 9x6 \
  tw-insight-authors.png

后期处理

可以看出,ThoughtWorks洞见中最为关键(Top 10)的信息依次是:

1
2
3
4
5
6
7
8
9
10
团队
测试
开发
服务
技术
用户
代码
项目
需求
工作

这个和ThoughtWorks的专业服务公司的属性基本吻合。不过前20里竟然没有诸如敏捷精益这些原本以为会入围的词。

完善

基本的图形设计完成之后,再加入一些视觉元素,比如ThoughtWorks的标志性图案(代表开发文化和多样性),以及对应的说明性文字(文字的大小也错落开,和文字云遥相呼应):

资料

文中使用了比较简单的CountVectorizer做统计,sk-learn还提供了其他的向量化机制。我使用TdidfVectorizer做了一些计算,不过可能由于语料库的尺寸原因,效果比较奇怪,就暂时没有采用这种方式。

不过,使用TDIDF来做关键词抽取在文本处理上也算是必备的技能,这里列一些参考资料,有兴趣的可以进行进一步的探索。

  1. 完整的代码在此。
  2. 阮一峰老师对TFIDF的解释文章
  3. 陈皓(左耳朵耗子)对TFIDF的解释文章

一张漂亮的可视化图表背后

可视化之根

多年前读过一篇非常震撼的文章,叫《Lisp之根》(英文版:The roots of Lisp),大意是Lisp仅仅通过一种数据结构(列表)和有限的几个函数,就构建出了一门极为简洁,且极具扩展性的编程语言。当时就深深的被这种设计哲学所震撼:一方面它足够简单,每个单独的函数都足够简单,另一方面它有非常复杂,像宏,高阶函数,递归等机制可以构建出任意复杂的程序,而复杂的机制又是由简单的组件组成的。

数据的可视化也是一样,组成一幅内容清晰、表达力强、美观的可视化信息图的也仅仅是一些基本的元素,这些元素的不同组合却可以产生出令人着迷的力量。

要列出“可视化元素之根”很容易:位置、长度、角度、形状、纹理、面积(体积)、色相、饱和度等几种有限的元素,邱南森在他的《数据之美》中提供了一张视觉元素的图,其中包含了大部分常用的元素。

令人振奋的是,这些元素可以自由组合,而且组合旺旺会产生1+1>2的效果。

心理学与认知系统

数据可视化其实是基于人类的视觉认知系统的,因此对人类视觉系统的工作方式有一些了解可以帮助我们设计出更为高效(更快的传递我们想要表达的信息给读者)的可视化作品。

心理物理学

在生活中,我们会遇到这样的场景:一件原价10元的商品,如果降价为5元,则消费者很容易购买;而一件原价100元的商品,降价为95元,则难以刺激消费者产生购买的冲动。这两个打折的绝对数字都是5元,但是效果是不一样的。

韦伯-费希纳定理描述的正是这种非理性的场景。这个定理的一个比较装逼的描述是:

感觉量与物理量的对数值成正比,也就是说,感觉量的增加落后于物理量的增加,物理量成几何级数增长,而心理量成算术级数增长,这个经验公式被称为费希纳定律或韦伯-费希纳定律。

– 摘自百度百科

这个现象由人类的大脑构造而固有,因此在设计可视化作品时也应该充分考虑,比如:

  • 避免使用面积图作为对比
  • 在做对比类图形时,当差异不明显时需要考虑采用非线性的视觉元素
  • 选用多种颜色作为视觉编码时,差异应该足够大

比如:

如上图中,当面积增大之后,肉眼越来越难从形状的大小中解码出实际的数据差异,上边的三组矩形(每行的两个为一组),背后对应的数据如下,可以看到每组中的两个矩形的绝对差都是5:

1
2
3
4
5
6
7
8
9
10
var data = [
  {width: 5, height: 5},
  {width: 10, height: 10},

  {width: 50, height: 50},
  {width: 55, height: 55},

  {width: 100, height: 100},
  {width: 105, height: 105}
];

格式塔学派

格式塔学派是心理学中的一个重要流派,她强调整体认识,而不是结构主义的组成说。格式塔认为,人类在看到画面时,会优先将其简化为一个整体,然后再细化到每个部分;而不是先识别出各个部分,再拼接为整体。

比如那条著名的斑点狗:

我们的眼睛-大脑可以很容易的看出阴影中的斑点狗,而不是先识别出狗的四条腿或者尾巴(事实上在这张图中,人眼无法识别出各个独立的部分)。

格式塔理论有几个很重要的原理:

  • 接近性原理
  • 相似性原理
  • 封闭性原理
  • 连续性原理
  • 主体/背景原理

当然,格式塔学派后续还有一些发展,总结出了更多的原理。工程上,这些原理还在大量使用,指导设计师设计各式各样的用户界面。鉴于网上已经有众多的格式塔理论及其应用的文章,这里就不在赘述。有兴趣的同学可以参考这几篇文章:

视觉设计的基本原则

《写给大家看的设计书》一书中,作者用通俗易懂的方式给出了几条设计的基本原则,这些原则完全可以直接用在数据可视化中的设计中:

  • 亲密性(将有关联的信息物理上放在一起,而关联不大的则通过留白等手段分开)
  • 对齐(将元素通过水平,垂直方向对齐,方便视觉识别)
  • 重复(重复使用某一模式,比如标题1的字体颜色,标题2的字体颜色等,保持重复且一致)
  • 对比(通过强烈的对比将不同的信息区分开)

如果稍加留意,就会发现现实世界中在大量的使用这几个原则。1,2,3三个标题的形式就是重复性的体现;每个标题的内容自成一体是因为组成它的元素(数字,两行文字)的距离比较近,根据亲密性原则,人眼会自动将其归为一类;超大的数字字体和较小的文字形成了对比;大标题的颜色和其他内容形成了对比等等。

这些原则其实跟上面提到的格式塔学派,以及韦伯-费希纳定理事实上是相关的,在理解了这些人类视觉识别的机制之后,使用这些原则就非常自然和得心应手了。

一些例子

  • 淡化图表的网格(和数据图形产生对比)
  • 通过深色来强调标尺(强烈的线条和其余部分产生对比)
  • 离群点的高亮(通过不同颜色产生对比)
  • 使用颜色(通过不同的颜色,利用亲密性原则方便读者对数据分组)
  • 元素颜色和legend(使用重复性原则)
  • 同一个页面上有多个图表,采取同样的图例,色彩选择(强调重复性原则)

实例

上篇文章提到我通过一个手机App收集到了女儿成长的一些记录,包括哺乳信息,换尿布记录,以及睡眠信息。这个例子中,我会一步步的介绍如何将这些信息可视化出来,并解释其中使用的视觉原理。

可视化的第一步是要明确你想要从数据中获取什么信息,我想要获取的信息是孩子的睡眠总量以及睡眠时间分布情况。

进阶版的条形图

确定了可视化的目的之后,第二步是选取合适的视觉编码。上面提到过,对于人眼来说,最精确的视觉编码方式是长度。我们可以将睡眠时间转化为长度来展现,最简单的方式是按天聚合,然后化成柱状图。比如:

1
2
3
2016/11/21,768
2016/11/22,760
2016/11/23,700

不过这种图无法看出时间的分布。我们可以考虑通过条形图的变体来满足前面提到的两个核心诉求。先来在纸上画一个简单的草图。纵轴是24小时,横轴是日期。和普通的条形图不一样的是,每个条形的总长度是固定的,而且条形代表的不是简单非数据类型,而是24小时。在草稿中,每个画斜线的方块表示孩子在睡眠状态,而虚线部分表示她醒着。

原始数据

1
2
3
4
5
6
7
8
9
10
11
name,date,length,note
心心,2016/11/21 19:23,119,
心心,2016/11/21 22:04,211,
心心,2016/11/22 02:03,19,
心心,2016/11/22 02:23,118,
心心,2016/11/22 05:58,242,
心心,2016/11/22 10:57,128,
心心,2016/11/22 14:35,127,
心心,2016/11/22 17:15,127,
心心,2016/11/22 20:02,177,
心心,2016/11/23 01:27,197,

这里有个问题,我们的纵轴是24小时,如果她晚上23点开始睡觉,睡了3个小时,那么这个条形就回超出24格的轴。我写了一个函数来做数据转换:

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
36
require 'csv'
require 'active_support/all'
require 'json'

csv = CSV.read('./visualization/data/sleeping_data_refined.csv', :headers => :first_row)

data = []
csv.each do |row|
    date = DateTime.parse(row['date'], "%Y/%m/%d %H:%M")

    mins_until_end_of_day = date.seconds_until_end_of_day / 60
    diff = mins_until_end_of_day - row['length'].to_i

    if (diff >= 0) then
        data << {
            :name => row['name'],
            :date => row['date'],
            :length => row['length'],
            :note => row['note']
        }
    else
        data << {
            :name => row['name'],
            :date => date.strftime("%Y/%m/%d %H:%M"),
            :length => mins_until_end_of_day,
            :note => row['note']
        }

        data << {
            :name => row['name'],
            :date => (date.beginning_of_day + 1.day).strftime("%Y/%m/%d %H:%M"),
            :length => diff.abs,
            :note => row['note']
        }
    end
end

有了干净的数据之后,我们可以编写一些前端的代码来绘制条形图了。画图的时候有几个要注意的点:

  • 每天内的时间段对应的矩形需要有相同的X坐标
  • 不同的睡眠长度要有颜色区分(睡眠时间越长,颜色越深)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var dateRange = _.uniq(data, function(d) {
  var date = d.date;
  return [date.getYear(), date.getMonth(), date.getDate()].join("/");
});

xScale.domain(dateRange.map(function(d) { return d.date; }));

function getFirstInDomain(date) {
  var domain = xScale.domain();

  var index = _.findIndex(domain, function(d) {
      return date.getYear() === d.getYear()
          && date.getMonth() === d.getMonth()
          && date.getDate() === d.getDate();
  });

  return domain[index];
}

函数getFirstInDomain可以根据一个日期值返回一个X坐标,这样2016/11/21 19:232016/11/21 22:04都会返回一个整数值(借助d3提供的标尺函数)。

另外,我们根据每次睡觉的分钟数将睡眠质量划分为5个等级:

1
2
3
var level = d3.scale.threshold()
  .domain([60, 120, 180, 240, 300])
  .range(["low", "fine", "medium", "good", "great", "prefect"]);

然后在绘制过程中,根据实际数据值来确定不同的CSS Class

1
2
3
4
5
6
7
8
svg.selectAll(".bar")
  .data(data)
  .enter()
  .append("rect")
  .attr("class", function(d) {
      return level(d.length)+" bar";
  })
//...

实现之后,看起来是这个样子的。事实上这个图标可以比较清楚的看出大部分睡眠集中在0-6点,而中午的10-13点以及黄昏18-20点基本上只有一些零星的睡眠。

星空图

上面的图有一个缺点,是当日期很多的时候(上图差不多有100天的数据),X轴会比较难画,如果缩减成按周,或者按月,又会增加很多额外的复杂度。

另外一个尝试是变形:既然这个统计是和时间相关的,那么圆形的钟表形象是一个很好的隐喻,每天24小时自然的可以映射为一个圆。而睡眠时间可以通过弧长来表示,睡眠时间越长,弧长越大:

角度转弧度

我们首先将整个圆(360度)按照分钟划分,则每分钟对应的角度数为:360/(24*60),再将角度转化为弧度:degree * π/180

1
var perAngle = (360 / (24 * 60)) * (Math.PI/180);

那么对于指定的时间,比如10:20,先计算出其分钟数:10*60+20,再乘以preAngle,就可以得出起始弧度;起始时间的分钟数加上睡眠时长,再乘以preAngle,就是结束弧度。

1
2
3
4
5
6
7
8
9
function startAngle(date) {
    var start = (date.getHours() * 60 + date.getMinutes()) * perAngle;
    return Math.floor(start*1000)/1000;
}

function endAngle(date, length) {
    var end = (date.getHours() * 60 + date.getMinutes() + length) * perAngle;
    return Math.floor(end*1000)/1000;
}

实现的结果是这样的:

初看起来,它像是星空图,但是图中的不同颜色含义没有那么直观,我们需要在图上补充一个图例。通过使用d3的线性标尺和定义svg的渐变来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var colorScale = d3.scale.linear()
  .range(["#2c7bb6", "#00a6ca","#00ccbc","#90eb9d","#ffff8c","#f9d057"].reverse());

var defs = vis.append("defs");

var linearGradient = defs.append("linearGradient")
    .attr("id", "linear-gradient")
    .attr("x1", "0%")
    .attr("y1", "0%")
    .attr("x2", "100%")
    .attr("y2", "0%");

linearGradient.selectAll("stop")
  .data( colorScale.range() )
  .enter().append("stop")
  .attr("offset", function(d,i) { return i/(colorScale.range().length-1); })
  .attr("stop-color", function(d) { return d; });

定义好渐变和渐变的颜色取值范围之后,就可以来绘制图例了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var legendWidth = 300;

var legendsvg = vis.append("g")
  .attr("class", "legendWrapper")
  .attr("transform", "translate(" + (width/2+legendWidth) + "," + (height - 40) + ")");

//Draw the Rectangle
legendsvg.append("rect")
  .attr("class", "legendRect")
  .attr("x", -legendWidth/2)
  .attr("y", 0)
  .attr("width", legendWidth)
  .attr("height", 3.5)
  .style("fill", "url(#linear-gradient)");

//Append title
legendsvg.append("text")
  .attr("class", "legendTitle")
  .attr("x", 0)
  .attr("y", -10)
  .style("text-anchor", "middle")
  .text("Sleeping Minutes");

图上的每段弧都会有鼠标移动上去的tooltip,这样可以很好的和读者大脑中的钟表隐喻对照起来,使得图表更容易理解。

由于我将整个圆分成了24份,这点和普通的钟表事实上有差异,那么如果加上钟表的刻度,会不会更好一些呢?从结果来看,这样的标线反而有点画蛇添足,所以我在最后的版本中去掉了钟表的标线。

可以看到,我们通过圆形的钟表隐喻来体现每一天的睡眠分布,然后用颜色的深浅来表示每次睡眠的时长。由于钟表的形象已经深入人心,因此读者很容易发现0点在圆环群的正上方。中心的黄色实心圆帮助读者视线先聚焦在最内侧的圆上,然后逐渐向外,这和日期的分布方向正好一致。

最终的结果在这里:心心的睡眠记录,完整的代码在这里

更进一步

一个完整的可视化作品,不但要运用各种视觉编码来将数据转换为视觉元素,背景信息也同样重要。既然这个星空图是关于睡眠主题的,那么一个包含她在睡觉的图片集合则会加强这种视觉暗示,帮助读者快速理解。

制作背景图

我从相册中选取了很多女儿睡觉时拍的照片,现在需要有个工具将这些照片缩小成合适大小,然后拼接成一个大的图片。这其中有很多有趣的地方,比如图片有横屏、竖屏之分,有的还是正方形的,我需要让缩放的结果是正方形的,这样容易拼接一些。

好在有imagemagick这种神器,只需要一条命令就可以做到:

1
2
$ montage *.jpg -geometry +0+0 -resize 128x128^ \
-gravity center -crop 128x128+0+0 xinxin-sleeping.jpg

这条命令将当前目录下的所有的jpg文件缩放成128x128像素,并从中间开始裁剪-gravity center+0+0表示图片之间的缝隙,最后将结果写入到xinxin-sleeping.jpg中。

拼接好图片之后,就可以通过CSS或者图片编辑器为其添加模糊效果,并设置深灰色半透明遮罩。

1
2
3
4
5
body {
  background-image:url('/xinxin-sleeping.png');
  background-size:cover;
  background-position:center;
}

当然,背景信息只是补充作用,需要避免喧宾夺主。因此图片做了模糊处理,且加上了深灰色的半透明Mask(此处应用了格式塔理论中的主体/背景原理)。

小结

这篇文章讨论了可视化作品背后的一些视觉元素理论,以及人类的视觉识别机制。在这些机制的基础上,介绍了如何运用常用的设计原则来进行视觉编码。最后,通过一个实例来介绍如何运用这些元素 – 以及更重要的,这些元素的组合 – 来制作一个漂亮的、有意义的可视化图表。

参考资料

这里有一些关于认知系统和设计原则的书籍,如果你感兴趣的话,可以用来参考

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

数据来源

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

我和我老婆的手机上都安装了宝宝生活记录(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和指导。