I code it

Code and Life

团队里的两类程序员

程序员的分类

最近几年,我在多个不同类型的项目上,以不同的角色工作过:有时候会为项目前期做一些预研、然后为后续的交付估算工作量;有时候则在项目中期加入团队,做本职的交付工作(就是写业务代码);而另外有些时候则会帮助客户的团队进行能力建设等等。

由于要在不同的场景和不同的上下文中频繁切换,我和很多不同水平、有着不同变成习惯和思维方式的程序员一起工作过。我觉得对于程序员这样一种角色,还可以再细化成两大类。虽然都是程序员,但是其职责,技能要求,甚至思维方式都可能有很大的不同。

这两大类程序员分别为:

  • 原型类程序员(prototyper)
  • 产品类程序员(engineer)

原型类程序员

如果通过敏捷的方式来运作一个项目,在项目开始的时候,开发往往需要客户/用户,BA(业务分析师)等角色一起协作。在有一个对需求的粗略梳理之后,通过技术手段快速实现应用程序的原型,用以快速验证业务场景。由于对需求的梳理是较为粗略的,其中有很多待验证的假设,如果按照传统的方式来运作,则会有很多不必要的浪费

  1. 设计师通过Photoshop作出页面
  2. 开发根据PS文件来开发
  3. 开发的同时,业务分析可能有了新的发现
  4. 设计师对Photoshop作出修改
  5. 开发根据新的PS来做修改

这种重量级的,瀑布方式的工作体验非常糟糕,不论是设计师,还是开发,总觉得很多事情都在变化,自己花费了很大力气做的像素级别的精准调整可能在第二天就要被推翻。不过现实世界就是这样:需求的变化是不可避免的,我们唯一能做的是积极拥抱变化,而不是在最开始就尝试设计、实现完美的效果。

与其在一开始就试图设计一个完美的、确定的系统,不如放弃幻想,每次只做简陋而可以示意的原型,然后不断去迭代,最后达到可用的系统。

比如,如果设计师完全不使用Photoshop,也不产出任何的PS文件,而是和开发坐在一起,开发一个简陋,但是易于修改(比如HTML+CSS)的页面,然后向业务分析师或者客户来确认,然后根据反馈来快速调整。在迭代过程中,每次确认之前,只做非常少量的工作,这样既可以避免返工,又可以让将来的调整更容易。

如果用这种方式来运作,我们队程序员的能力要求是这样的:

  • 手速快(可以在很短时间内作出一个可以工作的原型)
  • 技术广博(前后端)
  • 可以与不同角色一起工作(最好是通过结对的方式)

比如通过python -m http.server 8080来运行一个Web服务器,而不是去考虑负载均衡或者HTTP缓存等,也无需考虑在为前端代码加入动态路由。

很有可能,一个在<head>里通过CDN引入外部依赖,然后将所有代码都写入main.js就可以让我们的想法变成触手可及的应用,在建立起快速反馈的机制之后,就可以和我们的BA,甚至客户一起来协作,并快速打磨原型,使之和客户的期望契合。

原型驱动

去年我参加了一个非常有意思的项目,在我加入的时候,客户只有一个非常粗糙的想法:在网络(不是计算机网络)中,如果某个实体被攻击了,那么对整个系统的影响是什么?比如某个变电站由于气温太高爆炸了(6·18西安变电站爆炸事故),那么哪些地铁站会受到影响?以及收到多大影响?又或者某个运营商的网络中断了,那么它对该城市的各大银行的转账业务有多大影响?

客户希望有一个比较直观的方式来展现网络中的节点,以及当故障发生时会有什么样的影响。我们从原型开始,没有设计师的参加,也没有专门的业务分析师,一开始只有有一个简单的文档来描述,然后我根据文档画了一个草图:

和客户确认了,我用bootstrap+leaflet做了一个简单的页面:

客户表示大致是这么个意思。这时候我的代码是这样的:

  • 一个HTML文件
  • CDN来获取jQuerybootstrapleaflet
  • inline的script中写了一点微小的代码
  • inline的css

确定了这就是客户想要的效果之后,我做了一些简单的调整:

  • 将第三方依赖下载下来,放到本地的一个目录中
  • 将inline的JavaScriptCSS抽取到文件
  • 写了一个简单的shell script用来启停本地的HttpServer

几周之后,客户对右侧的两个panel有一些新的需求,要求有一棵树,可以点击,然后要做各种同步:

这时候,我的代码还是几个微小的jQuery写的文件,通过简单的事件机制来完成交互。很快,客户有了一个新的需求:中间区域的图中的非叶子节点要可点击,点击之后右侧的一个panel显示该节点下的所有叶子,同样,右侧panel中的节点也需要可点击。

也就是说,中间的树需要两个不同的视图,而背后的数据只有一份(这样点击时的展开和收起才会是同步的),这时候用事件机制就很难做到了,而且代码的复杂度直线上升,而且可维护性基本没有。

