I code it

Code and Life

你为什么应该试一试Reflux?

一点背景

React在设计之初就只关注在View本身上,其余部分如数据的获取事件处理等,全然不在考虑之内。不过构建大型的Web前端应用,这些点又实在不可避免。所以Facebook的工程师提出了前端的Flux架构,这个架构的最大特点是单向数据流(后面详述)。但是Flux本身的实现有很多不合理的地方,比如单例的Dispatcher会在系统中有多种事件时导致臃肿的switch-cases等。

这里是Facebook官方提供的提供Flux的结构图

Flux Diagram

其实整个Flux背后的思想也不是什么新东西。在很久之前,Win32的消息机制(以及很多的GUI系统)就在使用这个模型,而且这也是一种被证实可以用来构建大型软件的模型。

鉴于Flux本身只是一个架构,而且Facebook提供的参考实现又有一些问题,所以社区有了很多版本的Flux实现。比如我们这里会用到的Reflux

Reflux简介

简而言之,Reflux里有两个组件:Store和Action。Store负责和数据相关的内容:从服务器上获取数据,并更新与其绑定的React组件(view controller);Action是一个事件的集合。Action和Store通过convention来连接起来。

具体来说,一个典型的过程是:

  1. 用户的动作(或者定时器)在组件上触发一个Action
  2. Reflux会调用对应的Store上的callback(自动触发)
  3. 这个callback在执行结束之后,会显式的触发(trigger)一个数据
  4. 对应的组件(可能是多个)的state会被更新
  5. React组件检测到state的变化后,会自动重绘自身

reflux data flow

一个例子

我们这里将使用React/Reflux开发一个实际的例子,从最简单的功能开始,逐步将其构建为一个较为复杂的应用。

这个应用是一个书签展示应用(数据来源于我的Google Bookmarks)。第一个版本的界面是这样的:

bookmarks list

要构建这样一个列表应用,我们需要这样几个部分:

  1. 一个用来fetch数据,存储数据的store (BookmarkStore)
  2. 一个用来表达事件的Action(BookmarkActions)
  3. 一个列表组件(BookmarkList)
  4. 一个组件条目组件(Bookmark)

定义Actions

1
2
3
4
5
6
var Reflux = require('reflux');
var BookmarkActions = Reflux.createActions([
  'fetch'
]);

module.exports = BookmarkActions;

第一个版本,我们只需要定义一个fetch事件即可。然后在Store中编写这个Action的回调:

定义Store

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
var $ = require('jquery');
var Reflux = require('reflux');
var BookmarkActions = require('../actions/bookmark-actions');

var Utils = require('../utils/fetch-client');

var BookmarkStore = Reflux.createStore({
  listenables: [BookmarkActions],

  init: function() {
      this.onFetch();
  },

  onFetch: function() {
      var self = this;
      Utils.fetch('/bookmarks').then(function(bookmarks) {
          self.trigger({
              data: bookmarks,
              match: ''
          });
      });
  }
});

module.exports = BookmarkStore;

此处,我们使用listenables: [BookmarkActions]来将Store和Action关联起来,根据conventionon+事件名称就是回调函数的名称。这样当Action被触发的时候,onFetch会被调用。当获取到数据之后,这里会显式的trigger一个数据。

List组件

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
var React = require('react');
var Reflux = require('reflux');

var BookmarkStore = require('../stores/bookmark-store.js');
var Bookmark = require('./bookmark.js');

var BookmarkList = React.createClass({
  mixins: [Reflux.connect(BookmarkStore, 'bookmarks')],

  getInitialState: function() {
      return {
          bookmarks: {data: []}
      }
  },

  render: function() {
      var list = [];
      this.state.bookmarks.data.forEach(function(item) {
        list.push(<Bookmark title={item.title} created={item.created}/>)
      });
  
      return <ul>
          {list}
      </ul>
  }
});

module.exports = BookmarkList;

在组件中,我们通过mixins: [Reflux.connect(BookmarkStore, 'bookmarks')]将Store和组件关联起来,这样List组件state上的bookmarks就和BookmarkStore连接起来了。当state.bookmarks变化之后,render方法就会被自动调用。

对于每一个书签,就只是简单的展示内容即可:

Bookmark组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var React = require('react');
var Reflux = require('reflux');
var moment = require('moment');

var Bookmark = React.createClass({

  render: function() {
      var created = new Date(this.props.created * 1000);
      var date = moment(created).format('YYYY-MM-DD');

      return <li>
          <div className='bookmark'>
              <h5 className='title'>{this.props.title}</h5>
              <span className='date'>Created @ {date}</span>
          </div>
      </li>;
  }
});

module.exports = Bookmark;

这里我使用了moment库将unix timestamp转换为日期字符串,然后写在页面上。

最后,Utils只是一个对jQuery的包装:

1
2
3
4
5
6
7
8
9
10
11
12
var $ = require('jquery');
var Promise = require('promise');

module.exports = {
    fetch: function(url) {
        var promise = new Promise(function (resolve, reject) {
            $.get(url).done(resolve).fail(reject);
        });

        return promise;
    }
}

我们再来总结一下,BookmarkStore在初始化的时候,显式地调用了onFetch,这个动作会导致BookmarkList组件的state的更新,这个更新会导致BookmarkList的重绘,BookmarkList会依次迭代所有的Bookmark

更复杂一些

当然,Reflux的好处不仅仅是上面描述的这种单向数据流。当StoreActions以及具体的组件被解耦之后,构建大型的应用才能成为可能。我们来对上面的应用做一下扩展:我们为列表添加一个搜索功能。

随着用户的键入,我们发送请求到服务器,将过滤后的数据渲染为新的列表。我们需要这样几个东西

  1. 一个SearchBox组件
  2. 一个新的Actionsearch
  3. BookmarkStore上的一个新方法onSearch
  4. 组件SearchBox需要和BookmarkActions关联起来

为了让用户看到匹配的效果,我们需要将匹配到的关键字高亮起来,这样我们需要在Bookmark组件中监听BookmarkStore,当BookmarkStore发生变化之后,我们就可以即时修改书签的title了。

搜索框组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var React = require('react');
var BookmarkActions = require('../actions/bookmark-actions');

var SearchBox = React.createClass({
  performSearch: function() {
      var keyword = this.refs.keyword.value;
      BookmarkActions.search(keyword);
  },

  render: function() {
      return <div className="search">
          <input type='text'
              placeholder='type to search...'
              ref="keyword"
              onChange={this.performSearch} />   
      </div>
  }
});

module.exports = SearchBox;

BookmarkStore

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
var $ = require('jquery');
var Reflux = require('reflux');
var BookmarkActions = require('../actions/bookmark-actions');

var Utils = require('../utils/fetch-client');

var BookmarkStore = Reflux.createStore({
  listenables: [BookmarkActions],

  init: function() {
      this.onFetch();
  },

  onFetch: function() {
      var self = this;
      Utils.fetch('/bookmarks').then(function(bookmarks) {
          self.trigger({
              data: bookmarks,
              match: ''
          });
      });
  },

  onSearch: function(keyword) {
      var self = this;

      Utils.fetch('/bookmarks?keyword='+keyword).then(function(bookmarks) {
          self.trigger({
              data: bookmarks,
              match: keyword
          });
      });
  }
});

module.exports = BookmarkStore;

我们在BookmarkStore中添加了onSearch方法,它会根据关键字来调用后台API进行搜索,并将结果trigger出去。由于数据本身的结构并没有变化(只是数量会由于过滤而变少),因此BookmarkList是无需修改的。

书签高亮

当搜索匹配之后,我们可以将对应的关键字高亮起来,这时候我们需要修改Bookmark组件:

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
37
38
39
40
41
42
43
var React = require('react');
var Reflux = require('reflux');
var moment = require('moment');

var BookmarkStore = require('../stores/bookmark-store.js');

var Bookmark = React.createClass({
  mixins: [Reflux.listenTo(BookmarkStore, 'onMatch')],

    onMatch: function(data) {
        this.setState({
            match: data.match
        });
    },

  getInitialState: function() {
      return {
          match: ''
      }
  },

  render: function() {
      var created = new Date(this.props.created * 1000);
      var date = moment(created).format('YYYY-MM-DD');

      var title = this.props.title;
      if(this.state.match.length > 0) {
          title = <span
              dangerouslySetInnerHTML={{
                __html : this.props.title.replace(new RegExp('('+this.state.match+')', "gi"), '<span class="highlight">$1</span>')
              }} />
      }

      return <li>
          <div className='bookmark'>
              <h5 className='title'>{title}</h5>
              <span className='date'>Created @ {date}</span>
          </div>
      </li>;
  }
});

module.exports = Bookmark;

mixins: [Reflux.listenTo(BookmarkStore, 'onMatch')]表示,我们需要监听BookmarkStore的变化,当变化发生时,调用OnMatch方法。OnMatch会修改组件的match属性,从而触发render

render中,我们替换关键字为<span class="highlight">$keyword</span>,这样就可以达到预期的效果了:

bookmark search

结论

从上面的例子可以看到,我们从一开始就引入了Reflux。虽然第一个版本和React原生的写法差异并不是很大,但是当加入SearchBox功能之后,需要修改的地方非常清晰:添加Actions,在对应的Store中添加callback,然后在组件中使用。这种方法不仅可以最大程度的使用React的长处(diff render),而且使得代码逻辑变得较为清晰。

随着业务代码的不断增加,Reflux提供的方式确实可以在一定程度上控制代码的复杂性和可读性。

完整的代码地址在这里

其他参考

看看这些年你都学了什么?

数据可视化

多年下来,我的Google Bookmarks里已经有近万条的书签。大部分内容是我在读过一遍之后就收藏起来的,也有很多看了一眼之后,觉得不错,然后收藏起来准备以后读的(当然,你也知道,再也没有打开过)。

有没有一个方法可以让我以可视化的方式,看到这些年我都学了那些东西呢?将书签列表作为源数据,然后将这些信息可视化出来会非常有意思:比如收藏夹中的热门词是什么,哪段时间收藏了多少条的书签(学习投入程度趋势)等等。

下图是我的书签中,排行前30的关键字排序。可以明显的看出,我对于JavaScript的喜爱程度相当高,对美食的喜爱也超过了pythonlinux

bookmarks trending

这里我将使用python,结合python的一些库来实现书签可视化。简而言之,整个过程可以分成这样几个步骤:

  1. 将Google Bookmarks导出为本地文件
  2. 将书签文件解析为容易处理的内部格式(比如python的dict等)
  3. 由于书签中会有中文的句子,所以会涉及到分词
  4. 统计词语的频率,并绘制成图标

数据源

Google Bookmarks本身可以直接导出成HTML文件。该HTML文件包含了时间戳和书签的标题,我们可以通过python的库BeautifulSoup将HTML中的文本抽取出来:

1
2
3
4
5
6
7
8
from bs4 import BeautifulSoup

def load_bookmarks_data():
  soup = BeautifulSoup(open('bookmarks_10_21_15.html').read(), "html.parser")
  return soup.get_text()
  
if __name__ == "__main__":
  print load_bookmarks_data()    

BeautifulSoup提供非常好用的API来抽取结构化文档中的内容。

bookmark titles

分词

BeautifulSoup获得的是一条条独立的句子,我们需要使用分词器将所有的句子分解成片段。这里我使用了jieba结巴分词)分词器来完成这个任务:

