I code it

Code and Life

从三明治到六边形

本文首先发表于ThoughtWorks洞见

软件项目的套路

如果你平时的工作是做各种项目(而不是产品),而且你工作的时间足够长,那么自然见识过很多不同类型的项目。在切换过多次上下文之后,作为程序员的你,自然而然的会感到一定程度的重复:稍加抽象,你会发现所有的业务系统都几乎做着同样的事情:

  • 从某种渠道与用户交互,从而接受输入(Native App,Mobile Site,Web Site,桌面应用等等)
  • 将用户输入的数据按照一定规则进行转换,然后保存起来(通常是关系型数据库)
  • 将业务数据以某种形式展现(列表,卡片,地图上的Marker,时间线等)

稍加简化,你会发现大部分业务系统其实就是对某种形式的资源进行管理。所谓管理,也无非是增删查改(CRUD)操作。比如知乎是对“问题”这种资源的管理,LinkedIn是对“Profile”的管理,Jenkins对构建任务的管理等等,粗略的看起来都是这一个套路(当然,每个系统管理的资源类型可能不止一种,比如知乎还有时间线,Live,动态等等资源的管理)。

这些情况甚至会给开发者一种错觉:世界上所有的信息管理系统都是一样的,不同的仅仅是技术栈和操作的业务对象而已。如果写好一个模板,几乎都可以将开发过程自动化起来。事实上,有一些工具已经支持通过配置文件(比如yaml或者json/XML)的描述来生成对应的代码的功能。

如果真是这样的话,软件开发就简单多了,只需要知道客户业务的资源,然后写写配置文件,最后执行了一个命令来生成应用程序就好了。不过如果你和我一样生活在现实世界的话,还是趁早放弃这种完全自动化的想法吧。

复杂的业务

现实世界的软件开发是复杂的,复杂性并不体现在具体的技术栈上。如Java,Spring,Docker,MySQL等等具体的技术是可以学习很快就熟练掌握的。软件真正复杂的部分,往往是业务本身,比如航空公司的超售策略,在超售之后Remove乘客的策略等;比如亚马逊的打折策略,物流策略等。

用软件模型如何优雅而合理的反应复杂的业务(以便在未来业务发生变化时可以更快速,更低错误的作出响应)本身也是复杂的。要将复杂的业务规则转换成软件模型是软件活动中非常重要的一环,也是信息传递往往会失真的一环。业务人员说的A可能被软件开发者理解成Z,反过来也一样。

举个例子,我给租来的房子买了1年的联通宽带。可是不多过了6个月后,房东想要卖房子把我赶了出来,在搬家之后,我需要通知联通公司帮我做移机服务。

如果纯粹从开发者的角度出发,写出来的代码可能看起来是这样的:

1
2
3
4
5
6
7
8
9
10
11
public class Customer {
  private String address;

  public void setAddress(String address) {
      this.address = address;
  }

  public String getAddress() {
      return this.address;
  }
}

中规中矩,一个简单的值对象。作为对比,通过与领域专家的交流之后,写出来的代码会是这样:

1
2
3
4
5
6
7
public class Customer {
  private String address;

      public void movingHome(String address) {
      this.address = address;
  }
}

通过引入业务场景中的概念movingHome,代码就变得有了业务含义,除了可读性变强之外,这样的代码也便于和领域专家进行交流和讨论。Eric在领域驱动设计(Domain Drvien Design)中将统一语言视为实施DDD的先决条件。

层次架构(三明治)

All problems in computer science can be solved by another level of indirection, except of course for the problem of too many indirections. – David Wheeler

上文提到,业务系统对外的呈现是对某种资源的管理,而且,现实世界里的业务系统往往要对多种资源进行管理。这些资源还会互相引用,互相交织。比如一个看板系统中的泳道、价值流、卡片等;LinkedIn中的公司,学校,个人,研究机构,项目,项目成员等,它们往往会有嵌套、依赖等关系。

为了管理庞大的资源种类和繁复的引用关系,人们自然而然的将做同样事情的代码放在了统一的地方。将不同职责的事物分类是人类在处理复杂问题时自然使用的一种方式,将复杂的、庞大的问题分解、降级成可以解决的问题,然后分而治之。