基本满足客户的需求之后,团队里加入了两位同事,这时候我做了一个比较大胆的决定,用vue.js将整个应用重写,将大部分功能都抽象为组件,方便以后的扩展。完成的时候,界面看起来是这样子的:

如果整理一下思路,原型类开发的流程是这样的:

  • CDN+胶水代码迅速实现
  • 及时与客户沟通,并根据反馈修改
  • 在需求相对稳定,方向确定之后,再做实际的技术选型
  • 重构 — 甚至重写(使用靠谱的技术站进行)

比如上面这个项目,如果现在让我再来做一次技术选型,我可能会这样选择:

  • 前端框架:React + Redux + React-router
  • 界面原型:AntDesign
  • 地图:Leaflet
  • 可视化:D3

产品类程序员

另一方面,当idea已经经过验证,我们需要通过严谨的工程实践将其产品化,那么对开发的要求却又有不同:

  • 非功能需求(安全/性能/容灾等)
  • 深刻而周全
  • 自动化测试
  • 软件架构

我在去年的另外一个项目上,负责一个具体模块的开发,一起在项目上有位同事,叫侯晓成。在kick off用户故事(开发和业务分析师,QA等角色一起确认需求,进入开发阶段之前的一个敏捷实践)的时候,他常常可以把BA问的答不上话来。

我们构建的系统需要管理零售中的促销活动,促销活动可以被多个不同的门店执行,这些门店可能位于全球任何一个地方。这时候就会涉及促销活动开始和结束的事件与时区的问题。比如管理员创建了一个圣诞促销,所有商品9折。但是由于时区的存在,各个国家的圣诞节并不是同一天,诸如此类的场景,在编码是都要考虑到。

侯晓成会非常全面的考虑各种业务场景。另一方面,大的系统往往涉及多个集成点,性能、安全,缓存的使用等等,都需要开发人员在编码时就考虑到。为了保证软件的内部质量,程序员还需要有非常好的测试意识和写测试的习惯,不论是比较高层次的集成测试还是更底层单元测试。

除了交付本身,产品型程序员可能还会考虑这样一些工程实践,来保证产品从开发到上线甚至到运维的各个方面:

  • 持续集成、持续交付
  • 自动化测试
  • DevOps

小结

毫无疑问,我们在实际项目中,这两种程序员都是缺一不可,相互补充的。在需求还没有完全明朗,需要快速验证市场,探索业务方向的正确性,我们需要原型类程序员。他们通过和业务分析师,设计师等角色一起工作,可以快速且轻量级的迭代出产品的原型。而一旦市场基本确定,我们就需要产品型程序员的帮助,将产品的开发、测试、部署、运维整个生命周期都规范化,引入相对重量且长期的工程实践,这样产品在后续的迭代中才可能保持非常高的内部质量。

反馈拯救世界

心流

你可能有过这样的体验:在玩一个很有趣的游戏时,时间会飞快的流逝,等你终于通关之后才发现已经是凌晨,而你的午饭和晚饭还都没吃。虽然可能饿着肚子,但是你内心却有一种很兴奋,很神清气爽的感觉。而当你在做一些不得不完成的工作/作业时(比如写年终总结报告),时间又会过得很慢,你的心情也常常变得焦虑或者暴躁。

通常来说,人的心情总是会在各种情绪中起伏不定,不过毋庸置疑,我们都希望永远或者至少是尽可能多的保持第一个场景中的状态。

这种精神高度集中,通过自己的努力不断完成挑战,并常常会有忘记时间流逝,甚至忘记自身存在,只剩下“做事情”本身的状态,在心理学上被称之为心流(Flow))。人们虽然很早就发现了这种现象,但是直到1975年,心理学家米哈里·齐克森米哈里(Mihaly Csikszentmihalyi)才将其概念化并通过科学的方式来研究。

心流(英语:Flow),也有别名以化境(Zone)表示,亦有人翻译为神驰状态,定义是一种将个人精神力完全投注在某种活动上的感觉;心流产生时同时会有高度的兴奋及充实感。

进入心流之后会有很多特征:

  • 愉悦
  • 全身心投入
  • 忘我,与做的事情融为一体
  • 主观的时间感改变

心流被普遍认为是一种绝佳的精神体验。根据齐克森米哈里的理论,与心流对应的,还有一些其他的心理状态:

当自身能力很高,但是做的事情很简单的话,你做起来会比较无聊;而当能力不足,要做的事情很困难的话,你又会陷入焦虑。有意思的是,如果你技能不足,而做的事情又比较简单的话,并不会产生“心流”体验。恰恰相反,这种状态(apathy)是很消极的,做事情的过程中,你既没有运用任何技能,也并没有接受到任何的挑战。

如何进入心流状态

齐克森米哈里要进入心流状态,需要满足至少三点:

  • 有清晰的目标
  • 有明确且事实的反馈
  • 能力和挑战的平衡(都处于比较高的状态)