1
2
3
4
5
6
7
import jieba

data = "我在出报表,你的博客写的怎么样了"
seg_list = jieba.cut(data, cut_all=False)

for seg in seg_list:
     print seg

将会输出:

1
2
3
4
5
6
7
8
9
10
11
12



报表



博客


怎么样

我们定义一个方法来将上一步中的文本分词:

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

分词之后,我们将单词转换为小写,并剔除掉那些长度小于1的字符串。这样可以保证所有的词都是词语。python的list推导式写起来非常简洁,一行代码就完成了过滤映射的工作。

可视化

有了分好的词之后,只需要统计每个词出现的频率,然后按照频率来绘制图表。我们使用python标准库中的Counter来实现:

1
2
3
4
5
from collections import Counter

def tokenize(): 
  filtered = extract_segments(load_bookmarks_data())
  return Counter([word for word in filtered])

要获取前N个,只需要使用most_common(N)即可:

1
tokenize().most_common(10)
1
[(u'and', 552), (u'the', 501), (u'with', 485), (u'to', 446), (u'javascript', 432), (u'in', 330), (u'for', 308), (u'...', 270), (u'java', 270), (u'blog', 269)]

有了数据之后,使用matplotlib做一个简单的bar图标:

1
2
3
4
5
6
7
8
9
10
11
import matplotlib
import matplotlib.pyplot as plt

from pandas import DataFrame

def visualize():
  frame = DataFrame(tokenize().most_common(20), columns=['keywords', 'frequencies'])

  ax = frame.plot(kind='bar')
  
  plt.savefig('bookmarks_trending.png', format='png', dpi=600)

stopwords

不过,上图中有很多噪音信息,如and, the等,这些在所有文章中都会出现的词并没有实际意义,统称为stopwords。通常在计算过程中会将其忽略:

1
2
3
4
5
6
def tokenize():  
  stoplist = ['i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your', 'yours', 'yourself', 'yourselves', 'he', 'him', 'his', 'himself', 'she', 'her', 'hers', 'herself', 'it', 'its', 'itself', 'they', 'them', 'their', 'theirs', 'themselves', 'what', 'which', 'who', 'whom', 'this', 'that', 'these', 'those', 'am', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'having', 'do', 'does', 'did', 'doing', 'a', 'an', 'the', 'and', 'but', 'if', 'or', 'because', 'as', 'until', 'while', 'of', 'at', 'by', 'for', 'with', 'about', 'against', 'between', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'to', 'from', 'up', 'down', 'in', 'out', 'on', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 's', 't', 'can', 'will', 'just', 'don', 'should', 'now']
  stoplist.extend(['...', 'com', 'using', u'使用', 'blog', u'博客', u'博客园', u'做法', u'论坛', 'part', u'部分', u'天下'])
  filtered = extract_segments(load_bookmarks_data())
  
  return Counter([word for word in filtered if word not in stoplist])

重新绘制即可获得上文中的图:

bookmarks trending

完整的代码请参考这里

工程中的编译原理 – Mapfile解析器

前言

Mapfile是MapServer用来描述一个地图的配置文件。它是一个很简单的声明式语言,一个地图(Map)可以有多个层(Layer),每个层可以有很多属性(键值对)。在一个层的定义中,还可以定义若干个类(Class),这个类用以管理不同的样式(Style)。而每个类或者样式都可以由若干个属性(键值对)。

这里有一个实际的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
LAYER
  NAME         "counties"
  DATA         "counties-in-shaanxi-3857"
  STATUS       default
  TYPE         POLYGON
  TRANSPARENCY 70

  CLASS
    NAME       "polygon"
    STYLE
      COLOR     255 255 255
      OUTLINECOLOR 40 44 52
    END
  END
END

最简单的层的定义

最简单的情形是,我们定义了一个层Layer,但是没有指定任何的属性:

1
2
LAYER
END

我们期望parser可以输出:

1
{layer: null}

要做到这一步,首先需要定义符号LAYEREND,以及一些对空格,非法字符的处理等:

1
2
3
4
5
6
\s+                     /* skip whitespace */
\n|\r\n                 /* skip whitespace */
"LAYER"                 return "LAYER"
"END"                   return "END"
<<EOF>>                 return 'EOF'
.                       return 'INVALID'

对于,空格,回车换行等,我们都直接跳过。对应的BNF也非常简单:

1
2
3
4
5
6
7
expressions
    : decls EOF {return $1;}
    ;

decls
    : LAYER END {$$ = {layer: null}}
    ;

为层添加属性

接下来我们来为层添加Name属性,首先还是添加符号NAME和对字符串的定义。这里的字符串被定义为:由双引号括起来的所有内容。

1
2
3
"NAME"                  return "NAME"
'"'("\\"["]|[^"])*'"'   return 'STRING'
[a-zA-Z]+               return 'WORD'

然后我们就可以为BNF添加一个新的节:

1
2
3
4
5
6
7
8
decls:
  LAYER decl END
  {$$ = {layer: $2}}
  ;

decl:
  NAME STRING
  {$$ = $2.substring(1, $2.length - 1)};

decl中,我们将获得的字符串两头的引号去掉$2.substring。这样decl的值就会是字符串本身,而不是带着双引号的字符串了。修改之后的代码可以解析诸如这样的声明:

1
2
3
LAYER
  NAME "counties"
END

并产生这样的输出:

1
{ layer: 'counties' }

但是如果我们用来解析两个以上的属性:

1
2
3
4
LAYER
  NAME         "counties"
  DATA         "counties-in-shaanxi-3857"
END

解析器会报告一个错误:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ node map.js expr

/Users/jtqiu/develop/ideas/jison-demo/mapfile/map.js:106
        throw new Error(str);
              ^
Error: Parse error on line 2:
...       "counties"  DATA         "counti
----------------------^
Expecting 'END', got 'WORD'
    at Object.parseError (/Users/jtqiu/develop/ideas/jison-demo/mapfile/map.js:106:15)
    at Object.parse (/Users/jtqiu/develop/ideas/jison-demo/mapfile/map.js:171:22)
    at Object.commonjsMain [as main] (/Users/jtqiu/develop/ideas/jison-demo/mapfile/map.js:620:27)
    at Object.<anonymous> (/Users/jtqiu/develop/ideas/jison-demo/mapfile/map.js:623:11)
    at Module._compile (module.js:456:26)
    at Object.Module._extensions..js (module.js:474:10)
    at Module.load (module.js:356:32)
    at Function.Module._load (module.js:312:12)
    at Function.Module.runMain (module.js:497:10)
    at startup (node.js:119:16)

即,期望一个END符号,但是却看到了一个WORD符号。我们只需要稍事修改,就可以让当前的语法支持多个属性的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
decls:
  LAYER pairs END
  {$$ = {layer: $2}}
  ;

pairs:
  pair
  {$$ = $1}
  |
  pairs pair
  {$$ = merge($1, $2)}
  ;

pair:
  NAME STRING
  {$$ = {name: $2.substring(1, $2.length - 1)}}
  | DATA STRING
  {$$ = {data: $2.substring(1, $2.length - 1)}};

先看,pair的定义,它由NAME STRING或者DATA STRING组成,是我们语法中的终结符。再来看pairs的定义:

1
pairs: pair | pairs pair;

这个递归的定义可以保证我们可以写一条pair或者多条pairs pair属性定义语句。而对于多条的情况,我们需要将这行属性规约在一起,即当遇到这样的情形时:

1
2
NAME         "counties"
DATA         "counties-in-shaanxi-3857"

我们需要产生这样的输出:{name: "counties", data: "counties-in-shaanxi-3857"}。但是由于符号是逐个匹配的,我们会得到这样的匹配结果:{name: "counties"}{data: "counties-in-shaanxi-3857"},因此我们需要编写一个简单的函数来合并这些属性:

1
2
3
4
5
6
7
8
9
10
11
12
  function merge(o1, o2) {
    var obj = {};

    for(var k in o1) {
      obj[k] = o1[k];
    }
    for(var v in o2) {
      obj[v] = o2[v];
    }

    return obj;
  }

按照惯例,这种自定义的函数需要被定义在%{}%括起来的section中:

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
...
[a-zA-Z]+               return 'WORD'
[0-9]+("."[0-9]+)?      return 'NUMBER'
<<EOF>>                 return 'EOF'
.                       return 'INVALID'

/lex

%{
  function merge(o1, o2) {
    var obj = {};

    for(var k in o1) {
      obj[k] = o1[k];
    }
    for(var v in o2) {
      obj[v] = o2[v];
    }

    return obj;
  }
%}

%start expressions

%% /* language grammar */
...

现在我们的解析器就可以识别多条属性定义了:

1
2
$ node map.js expr
{ layer: { name: 'counties', data: 'counties-in-shaanxi-3857' } }

嵌套的结构

现在新的问题又来了,我们的解析器现在可以识别对层的对个属性的解析了,不过由于CLASS并不是由简单的键值对定义的,所以还需要进一步的修改:

1
2
3
4
classes:
  CLASS pairs END
  {$$ = {class: $2}}
  ;

类由CLASS关键字和END关键字定义,而类的属性定义和Layer的属性定义并无二致,都可以使用pairs(多条属性)。而classes事实上是pair的另一种形式,就像对属性的定义一样,所以:

1
2
3
4
5
6
7
pair:
  NAME STRING
  {$$ = {name: $2.substring(1, $2.length - 1)}}
  | DATA STRING
  {$$ = {data: $2.substring(1, $2.length - 1)}}
  | classes
  {$$ = $1};

这样,解析器就可以识别CLASS子句了。我们注意到,在CLASS中,还可以定义STYLE,因此又需要稍作扩展:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pair:
  NAME STRING
  {$$ = {name: $2.substring(1, $2.length - 1)}}
  | DATA STRING
  {$$ = {data: $2.substring(1, $2.length - 1)}}
  | classes
  {$$ = $1}
  | styles
  {$$ = $1};

styles:
  STYLE pairs END
  {$$ = {style: $2}}
  ;

这样,我们的解析器就可以处理样例中的所有语法了:

1
2
3
4
5
6
7
node map.js expr
{ layer:
   { name: 'counties',
     data: 'counties-in-shaanxi-3857',
     status: 'default',
     type: 70,
     class: { name: 'polygon', style: [Object] } } }

完整的代码在github上的这个repo中

总结

使用BNF定义一个复杂配置文件的规则,事实上一个比较容易的工作。要手写这样一个解析器需要花费很多的时间,而且当你需要parser多种配置文件时,这将是一个非常无聊且痛苦的事情。学习jison可以帮助你很快的编写出小巧的解析器,在上面的Mapfile的例子中,所有的代码还不到100行。下一次再遇到诸如复杂的文本解析,配置文件读取的时候,先不要忙着编写正则表达式,试试更高效,更轻便的jison吧。

工程中的编译原理 – Jison入门篇

前言

在代码编写中,很多时候我们都会处理字符串:发现字符串中的某些规律,然后将想要的部分抽取出来。对于发杂一些的场景,我们会使用正则表达式来帮忙,正则表达式强大而灵活,主流的变成语言如JavaRuby的标准库中都对其由很好的支持。