比如在实践中 ,展现部分的代码只负责将数据渲染出来,应用部分的代码只负责序列化/反序列化、组织并协调对业务服务的调用,数据访问层则负责屏蔽底层关系型数据库的差异,为上层提供数据。这就是层级架构的由来:上层的代码直接依赖于临近的下层,一般不对间接的下层产生依赖,层次之间通过精心设计的API来通信(依赖通常也是单向的)。

以现代的眼光来看,层次架构的出现似乎理所应当、自然而然,其实它也是经过了很多次的演进而来的。以JavaEE世界为例,早期人们会把应用程序中负责请求处理、文件IO、业务逻辑、结果生成都放在servlet中;后来发明了可以被Web容器翻译成servlet的JSP,这样数据和展现可以得到比较好的分离(当然中间还有一些迂回,比如JSTL、taglib的滥用又导致很多逻辑被泄露到了展现层);数据存储则从JDBC演化到了各种ORM框架,最后再到JPA的大一统。

如果现在把一个Spring-Boot写的RESTful后端,和SSH(Spring-Struts-Hibernate)流行的年代的后端来做对比,除了代码量上会少很多以外,层次结构上基本上并无太大区别。不过当年在SSH中复杂的配置,比如大量的XML变成了代码中的注解,容器被内置到应用中,一些配置演变成了惯例,大致来看,应用的层次基本还是保留了:

  • 展现层
  • 应用层
  • 数据访问层

在有些场景下,应用层内还可能划分出一个服务层。

前后端分离

随着智能设备的大爆发,移动端变成了展现层的主力,如何让应用程序很容易的适配新的展现层变成了新的挑战。这个新的挑战驱动出了前后端分离方式,即后端只提供数据(JSON或者XML),前端应用来展现这些数据。甚至很多时候,前端会成为一个独立的应用程序,有自己的MVC/MVP,只需要有一个HTTP后端就可以独立工作。

前后端分离可以很好的解决多端消费者的问题,后端应用现在不区分前端的消费者到底是谁,它既可以是通过4G网络连接的iOS上的Native App,也可以是iMac桌面上的Chrome浏览器,还可以是Android上的猎豹浏览器。甚至它还可以是另一个后台的应用程序:总之,只要可以消费HTTP协议的文本就可以了!

这不得不说是一个非常大的进步,一旦后端应用基本稳定,频繁改变的用户界面不会影响后端的发布计划,手机用户的体验改进也与后端的API设计没有任何关系,似乎一切都变的美好起来了。

业务与基础设施分离

不过,如果有一个消费者(一个业务系统),它根本不使用HTTP协议怎么办?比如使用消息队列,或者自定义的Socket协议来进行通信,应用程序如何处理这种场景?

这种情况就好比你看到了这样一个函数:

1
httpService(request, response);

作为程序员,自然会做一次抽象,将协议作为参数传入:

1
service(request, response, protocol);

更进一步,protocol可以在service之外构造,并注入到应用中,这样代码就可以适配很多种协议(比如消息队列,或者其他自定义的Socket协议)。

比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public interface Protocol {
  void transform(Request request, Response response);
}

public class HTTP implements Protocol {
}

public class MyProtocol implements Protocol {
}

public class Service {     
   public Service(Protocol protocol) {
      this.protocol = protocol;    
   }       

   public void service(request, response) {
      //business logic here
      protocol.transfrom(request, response);     
   }
}

类似的,对于数据的持久化,也可以使用同样的原则。对于代码中诸如这样的代码:

1
persisteToDatabase(data);

在修改之后会变成:

1
persistenceTo(data, repository);

应用依赖倒置原则,我们会写出这样的形式:

1
2
3
4
5
6
7
8
9
10
public class DomainService {
  public BusinessLogic(Repository repository) {
        this.repository = repository
  }

      public void perform() {
        //perform business logic
        repository.save(record);
  }
}

对于Repository可能会有多种实现。根据不同的需求,我们可以自由的在各种实现中切换:

1
2
public class InMemoryRepository implements Repository {}
public class RDBMSRepository implements Repository {}