比如,玩游戏的时候,目标是明确的,不论是简单的通过策略消灭对方,还是将三个同一颜色的宝石移动到同一行);反馈也是实时的,同色宝石连在一起是发出的声音以及屏幕上闪过的炫目的光芒,敌人在被你手中武器杀死时的惨叫,你自己的血槽等等;最后,游戏不能过于简单,如果太简单,你很快会觉得无聊,又不能太难,这样你会觉得挑战太大。

不过要在工作上进入心流状态,远远比玩游戏要复杂。比如不明确的目标,冗长的反馈周期,能力与挑战的不均衡等等。

基于反馈的开发

2014年底,我在ThoughtWorks组织3周3页面工作坊的时候,发现了一个很有意思的现象:通常公司内部的培训/工作坊都会出现这种现象:报名的人很多,前几次课会来很多人,慢慢的人数会减少,能坚持到最后的人很少,能完成作业的就更少了。而在3周3页面中,参加的人数越来越多,而且作业的完成率非常高(接近100%)。

回想起来,我在培训的最开始就设置了一些机制,保证学员可以有一个非常容易沉浸其中的环境:

  • 通过watch、livereload等机制,保证每次修改的CSS/HTML都可以在1秒钟内在浏览器上自动刷新
  • 通过对比mockup和自己实现的样式的差异,来调整自己的目标
  • 将这些工具做成开箱即用的,这样经验不足者不至于被技术细节阻塞
  • 做完之后,学员们的作品直接发布到github的pages

事实上,这些实践恰好满足了上述的几个条件:

  • 目标明确
  • 快速且准确的反馈
  • 技能与挑战的平衡

由于工作坊是在周内下班后(8点结束),我见到很多学员在课后(很晚的时候)还在写代码、调样式,完全沉浸其中,忘记时间。到最后,参加培训的学员们被要求通过设计原则自己实际一个Web Site,很多没有前段开发背景的同事也做出了非常有“设计感”的作品。

编程语言的壁垒

使用JavaScript或者Ruby这种解释型语言的开发人员,在第一次接触之后就会深深的爱上它,并再也无法回到编译型语言中去了。想一想要用Java打印一行Hello World你得费多大劲?

解释型语言中,你很容易可以采用REPL环境,写代码会变成一个做实验的过程:函数名写错了,参数传错了,某个函数没有定义等等错误/手误,都可以在1秒钟内得到反馈,然后你再根据反馈来修正方向。

举个小例子,写一个字符串处理函数:将字符串”qiu,juntao”转换成“Qiu Juntao”,你会怎么做?你大概知道有这样一些原生的API:

如果用JavaScript来实现的话,你可以在ChromeDevTools中完成大部分的工作:

注意这里的每次操作的反馈(特别是出错的情况),你可以在1秒钟内就知道明确的反馈,而不是等等待漫长的编译过程。DevTools里的console提供的REPL(read-eval-print-loop)可以帮助你建立流畅的编码体验。

如果用Java来做,你需要一大堆的准备工作,抛开JDK的安装,JAVA_HOME的设置等,你还需要编译代码,需要定义一个类,然后在类中定义main方法,然后可能要查一些API来完成函数的编写。而每一个额外的步骤都会延长反馈的时间。

测试驱动开发

那么在编译型语言中如何获得这种体验呢?又如何缩短反馈周期呢?答案是使用测试驱动开发(Test Driven Development)!

通常TDD会包含这样几个步骤:

  1. 根据用户故事做任务分解
  2. 根据分解后的Task设计测试
  3. 编写测试
  4. 编写可以通过测试的实现
  5. 重构

步骤3-5可能会不断重复,直到所有的Task都完成,用户故事也就完成了。如果仔细分析,这些步骤也恰好符合产生心流的条件:

  • 划分任务
  • 清晰每一个小任务
  • 通过测试得到快速而明确的反馈

虽然对上边字符串转换的例子来说,TDD的方式还是比较重量级,不过反馈更明确,你可以在写完一个函数之后立即得到测试成功或者失败的反馈(编译过程并没有省略,不过我们通过测试的红和绿来不断强化反馈)。

而且这种方法的好处在于:对于更加复杂的需求来说,它仍然适用。如果开发者的技能和需求的难度都比较高的话,这种方式很容易达到心流的状态。

小结

要想在工作中让自己过得更舒服一些,你需要创造条件让自己进入心流状态。在这些条件中,最重要的是要建立其一套快速反馈的机制,这个机制可以是:

  • 一个可以自动运行的测试套件
  • 一个可以迭代的页面原型
  • 一个watch所有HTML/SCSS的脚本+live-reload
  • 一个和你一起写代码的同事(结对编程)

另一方面,你需要不断的学习和练习,提升自己的技能,这样在遇到新的问题时才可能比较从容应对。不要忘了,更熟练的技能本身也是进入心流的重要条件之一。

从三明治到六边形

本文首先发表于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的战略模式
  • 转变思维方式,为业务梳理和理解赋予最高的优先级
  • 通过工程实践来确保落地

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