但是有时候,当接收到的字符串结构更加复杂(往往会这样)的时候,正则表达式要么会变的不够用,要么变得超出我们能理解的复杂度。这时候,我们可能借助一些更为强大的工具。

下面是一个实际的例子,这个代码片段是MapServer的配置文件,它用来描述地图中的一个层,其中包含了嵌套的CLASS,而CLASS自身又包含了一个嵌套的STYLE节。显然,正则表达式在解释这样复杂的结构化数据方面,是无法满足需求的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
LAYER
  NAME         "counties"
  DATA         "counties-in-shaanxi-3857"
  STATUS       default
  TYPE         POLYGON
  TRANSPARENCY 70

  CLASS
    NAME       "polygon"
    STYLE
      COLOR     255 255 255
      OUTLINECOLOR 40 44 52
    END
  END
END

在UNIX世界,很早的时候,人们就开发出了很多用来生成解释器(parser)的工具,比如早期的lex)/yacc之类的工具和后来的bison。通过这些工具,程序员只需要定义一个结构化的文法,工具就可以自动生成解释器的C代码,非常容易。在JavaScript世界中,有一个非常类似的工具,叫做jison。在本文中,我将以jison为例,说明在JavaScript中自定义一个解释器是何等的方便。

注意,我们这里说的解释器不是一个编译器,编译器有非常复杂的后端(抽象语法树的生成,虚拟机器指令,或者机器码的生成等等),我们这里仅仅讨论一个编译器的前端

一点理论知识

本文稍微需要一点理论知识,当年编译原理课的时候,各种名词诸如规约推导式终结符非终结符等等,

上下文无关文法(Context Free Grammar)

先看看维基上的这段定义:

在计算机科学中,若一个形式文法 G = (N, Σ, P, S) 的产生式规则都取如下的形式:V -> w,则称之为上下文无关文法(英语:context-free grammar,缩写为CFG),其中 V∈N ,w∈(N∪Σ)* 。上下文无关文法取名为“上下文无关”的原因就是因为字符 V 总可以被字串 w 自由替换,而无需考虑字符 V 出现的上下文。

基本上跟没说一样。要定义一个上下文无关文法,数学上的精确定义是一个在4元组:G = (N, Σ, P, S),其中

  1. N是“非终结符”的集合
  2. Σ是“终结符”的集合,与N的交集为空(不想交)
  3. P表示规则集(即N中的一些元素以何种方式)
  4. S表示起始变量,是一个“非终结符”

其中,规则集P是重中之重,我们会在下一小节解释。经过这个形式化的解释,基本还是等于没说,在继续之前,我们先来看一下BNF,然后结合一个例子来帮助理解。

话说我上一次写这种学院派的文章还是2009年,时光飞逝。

巴科斯范式(Backus Normal Form)

维基上的解释是:

巴科斯范式(英语:Backus Normal Form,缩写为 BNF),又称为巴科斯-诺尔范式(英语:Backus-Naur Form,也译为巴科斯-瑙尔范式、巴克斯-诺尔范式),是一种用于表示上下文无关文法的语言,上下文无关文法描述了一类形式语言。它是由约翰·巴科斯(John Backus)和彼得·诺尔(Peter Naur)首先引入的用来描述计算机语言语法的符号集。

简而言之,它是由推导公式的集合组成,比如下面这组公式:

1
2
3
4
S -> T + T | T - T | T
T -> F * F | F / F | F
F -> NUMBER | '(' S ')'
NUMBER ->  0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9

可以被“继续分解”的元素,我们称之为“非终结符”,如上式中的S, T, NUMBER,而无法再细分的如0..9()则被称之为终结符。|表示或的关系。在上面的公式集合中,S可以被其右边的T+T替换,也可以被T-T替换,还可以被T本身替换。回到上一小节最后留的悬疑,在这里:

  1. N就是{S, T, F, NUMBER}
  2. Σ就是{0, 1, …, 9, (, ), +, -, *, /}
  3. P就是上面的BNF式子
  4. S就是这个的S(第一个等式的左边状态)

上面的BNF其实就是四则运算的形式定义了,也就是说,由这个BNF可以解释一切出现在四则运算中的文法,比如:

1
2
3
1+1
8*2+3
(10-6)*4/2

而所谓上下文无关,指的是在推导式的左边,都是非终结符,并且可以无条件的被其右边的式子替换。此处的无条件就是上下文无关。

实现一个四则运算计算器

我们这里要使用jison,jison是一个npm包,所以安装非常容易:

1
npm install -g jison

安装之后,你本地就会有一个命令行工具jison,这个工具可以将你定义的jison文件编译成一个.js文件,这个文件就是解释器的源码。我们先来定义一些符号(token),所谓token就是上述的终结符

第一步:识别数字

创建一个新的文本文件,假设就叫calc.jison,在其中定义一段这样的符号表:

1
2
3
4
\s+                   /* skip whitespace */
[0-9]+("."[0-9]+)?    return 'NUMBER'
<<EOF>>               return 'EOF'
.                     return 'INVALID'

这里我们定义了4个符号,所有的空格(\s+),我们都跳过;如果遇到数字,则返回NUMBER;如果遇到文件结束,则返回EOF;其他的任意字符(.)都返回INVALID

定义好符号之后,我们就可以编写BNF了:

1
2
3
4
5
6
7
expressions
    : NUMBER EOF
        {
        console.log($1);
        return $1;
        }
    ;

这里我们定义了一条规则,即expressions -> NUMBER EOF。在jison中,当匹配到规则之后,可以执行一个代码块,比如此处的输出语句console.log($1)。这个产生式的右侧有几个元素,就可以用$加序号来引用,如$1表示NUMBER实际对应的值,$2EOF

通过命令

1
jison calc.jison

可以在当前目录下生成一个calc.js文件,现在来创建一个文件expr,文件内容为一个数字,然后执行:

1
node calc.js expr

来测试我们的解释器:

1
2
3
$ echo "3.14" > expr
$ node calc.js expr
3.14

目前我们完整的代码仅仅20行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* lexical grammar */
%lex
%%

\s+                   /* skip whitespace */
[0-9]+("."[0-9]+)?    return 'NUMBER'
<<EOF>>               return 'EOF'
.                     return 'INVALID'

/lex

%start expressions

%% /* language grammar */

expressions
    : NUMBER EOF
        {
        console.log($1);
        return $1;
        }
    ;

加法

我们的解析器现在只能计算一个数字(输入给定的数字,给出同样的输出),我们来为它添加一条新的规则:加法。首先我们来扩展目前的BNF,添加一条新的规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
expressions
    : statement EOF
        {
        console.log($1);
        return $1;
        }
    ;

statement:
  NUMBER PLUS NUMBER
  {$$ = $1 + $3}
  |
  NUMBER
  {$$ = $1}
  ;

即,expressionsstatement组成,而statement可以有两个规则规约得到,一个就是纯数字,另一个是数字 加号 数字,这里的PLUS是我们定义的一个新的符号:

1
"+"    return "PLUS"

当输入匹配到规则数字 加号 数字时,对应的块{$$ = $1 + $3}会被执行,也就是说,两个NUMBER对应的值会加在一起,然后赋值给整个表达式的值,这样就完成了语义的翻译。

我们在文件expr中写入算式:3.14+1,然后测试:

1
2
3
$ jison calc.jison
$ node calc.js expr
13.14

嗯,结果有点不对劲,两个数字都被当成了字符串而拼接在一起了,这是因为JavaScript中,+的二义性和弱类型的自动转换导致的,我们需要做一点修改:

1
2
3
4
5
6
7
statement:
  NUMBER PLUS NUMBER
  {$$ = parseFloat($1) + parseFloat($3)}
  |
  NUMBER
  {$$ = $1}
  ;

我们使用JavaScript内置的parseFloat将字符串转换为数字类型,再做加法即可:

1
2
3
$ jison calc.jison
$ node calc.js expr
4.140000000000001

更多的规则

剩下的事情基本就是把BNF翻译成jison的语法了:

1
2
3
4
S -> T + T | T - T | T
T -> F * F | F / F | F
F -> NUMBER | '(' S ')'
NUMBER ->  0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
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
expressions
    : statement EOF
        {
        console.log($1);
        return $1;
        }
    ;

statement:
  term PLUS term {$$ = $1 + $3}
  |
  term MINUS term {$$ = $1 - $3}
  |
  term {$$ = $1}
  ;

term:
  factor MULTIPLE factor {$$ = $1 * $3}
  |
  factor DIVIDE factor {$$ = $1 / $3}
  |
  factor {$$ = $1}
  ;

factor:
  NUMBER {$$ = parseFloat($1)}
  |
  LP statement RP {$$ = $2}
  ;

这样,像复杂一些的四则运算:(10-2) * 3 + 2/4,我们的计算器也已经有能力来计算出结果了:

1
2
3
$ jison calc.jison
$ node calc.js expr
24.5

总结

我们在本文中讨论了BNF和上下文无关文法,以及这些理论如何与工程实践联系起来。这里的四则运算计算器当然是一个很简单的例子,不过我们从中可以看到将BNF形式文法翻译成实际可以工作的代码是多么方便。我在后续的文章中会介绍jison更高级的用法,以及如何在实际项目中使用jison产生的解释器。

可视化你的足迹 - Web端

可视化你的足迹

上一篇文章讲述了如何在服务器端通过MapServer来生成地图。虽然MapServer发布出来的地图是标准的WMS服务,但是我们还需要一个客户端程序来展现。我们在上一篇中,通过一些小脚本将照片中的地理信息抽取到了一个GeoJSON文件中。GeoJSON是一种向量图层格式,向量数据可以在服务器端绘制成栅格图,也可以直接在客户端canvas上直接绘制出来。当数据量比较大的时候,我们更倾向于在服务器端绘制,这样只需要在网络上传输一张图片(而且可以做缓存)。大数据量的客户端绘制在性能上会比较差(当然现在已经有了一些新的解决方案,我们后续再细谈),特别是有用户交互时,会出现明显的卡顿。

在本文中,我将分别使用客户端和服务端绘制的两种方式来展现两种不同的地图:使用OpenLayers直接在客户端绘制矢量图,以及使用Leaflet来展示在服务器端绘制好的栅格图层。

使用OpenLayers3展示GeoJSON

展示GeoJSON非常容易,也是一种比较直接的方式,只需要将GeoJSON文件发送到前端,然后直接通过客户端渲染即可。使用OpenLayers3的API,代码会是这样:

1
2
3
4
5
6
7
  $.getJSON('data/places-ive-been-3857.json').done(function(geojson) {

    var vectorSource = new ol.source.Vector({
      features: (new ol.format.GeoJSON()).readFeatures(geojson)
    });

  });

客户端发送一个ajax请求,得到GeoJSON数据之后,将其转换成一个向量类型。OpenLayers定义了很多中格式读取器,比如KML的,GML的,GeoJSON的等等。然后我们可以定义一个样式函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  var image = new ol.style.Circle({
    radius: 5,
    fill: null,
    stroke: new ol.style.Stroke({color: '#f04e98', width: 1})
  });

  var styles = {
    'Point': [new ol.style.Style({
      image: image
    })]
  };

  var styleFunction = function(feature, resolution) {
    return styles[feature.getGeometry().getType()];
  };

这个函数会应用到向量集的Point类型,将其绘制为一个红色,半径为5像素的圆圈。有了数据和样式,我们再来创建一个新的向量,然后生成一个新的图层:

1
2
3
4
var vectorLayer = new ol.layer.Vector({
  source: vectorSource,
  style: styleFunction
});

创建地图,为了方便对照,我们加入了另外一个ol.source.Stamen图层作为参照。这样当缩放到较小的区域时,我们可以清楚的知道当前的点和地物的对照,比如道路名称,建筑名称等,从而确定目前的位置。这是一种非常常见的GIS应用的场景,但是需要注意的是,不同的图层需要有相同的空间映射方式,OpenLayers默认才用EPSG:3857,所以需要两者都采用该投影:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
var map = new ol.Map({
  layers: [
    new ol.layer.Tile({
      source: new ol.source.Stamen({
        layer: 'toner'
      })
    }),
    vectorLayer
  ],
  target: 'map',
  controls: ol.control.defaults({
    attributionOptions: /** @type {olx.control.AttributionOptions} */ ({
      collapsible: false
    })
  }),
  view: new ol.View({
    center: ol.proj.transform([108.87316667, 34.19216667], 'EPSG:4326', 'EPSG:3857'),
    zoom: 2
  })
});

创建地图时,我们可以通过layers来指定多个图层。在OpenLayers中,有很多类型的TileStamen是一个专注于做非常漂亮的地图的组织

在view中,我们需要将经纬度(EPSG:4326)转换为墨卡托投影(EPSG:3857)。这样我们就可以得到一个很漂亮的地图了:

geojson toner

使用Leaflet展示栅格数据

我们在上一篇中已经生成了MapServer的WMS地图,这里可以用Leaflet来消费该地图(使用OpenLayers也可以消费,不过V3似乎在和WMS集成时有些问题,我此处使用了Leaflet)。

首先创建一个地图:

1
var map = L.map('map').setView([34, 108], 10);

然后就可以直接接入我们在上一篇中生成的地图:

1
2
3
4
5
6
7
  L.tileLayer.wms("http://localhost:9999/cgi-bin/mapserv?map=/data/xian.map", {
            layers: 'places',
            format: 'image/png',
            transparent: true,
            maxZoom: 16,
            minZoom: 2,
        }).addTo(map);

便于对照,我们先添加一个底图:

1
2
3
4
5
L.tileLayer( 'http://{s}.mqcdn.com/tiles/1.0.0/map/{z}/{x}/{y}.png', {
      attribution: '&copy; <a href="http://osm.org/copyright" title="OpenStreetMap" target="_blank">OpenStreetMap</a> contributors | Tiles Courtesy of <a href="http://www.mapquest.com/" title="MapQuest" target="_blank">MapQuest</a> <img src="http://developer.mapquest.com/content/osm/mq_logo.png" width="16" height="16">',
      subdomains: ['otile1','otile2','otile3','otile4'],
      detectRetina: true
  }).addTo( map );

这样就可以看到最终的地图:

xian leaflet

使用Web界面,我们可以自由的拖拽,移动,并且方便的放大缩小。如果观察浏览器的网络标签,在放大地图时,可以看到很多的WMS请求:

xian leaflet

其他

如果你对代码感兴趣,可以参看这个repo

可视化你的足迹 - 服务器端

可视化你的足迹

数据可视化可以让读者以一种轻松的方式来消费数据,人类大脑在处理图形的速度是处理文本的66,000倍,这也是人们常常说的一图胜千言。在本文中,我们通过将日常中很容易收集到的数据,通过一系列的处理,并最终展现在地图上。这仅仅是GIS的一个很简单场景,但是我们可以看到,当空间数据和地图结合在一起时,可以在可视化上得到很好的效果,读者可以很容易从中获取信息。

steps

我们在本文中会制作一个这样的地图,图中灰色的线是城市中的道路,小六边形表示照片拍摄地。颜色表示当时当地拍摄照片的密度,红色表示密集,黄色为稀疏。可以看到,我的活动区域主要集中在左下角,那是公司所在地和我的住处,:)

要展现数据,首先需要采集数据,不过这些已经在日常生活中被不自觉的被记录下来了:

数据来源

如果你开启了iPhone相机中的定位功能,拍照的时候,iPhone会自动把当前的地理信息写入到图片的元数据中,这样我们就可以使用这些数据来做进一步的分析了。

我在去年学习OpenLayers的时候已经玩过一些简单的足迹可视化,另外还有一篇全球地震信息的可视化,但是仅仅是展示矢量信息,并没有深入,而且都是一些前端的JavaScript的代码。最近又在重新整理之前的GIS知识,重新把这个作为例子来练手。当然,这次会涉及一些地图编辑空间计算的内容。

我的照片一般都通过Mac自带的Photos管理(前身iPhoto),手机里照片会定期同步上去。老版本的iPhoto用的是XML文件来存储照片的EXIF数据,新的Photos的实现里,数据被存储在了好几个SQLite数据库文件中,不过问题不大,我们只需要写一点Ruby代码就可以将数据转化为标准格式,这里使用GeoJSON,GeoJSON既可以方便人类阅读,也可以很方便的导入到PostGIS或者直接在客户端展现。

实现步骤

我们现在要绘制照片拍摄的密度图,大概需要这样一些步骤:

  1. 抽取照片的EXIF信息(经度,纬度,创建时间等)
  2. 编写脚本将抽取出来的信息转换成通用格式(GeoJSON)
  3. 使用QGIS将这些点的集合导入为图层
  4. 插入一些由六边形组成的图层(设置合适的大小)
  5. 计算落在各个多边形中的点的个数,并生成新的图层heatmap
  6. 使用MapServer来渲染基本地图

数据抽取

Mac上的Photos会将照片的元数据存储在一个SQLite3格式的数据库中,文件名为Library.apdb,通常位于这个位置~/Pictures/Photos\ Library.photoslibrary/Database/apdb/Library.apdb。这个文件可以通过SQLite3的客户端直接打开,不过由于可能有其他进程(Mac自己的)打开了该文件,所以会有锁文件,你可能需要先将这个文件拷贝到另外一个位置。

然后将表RKVersion中的部分信息导出即可,SQLite内置了很方便的导出功能,通过它提供的shell客户端sqlite3,将信息导出到csv文件中:

1
2
3
4
5
sqlite> .mode csv
sqlite> .headers on
sqlite> .output places-ive-been.csv
sqlite> select datetime(imageDate+978307200, 'unixepoch', 'localtime') as imageDate, exifLatitude, exifLongitude from RKVersion where exifLatitude and exiflongitude;
sqlite> .output stdout

注意这里的日期,苹果的日期偏移和其他公司不同,始于2001年1月1日,所以要在imageDate之后加上这个base值,然后将文件以.csv的格式导出到places-ive-been.csv中,该文件包含3列:时间,纬度,精度。

1
2
3
4
5
6
7
imageDate,exifLatitude,exifLongitude
"2012-10-25 16:34:01",34.19216667,108.87316667
"2012-10-28 14:45:53",35.1795,109.9275
"2012-10-28 14:45:45",35.1795,109.9275
"2012-10-25 16:34:04",34.19216667,108.87316667
"2012-10-19 23:01:05",34.19833333,108.86733333
...

转换为GeoJSON

方便以后的转换起见,我们将这个文件转换成GeoJSON(其实很多客户端工具可以支持CSV的导入,不过GeoJSON更为标准一些)。

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
require 'csv'
require 'json'

lines = CSV.open("places-ive-been.csv").readlines
keys = lines.delete lines.first

File.open("places-ive-been.json", 'w') do |f|
    data = lines.map do |row|
        {
            :type => "Feature",
            :geometry => {
              :type => "Point",
              :coordinates => [row[2].to_f, row[1].to_f]
            },
            :properties => {
              :created_at => row[0]
            }
        }
    end

    f.puts JSON.pretty_generate({
        :type => "FeatureCollection",
        :crs => {
          :type => "name",
          :properties => {
            :name => "EPSG:4326"
          }
        },
        :features => data
    })
end

这段脚本可以将我们的.csv转换成标准的geojson格式,注意此处的空间投影使用的是EPSG:4326

导入为QGIS图层

QGIS是一个开源的GIS套件,包括桌面端的编辑器和服务器端,这里我们只是用器桌面端来进行图层的编辑。

将我们的GeoJSON导入之后,会看到这样的一个可视化的效果!

points

我们还可以导入其他的地图图层,这样可以清楚的看到点所在的区域(国家地图图层可以在此处下载):

points with countries

好了,有了基础数据之后,我们来作进一步的数据分析 – 即生成密度图。首先使用QGIS的插件MMQGIS生成多边形图层功能(Create -> Create Grid Layer),为了处理速度,我们可以将地图放大到一定范围(我选择西安市,我在这里活动比较密集)。

选择六边形hexagon,并设置合适的大小(如果是3857参考系,即按照公里数来设置,会比较容易一些,如果是4326,则需要自己计算)。简而言之,需要保证每个格子都包含一些点,不至于太密,也不至于太稀疏。

hexagon

计算密度

QGIS提供了很多的数据分析功能,我们在这个例子中使用(Vector -> Analysis Tools -> Points in Polygon)工具,这个工具需要两个图层,一个是点集图层,一个是多边形图层。然后会将结果生成到一个新的图层中,我们可以将其命名为places-ive-been-density.shp,同时需要指定一个字段来存储统计出来的值(density)。

这个过程可能会花费一点时间,根据需要计算的点集合多边形的格式(也就是地图上的区域)。

完成之后会得到一个Shapefile(其实是一组,具体可以参看这里)。其实在这个过程中,绝大多数多边形是不包含任何数据的,我们需要过滤掉这些多余的多边形,这样可以缩减绘制地图的时间。

我们可以将这个文件导入到PostGIS中进行简化:

1
2
shp2pgsql -I -s 4326 data/places-ive-been/places-ive-been-3857-density.shp places_density |\
PGUSER=gis PGPASSWORD=gis  psql -h localhost -d playground

这里的shp2pgsql命令是GDAL工具包提供的命令,用以将Shapefile导入到PostGIS中,你可以通过

1
$ brew install gdal --with-postgresql

来安装。

GDAL会提供很多的工具,比如用来转换各种数据格式,投影,查看信息等等。

导入之后,我们可以在PostGIS的客户端查看,编辑这些数据等。比如在过滤之前,

1
select count(*) from places_density;

我们导入的数据中有103166条记录:

1
select count(*) from places_density where density IS NOT NULL;

而过滤之后,我们仅剩下749条数据。

通过GDAL提供的另一个工具ogr2ogr可以方便的执行过滤,并生成新的Shapefile:

1
2
3
4
$ ogr2ogr -f "ESRI Shapefile" data/places-ive-been/places_heatmap.shp \
PG:"host=localhost user=gis dbname=playground pass
word=gis" \
-sql "SELECT density, geom FROM places_density WHERE density IS NOT NULL;"

这条命令可以得到一个新的文件,这个就是最终的用来绘制地图的文件了。

绘制地图

开源世界中有很多的工具可以完成地图的绘制,比如MapServerGeoServerMapnik等等。我们在这篇文章中使用MapServer来完成地图的绘制,MapServer的安装和配置虽然比较容易,但是也需要花费一些时间,所以我将其放到了这个repo中,你可以直接clone下来使用。(需要你在虚拟机中安装ansible来完成provision)。