这样业务逻辑和外围的传输协议、持久化机制、安全、审计等等都隔离开来了,应用程序不再依赖具体的传输细节,持久化细节,这些具体的实现细节反过来会依赖于应用程序。

通过将传统内置在层次架构中的数据库访问层、通信机制等部分的剥离,应用程序可以简单的分为内部和外部两大部分。内部是业务的核心,也就是DDD(Domain Driven Design)中强调的领域模型(其中包含领域服务,对业务概念的建立的模型等);外部则是类似RESTful API,SOAP,AMQP,或者数据库,内存,文件系统,以及自动化测试。

这种架构风格被称为六边形架构,也叫端口适配器架构。

六边形架构(端口适配器)

六边形架构最早由Alistair Cockburn 提出。在DDD社区得到了发展和推广,然后IDDD(《实现领域驱动设计》)一书中,作者进行了比较深入的讨论。

图片来源: slideshare

简而言之,在六边形架构风格中,应用程序的内部(中间的橙色六边形)包含业务规则,基于业务规则的计算,领域对象,领域事件等。这部分是企业应用的核心:比如在线商店里什么样的商品可以打折,对那种类型的用户进行80%的折扣;取消一个正在执行的流水线会需要发生什么动作,删除一个已经被别的Job依赖的Stage又应该如何处理。

而外部的,也是我们平时最熟悉的诸如REST,SOAP,NoSQL,SQL,Message Queue等,都通过一个端口接入,然后在内外之间有一个适配器组成的层,它负责将不同端口来的数据进行转换,翻译成领域内部可以识别的概念(领域对象,领域事件等)。

内部不关心数据从何而来,不关心数据如何存储,不关心输出时JSON还是XML,事实上它对调用者一无所知,它可以处理的数据已经是经过适配器转换过的领域对象了。

六边形架构的优点

  • 业务领域的边界更加清晰
  • 更好的可扩展性
  • 对测试的友好支持
  • 更容易实施DDD

要新添加一种数据库的支持,或者需要将RESTful的应用扩展为支持SOAP,我们只需要定义一组端口-适配器即可,对于业务逻辑部分无需触碰,而且对既有的端口-适配器也不会有影响。

由于业务之外的一切都属于外围,所以应用程序是真的跑在了Web容器中还是一个Java进程中其实是无所谓的,这时候自动化测试会容易很多,因为测试的重点:业务逻辑和复杂的计算都是简单对象,也无需容器,数据库之类的环境问题,单元级别的测试就可以覆盖大部分的业务场景。

这种架构模式甚至可能影响到团队的组成,对业务有深入理解的业务专家和技术专家一起来完成核心业务领域的建模及编码,而外围的则可以交给新人或者干脆外包出去。

在很多情况下,从开发者的角度进行的假设都会在事后被证明是错误的。人们在预测软件未来演进方向时往往会做很多错误的决定。比如对关系型数据库的选用,对前端框架的选用,对中间件的选用等等,六边形架构可以很好的帮助我们避免这一点。

小结

软件的核心复杂度在于业务本身,我们需要对业务本身非常熟悉才可能正确的为业务建模。通过统一的语言我们可以编写出表意而且易于和业务人员交流的模型。

另一方面模型应该尽可能的和基础设施(比如JSON/XML的,数据库存储,通信机制等)分离开。这样一来可以很容易用mock的方式来解耦模型和基础设施,从而更容易测试和修改,二来我们的领域模型也更独立,更精简,在适应新的需求时修改也会更容易。

这里有一段很微小的代码,有兴趣的同学可以看看。

如何成为一名优秀的程序员?

作为一个从业快10年的程序员,我想给新入行的程序员们一些建议。这些建议是我希望自己可以在毕业的时候就读到的,也希望它们可以帮助你成为一个更好的程序员。

简单归纳一下,总共有7条:

  1. 保持健康
  2. 编程之外的爱好
  3. 持续学习
  4. 正确应对犯错
  5. 不要囿于角色
  6. 展示你的创意
  7. 刻意练习手速

下面我来详细说说每一点。

保持健康

三寸气在千般用,一旦无常万事休

–《金瓶梅》