MapServer的配置很简单,类似于一个XML,不过是自定义的格式:

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
MAP
  IMAGETYPE      PNG
  EXTENT         11859978.732852 3994742.227345 12753503.595559 4580388.268737
  SIZE           8000 6000
  SHAPEPATH      "/data/heatmap"
  IMAGECOLOR     255 255 255

  PROJECTION
    "init=epsg:3857"   ##required
  END

  LAYER # States polygon layer begins here
    NAME         heatmap
    DATA         heatmap_3857
    STATUS       default
    TYPE         POLYGON

    CLASS
      NAME "basic"
      STYLE
        COLOR        255 255 178
        OUTLINECOLOR 255 255 178
      END
    END
  END

END

这些配置基本上都比较自解释,比如设置图片格式,图片大小,Shapefile的路径,图层的名称等,MapServer的文档在开源软件中来说,都算比较烂的,但是对于这些基本概念的解释还比较详尽,大家可以去这里参考

这里我们定义了一个图层,每个Map中可以定义多个图层(我们完成的最终效果图就是西安市的道路图和照片拍摄密度图两个图层的叠加)。

这个配置绘制出来的地图是没有颜色差异的,全部都是255 255 178。不过MapServer的配置提供了很好的样式定义,比如我们可以定义这样的一些规则:

  1. 如果密度为1,则设置颜色为淡黄
  2. 如果密度在1-2,则设置为比淡黄红一点的颜色
  3. 以此类推
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
37
38
39
40
41
42
43
44
45
46
47
48
  LAYER
    NAME         heatmap
    DATA         heatmap_3857
    STATUS       default
    TYPE         POLYGON
    #CLASSITEM density

    CLASS
      EXPRESSION ([density] = 1)
      STYLE
        COLOR        255 255 178
        OUTLINECOLOR 255 255 178
      END
    END

    CLASS
      EXPRESSION ([density] > 1 AND [density] <= 2)
      STYLE
        COLOR        254 204 92
        OUTLINECOLOR 254 204 92
      END
    END

    CLASS
      EXPRESSION ([density] > 2 AND [density] <= 3)
      STYLE
        COLOR        253 141 60
        OUTLINECOLOR 253 141 60
      END
    END

    CLASS
      EXPRESSION ([density] > 3 AND [density] <= 10)
      STYLE
        COLOR        240 59 32
        OUTLINECOLOR 240 59 32
      END
    END

    CLASS
      EXPRESSION ([density] > 10 AND [density] < 3438)
      STYLE
        COLOR        189 0 38
        OUTLINECOLOR 189 0 38
      END
    END

  END

这样我们的地图展现出来就会比较有层次感,而且通过颜色的加深,也能体现热图本身的含义。

同样的原理,如果将那些自己创建的多边形替换为行政区域划分的多边形,则可以得到另外一种形式的热图

shaanxi-heatmap

总结

我们通过使用一些开源工具(MapServer,QGis,PostGIS,GDAL等),构建出一个基于GIS的数据可视化框架。在这个stack上,我们可以很容易的将一些其他数据也通过可视化的方式展现出来(公用自行车站点分布,出租车分布等等)。MapServer可以发布标准的WMS服务,因此可以很好的和客户端框架集成,从而带来更加友好的用户体验。

如何写一本书?

我在过去的几年中,写了4本书。有传统意义上的两本实体书:《JavaScript核心概念及实践》《轻量级Web应用开发》,还有两本电子书《3周3页面》《函数式编程乐趣》。当然对我而言,主职工作是软件开发,写作是个副业。

在写作的过程中,有一些有趣的心得。

  • 写作本身是一个很好的学习过程(至少是一个驱动你学习的动力)
  • 写书非常枯燥,特别是校对的时候
  • 写作不会让你变得富有,但是有时候会让你开心(不总是)

写文章 vs 写书

写博客/文章和写书还是有很大差别的,一个明显的差异是写文章会比较随意,而且应该尽量保持精简。一篇文章提供一些信息即可,应该尽量远离细节(如果写一篇教程,则另当别论)。而写书则应该尽可能的深入细节,尽可能可以让读者依书自修。

投入与回报

首先要明白的一点是,不要指望用写书来赚钱,至少前4本是这样的。粗略的算一下:我的第一本书卖了3000册,每卖一本我可以得到4元RMB,一共就是12,000元RMB。而这本书我断断续续写了三年。那是很多个周末,很多个假期,很多个夜晚的付出换来的,如果真正要计算投入产出比的话(纯粹金钱上),这显然是一个毫不合算的事情。

作为一个参考,IBM developerWorks的投稿,千字200元,一般写5,000字以内,也就是800元RMB左右。而要写一篇这样的文章,我只需要一天(当然需要数周/数月的积累)。12,000元RMB需要写15篇文章,如果每周写一篇,不到4个月就可以写完,而且写文章比写书容易多了,毕竟篇幅比较短小,易于校对。而且对于大部分开发者来说,固定在一个主题上的难度要比15个独立的主题简单的多,因为无需特别深入。

所以根据经验,要抱着公益的情怀来写书。也就是说为了让知识更好的分享,让你学习到的先进科学技术来帮助更多的开发者,提高他们的开发效率,让他们可以在周末多休息一天。而至于翻译技术书籍,那基本上就是免费的了,完全是一个公益活动(耗时数月,斟酌字句,推敲表达方式,但是价格极为低廉:千字60元RMB),所以下次见了技术书籍的译者,就多少给他捐点吧,他们才是在为人民服务

知识的诅咒

“知识的诅咒”是指人们在获得了某种知识之后,就无法想象没有这种知识的情况了。这种现象随处可见,比如一个你到了一个从未去过的陌生城市,遇到以为当地人,然后向他问路。当地人觉得已经说的很清楚了,但是你还是不知道该怎么走。另一个例子是:假设你不认识泰文,然后你打开任何一本泰文写的小说,你只能依稀感觉到这是一种文字,除此之外你并不能从中获取任何的信息。但是当你学习了一段时间泰文之后,再来看这本小说,之前的那种感受就再也没有了。

curse

写书的时候,你首先需要具备某种知识。但是写书的目的是将这些知识传递给那些不具备此知识的人,而根据“知识的诅咒”,你又无法确知那些初学者会遇到哪些问题!解决这个问题的方法就是找初学者来试读。而且为了保险起见,还应该找尽可能多的人来试读。

写作方式

一种方式是自下而上的,写一些独立的文章,最后发现可以串起来,然后形成一本书,另一种方式是自上而下,但是又会逐步调整。根据经验,不论是写一篇简单的博客,还是写一本书,都需要按照自上而下的方式。随心所欲的写下去,基本上都收不住,而且整个文章支离破碎,貌似有很多内容,但是不成章法,读者也无法轻松的获取知识。

先列出大的章节,然后逐步细化,但是未必是按照顺序来写。先编写自己最熟悉的部分,然后逐步完善。例子的选取需要精妙而恰当,最好有图例来说明。

配图制作

一般而言,我在书中会使用两种图:流程图和一些截屏。截屏通常使用Mac OSX自身的功能就已经足够,而流程图我会采用一些额外的工具如:

  • graphviz
  • keynote/sketch

cgi flow

用Graphviz画图的好处就是可以将图像代码一样放入版本库来管理。

除此之外,我还学习了一些设计软件的基本用法,事实上只需要用一些简单的元素就可以做出非常专业的配图:

  • 字形/字体(大小,粗细的变化)
  • 颜色(基本的配色理论就可以做出很舒服的配色)
  • 层次(尺寸,位置,颜色的深浅)
  • 阴影

front-end deployment

代码格式

书中实例需要很多代码来说明,如果是制作电子书的话,可以使用Markdown预处理器自带的功能来高亮。另外如果需要RTF格式,可以使用这些工具:

  • highlight工具
  • intelij中的插件copy on steriod

这里有一篇博客来说明如何将你的代码带着格式拷贝到剪贴板中,拷贝之后,就可以将这些内容粘贴到Word或者Keynote中了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
jest.dontMock('../components/headline.jsx');

describe('Headline', function() {
  var React = require('react/addons');
  var Headline = require('../components/headline.jsx');
  var TestUtils = React.addons.TestUtils;

  it('#render', function() {
    var text = "this is a title";
    var headline = TestUtils.renderIntoDocument(<Headline title={text} />);
    var title = TestUtils.findRenderedDOMComponentWithTag(headline, 'h4');
    expect(headline.getDOMNode()).toBeDefined();
    expect(headline.getDOMNode().textContent).toEqual(text);
  });
});

一些潜在的

在写作的过程中,会有一些潜在的坑。这些所谓的坑是新人可能无法想到的。相对于言之无物,不知如何下笔,最痛苦的其实在于平淡。大部分时候,你可能很容易就能写出开头,但是很难坚持到最后。即使好不容易写完了第一版,后续的重读和修改,会让你苦不堪言。

内容写好之后,样式是下一个重要的问题,好的内容需要有与之匹配的排版。在中国,作者不但要负责内容,还要负责一些排版的事情。这一点非常奇葩,但是又是实情。这也是我更推荐电子版的原因(排版更加美观,选择更加多样,而且一旦有问题可以更容易的修改)。

另外一个问题是错别字检查!检查错别字对于作者来说,是一件非常困难的实情。而对于读者来说,则是一件很容易的事情。这跟知识的诅咒的道理一样。

typo

发布方式

实体书

传统的出版方式有一些明显的问题,这些问题已经和现代的知识传递方式产生了冲突:

  1. 时滞性(新技术的更新速度远远超过审批,印刷等流程的时间)
  2. 排版(如何低成本做到语法高亮,或者彩图)
  3. 更新频率(当技术更新之后,如何更新,是传统纸质书无法解决的问题)

传统的出版方式有点像传统的软件开发,一本书从开始写作到最终出版,要经过很多环节。忽略掉写作过程,从交稿到出版会经历很多次审核和校对,可能会历时4-8个月,着这个过程中,很多东西都可能发生了变化,一个典型的例子是《用AngularJS开发下一代Web应用》,原版为英文版,翻译成中文版再到出版之后,书中的很大一部分内容已经过时。读者拿到书之后,会发现书中的内容已经和当前的版本/文档不匹配了。这种现状随着技术的更新速度和频率还会再加剧。

第二点是排版。我听说国内有些出版社已经开始接受Markdown作为稿件的格式,但是大部分还采用Word或者WPS等格式,这样排版就变成了一个大问题。以我自己为例,我的原稿用Markdown写,但是写了几章之后不得不切换到Microsoft word上,而我自己的Mac OSX下的排版到编辑的Windows下就会变样,而且还会涉及字符集,字体,Word版本等等问题的影响,最后导致印刷出来和原始稿件出入很大。

最后一点是更新频率,如果发现了错别字或者错误的地方(即使之前检查过多次,仍然会有漏网之鱼),由于实体书的特殊性,一般需要等到再次印刷才能解决。这意味着先购买的读者会承担一些风险,更新后的版本又如何让读者看到呢?总不能又买一本吧。

但是这些问题都可以通过电子书来很好的解决。首先,电子书可以随时更新,最低限度的降低时滞性。排版上来说,作者可以使用Markdown来编写,而展现则可以应用一些预定义的模板来完成。最后,更新频率完全可以控制,对读者来说风险更低,因为电子版书籍的可以很容易的追踪交易记录,从而得到免费的更新过的版本。

电子书

目前已经有很多的渠道可以发布电子书,比如gitbhook知笔墨。这些应用的出现,大大降低了发布书籍的成本,我的《函数式编程乐趣》,用了3天就完成了草稿,而发布只需要数秒。

另外一个问题是书籍的价格和作者的收入。一本书定价50元RMB,出版社给作者的版税是8%,也就是说,每卖出一本,作者可以得到4元,如果你的书非常畅销,这还是一个不错的价格。但是可能90%的书籍都不会是畅销书(就好比每个班级都会有优等生,但是他们仅占全班人数的10%一样)。这对作者是一种浪费:你需要耗时数月甚至数年来写一本书,然后市场的反馈又非常慢(毕竟你无法出版一本未完成的书)。

我在selfstore.io上有两本电子书:《3周3页面》《函数式编程乐趣》《3周3页面》定价为16元,每卖出一本,扣除掉交易费之后,我可以得到14.7元。

对我来说,这样可以得到更多的回报,对于读者则可以更加快速的得到更新,而且由于有预览版和一系列的其他信息,还可以在很大程度上降低读者的风险(更不用说快递费,等待时间等问题)。我在gitbook上的统计显示,《3周3页面》已经被累计下载了28,861次,实际的读者也将近5,000。而且没有任何的审核流程,也没有排版的时间浪费,我只需要关注内容即可。

如何快速发布你的点子?

说实话,这是一篇软文,为我的新书《轻量级Web应用开发》写的软文。如果你不想接着往下读,可以直接去这里买一本来看,:)

之过去的几年中,我参加过好多次Hackday活动。每次看到在为期两天的时间里,2-3个人将一个想法变成现实,都会有一种强烈的成就感。而且这个Hack的过程中,会重拾编程的乐趣,大家的积极性都非常高,用着各种有趣的技术(大数据,开源硬件,Node.js,GIS系统),逐步的将模糊的想法,变成现实,并最终为客户带来价值。最新的一次在这里

通过这些Hackday的经历,以及在众多项目中的经验,我总结了一些轻量级的方法/实践。这些方法/实践非常容易落地,并且久经验证。在很多项目中已经在不断的使用。它们可以帮你更好的将一个想法变成现实,并且在随后的开发中还可以继续发挥作用而不至失效(测试,构建脚本,自动化部署等等)。我希望你可以在自己的项目中尝试这些方法/实践,也希望这些方法/实践可以真正的帮助你和你的项目取得成功。

细化你的“点子”

根据一个已有的产品来参考,演绎,并形成自己的产品并非难事,而创新则是一件非常困难的事情,因为你需要“无中生有”。在ThoughtWorks,我们有这样一些步骤可以帮助客户来梳理信息,并最终交付产品,简而言之,可以归结为这样几个步骤:

  1. Discovery(用户研究,探索)
  2. Define(归纳洞见,发现)
  3. Design(原型设计,验证)
  4. Delivery(制定计划,实施)

prototype

上图是一个“点子”的原型(一个交换技能的应用,用户可以教别人自己擅长的技能,作为交换,也可以从别人那里学习心得技能),原型事实上是第三步的产物。我们通过一些调查(口头采访,或者问卷调查)得到一些基本的信息,然后归纳这些信息,并和真实用户再次确认,得到一个概念。有了概念,再来设计一个基本的原型,这个原型还可以迭代数次,然后进入下一个阶段。

前三个阶段更多的是用户体验设计师,以及客户的业务人员参与的。在前三步完成之后,进入实施的时候,软件工程师开始投入。这篇文章更关注第四步(Delivery)中的各种实践,通过这些实践,我们可以很好的完成交付计划,使得我们的好想法最终可以变成为用户提供服务的产品。

实现“点子”的方法

在软件领域,将一个想法变成实际的产品需要经历若干个阶段。按照传统的软件开发方式,会有前期的调研,需求分析,概要设计,详细设计,编码实现,测试,发布等一系列的流程。这种方式对每个阶段的定义都非常明确,而且每个阶段需要依赖前一个阶段的输出,因此往往被称为“瀑布模型”。后来慢慢发现,这个模型的反馈周期太长,一个软件从调研到发布往往需要数年,当发布之后,可能市场早已经沧海桑田。人们后来发明了更加符合现代市场需求的“敏捷开发”,在敏捷中,更加强调短平快的将需求变为产品。

简而言之,敏捷开发更强调:

  1. 快速发布
  2. 渐进增强
  3. 小步迭代

而在敏捷开发的继承者精益中,这几点理念也被更进一步的深化。由于没有办法预见未来,我们只能用一种边做边看的方式来验证想法。简而言之,就是先根据经验和调查,做出一个合理的推断,然后定义好范围,构想出一个最小可行产品(MVP),这个MVP的功能非常内聚,非常紧凑,我们需要尽可能快的让其上线,并被真是的用户使用,测试。根据这些用户的反馈,我们会做一些调整,比如去掉那些很少人使用的功能,聚焦在用户喜欢的功能上;从用户的实际使用中,调整界面元素的位置,子功能的入口等等。这个过程会持续多轮,最后的结果会是一个有真是用户使用,并且比较贴近真实需求的产品。当然这还不够,我们需要不断的打磨,渐进式的增强产品的功能,逐步完善功能等。

有一个非常形象的图,可以看出瀑布模型和敏捷开发两种方法的对比:

waterfall vs agile

敏捷开发通过逐步细化,迭代前进的方式,分阶段的将需求实现,在整个过程中,更容易做到快速调整。

所有的这些过程,都非常依赖“快速”这个关键点。如果MVP花了3周就产生了,但是为了让其上线,你花费了1个月,那么很可能这个MVP已经过时了;如果你确实快速的将MVP发布了,在得到了用户的很多反馈之后,花费1个月来实现这些反馈,又会让你落在竞争对手之后;如果快速的发布了多次,并且幸运的是,你的用户量变多了,如果花费很长时间来调整架构,则可能失去当前的市场窗口。也就是说,你需要非常快速地对变化做出反应!

轻量级的开发方式

开发中的三个重点

在工程实践中,我认为有三个特别需要注意重要的点,这三点可以极大程度的改善项目现状,提高效率,并使得产品的高质量交付成为可能,它们分别是:

  1. 自动化(自动化一切)
  2. 质量内嵌(defect的数量,是否真正满足了需求)
  3. 代码本身的质量(可读性,可维护性,可扩展性)

自动化包括,自动provision,自动部署,自动化测试,自动打包等等。这是提高团队开发效率的必要工具。比如书中提到的grunt/gulp脚本,jasmine/rspec/capybara测试,部署脚本,vagrant/Chef等,都是关于如何将日常开发中的任务尽可能的自动化。

软件没有Bug当然是所有人都追求的,我们有很多中方式来保证代码质量。而在编写产品代码的同时,写大量的自动化测试,是投入产出比最高的一种了。通过单元测试,集成测试,以及一些有限但是关键的UI测试,我们可以覆盖很多的需求,而将这些测试自动化起来之后,可以节省大量的开发/测试成本,并减少回归测试的代价。

要支撑快速的发布,我们需要一系列的技术实践。这些技术包括环境的搭建,框架的使用,代码的编写,产品的发布;而且包括后台的数据库设计,业务代码,同样还有前端的展现等。

software life cycle

何为轻量级?

《轻量级Web应用开发》中,我介绍了一系列的实践/工具,这些实践/工具贯穿整个软件开发的生命周期,使得敏捷开发/精益的开发方式变得可以“落地”。比如如何使用轻量级的开发框架来搭建API原型,如何将应用发布在免费的云平台上,如何通过虚拟化技术快速搭建开发环境,从而节省环境配置的投入,如何快速平滑的发布,如何使用测试先行的方式来保证代码质量,如何做高效的自动化UI测试等等。

  • 轻量级Web框架
  • 前端开发流程
  • 构建工具
  • 环境自动化(开发环境的搭建,CI服务器的搭建)
  • 自动化部署
  • UI测试
  • 实例驱动(书中有很多的实例,也有很多从项目中总结出来的实践)

这是一本主要关注开发实践的书,书中通过很多实际的例子来帮助读者建立一套完整,高效,轻量级的开发方式,这些方式可以直接在你的下一个项目中使用。甚至如果项目的技术栈变成了另外一种语言,你也可以迅速找到同类的替代品。比如rake之于gradlesinatra至于spring-mvc等等。

每个组件都是可以替换掉的,比如ORM,如果你觉得DataMapper无法满足实际需要,那么可以换成ActiveRecord。如果Rails太重,使用Sinatra或者Grape或许是一个更好的选择。AngularJS包含了太多东西,Backbone.js或许适合你的场景,而也未尝不可以用Riot.js来替换掉Backbone中的view层。

在本地,可以将应用部署到一个vagrant+chef来provision的环境中,而通过部署自动化,这个动作可以很容易的在AWS的云上实现。轻量级的开发方式,帮助你用最小的代价来替换系统中的任意一个组件,因为每个组件在一开始都是按照可替换原则选用的。

另一方面,轻量级的另一个意思是:现发布静态的版本,然后再将内容替换为动态版本。发布一个静态的页面非常容易,具体细节可以参考这篇文章。当需要动态内容是,免费版的Heroku是一个触手可得的选择,AWS则是一个更加专业的选择(各种服务都配置完善,你只需要关注自己的应用部署即可)。

项目经理是大傻B吗?

一些背景故事

坊间流传着很多关于PM(Project Manager,项目经理)的笑话,在这些不无刻薄的笑话中,PM往往被描述成一个盲目的承诺客户需求变更,不了解实际情况而又喜欢指手画脚的专门坑开发的家伙。毋庸置疑,这些笑话当然是那些聪明的开发发明的(不过你得承认,在很多团队,这些笑话其实是实实在在每天都在发生着的)。

在智力工作中,对于开发的实际进度,开发速率等问题,具体着手做的人永远比在背后指手画脚的人更有发言权。软件开发正是一项智力活动,优秀的软件无法通过人力的堆积而产生。一个关于PM的经典的讽刺是:PM就是那些指望着9个女人在1个月内生出1个小孩的二货。从传统的意义上来说,这个笑话还真是一针见血。

project manager, 来源:http://sd.keepcalm-o-matic.co.uk/i/keep-calm-i-m-the-project-manager.png

我记得在加入ThoughtWorks不久的时候,私底下经常听到这种论调:PM基本就是项目上被人鄙视(当然大家不会表现的那么明显就是了)的角色,基本上负责团队建设去哪儿这种杂事儿就行了,团队的其他人员可以高度自治,并不需要被管理,项目就会如预期般按时交付。

这些论调在某些情况下可能是对的。但是如果在国内项目的这个上下文里,没有一个专业的PM来协助项目,控制需求,划定项目范围,与客户谈判等等,没有任何一个项目是可以真正成功交付的,指望高度自治的开发们来完成项目?咱们还是现实一些吧。

一个悲剧的事实是,开发人员往往都恃才傲物,有时还会带着一幅要来拯救世界的心态来做项目,这事实上和客户的期望,以及PM的期望是有很大出入的。在项目启动之初,PM会面临重重困难:首先,团队里的每个人都不好管,而且每个人都认为自己不需要被管理(当然这种想法在大部分时候都是错误的);其次,PM需要和客户快速建立信任,并推动项目进入正轨;最后,往往留给PM自己的时间也非常有限,他们也需要学习大量的项目相关的上下文(业务上下文,人员关系,资源协调等)。