首先要说的当然是健康,脱离的这个本钱,一切都无从谈起。久坐、不运动、睡眠不足、不注意及时补充水分、长期的伏案工作会对健康造成很大的影响,而不幸的是程序员这几样全都占了。很多程序员往往很年轻就已经有了各种各样的疾病:颈椎病、腰椎间盘突出、高血脂/高血压、胆结石、腱鞘炎等等,关于程序员过劳死的新闻更是隔一段时间就来刺激一下我们的神经。

研究表明,长期保持同一姿势(不论坐着还是站着)对身体都有不同程度的害处,而且这种害处是无法事后弥补的。也就是说,如果白天上班坐8个小时,那么就算你下班后去健身房练一个小时也于事无补。这几年很流行的站立式办公也是一样,如果你白天站立时间过久,会对膝关节造成较大的压力,同样会损害健康。比较推荐的方式是,写30-40分钟代码就起来走一走,喝杯水,远眺一会,跟同事聊聊天。

我知道,作为程序员我也常遇到那种写代码写High了连厕所也不想去的时候。不过为了长远的健康,还是要养成良好的习惯。

戒除不良习惯

除了长时间保持同一姿势之外,有很多程序员还有各种不良习惯。比如:

  • 吸烟
  • 喝酒
  • 嗜糖(碳酸饮料,其他高糖饮料)

这些习惯一般都会美其名曰提神,大家都知道,程序员加班在业界算是比较常见的,萎靡不振是常态。然而这些号称提神的方法,其实没有一个是真正管用的。这些不良习惯说到底都是一种毒瘾,跟吸食大麻在本质上并无二致。不过好消息是你完全可以戒除这些不良习惯,只需要坚持一段时间,让毒瘾过去就好了(和真正的毒瘾一样,它们更多的是精神依赖,一旦你战胜了自己对它的精神依赖,就可以获得自由)。

我在大学和刚开始工作的前几年,也有烟瘾。写代码写累了就回去办公室外边冒一根,那种一氧化碳中毒带来的短暂的微醺感确实令人有放松的错觉,但是抽完烟回来写代码会感觉更累。而且口中老感觉有异味,咽喉不适,最主要的是精神萎靡,终于有一天我受不了了,决定戒烟(事实上和很多人一样,之前也有过无数次的戒烟)。当烟瘾发作的时候,我就去喝杯水,晚上则站站桩(站完之后口齿生津,神清气爽)。刚开始的3天是最难的,一周之后我就基本可以控制住去抽烟的欲望,然后就越来越轻松,完全感觉不到烟瘾对我的影响了。

碳酸饮料,高糖饮料也是一样。在饮食本来就不充裕的自然界,我们的祖先遇到了富含可以为身体提供能量的糖(比如蜂蜜)自然会大量摄入。这种嗜糖的基因在今天还在不断的产生作用,但是不同的是,我们现在可以很轻松的在食物、饮料中摄入比身体所需多得多的糖。这些糖会给健康带来很多问题,比如肥胖,高血糖,冠心病等等。

更多时候,我们想要喝饮料更多的是精神上的依赖,也就是上面说到的毒瘾。戒除对糖的依赖比烟和酒要困难一些,因为生活中有很多陷阱,比如酸奶,面包,饼干,水果等等。

零度可乐的陷阱

现在香烟的包装上印有焦油含量,有10mg的,有15mg的。焦油含量是影响一支烟口感的重要因素,通常说的“绵”其实是说焦油含量角度,这会让你感觉比较健康。然而陷阱是,一支烟抽完觉得不过瘾,神经感受到的刺激不够强烈,这会驱动你抽第二支,结果吸入的焦油反而更多。本来15mg焦油的一支烟就可以让你过瘾,现在两支10mg的才能达到同样的效果,相当于摄入了20mg。

零度可乐也是一样,那种无糖的有着甜味的添加剂会刺激你对糖的渴求,你需要摄入更多的糖来抵消这种虚幻的渴求 – 然后变得更不健康。

有人可能会说,没有这些嗜好,那活着有什么意思呢?相信我,当你戒除了这些毒瘾,有了一个健康的体魄,才真正能体会到活着的乐趣。当你为这些嗜好所控制,产生的那种病态的舒适感其实是虚无缥缈的。

一些建议