除了催进度,PM平时还干点啥?

本质上开说,PM其实就是一个轮询器:识别所有的项目风险,然后不断跟进。项目风险可能是技术风险,比如某个技术上压根搞不定的问题。也可能资源风险,比如人手不够,或者开发者很多,但是没有足够的设计师协助,这些风险都会导致项目无法按照时间交付。一个客观事实是,所有项目都会变化,做完售前到需求分析结束之后,需求可能会发生巨大变化,如果还按照报价来做项目很可能会亏本

PM的一个重要职责就是在项目之初将项目范围定下来,这个范围的划分非常依赖经验:划得少了团队得天天加班,累得跟狗一样,然后才能保证交付(据我的经验,虽然项目一般不会天天加班,但是总会有一些攻关,打补丁的事儿,最后还是会累成狗),划得多了客户不买单,意思是就这个小功能你要做两个月,绝对不行。PM需要协调这些不一致,还需要和销售,客户等方面不断谈判,写方案,排计划,简而言之,也是累跟狗一样(而且潜在的,还可能被那些天真幼稚的开发坑 — 开发经常会高估自己的开发速度,反正我还没遇到过低估的,你见过吗?)。

我们每天看到的PM干的最多的事情就是:元芳,那个接口怎么样了?什么时候能做完,有什么blocker?李柯,昨天说的代理的事情怎么样了?小波,高保真什么时候出?何方,我们周三下午要showcase,麻烦你订一下会议室吧……

pm

除了写代码,Dev平时还干点啥?

如果脱离开PM的角度,做为一个孤傲的开发,时常会觉得PM为什么老是问我进度,是不是怀疑我的能力?为什么监视我的工作?相信我,其实他才不想监视你。但是你设想一下:如果你不参与代码编写,每天只是看旁边的哥们写,你如何知道他实际的进度呢?而且众所周知,开发很难准确的更新自己的工作进度,而且遇到问题也很少积极主动的报告,通常都会自己埋头尝试解决。那么,轮询显然是一种成本最低,反馈最快的方法。

不主动更新进度是另外一个大问题,不过这个得单独说。关于更新进度,典型的的场景是:早上站会的时候,开发目光呆滞的盯着某个卡片,努力回忆其中的验收条件以及自己的当前进度,如果恰好脑海中的技术细节和卡片的描述在某个点上匹配了,他会迅速的告诉你,目前进展良好,今天上午应该就可以做完。开发在更新进度时,不是盲目乐观,就是跳进太细节的地方进行讨论,最后讨论的结果就是:跟没更新一样,除了浪费了10分钟时间。但是别忘了,PM会在15分钟之后再来轮询一次。

PM每周都需要汇总很多数字,比如本迭代完成的点数,剩余的点数,总体进度如何,有没有人有请假计划,遇到什么blocker,每个blocker的具体原因,每个风险点的最终日期是何时,等等等等。他肯定不能记住这些数字,所以可能一天之内向你询问数次。

PM的其他职责/技能

上边说到的其实只是描述PM的辛苦,而最微妙,最考验PM的是其“察言观色”的技能。这绝对是一个工作经验在10年之内完全无法获得的技能(而且是在中国的项目上工作10年)。比如,在showcase的时候,有个客户说,嗯,挺好,整个流程就是这样的,后续你们的UI是不是还会美化?如果你遇到这个情况,请问,这个客户是什么意思?

如果你能回答上这个问题(而不是提出问题),那说明你还离PM差十万八千里。成熟的PM会先判断,问这个问题的人是什么角色,以及他在系统中的话语权如何,还有其他人就此问题的反应如何等等因素,然后找到一个合适的答案。

PM另一个绝技是扯皮(不是贬义),开发会花一个下午(我是说10分钟)去跟客户讨论需求的范围吗?或者会为5个人天来讨价还价吗?我想开发大概会说,尼玛,找其他供应商吧,老子不伺候了。

一个项目的成功,需要多方合作,这里说的合作并不局限在甲方和乙方之间,即使乙方的团队之中,也需要很紧密的合作。比如项目经理和开发,设计师之间的合作。如果仅仅从开发的角度来看,PM有时候看起来就是和客户站在一起来整开发的一样,比如催进度,过分保守的估算人天(导致团队加班赶工)。PM需要释放团队中的负面情绪,保证团队士气,还需要他做一些开发不屑于做的琐碎的事情。

设身处地,替他人着想

本质来来说,每个项目都是一次生意。在去掉那些繁杂的流程和形式之后,做一个项目和你去菜市场买菜其实并无二致。举个例子,根据传统,软件开发界特别喜欢找建筑行业做类比,我也找个建筑方面例子。装修房子的时候,我们会要求施工方提供图纸(水电改造,基本设计等),按期交付(确定工期),同时会界定项目范围(比如刷墙,贴地砖,吊顶,封阳台等等),会要求工人按时来上班,正常出勤,认真工作,直到项目结束。过程中我们还会讨价还价,比如捎带着把栏杆拆除,捎带着敲掉一面隔离墙等等。在过程中,我们还会敲敲地砖,检查过门石,检查吊顶,测试水电等等。作为甲方,这些活动相信没有人会觉得过分。

但是一旦我们做乙方,也就是施工方的时候,情况就全变了。比如客户要求打卡,有人会觉得不爽,客户要求代码review,有人会觉得不爽,要求代码有设计文档,有人会觉得不爽,要求设计有多个备选方案,有人会觉得不爽。大多数情况下,这都是虚无缥缈的虚荣在作祟,这种情况所在多有,不过还不致命。一旦涉及到讨价还价(不是商务上的讨价还价,而是和客户就工作量达不成一致,或者就某个技术方案达不成一致之后),开发全部歇菜,一言不合,转身就走,压根不具备讨价换件的能力,这样还怎么做生意啊?设身处地想一下,如果你是甲方,当提出了一些合理的要求(比如需要一方提供验收标准,通过验收测试等),结果施工方还一脸的“我不跟你说了,你就是以大傻B”,你能乐意吗?

如何合作?

说了这么多,这两种角色在同一个项目上要如何合作呢?我想,作为开发来说,有这样几点可能:

首先,理解PM的工作。在很多时候,开发会有莫名其妙的优越感(其实每个角色都会有了,比如销售看不上技术人员,技术Lead看不上PM等等),主要原因其实是坐井观天,对其他角色的辛苦和工作不清楚。然后错误的认为别人的工作都很low。

之前听一个同事讲过一个小session,里面有一点我印象非常深刻:不要因为一个人不会某个技术而鄙视他。就好比你不应该因为不会弹钢琴,而被一个会弹钢琴的人鄙视一样。道理很简单,但是开发在长期的“宅”生涯或者坐井观天中,进化出了这种非理性的观点:如果一个人连vim(此处的vim可以换成任何其他技术)都不会,就压根不足以谈人生。

其次,学习如何报告进度。PM催你的根本原因是进度不明确,如果每一个潜在的风险都清楚的显示着进度,而且有明确的负责人,PM就会降低轮询的频率。这需要开发经过刻苦的练习才能达到:

  • 站会前自己花3分钟整理一下昨天做的工作
  • 根据story的验收条件(最好有和BA/QA一起的讨论需求),进行合理的任务划分(tasking技能)
  • 可以借助便签纸等工具,帮助自己明确进度(划分了5个子任务,昨天完成了3个,那么可以粗略的估计为60%)

tasking

再次,合理估算。有些时候,新人(来自于传统管理环境的新人)可能会误以为PM是一个管理的角色,或者处于某些考虑会在PM询问进度时做出一些错误的回答。比如PM在迭代启动会议上是问这个迭代我们有没有可能做完所有计划内的任务,作为一个负责任的开发,你需要在第一时间指出那些“非理性”的期望,以便PM进行更加准确的计划。

  • 明确告诉PM,有哪些需求是不可能按时交付的,PM会根据实际情况来重新定计划,并和客户确认
  • 明确告诉PM一些可能的风险,团队整体对交付负责,而不是PM,或者开发

按照经验,项目从来就不会按照计划进行,在做好一个粗略的计划之后,PM的职责更多的是进行动态调整。所以团队内部至少需要保持信息的流通,虽然可能短期来看可能会影响开发速度,但是从整体上来看,可以减少很多不必要的浪费。

简而言之,要站在别人的角度考虑问题:如果换做是你,你会怎么做?

前后端分离了,然后呢?

前言

前后端分离已经是业界所共识的一种开发/部署模式了。所谓的前后端分离,并不是传统行业中的按部门划分,一部分人纯做前端(HTML/CSS/JavaScript/Flex),另一部分人纯做后端,因为这种方式是不工作的:比如很多团队采取了后端的模板技术(JSP, FreeMarker, ERB等等),前端的开发和调试需要一个后台Web容器的支持,从而无法做到真正的分离(更不用提在部署的时候,由于动态内容和静态内容混在一起,当设计动态静态分流的时候,处理起来非常麻烦)。关于前后端开发的另一个讨论可以参考这里

即使通过API来解耦前端和后端开发过程,前后端通过RESTFul的接口来通信,前端的静态内容和后端的动态计算分别开发,分别部署,集成仍然是一个绕不开的问题 — 前端/后端的应用都可以独立的运行,但是集成起来却不工作。我们需要花费大量的精力来调试,直到上线前仍然没有人有信心所有的接口都是工作的。

一点背景

一个典型的Web应用的布局看起来是这样的:

typical web application

前后端都各自有自己的开发流程,构建工具,测试集合等等。前后端仅仅通过接口来编程,这个接口可能是JSON格式的RESTFul的接口,也可能是XML的,重点是后台只负责数据的提供和计算,而完全不处理展现。而前端则负责拿到数据,组织数据并展现的工作。这样结构清晰,关注点分离,前后端会变得相对独立并松耦合。

上述的场景还是比较理想,我们事实上在实际环境中会有非常复杂的场景,比如异构的网络,异构的操作系统等等:

real word application

在实际的场景中,后端可能还会更复杂,比如用C语言做数据采集,然后通过Java整合到一个数据仓库,然后该数据仓库又有一层Web Service,最后若干个这样的Web Service又被一个Ruby的聚合Service整合在一起返回给前端。在这样一个复杂的系统中,后台任意端点的失败都可能阻塞前端的开发流程,因此我们会采用mock的方式来解决这个问题:

mock application

这个mock服务器可以启动一个简单的HTTP服务器,然后将一些静态的内容serve出来,以供前端代码使用。这样的好处很多:

  1. 前后端开发相对独立
  2. 后端的进度不会影响前端开发
  3. 启动速度更快
  4. 前后端都可以使用自己熟悉的技术栈(让前端的学maven,让后端的用gulp都会很不顺手)

但是当集成依然是一个令人头疼的难题。我们往往在集成的时候才发现,本来协商的数据结构变了:deliveryAddress字段本来是一个字符串,现在变成数组了(业务发生了变更,系统现在可以支持多个快递地址);price字段变成字符串,协商的时候是number;用户邮箱地址多了一个层级等等。这些变动在所难免,而且时有发生,这会花费大量的调试时间和集成时间,更别提修改之后的回归测试了。

所以仅仅使用一个静态服务器,然后提供mock数据是远远不够的。我们需要的mock应该还能做到:

  1. 前端依赖指定格式的mock数据来进行UI开发
  2. 前端的开发和测试都基于这些mock数据
  3. 后端产生指定格式的mock数据
  4. 后端需要测试来确保生成的mock数据正是前端需要的

简而言之,我们需要商定一些契约,并将这些契约作为可以被测试的中间格式。然后前后端都需要有测试来使用这些契约。一旦契约发生变化,则另一方的测试会失败,这样就会驱动双方协商,并降低集成时的浪费。

一个实际的场景是:前端发现已有的某个契约中,缺少了一个address的字段,于是就在契约中添加了该字段。然后在UI上将这个字段正确的展现了(当然还设置了字体,字号,颜色等等)。但是后台生成该契约的服务并没有感知到这一变化,当运行生成契约部分测试(后台)时,测试会失败了 — 因为它并没有生成这个字段。于是后端工程师就找前端来商量,了解业务逻辑之后,他会修改代码,并保证测试通过。这样,当集成的时候,就不会出现UI上少了一个字段,但是谁也不知道是前端问题,后端问题,还是数据库问题等。

而且实际的项目中,往往都是多个页面,多个API,多个版本,多个团队同时进行开发,这样的契约会降低非常多的调试时间,使得集成相对平滑。

在实践中,契约可以定义为一个JSON文件,或者一个XML的payload。只需要保证前后端共享同一个契约集合来做测试,那么集成工作就会从中受益。一个最简单的形式是:提供一些静态的mock文件,而前端所有发往后台的请求都被某种机制拦截,并转换成对该静态资源的请求。

  1. moco,基于Java
  2. wiremock,基于Java
  3. sinatra,基于Ruby

看到sinatra被列在这里,可能熟悉Ruby的人会反对:它可是一个后端全功能的的程序库啊。之所以列它在这里,是因为sinatra提供了一套简洁优美的DSL,这个DSL非常契合Web语言,我找不到更漂亮的方式来使得这个mock server更加易读,所以就采用了它。

一个例子

我们以这个应用为示例,来说明如何在前后端分离之后,保证代码的质量,并降低集成的成本。这个应用场景很简单:所有人都可以看到一个条目列表,每个登陆用户都可以选择自己喜欢的条目,并为之加星。加星之后的条目会保存到用户自己的个人中心中。用户界面看起来是这样的:

bookmarks

不过为了专注在我们的中心上,我去掉了诸如登陆,个人中心之类的页面,假设你是一个已登录用户,然后我们来看看如何编写测试。

前端开发

根据通常的做法,前后端分离之后,我们很容易mock一些数据来自己测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
    {
        "id": 1,
        "url": "http://abruzzi.github.com/2015/03/list-comprehension-in-python/",
        "title": "Python中的 list comprehension 以及 generator",
        "publicDate": "2015年3月20日"
    },
    {
        "id": 2,
        "url": "http://abruzzi.github.com/2015/03/build-monitor-script-based-on-inotify/",
        "title": "使用inotify/fswatch构建自动监控脚本",
        "publicDate": "2015年2月1日"
    },
    {
        "id": 3,
        "url": "http://abruzzi.github.com/2015/02/build-sample-application-by-using-underscore-and-jquery/",
        "title": "使用underscore.js构建前端应用",
        "publicDate": "2015年1月20日"
    }
]

然后,一个可能的方式是通过请求这个json来测试前台:

1
2
3
4
5
6
7
8
$(function() {
  $.get('/mocks/feeds.json').then(function(feeds) {
      var feedList = new Backbone.Collection(extended);
      var feedListView = new FeedListView(feedList);

      $('.container').append(feedListView.render());
  });
});

这样当然是可以工作的,但是这里发送请求的url并不是最终的,当集成的时候我们又需要修改为真实的url。一个简单的做法是使用Sinatra来做一次url的转换:

1
2
3
4
get '/api/feeds' do
  content_type 'application/json'
  File.open('mocks/feeds.json').read
end

这样,当我们和实际的服务进行集成时,只需要连接到那个服务器就可以了。

注意,我们现在的核心是mocks/feeds.json这个文件。这个文件现在的角色就是一个契约,至少对于前端来说是这样的。紧接着,我们的应用需要渲染加星的功能,这就需要另外一个契约:找出当前用户加星过的所有条目,因此我们加入了一个新的契约:

1
2
3
4
5
6
7
8
[
    {
        "id": 3,
        "url": "http://abruzzi.github.com/2015/02/build-sample-application-by-using-underscore-and-jquery/",
        "title": "使用underscore.js构建前端应用",
        "publicDate": "2015年1月20日"
    }
]

然后在sinatra中加入一个新的映射:

1
2
3
4
get '/api/fav-feeds/:id' do
  content_type 'application/json'
  File.open('mocks/fav-feeds.json').read
end

通过这两个请求,我们会得到两个列表,然后根据这两个列表的交集来绘制出所有的星号的状态(有的是空心,有的是实心):

1
2
3
4
5
6
7
8
9
10
11
$.when(feeds, favorite).then(function(feeds, favorite) {
    var ids = _.pluck(favorite[0], 'id');
    var extended = _.map(feeds[0], function(feed) {
        return _.extend(feed, {status: _.includes(ids, feed.id)});
    });

    var feedList = new Backbone.Collection(extended);
    var feedListView = new FeedListView(feedList);

    $('.container').append(feedListView.render());
});

剩下的一个问题是当点击红心时,我们需要发请求给后端,然后更新红心的状态:

1
2
3
4
5
6
7
8
toggleFavorite: function(event) {
    event.preventDefault();
    var that = this;
    $.post('/api/feeds/'+this.model.get('id')).done(function(){
        var status = that.model.get('status');
        that.model.set('status', !status);
    });
}

这里又多出来一个请求,不过使用Sinatra我们还是可以很容易的支持它:

1
2
post '/api/feeds/:id' do
end

可以看到,在没有后端的情况下,我们一切都进展顺利 — 后端甚至还没有开始做,或者正在由一个进度比我们慢的团队在开发,不过无所谓,他们不会影响我们的。

不仅如此,当我们写完前端的代码之后,可以做一个End2End的测试。由于使用了mock数据,免去了数据库和网络的耗时,这个End2End的测试会运行的非常快,并且它确实起到了端到端的作用。这些测试在最后的集成时,还可以用来当UI测试来运行。所谓一举多得。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#encoding: utf-8
require 'spec_helper'

describe 'Feeds List Page' do
  let(:list_page) {FeedListPage.new}

  before do
      list_page.load
  end

  it 'user can see a banner and some feeds' do
      expect(list_page).to have_banner
      expect(list_page).to have_feeds
  end

  it 'user can see 3 feeds in the list' do
      expect(list_page.all_feeds).to have_feed_items count: 3
  end

  it 'feed has some detail information' do
      first = list_page.all_feeds.feed_items.first
      expect(first.title).to eql("Python中的 list comprehension 以及 generator")
  end
end

end 2 end

关于如何编写这样的测试,可以参考之前写的这篇文章

后端开发

我在这个示例中,后端采用了spring-boot作为示例,你应该可以很容易将类似的思路应用到Ruby或者其他语言上。

首先是请求的入口,FeedsController会负责解析请求路径,查数据库,最后返回JSON格式的数据。

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
@Controller
@RequestMapping("/api")
public class FeedsController {

    @Autowired
    private FeedsService feedsService;

    @Autowired
    private UserService userService;

    public void setFeedsService(FeedsService feedsService) {
        this.feedsService = feedsService;
    }

    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @RequestMapping(value="/feeds", method = RequestMethod.GET)
    @ResponseBody
    public Iterable<Feed> allFeeds() {
        return feedsService.allFeeds();
    }


    @RequestMapping(value="/fav-feeds/{userId}", method = RequestMethod.GET)
    @ResponseBody
    public Iterable<Feed> favFeeds(@PathVariable("userId") Long userId) {
        return userService.favoriteFeeds(userId);
    }
}

具体查询的细节我们就不做讨论了,感兴趣的可以在文章结尾处找到代码库的链接。那么有了这个Controller之后,我们如何测试它呢?或者说,如何让契约变得实际可用呢?

spring-test提供了非常优美的DSL来编写测试,我们仅需要一点代码就可以将契约用起来,并实际的监督接口的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private MockMvc mockMvc;
private FeedsService feedsService;
private UserService userService;

@Before
public void setup() {
    feedsService = mock(FeedsService.class);
    userService = mock(UserService.class);

    FeedsController feedsController = new FeedsController();
    feedsController.setFeedsService(feedsService);
    feedsController.setUserService(userService);

    mockMvc = standaloneSetup(feedsController).build();
}

建立了mockmvc之后,我们就可以编写Controller的单元测试了:

1
2
3
4
5
6
7
8
9
10
@Test
public void shouldResponseWithAllFeeds() throws Exception {
    when(feedsService.allFeeds()).thenReturn(Arrays.asList(prepareFeeds()));

    mockMvc.perform(get("/api/feeds"))
            .andExpect(status().isOk())
            .andExpect(content().contentType("application/json;charset=UTF-8"))
            .andExpect(jsonPath("$", hasSize(3)))
            .andExpect(jsonPath("$[0].publishDate", is(notNullValue())));
}

当发送GET请求到/api/feeds上之后,我们期望返回状态是200,然后内容是application/json。然后我们预期返回的结果是一个长度为3的数组,然后数组中的第一个元素的publishDate字段不为空。

注意此处的prepareFeeds方法,事实上它会去加载mocks/feeds.json文件 — 也就是前端用来测试的mock文件:

1
2
3
4
5
private Feed[] prepareFeeds() throws IOException {
    URL resource = getClass().getResource("/mocks/feeds.json");
    ObjectMapper mapper = new ObjectMapper();
    return mapper.readValue(resource, Feed[].class);
}

这样,当后端修改Feed定义(添加/删除/修改字段),或者修改了mock数据等,都会导致测试失败;而前端修改mock之后,也会导致测试失败 — 不要惧怕失败 — 这样的失败会促进一次协商,并驱动出最终的service的契约。

对应的,测试/api/fav-feeds/{userId}的方式类似:

1
2
3
4
5
6
7
8
9
10
11
12
@Test
public void shouldResponseWithUsersFavoriteFeeds() throws Exception {
    when(userService.favoriteFeeds(any(Long.class)))
        .thenReturn(Arrays.asList(prepareFavoriteFeeds()));

    mockMvc.perform(get("/api/fav-feeds/1"))
            .andExpect(status().isOk())
            .andExpect(content().contentType("application/json;charset=UTF-8"))
            .andExpect(jsonPath("$", hasSize(1)))
            .andExpect(jsonPath("$[0].title", is("使用underscore.js构建前端应用")))
            .andExpect(jsonPath("$[0].publishDate", is(notNullValue())));
}

总结

前后端分离是一件容易的事情,而且团队可能在短期可以看到很多好处,但是如果不认真处理集成的问题,分离反而可能会带来更长的集成时间。通过面向契约的方式来组织各自的测试,可以带来很多的好处:更快速的End2End测试,更平滑的集成,更安全的分离开发等等。

代码

前后端的代码我都放到了Gitbub上,感兴趣的可以clone下来自行研究:

  1. bookmarks-frontend
  2. bookmarks-server