有规律的做一些运动,可以缓解颈椎,腰椎的不适,可以加快新陈代谢的速度,消耗多余的会沉积下来的能量。比如比较容易接触到,也容易上手的运动:

  • 瑜伽/普拉提
  • 乒乓球
  • 跳绳

选择一个适合自己的运动方式,然后将其培养成一个习惯(比如坚持每周两次瑜伽,或者每天中午打30分钟的乒乓球)。如果这些和工作有冲突的话,比如公司要求长期晚上加班,那你可以考虑换一家公司。

培养一个编程之外的爱好

如果让不同的人对程序员打标签并排序,一定会排在前三。在任何的聚会上,程序员总是很容易被识别出来的:聪明,戴眼镜,话不多,略显闷骚,聊天容易冷场等等。也难怪,长期钻研技术,沉浸在非黑即白的二进制世界,爱刨根问底,这样很容易把天聊死。

我建议新手程序员可以找一个编程之外的爱好,一来可以拓展自己的社交圈,周末可以有个不一样的过法(而不是宅在家里写代码);二来可以帮助你成为更好的程序员。

你肯定有过这样的经历:一个编程问题一直困扰着你,试了很久都找不到解决方法,结果出去散了会步,或者和别人唠家常,突然脑海里灵光一闪,想到了问题的答案。事实上,我们大脑的工作方式就是如此奇妙,换一个完全不同的上下文就可以让大脑得到很好的休息,而且往往可以产生1+1>2的效果。写代码写累了去听听音乐,或者打一会乒乓球就可以很好的缓解疲劳,甚至可以打开思路,产生新的灵感。

一些建议

学习一项与编程无关的技能,比如:

  • 乐器(比如吉他,架子鼓)
  • 绘画(素描,水粉,水彩等)或者书法
  • 制作美食
  • 某一项武术(拳击,泰拳,空手道等)

这些看似毫不相干的爱好可以帮助大脑休息。另外需要注意的是,你无需真正成为某一项爱好的专家,不要有额外的压力:担心演奏不好、没有绘画天赋等等。没关系,它只是一个爱好而已。

我自己就尝试过很多不同的爱好,比如素描、书法等。

持续学习

软件开发是一个需要终身学习的行业(其实如果你不想做那种混吃等死的人的话,基本上每个行业都是这样)。我毕业的时候,SSH(Spring Struts Hibernate)是Web开发的主流,jQuery则是前端的新锐。有一些企业开始尝试AdobeActionScript,不过这个语言很快就消逝在了人们的视野。基于jQUery,但是融入了MVC理念的Backbone.js提供更高级的抽象能力,成为了开发“大型”前端应用的首选;紧随其后的,大而全的Angular.js则通过内置的双向绑定,依赖注入,完善的测试支持等让前端开发变得和后端开发一样健全;再后来虚拟DOMReactive范式React栈则又一次颠覆了前端的开发方式。虽然现在还不知道下一次的颠覆会在哪里发生,但是可以肯定的是它一定会发生

除了基础框架之外,各种构建工具也是层出不穷,从最早和后端放在一起的mavenrake,到基于NodeJSgrunt,再到gulp,到webpack,最后又回归到npm script

程序员被裹挟在技术演进的洪流中,不能自已。作为程序员,你不但要非常扎实的掌握基础知识(操作系统原理,计算机网络,数据结构,算法等),还需要有非常强的快速学习能力,以及愿意不断去学习的态度,而后者可能更重要。

一些建议

  • 读书
  • 通过视频/文本教程等学习新技术

建议新手可以每天抽出一个小时来读书,周末可以多读一些。ThoughtWorks有个读书雷达,是一个很不错的书单,包括了很多的经典书籍。读书之外,还可以在线学习一些教程,比如TutorialplusEgghead等,都非常值得经常去看看,如果有比较新鲜有趣的技术,不妨自己亲自动手试一试。

关于英文能力

毫不夸张的说,英文能力是优秀程序员和普通程序员的华丽分割线。有了好的英文能力,可供你学习的资料库会立刻扩大数百甚至数千倍:海量的优质免费教程,视频。很优秀的中文教程一样,它们都深入浅出,通俗易懂,风趣幽默,只不过中文的会比较少,而且一般总是会滞后于英文版本而已。

英文能力不但可以帮你熟悉各种前端库,CSS框架等的介绍。还可以让你学习世界各国程序员对各种库的测评、框架的使用心得、踩过的坑等等。

我在2012年加入ThoughtWorks的时候,面试时磕磕绊绊的说不出话来。等到6个月试用期结束的时候,已经可以出差去澳洲和客户的OPs谈笑风生了。2013年的8月,在印度普内,我已经可以用英文给来自世界各国的学生讲课。

除了更顺畅的和不同文化的人交流,讨论问题之外,可以明显感觉到学习的速度变得更快,更有效率。

我自己实践过的一个比较有效的方法。我每天会花两个小时(早晚各一个小时)看澳洲之音上的视频,我会听写出视频中的每一句话,如果听不清就重复,有的句子可能会重复十遍。听到最后,视频中的每句话我都能听懂,而且能一边听一边写出来。这样坚持了差不多3个月,我基本上就可以听懂客户的需求澄清,开会的时候也可以比较完整的听明白每个人讨论的点。

其实诀窍就是坚持,这3个月中,每天两个小时,我没有一天间断。过了这一关之后,就很容易了,尽量多听多说就好。

另一个提高的方法是翻译书,我更建议你可以更另外一个有经验的同事一起翻译,大家互相监督,也有个照应,比较不容易半途而废。

正确应对犯错

斯坦福大学的Carol Dweck教授通过一些实验和后续的研究提出了很有名的心智模型(Mindset)理论,简而言之,她发现不同的人们对待失败这件事有着完全不同的态度:有一类人害怕失败,当失败后会变得不能接受,而且容易否定自身并影响进一步的尝试,Dweck教授称这类人为固定型思维模式(Fixed Mindset);而另一类人则“喜欢”失败,视失败/犯错为学习的一种方式,他们更关注过程而不是结果,Dweck教授称其为成长型思维模式(Growth Mindset)。

Dweck在演讲中提到,通过像成长型思维模式的转变,关注从失败/犯错中学习,人们的潜力可以得到很好的发挥,也更容易获得理想的结果。

很多新人不敢尝试,又不愿意让同事知道自己的不足,这样的态度会导致他更倾向于选择更容易的工作,这样就可以避免暴露自己的不足,久而久之就会形成恶性循环。其实企业对于新人的期望一般都不会很高,对于新人犯错也是有容忍度的,新人要勇于承认自己的不足,勇于尝试新的事物,勇于犯错并从中学习。

承认自己的不足在刚开始是一件很困难的事情,不过再尝试过几次之后,你就会发现其实也没有那么恐怖。你慢慢会喜欢那种不带任何包袱的、全身心聚焦在学习本身上的快乐。

不要被角色限制

都梁在《血色浪漫》里有段描述陕北农民的文字:

钟跃民惊讶地发现,在如此贫困恶劣的生存状态下,村民们却很少愁眉苦脸, 他们始终很乐观,他们最喜欢谈论的话题是饮食男女。在饮食方面,由于他们没见 过更好的食品,所以坚持认为酸汤饺子和油泼辣子是天下最美味的食品,如果有人 提出世上还有很多更好吃的东西,那大家会一致认为此人太没见过世面,这驴日的 八成是没吃过酸汤饺子,才在这儿胡咧咧.

就像酸汤饺子并非天下最美味的食品一样,开发也不是世界上最牛逼的工作。任何一个良好的,健康的产品、项目需要不同的角色共同配合,共同努力。如果仅仅将自己局限在程序员这一角色,时间久了未免会有坐井观天的狭隘。

坐井观天

作为程序员,既可以往上游去探索需求的梳理,用户痛点的分析,业务价值的挖掘,又可以向下游如测试的编写,产品的发布,运维监控。视野开拓了,才有可能对产品有整体的了解,也更容易在程序员这个角色上做的更好。

作为新人,能在自己擅长的方面发挥长处当然很好,但是如果仅仅局限在自己擅长的方面则未免太过单薄。如果你在前端非常有经验,那么除了将这些经验和知识分享给别人之外,你还可以向别的角色学习他们擅长的技能,比如向测试学习自动化,SBE等;向后端学习高性能,高可用服务器的技术,数据库设计及优化,API设计等;向DevOps学习运维技能,自动化provision技能等等。

这些不同的技术不但可以让你的视野更加开阔,也可以为自己以后尝试不同的角色和机会打好基础。以我自己为例,我刚工作的时候是一个Java开发,后来开始做产品的前端开发。换了工作后又跑到Linux下用C写服务,再后来加入ThoughtWorks后,正经职位是开发,不过在项目上还兼职过一段时间QA,在有些项目上当UX不在场的时候还可以做些简单的设计,在技术社区当讲师,还在一些客户现场做过咨询顾问。我自己觉得在不同的角色上切换非常有意思,我自己也很享受整个过程。

展示你的创意

将一个创意、复杂概念或者想法简洁而准确的描述出来是一个非常重要的能力。我见过太多的程序员都是沉默寡言,讲东西声音又小,又紧张,即使有很好的想法也难以完整的表述出来。

不过这个能力是可以锻炼的,只需要借助原型的制作就可以了:

  • 画图
  • 静态原型
  • 纸上原型

俗话说,一图胜千言。你只需要学习一些简单的绘画技巧就可以大大提高自己的表述能力。

通过用静态页面(HTML/CSS/JS),mock数据等方式,快速的将创意表达出来是程序员的一个优势,你可以用静态数据,数据文件等方式,通过一些简单的代码快速的作出可以做交互的原型,然后通过和用户不断确认的方式来渐进增强,这种做法可以避免太大的浪费,尽早的将客户价值交付。

原型并不局限在草图,可以工作的静态页面,还可以是一个清晰简洁的演讲。基于PPT的原型还可以用来分析目前产品痛点,对比方案的优劣,展示自己的看法等等。

纸上原型是另一种低成本,可供快速交流沟通的原型方式(图片来自我在ThoughtWorks的同事刘海生):

手速

关于程序员是否要求很快的手速是一个颇具争议的话题。支持者认为这属于基本功,每个程序员都应该打字都很快;反对者则认为程序员的价值在于思考并解决问题,追求速度快,那还不如招个打字员。我个人的观点是,好的程序员应该有很快的手速(包括打字的速度,但不局限于此)。

我在ThoughtWorks西安办公室组织过很多次提升手速的工作坊,比如三周三页面闪电计划等。基本原则就是对一个具体的“作业”,不断的重复练习。

最近带两个新人,我给他们布置了一个简单的作业:

图片来源:dirbbble.com

基本要求是以最快的速度实现这个页面,并有一点微小的交互(比如选择联系人之后的checkbox会显示选中状态,剩余invites的数量减少等)。第一次做他们用了5个多小时(连同搭建环境,安装Node.js,npm包等),第二次用时2个半小时,第三次用时1个半小时,第四次用时50分钟。

对同一个页面的不断练习听起来是在做重复工作,其实可以联系到很多的内容:

  • 命令行的熟悉程度
  • 快捷键的使用
  • 搜索引擎的使用
  • Stackoverflow使用

当你真的可以熟极而流的时候,你才有时间来考虑如何优化,比如如何抽取模板工程(这样下次做同样的事情就回快很多),如何精简DOM结构,如何用命令行工具来帮助自己提速等。手速是大前提,没有速度,一切优化都是脑海中的意淫,无法真实落地。

总结

要成为一个厉害的程序员,首先当然是要有一个好的身体。此外需要培养一个编程之外的爱好,这样可以让你活的像一个正常人(而不是传统的工科书呆子)。程序员是一个需要不断学习,不断充实的职业,在学习的过程中,英文能力可以帮助你学的更快,更有效,另外正确的应对学习过程中必然会犯的错误,并将每次错误都当成学习的机会。

开发只是软件开发流程中的一环,程序员需要拓展自己的视野,和其他角色一起配合才能保证产品的交付。在日常的开发中,程序员还需要快速、准确的将自己的想法和创意表达出来。最后,更快速的完成手头的工作可以让你有更多的时间来思考,来改进那些低效的工作方式。

扩展阅读

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个售前

其他

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

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

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