I code it

Code and Life

保护你的API(上)

保护你的API

在大部分时候,我们讨论API的设计时,会从功能的角度出发定义出完善的,易用的API。而很多时候,非功能需求如安全需求则会在很晚才加入考虑。而往往这部分会涉及很多额外的工作量,比如与外部的SSO集成,Token机制等等。

这篇文章会以一个简单的例子,从应用程序和部署架构上分别讨论几种常见的模型。这篇文章是这个系列的第一篇,会讨论两个简单的主题:

  • 基于Session的用户认证
  • 基于Token的RESTful API(使用Spring Security)

使用Session

由于HTTP协议本身是无状态的,服务器需要某种机制来区分每个请求。比如在返回给客户的响应中加入一些ID,客户端再次请求时带上这个ID,这样服务器就可以区分出来每个请求,并完成事务性的操作(完成订单的创建,更新,商品派送等等)。

在多数Web容器中,这种机制通过Session来实现。Web容器会为每个首次请求创建一个Session,并将Session的ID以浏览器Cookie的方式返回给客户端。客户端(常常是浏览器)在后续的请求中带上这个Session的ID来表明自己的身份。这种机制同样被用在了鉴权方面,用户登录系统之后,系统分配一个Session ID给他。

除非Session过期,或者用户从客户端的Cookie中主动删了Session ID,否则在服务器端来看,用户的信息会和这个Session绑定起来。后台系统也可以随时知道请求某个资源的真实用户是谁,并以此来判断该用户时候真的有权限这么做。

1
2
3
4
5
6
HttpSession session = request.getSession();
String user = (String)session.getAttribute("user");

if(user != null) {
    //
}

Session的问题

这种做法在小规模应用中工作良好,随着用户的增多,企业往往需要部署多台服务器形成集群来对外提供服务。在集群模式下,当某个节点挂掉之后,由于Session默认是保存在部署Web容器中的,用户会被误判为未登录,后续的请求会被重定向到登陆页面,影响用户体验。

这种将应用程序状态内置的方法已经完全无法满足应用的扩展,因此在工程实践中,我们会采用将Session外置的方式来解决这个问题。即集群中的所有节点都将Session保存在一个公用的键值数据库中:

1
2
3
4
@Configuration
@EnableRedisHttpSession
public class HttpSessionConfig {
}

上面这个例子是在Spring Boot中使用Redis来外置Session。Spring会拦截所有对HTTPSession对象的操作,后续的对Session的操作,Spring都会自动转换为与后台的Redis服务器的交互,从而避免节点挂掉之后Session丢失的问题。

1
2
3
spring.redis.host=192.168.99.100
spring.redis.password=
spring.redis.port=6379

如果你跟我一样懒的话,直接启动一个redis的docker container就可以:

1
$ docker run --name redis-server -d redis

这样,多个应用共享这一个实例,任何一个节点的终止、异常都不会产生Session的问题。

基于Token的安全机制

上面说到的场景中,使用Session需要额外部署一个组件(或者引入更加复杂的Session同步机制),这会带来另外的问题,比如如何保证这个节点的高可用,除了Production环境之外,Staging和QA环境也需要这个组件的配置、测试和维护。

很多项目现在会采用另外一种更加简单的方式:基于Token的安全机制。即不使用Session,用户在登陆之后,会获得一个Token,这个Token会以HTTP Header的方式发送给客户,同样,客户再后续的资源请求中也需要带着这个Token。通常这个Token还会有过期时间的限制(比如只能使用1周,一周之后需要重新获取)。

基于Token的机制更加简单,和RESTful风格的API一起使用更加自然,相较于传统的Web应用,RESTful的消费者可能是人,也可能是Mobile App,也可能是系统中另外的Service。也就是说,并不是所有的消费者都可以处理一个登陆表单!

Restful API

我们通过一个实例来看使用Spring Security保护受限制访问资源的场景。

对于Controller:

1
2
3
4
5
6
7
8
9
@RestController
@RequestMapping("/protected")
public class ProtectedResourceController {

    @RequestMapping("/{id}")
    public Message getOne(@PathVariable("id") String id) {
        return new Message("Protected resource "+id);
    }
}

我们需要所有请求上都带有一个X-Auth-Token的Header,简单起见,如果这个Header有值,我们就认为这个请求已经被授权了。我们在Spring Security中定义这样的一个配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
protected void configure(HttpSecurity http) throws Exception {
    http.
            csrf().disable().
            sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).
            and().
            authorizeRequests().
            anyRequest().
            authenticated().
            and().
            exceptionHandling().
            authenticationEntryPoint(new RestAuthenticationEntryPoint());
}

我们使用SessionCreationPolicy.STATELESS无状态的Session机制(即Spring不使用HTTPSession),对于所有的请求都做权限校验,这样Spring Security的拦截器会判断所有请求的Header上有没有”X-Auth-Token”。对于异常情况(即当Spring Security发现没有),Spring会启用一个认证入口:new RestAuthenticationEntryPoint。在我们这个场景下,这个入口只是简单的返回一个401即可:

1
2
3
4
5
6
7
8
9
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException authException ) throws IOException {
        response.sendError( HttpServletResponse.SC_UNAUTHORIZED, "Unauthorized" );
    }
}

这时候,如果我们请求这个受限制的资源:

1
2
3
4
5
6
7
8
$ curl http://api.kanban.com:9000/api/protected/1 -s | jq .
{
  "timestamp": 1462621552738,
  "status": 401,
  "error": "Unauthorized",
  "message": "Unauthorized",
  "path": "/api/protected/1"
}

过滤器(Filter)及预认证(PreAuthentication)

为了让Spring Security可以处理用户登录的case,我们需要提供一个Filter。当然,Spring Security提供了丰富的Filter机制,我们这里使用一个预认证的Filter(即假设用户已经在别的外部系统如SSO中登录了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class KanBanPreAuthenticationFilter extends AbstractPreAuthenticatedProcessingFilter {
    public static final String SSO_TOKEN = "X-Auth-Token";
    public static final String SSO_CREDENTIALS = "N/A";

    public KanBanPreAuthenticationFilter(AuthenticationManager authenticationManager) {
        setAuthenticationManager(authenticationManager);
    }

    @Override
    protected Object getPreAuthenticatedPrincipal(HttpServletRequest request) {
        return request.getHeader(SSO_TOKEN);
    }

    @Override
    protected Object getPreAuthenticatedCredentials(HttpServletRequest request) {
        return SSO_CREDENTIALS;
    }
}

过滤器在获得Header中的Token后,Spring Security会尝试去认证用户:

1
2
3
4
5
6
7
8
9
10
11
@Override
protected void configure(AuthenticationManagerBuilder builder) throws Exception {
    builder.authenticationProvider(preAuthenticationProvider());
}

private AuthenticationProvider preAuthenticationProvider() {
    PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider();
    provider.setPreAuthenticatedUserDetailsService(new KanBanAuthenticationUserDetailsService());

    return provider;
}

这里的KanBanAuthenticationUserDetailsService是一个实现了Spring Security的UserDetailsService的类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class KanBanAuthenticationUserDetailsService
        implements AuthenticationUserDetailsService<PreAuthenticatedAuthenticationToken> {

    @Override
    public UserDetails loadUserDetails(PreAuthenticatedAuthenticationToken token) throws UsernameNotFoundException {
        String principal = (String) token.getPrincipal();

        if(!StringUtils.isEmpty(principal)) {
            return new KanBanUserDetails(new KanBanUser(principal));
        }

        return null;
    }
}

这个类的职责是,查看从KanBanPreAuthenticationFilter返回的PreAuthenticatedAuthenticationToken,如果不为空,则表示该用户在系统中存在,并正常加载用户。如果返回null,则表示该认证失败,这时根据配置,Spring Security会重定向到认证入口RestAuthenticationEntryPoint

加上这个过滤器的配置之后:

1
2
3
4
5
6
7
8
9
10
@Override
protected void configure(HttpSecurity http) throws Exception {
    //...
    http.addFilter(headerAuthenticationFilter());
}

@Bean
public KanBanPreAuthenticationFilter headerAuthenticationFilter() throws Exception {
    return new KanBanPreAuthenticationFilter(authenticationManager());
}

这样,当我们在Header上加上X-Auth-Token之后,就会访问到受限的资源了:

1
2
3
4
$ curl -H "X-Auth-Token: juntao" http://api.kanban.com:9000/api/protected/1 -s | jq .
{
  "content": "Protected resource for 1"
}

总结

下一篇文章会以另外一个方式来完成鉴权机制和系统的集成问题。我们会在反向代理中做一些配置,将多个Endpoint组织起来。要完成这样的功能,使用Spring Security也可以做到,不过可能会为应用程序本身引入额外的复杂性。

CI上的Web前端性能测试

Web站点的响应速度

雅虎在2006年就发布了一份提升Web站点响应速度的最佳实践指南。该指南包含了35条规则,分为7个类别。这些规则已经被广泛使用,并指导人们在新的站点设计时更有针对性的考虑问题。这份指南已经成了Web前端性能度量的一个事实标准了。

YSlow是一个基于这份指南的测试工具,它可以测试一个站点是否“慢”,以及为什么“慢”?你可以通过很多方式来使用YSlow,比如Firefox,Chrome的插件,命令行工具,甚至PhantomJS这样的无头(Headless)浏览器。YSlow会检测你的站点中的资源是否没有压缩,是否缺失了超时设置,更进一步,它还会检测你的JS/CSS是否已经压缩/精简化,图片的尺寸,是否使用了CDN等等很多的维度。它还可以生成很多格式的报告,比如打分信息,TAP协议的输出,以及junit测试报告的格式。

我们这里讨论如何在持续集成服务器上设置一个YSlow任务,这个任务会在每次构建之后,测试你应用的性能指标,以帮助你更快的发现和定位问题。当然,我推荐你在staging环境,很多开发者在测试环境,本地开发环境都会启动很多对Debug友好的设置,比如未压缩的JS/CSS,没有超时设置的响应等,这会导致该构建任务的打分不够准确。

jenkins failure

搭建CI环境

按照传统方式,如果要搭建一个这样的CI任务,我们需要至少做这样一些事情:

然后设置环境变量,在Jenkins上创建任务,并运行YSlow.js脚本。这个任务很简单,只需要设置好参数,然后将结果输出为Jenkins上的报告即可。比如:

1
2
$ phantomjs /var/share/yslow.js -i grade -threshold "B" -f junit \
http://bookmarks-frontend.s3-website-us-west-2.amazonaws.com/ > yslow.xml
  • -i grade 展示打分(grade)信息(还可以是basic/stats/all)等
  • -threshold "B" 指定失败的阈值为B
  • -f junit 输出为junit识别的XML格式

这里的阈值可以是数字(0-100分),字母(A-F级别)或者一个JSON字符串(混合使用)

1
-threshold '{"overall": "B", "ycdn": "F", "yexpires": 85}'

上面的命令会测试站点http://bookmarks-frontend.s3-website-us-west-2.amazonaws.com/的各项指标,并应用雅虎的那35条规则,并最终生成一个junit测试报告格式的文件:yslow.xml

但是维护CI环境是一个比较麻烦的事情,而且既然每个项目都可能会用到这样的基础设施,一种好的做法就是将其做成一个镜像保存起来,以方便其他项目的复用!这里我们使用docker来安装和配置我们的CI环境,配置完成之后,我们可以将docker的镜像分享给其他团队,也可以供我们在下一个项目中使用。

基于docker/docker-compose的环境搭建

docker出现之前,我们要搭建一个测试或者staging环境,往往需要很多个不同角色的机器:有专门的数据库服务器,文件服务器,缓存服务器,Web服务器,反向代理等等。这样在成本上显然是个不小的开销,如果将所有不同的组件部署在同一台机器上,则又可能互相干扰,只需要一个小小的失误,整个系统就需要重新配置。更可怕的是,这个环境和生产系统不一致,那么很可能真实的错误要等到系统上线之后才会被发现。

比如在2012年,我所在的一个项目中,客户的系统采用传统的J2EE架构。本地开发中,我们使用了Jetty作为容器,而测试Staging环境使用了Tomcat。由于Tomcat对空格的处理和Jetty有所不同,我们在本地测试通过,并且运行良好的代码,在Staging变得完全不能工作。这个问题花费了团队很多时间来排查错误。

docker出现之后,我们可以在一台物理机器上运行多个互不干涉的容器,每个容器可以是一个组件(比如运行数据库的容器,Web服务器容器等等)。这样不但缩减了成本,而且可以让我们的环境尽可能和生产环境一致(有的项目甚至直接将CI出来的镜像应用到生产中)。不过对多个容器的管理是一个很麻烦的事情,好在docker提供了docker-compose工具来解决这个问题。使用docker-compose可以定义一组互相独立,但是又可以协作在一起的容器,这样我们可以很容易的搭建一个仿生产环境。

比如我们可以定义个docker-compse.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
app:
  build: .
  links:
    - db:postgres
  ports:
    - 8000:8000
  volumes:
    - .:/app
  working_dir: /app
  entrypoint: /app/start.sh
  environment:
    JDBC_DATABASE_URL: jdbc:postgresql://postgres:5432/bookmarks
    DATABASE_USER: bookmarks-user
    DATABASE_PASS: bookmarks-password

db:
  image: postgres:9.3
  ports:
    - 5432:5432
  environment:
    POSTGRES_DB: bookmarks
    POSTGRES_USER: bookmarks-user
    POSTGRES_PASSWORD: bookmarks-password

这个docker-compose定义了两个组件,appdbdb使用了postgres:9.3镜像,并设置了自己的环境变量。app则从当前目录.构建一个新的镜像,appdb通过links属性连接起来。

如果在当前目录执行docker-compose build命令,docker-compose会找到本地的Dockerfile,然后构建出一个docker的镜像,并启动该容器,同时,它还会启动postgres:9.3容器作为数据库组件。这样我们的环境就被完整的搭建好了。

搭建CI环境

1
2
3
4
5
6
7
app:
  build: .
  ports:
    - 8080:8080
    - 50000:50000
  volumes:
    - ./data:/var/jenkins_home

这个配置,表明我们会根据当前目录的Dockerfile来构建一个镜像。

通过命令volumns,我们将本地目录./data映射为jenkins_home,这样我们定义的job信息,以及插件的安装都会放到本地的目录中,方便管理。配置完成之后,构建并启动该容器:

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
FROM jenkins:latest

# Env
ENV PHANTOMJS_VERSION 1.9.6
ENV PHANTOMJS_YSLOW_VERSION 3.1.8
ENV SHARE_BIN /var/share

# Install stuff by using root
USER root
RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y git wget libfreetype6 libfontconfig bzip2

RUN mkdir -p $SHARE_BIN

RUN wget -q --no-check-certificate -O /tmp/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 \
https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2
RUN tar -xjf /tmp/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 -C /tmp
RUN rm -f /tmp/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2
RUN mv /tmp/phantomjs-$PHANTOMJS_VERSION-linux-x86_64/ $SHARE_BIN/phantomjs
RUN ln -s $SHARE_BIN/phantomjs/bin/phantomjs /usr/bin/phantomjs

RUN wget -q --no-check-certificate -O /tmp/yslow-phantomjs-$PHANTOMJS_YSLOW_VERSION.zip \
http://yslow.org/yslow-phantomjs-$PHANTOMJS_YSLOW_VERSION.zip
RUN unzip /tmp/yslow-phantomjs-$PHANTOMJS_YSLOW_VERSION.zip -d $SHARE_BIN/
USER jenkins

执行下面的命令来设置并启动CI服务器:

1
docker-compose up

jenkins in docker

创建新任务,并指定该任务执行的命令为:

1
2
$ phantomjs /var/share/yslow.js -i grade -threshold "B" -f junit \
http://bookmarks-frontend.s3-website-us-west-2.amazonaws.com/ > yslow.xml

由于此时phantomjs已经被安装到了容器中,我们可以直接在jenkins中使用。运行结束之后,这个命令会生成一个报告:

jenkins failure

  • 没有压缩内容
  • 没有添加过期的头信息

在产品环境,我们需要使用反向代理来添加这些头信息,以保证最终用户在使用Web站点时的体验。

总结

我们只需要很少的配置就可以设置好一个工作良好的CI任务,这样在程序员某天引入了未经压缩的JS/CSS或者UX不小心提供了巨大而未经处理的图片时,你可以尽快的得到通知,并在正是上线之前发现这些问题。

代码配置在这里。

技术的执念

知识漩涡

只需稍加留意,我们就会发现自己被各种技术,工具包围。ThoughtWorks的技术雷达差不多每隔半年就会更新一次,而项目中更是会遇到很多已经从技术雷达上消失的技术,项目上的旧技术/旧框架还在服役,新的技术/工具/语言/框架又在迅速的出现,有些昙花一现,迅速被新的后来者取代,有的留下来了,不过也都在不断的演化,改变(不兼容的API,不同的版本等等随处可见)。

如果你不幸是一个前端工程师,那么这个更新速度还要更加迅速。三年前Backbone是主流,两年前是Angular.js,去年是React,紧接着FluxReflux等作为React的扩展而成为了新的主流;Grunt流行过一段时间,很快被Gulp替代,而新的Webpack又依稀有大一统的趋势。每周几乎都能看到新的框架涌现,双向绑定,虚拟DOM,事件代理,同构,后端渲染,更友好的语法糖,更快的执行速度等等等等,几乎任何一个方向都有无穷无尽的变化。

full stack js

而后端也好不到哪里去,容器技术,Web框架,ORM,构建脚本,自动化测试工具,依赖管理,应用服务器等等,你总有很多的选项,却又无法在事先区分到底哪个技术/工具更靠谱,更适合项目。

置身其中,往往有眼花缭乱,应接不暇的感觉。知识工作者当然需要终身不断的学习,但是像目前这种节奏,我很怀疑这是一种健康的状态。周围经常有人抱怨,好不容易上手了一个前端的MVC框架,一看周围的项目,大家已经在spike另外的框架/工具了(这意味着你在项目上无法使用该框架了……)。仅仅从学习的速度上来讲,我们已经远远无法跟上科技演化的节奏了,这是人类自身的一种限制。

知识的陷阱

假设你在一个Ruby项目上,学习了Rails/ActiveRecord/RSpec/MySQL。如果下一个项目还是Ruby,同样的技术站,你会觉得这是一种重复,因为除了业务逻辑、业务对象变化了之外,并没有新的内容,还是同样的技术。如果下一个项目是Python,技术栈变成了Django/nose/PostgreSQL,你可能会觉得有所提升,因为学到了不同的技术,框架,共建工具,测试工具等等,其实仔细观察,这还是一种重复,古人云:“换汤不换药”者,是也。

在目前我们所处的时代,信息以远远超过人们能接受的速度不断的被创造出来,一方面信息传播的速度大大提升了,另一方面是信息传播的渠道也极具多样化。我们无时无刻不被过载的信息包围着,即使你不主动的去尝试获取新的信息,手机App里的微信,微博,Flipboard,Pocket,知乎,开发者头条,Feedly,果壳,丁香园等等的推送已经足以提供给你足够的信息(大部分甚至都来不及消费就变成了历史信息而被忽略)。

以我自己为例,从2015年10月到现在(2016年2月),我学习了很多东西,看一下下面这张图:

tech tree

图中的灰色方框中的内容是项目要求的知识,另外的则是我根据自己的兴趣学习的(两者基本上各占一半)。事实上有很多内容(尤其是根据自己兴趣学习的)在真正要使用时,可能还需要学一遍。这些内容可能让我产生了我学到了好多东西的错觉。其实这个在另一个角度显现了技术人员的一个误区:以为自己可以掌握所有软件开发相关的知识(或者说太过于纵容自己的好奇心和兴趣)。

过载的信息

身处这样的信息过载环境,我们很难不为自己对信息的缺乏而感到不安,担心自己错过了什么重要的信息,这种担心和焦虑会促使我们进一步将时间消耗在对信息的获取上,从而更无暇思考什么是真正重要的。

《如何阅读一本书》将书分为两类:一种是提供资讯/信息(known)的,一种是帮助你理解(understand)信息的。相对于理解来讲,资讯本身其实并不那么重要。我们大部分人目前采用的碎片化的阅读方式无法提供给我们足够的“理解力”。我们都有这样的体验,有些书特别耗费脑力,读起来很累,而另一些书则非常轻松,易于消费。碎片话的阅读方式易于消费,只需要很少的思考就可以读懂,但是危害严重,它们并不会让帮助你提升理解力。

但是直觉上我们会选择容易的事情来做,虽然这种浅层次的阅读只对扩展信息/资讯有帮助,对提升理解力则几乎无用。而我们在处理日常工作中的问题时,能真正帮助的,只有理解了的那部分知识。我在2014年,曾经有几个月屏蔽了所有微信,微博,内容聚合类的应用,也尽量少的去技术论坛,每天就是写代码,读纸质书,除了最初几天的忐忑之外,整个过程的收获非常大(而且也没有漏掉任何重要的信息)。

知识框架

技术人员有时候会有一种想要把所有技术都掌握的执念,这在局外人来看是一种荒诞不经的想法,但是置身其中,你很难看出这一点。毕竟,有意思的东西是在太多了,各种范式的编程语言,编译器技术,人工智能,数据可视化,地理信息系统,嵌入式设备,软硬件结合,大数据,自动化测试等等,每一个方向都有无穷无尽的有意思的东西。

但是在知识规模如此巨大的今天,一个人是无法掌握所有技术的(更不用说新的技术还在不断的涌现出来)!这就要求我们有节制的来聚焦在某些技术上,而视其他技术如无物。当然这需要很大的勇气和魄力,不过唯有如此,技术人员才有可能有真正的长进和成就。

我基于自己的经验,绘制了一个Web开发方面的知识框架,这个框架上包含了一个比较全的技能/知识集合,也是我认为一个Web开发人员应该掌握的一些知识点。

knowledge framework

在成为一个专家之前,你需要先对要学习的领域有一个全面的认识。也就是说,做Web开发,需要尽可能覆盖到这个框架上的所有点。一旦完成了这棵树上的所有节点,就不用再去做第二次了,这时候你可以尝试找到树上的某一个分支,深入下去。这个听起来好像和我之前文章中的观点有所矛盾,其实不然。我在《我们真的缺前端工程师》一文中提到过,工程师不应该将自己束缚在前端开发上,要了解整个软件开发的全生命周期。这里的观点其实是一致的,即首先要了解软件开发全生命周期中的所有节点,然后再有所侧重的去找自己的兴趣点来发展,即:先建立广度,再建立深度。

应对方法

对于知识的陷阱

当因自己的兴趣(而不是项目驱动,也就是没有实际的土壤来验证)而想要学习一个新的知识时,对照知识框架,如果发现你已经在历史上学过它了,那就强迫自己放弃这个念头。比如如果你很熟悉用rspec来编写测试,忽然有一天心血来潮,想要学习JUnit,正确的做法就是泡杯茶,等这种冲动自己过去。相信我,一旦有了Java项目,你可以非常快速的掌握JUnit,而且很快会找到对应的feature,就像一个长期工作在Java技术栈上的同事那样!

对于过载的信息

实践中,首先要令自己相信:你无法掌握所有的知识,即使仅仅在软件开发领域。有了这个大前提之后,你只需要采取先建立广度,再建立深度的原则即可:

  • 做减法(在建立了知识框架之后,有针对性的学习)
  • 主动,深度阅读经典
  • 为那些有趣但非自己关注方向的知识赋予较低的优先级

另外,还可以尝试将微信,微博关闭一段时间,或者至少可以不去点那些朋友圈里的《老X聊微服务》或者《12个你不知道的Sublime技巧》文章,保持专注,保持简单。

不想当UX的开发不是好咨询师

成为咨询师

本文旨在帮助开发完成向咨询师的转变,内容不但涉及向UX学习,还包括思维方式的转变。我尽量采用一些亲历的例子来说明该如何做,也会适当的解释为什么需要这样做。不过在展开详细讨论之前,首先来澄清这里提到的三种角色。

开发(Developer)角色

开发是指那些喜欢写代码,享受写代码,喜欢纯粹,讨厌办公室政治,永远穿T恤的有些偏执的程序员。跟他们打交道,有这样一些注意事项:

  • 不要让他们帮你盗QQ
  • 不要让他们帮你修电脑或者装Windows系统
  • 不要跟他们讨论人文/政治类的问题

开发往往还单纯的可爱,除此之外,他们还有这样一些特点:

  • 逻辑清晰
  • 与人争辩时往往可以通过清晰的逻辑而获胜
  • 单身

业界已经有很多关于开发的描述了,我这里也有一个描述开发的列表:

当然,要严格界定一个人是不是开发是非常困难的,大多数情况下,他们沉默寡言,遇到程序中的bug或者在调试某些库的问题时眼神呆滞,口中念念有词,他们不太喜欢和陌生人说话,在晚上精神充沛,白天则显得有些呆滞,喜欢喝咖啡,相信世界上有绝对的正确和错误,往往会带着非黑即白的二分法来看待事物,生活很难自理,喜欢机械键盘/电子设备,周末宁愿宅在家里写代码也不去做社交……

用户体验设计师(UX)

UX是指用户体验设计师,在本文的上下文中,更偏向与非视觉设计的那些设计师(产品设计师)。在项目中,他们会做用户调研,竞品分析,信息架构简历,交互设计(纸上原型,低保真)等活动,并负责开发纸上原型,验证这些原型等。

UX打交道,也有一些应该注意的点,比如:

  • 不要叫他们美工
  • 不要对他们说诸如:“帮我美化一下这个页面”,“这个颜色得再亮一些”之类的话
  • 不要跟他们讲关于程序员的笑话

事实上,人们对UX的误解很深。提到UX人们的第一反应是PhotoShop,P图/切图。这仅仅是他们日常工作中很小的一部分。大部分UX还要做很多用户研究,信息架构整理的事情。老实说,我在去年5月之前的对UX的认识和大部分开发的认识是一样的,但是在后来的项目上和多个UX合作过之后,我彻底改变了原先那种偏见,开始敬佩他们,并向他们学习。

设计工作可以细分为这样一些不同的方面(图片来源网络):

JJG

UX的一项特别的技能在于能从复杂的现实世界中抽象出清晰的信息(用户画像,体验地图甚至最后的用户故事)。这项技能不但重要,而且还很牛逼。

知识的诅咒

《反脆弱》里有个有意思的例子:人们仅仅创造了非常有限的词汇来描述颜色,比如蓝色,红色,而任何一个视觉正常的人都可以轻松的识别出数百种不同的颜色。也就是说,人们可以很轻松的理解相当复杂的事物,但是很难向别人描述该事物(想象一下向别人描述一只章鱼的颜色)。

人们对于现实世界中的事情(特别是复杂的业务场景)往往只能意会而很难言传,再加上知识的诅咒(我在《如何写一本书》里,详细讨论了这种常见的陷阱)的存在,当用户在描述A的时候,在没有上下文的人听来,很可能是B或者C。这种情况在软件开发中非常常见,也是很多项目之所以延期的原因(大量并无必要的返工,需求澄清等)。

在项目前期,UX需要和客户坐在一起,将客户的需求分析清晰。分析细节包括业务场景,用户画像生成,信息架构,体验地图等等,这些信息并不是天然就显现的,恰恰相反,它们需要UX经过很多轮的辛苦引导,从用户的脑海里提取出来的。

这里需要UX的核心能力是:

  • 有目的的抛出问题,引导客户进行发散
  • 有节奏的收敛,形成共识
  • 不断修正过程中的错误
  • 可视化能力(这可能是大部分人觉得唯一和UX相关的点)

咨询师

咨询师是指那些根据自己的丰富经验来帮助客户解决具体问题的人。这些问题并不一定局限在技术上 —— 比如架构的设计,具体前端/后端技术的选定,还包括一些流程的改善。比如引入新的工程实践来缩减项目的周期时间,帮助团队发现问题,建设团队的能力,作为各个团队间的润滑剂帮助项目成功等等。

咨询师工作中的一个常见的场景是:

  • 列出目前遇到的问题
  • 确定各个问题的优先级(和各个利益方)
  • 制定方案
  • 给方案加上时间,形成计划
  • 细化计划中的条目,并促成它

引导/启发

我在印度的某一期TWU当教练的时候,发现了一个很有意思的现象,国外的同事在组织培训时更强调用引导/启发的方式,让学生们自己得出结论,并在课堂上进行讨论,以期教学相长。只有在过程中有启而不发的情况出现时,教练才会适当抛出自己的开发,并再次启动讨论。

TWU 33

与我一直的认识不同的是,这种方式效果很好。通过一些适当的启发,学生很容易自己讨论出一些有趣的看法,然后教练在这个基础上做一些总结,并帮助他们分析不同看法/想法之间的优劣。

我非常认同这种模式,后来自己组织的其他培训/workshop也都尽量采取这种方式。咨询师在客户现场,也应该采取这种引导的方式帮助团队来完成能力建设,而不是事必躬亲。

角色转化

开发者视角切换到咨询师的第一要诀就是:让团队解决自己遇到的问题!乍听起来,咨询师好像变成一个多余的角色了:既然团队自己可以搞定,还要咨询师干什么呢?咨询师的职责是让团队意识到问题,理清思路,制定解决方案,并逐步实施。

使能/赋能

我们来看一个简单的例子:在客户现场,你发现团队往往在集成时会花费很多额外的时间和返工,开发过程中大家各自为政,没有人知道一次commit会给软件包造成什么影响。

如果你是一个咨询师,应该如何解决这个问题?一个常犯的错误是,直接上手帮助团队搭建持续集成(CI)环境,并设置CI纪律(比如build红了不许过夜,红的时候其他人都不许commit等)。

一种更好的做法是:做为咨询师,首先需要帮助团队认识到这个问题,你需要让所有人都知道,我们现在的问题是什么。在所有人都清楚了这一点之后,你需要提出(或者引导出)持续集成的概念(因为根据经验,这是一种可以很好的解决集成时额外的返工现象的好办法)。

但是对于不熟悉持续集成的团队来说,搭建一个持续集成环境是一个非常复杂的任务。因此你需要分解这个任务为一些更小的,可以被解决的问题。

  • 申请虚拟机资源
  • 安装jenkins(包括安装JVM,创建用户等)
  • 配置本地构建脚本到jenkins(构建脚本,自动化测试等)
  • 申请显示器资源(作为CI Monitor)
  • 将结果显式在CI Monitor上

有了任务之后,你需要分别为这些子任务分配owner。对比搭建持续集成环境这样的大任务,这些小的任务已经非常具体,更重要的是,他可以被团队中任何人理解并解决。

学习做引导

除了思维方式的转变,以及自身过硬的专业技能(比如clean code/重构能力,自动化测试,DevOps,持续交付经验等)之外,开发者需要从UX那里学习如何发现问题,并将问题可视化出来的技能。

当你发现团队面临某个问题是,可以通过组织一个类似头脑风暴的会议来帮助团队梳理:

  • 提出问题
  • 维护会议纪律,保证所有人都贡献自己的想法
  • 将想法/问题归类
  • 找出问题的解决方案
  • 制定计划(包括时间点和owner)

关于如何做引导的详细信息,还可以参考我的上一篇文章

进一步的阅读

除了上边提到的

  1. 思维方式的转变
  2. UX学习引导的技巧

之外,事实上还有很多技巧和内容需要学习:

当我们谈论引导时,我们谈些什么?

什么是引导(facilitation)

引导(facilitation)的词根来源与拉丁语“facil”,意思是“让……更容易”。而负责引导的引导师(facilitator)的核心职责是,通过一系列的活动、技巧,保证引导会议顺畅的进行,并解决整个过程中的问题,使得参与者就问题产生一个共识,达成一个结论。

其中可能涉及很多具体的问题,比如几乎在每个会议中都可能看到的:

  • 如果有人尝试将会议变成一言堂,如何处理?
  • 如果参与者不愿意分享自己的观点,如何处理?
  • 过程中,两个参与者产生了争执,如何处理?
  • 如何把握节奏,刺激与会者发散?
  • 如何在收集到足够信息后,进行收敛?

显然,这是一个技术活儿。一次好的引导可以将与会者的众多想法,信息聚合起来,形成对团队下一步要做什么有极强指导意义的方案

日常的引导活动

在平时的工作中,我们其实已经在频繁的使用引导活动,但是很少有人将其作为体系来关注,也很少有人能将这个能力应用在其他方面(比如在客户现场咨询,或者参加售前等)。引导是如此的常见,以至于我们对其视而不见。比如在interview完成之后,所有面试官和HR一起做的well/less well的列举;各种社区活动(Open Party,CDConf等)之后的回顾;每个项目在一个迭代结束后的Retro;对于某个问题的头脑风暴等等。

项目回顾会议

在开始前,引导师需要保证团队:

  • 每个人都有开发的态度
  • 整个过程需要在一个足够安全的环境中进行(Safe Check)

有时候,有Team Lead在场,新人可能不愿意对某事(比如最近加班有点过分)发表自己的看法等。这时候需要有Safe Check,比如分为1到5档,大家用不记名投票的方式来表述自己是否觉得安全。如果投票结果显示大部分人都觉得不安全,则需要与会的人中,职位最高的那个人离开会议,然后再做一次Safe Check,直到大家都觉得足够安全。不过,对于已经进行过多轮回顾的团队,我们往往会忽略掉这一步。

Retro过程是,团队坐在一起,回顾上一个迭代(通常是两个星期)做过的事情,有哪些做的比较好,哪些有待改进,有哪些疑惑等等。Retro可以有很多的形式,比如简单的Well/Less Well/Questions,更聚焦在产生Action的海星式等等。

pens

通常的顺序是:

  1. 引导者请大家用纸笔将想法写在便签(stick)上
  2. Time box这个过程(通常是5分钟)
  3. 大家将这些stick贴在墙上
  4. 引导者和团队一起过一遍所有的stick
  5. 归类相似的stick
  6. 引导者促进团队交流,讨论stick上的问题,并形成一些改进点(Action)

Action一定要足够具体,并且需要一个所有者,所有者负责确保该Action一定会发生。比如团队发现上一个迭代中Code review做的不够好,一个Action就是每天下午5点有人来提醒大家来进行Code review

如果这时候发现有太多的问题,团队可以用投票的方式选出本次Retro要讨论的数个stick。

引导会议

在日常工作中,我们几乎每天都有会议,而且越来越多的团队已经意识到冗长,无聊的会议有多大的杀伤力了。在很多会议上,与会者要么在刷新朋友圈,要么在对着笔记本电脑写代码或者读新闻,即使强制要求不许带电脑和手机的情况下,也无法限制参加者神游太虚。

根据《引导的秘诀》这本书里的定义,引导会议是

引导会议是一个高度结构化的会议,会议中的领导者(引导者)引导参会人通过预先设定好的步骤达成所有参会人产生,理解并接受的结果。

引导会议需要充分调用参与者的积极性,每个人都需要足够聚焦,这要求引导者可以有能力使得团队振奋(比如幽默的风格,或者具有挑战性的问题等)。另外,每个人的idea都需要被充分重视(一个细节就是不要随意篡改你听到的内容,这是没有经验的引导者常犯的错误之一)。一旦所有参与者都积极起来,引导者就可以稍微退后一些,将舞台交给团队。

而有时候,情景则相反,大家都不发言,也没有看到明显的发言的趋势,这时候需要一些方法来激励。如果是团队都比较茫然,引导者需要列出一些简单而容易理解的步骤,帮助团队按照预设的节奏来逐步前进。比如,在一开始的时候就将agenda板书在墙上,并通过头脑风暴的方式,鼓励参与者来将自己的idea可视化出来。

一个典型的误区是,引导会议的最后结论是本来就存在与引导者脑海中的想法。如果仅从结果来看,这种情况可能发生,但是只能说是碰巧而已。一个好的引导者需要帮助与会者自己产生,并得出一个可行的,被广泛认可的方案,而不是强加一个自己的给团队。

我们最为专业的引导活动是UX团队在客户现场的inceptioninception由一系列相互关联,环环相扣的工作坊组成,这些工作坊基本上都需要采用很多引导的技巧,帮助客户团队将自己的问题描述清楚,并形成一个所有参与者都达成一直的可行方案。

sticks

如果你不知道如何开始一个引导会议,一个简单而通用的模式是:

  1. 我们的现状是
  2. 我们的目标是
  3. 我们如何到达目标
  4. 在行进中,如何度量

《引导的秘诀》里还提到了一种5P模式:目的(Purpose),产出(Product),与会人(Participant),可能的问题(Probable issues)以及流程(Process)。

5P提示你在准备会议之前,需要尝试回答这几个问题

  • 为什么要开这次会议?主要目的是什么?
  • 会议后的产出是什么?
  • 谁需要参与会议?
  • 在会议中,我们可能遇到什么问题?
  • 遇到这些问题是,我们如何解决?

引导中的常用技巧

在引导活动中,有一些基本的规则,可以保证引导会议的顺畅性,比如

  • 引导师需要有足够的权威(可以打断那些长篇大论,保证过程的流畅)
  • 如果人数太多,可以使用token(比如一个玩具考拉,或者一个澳式橄榄球,只有持有token的人可以说话)
  • 保持one conversation(不要交头接耳)
  • 每张stick上只写一条问题/想法

引导师必须有控制会话何时结束的能力,否则引导活动将会变成一发不可收拾的冗长会议。坚持one conversation可以保证参与者足够聚焦,也保证所有人都在同一个频道上。如果发现有交头接耳的,引导者可以直接打断并提醒之。

每张便签上只写一条想法,首先可以保证多样性,便于讨论,也便于后续的分类。另外,简洁的描述在一定程度上可以促进与会者进行讨论,而一个冗长的描述则会让人丧失兴趣。

另外还有一些比较基础的技巧:

  • 所有讨论都应该对事不对人(特别是一些负面的总结)
  • 如果有人提出与议题并不特别相关,但是又特别重要的点时,可以将这些点记下来(不要轻易打击发言者的积极性)
  • 不定时的总结,以确保参与者都在同一频道,并且有助于大家对进度的了解(是不是快结束了)

穷人的持续集成与持续交付(下)

客户端程序的的持续交付

上篇文章介绍了如何使用一些免费的服务来实现服务器端API的持续集成、持续交付环境的搭建。有了服务端,自然需要有消费者,在本文中我们将使用另外一个工具来实现纯前端的站点的部署。

其中包括:

  • 持续集成(单元测试,集成测试等)
  • 持续部署/持续交付
  • 静态站点托管

除此之外,我们还会涉及到:

我们的应用最后看起来是这样子的。

bookmarks app

技术选型

我们在本文中,将采取另外一套免费服务来完成环境的搭建

Snap CI是一个非常易于使用的持续交付环境,由于很多关于持续集成,持续交付的概念和实践都跟ThoughtWorks有关,所以这个产品对于构建,流水线,部署等等的支持也都做的非常好。

S3是亚马逊的云存储平台,我们可以将静态资源完全托管在其上。S3的另一个好处是它可以将你的文件变成一个Web Site,比如你的目录中有index.html,这个文件就可以作为你的站点首页被其他人访问。这个对于我们这个前后端分离项目来说非常有用,我们的cssjsfont文件,还有入口文件index.html都可以托管于其上。

实例

在本文的例子中,我们将定义3个stageSnap CI将一次发布分为若干个stage,每个stage只做一件事情,如果一个stage失败了,后边的就不会接着执行。

我们的3个stage分别为:

  1. 单元测试
  2. 集成测试
  3. 部署

准备工作

bookmarks-frontend是一个纯前端的应用,它会消费后端提供的API,但是其实它并不知道(也不应该知道)后端的API部署在什么地方:

1
2
3
4
5
6
7
8
$(function() {
  var feeds = $.get(config.backend+'/api/feeds');
  var favorite = $.get(config.backend+'/api/fav-feeds/1');

  $.when(feeds, favorite).then(function(feeds, favorite) {
      //...
  });
});

由于我们在本地开发时,需要backend指向本地的服务器,而发布之后,则希望它指向上一篇文章中提到的服务器,因此我们需要编写一点构建脚本来完成这件事儿:

1
2
3
4
5
6
7
8
9
10
11
12
13
var backend = 'http://quiet-atoll-8237.herokuapp.com';

gulp.task('prepareConfig', function() {
    gulp.src(['assets/templates/config.js'])
    .pipe(replace(/#backend#/g, 'http://localhost:8100'))
    .pipe(gulp.dest('assets/script/'));
});

gulp.task('prepareRelease', function() {
    gulp.src(['assets/templates/config.js'])
    .pipe(replace(/#backend#/g, backend))
    .pipe(gulp.dest('assets/script/'));
});

我们定义了两个gulp的task,本地开发时,使用prepareConfig,要发布时,使用prepareRelease,然后定义一个简单的模板文件config.js

1
2
3
module.exports = {
  backend: '#backend#'
}

然后可以很简单的包装一下,方便本地开发和发布:

1
2
3
gulp.task('dev', ['prepareConfig', 'browserify', 'concatcss']);
gulp.task('build', ['prepareConfig', 'script', 'css']);
gulp.task('release', ['prepareRelease', 'script', 'css']);

这样,我们在本地开发时,只需要简单的执行:

1
$ gulp

即可。而在发布阶段,只需要执行:

1
$ gulp release

单元测试

我们在Snap CI上将github上的代码库关联起来,然后添加一个名叫unit-teststage,指定这个stage对应的命令为:

1
2
npm install
gulp

Snap CI unit

这样,每当我们有新的提交之后,Snap CI都会拿到新代码,并执行上述命令,如果执行成功,则本地构建成功。

集成测试

由于采取的是前后端分离的策略,我们的应用可以完全独立与后端进行开发,因此我们设置了一个fake server,具体细节可以参考我之前的博客,也可以看源码。不过这里我们要为集成测试编写一个脚本,并在Snap CI上执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#!/bin/bash

export PORT=8100
bundle install

# launch the application
echo "launch the application"
ruby app.rb 2>&1 &
PID=$!

# wait for it to start up
sleep 3

# run the rspec tests and record the status
rspec
RES=$?

# terminate after rspec
echo "terminate the application"
kill -9 $PID

# now we know whether the rspec success or not
exit $RES

这个脚本中,首先安装所有的gems,然后启动fake server并将这个server放置在后台运行,然后执行rspec。当rspec测试执行完成之后,我们终止服务进行,然后返回结果状态码。

这里使用了capybarapoltergeist来做UI测试,capybara会驱动phantomjs来在内存中运行浏览器,并执行定义好的UI测试,比如此处,我们的UI测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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
  
  ##...
end

Snap CI logs

部署

首先需要在S3上创建一个bucket,命名为bookmarks-frontend。然后为其设置static website hosting,这时候AWS会assign一个新的域名给你,比如http://bookmarks-frontend.s3-website-us-west-2.amazonaws.com/

然后你需要将这个bucket设置成public,这样其他人才可以访问你的bucket

AWS S3

有了这个之后,我们来编写一个小脚本,这个脚本可以将本地的文件上传至S3。

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/bin/bash

# install gulp and its dependencies
npm install

# package stuff, and point the server to the right place
gulp release

# upload the whold folder
aws s3 cp public/ s3://bookmarks-frontend \
  --recursive \
  --region us-west-2 \
  --acl public-read

aws命令是aws command line提供的,另外我们需要在环境变量中设置AWS提供给你的token:

1
2
AWS_ACCESS_KEY_ID=xxxxxxxxxx
AWS_SECRET_ACCESS_KEY=xxxxxxxxxx

然后我们就可以将本地的public目录递归的上传到S3的对应目录了!

snap ci pipeline

完整的代码可以在此处下载

总结

我们前端的持续交付也介绍完了。现在前后端应用完全独立,发布也互不影响。不论是服务器端新增加了API,还是添加了新数据,客户端的发布都不受影响;同样,修改样式,添加新的JavaScript也完全不会影响后端。更重要的是,所有的发布都是一键式的,开发者只需要一个git push就可以享受这些免费服务提供的自动构建,自动化测试以及自动部署的功能。

穷人的持续集成与持续交付(上)

服务器端应用的持续交付

本文将使用一些免费的服务来为你的项目搭建持续交付平台,这些服务包括

  • 持续集成环境
  • 持续部署环境
  • 服务端应用托管

以及一些可以用于本地开发使用的开源工具如:

除此之外,我们在过程中编写的脚本还可以用以本地构建,如果你的团队中正好已经有CI工具/CD工具,将这些脚本集成进去也是一件非常容易的事情。

heroku real time log

背景知识

软件的度量

传统的管理方法论,在软件开发这个领域来说基本上是不工作的。软件项目的不确定性使得人们畏惧,管理者希望通过一些数字,指标来让自己感到某种虚幻的“掌控感”。软件行数,测试覆盖率,代码故障率等数字的名声基本上已经很糟了,经常有人拿来讽刺那些追求虚幻掌控感的“领导”。

但是有一个数字,即使最顽固的“自由主义者”也会认为是有意义的,那就是周期时间(cycle time)。简而言之,就是一个需求从产生到最终上线所需要的时间。其中包括了需求分析,设计,编码,测试,部署,运维等活动,可能还会包含后续的监控。

其实不论是瀑布模型,还是迭代开发的方式,或者其他的方法论,周期时间的缩短都是至关重要的。而具体到周期内,单纯的开发时间变长或者测试时间变长都无关紧要。比如项目A的开发时间是测试时间的2倍,项目B则恰恰反过来,这并不能说A做的比B好,真正有意义的是A的周期时间是否比B更短。

单纯改善项目过程中的某一个阶段的时间,可能并不能达到预期的目的。局部优化并不一定会带来全局的优化。换言之,通过某些策略来提高软件测试的效率未必能减少周期时间!

持续交付

传统情况下,企业要进行软件开发,从用户研究到产品上线,其中会花费数月,甚至数年(我的一位印度同事给我聊起过,他的上家公司做产品,从版本启动到版本上线需要整整两年时间!)。而且一旦软件需求发生变更,又有需要数月才能将变更发布上线。除了为变更提交代码外,还有很多额外的回归测试,发布计划,运维部门的进度等等。而市场机会千变万化,在特定的时间窗口中,企业的竞争者可能早已发布并占领了相当大的市场份额。

在软件工程领域,人们提出了持续交付(continuous delivery)的概念,它旨在减少周期时间,强调在任何时刻软件都处于可发布状态。采用这种实践,我们可以频繁,快速,安全的将需求的变化发布出来,交由真实世界的用户来使用,在为用户带来价值的同时,我们也可以快速,持续的得到反馈,并激励新的变化产生(新的商业创新,新的模式等)。

持续交付包含了自动化构建,自动化测试以及自动化部署等过程,持续改进开发流程中的问题,并促进开发人员,测试人员,运维人员之间的协作,团队可以在分钟级别将变更发布上线。

持续交付相关技术及实践

  • 版本控制(配置管理)
  • 持续集成CI
  • 自动化测试
  • 构建工具及构建脚本
  • 部署流水线

团队通过版本控制来进行协作,所有的代码会在持续集成环境中编译,代码静态检查/分析,自动化测试(还可能产生报告等)。除此之外,CI还还需要有自动化验收测试,自动化回归测试等。

持续交付则更进一步,它将环境准备,持续集成,自动化部署等放在了一起。通过全自动(有些过程可以设置为手动,比如发布到产品环境)的方式,使得软件可以一键发布。如果上线后发现严重defect,还支持一键回滚的机制(其实就是将之前的一个稳定版本做一次发布,由于发布流程已经经过千锤百炼,所以发布本身就变得非常轻松,安全)

这篇文章中,我们会使用git+github作为版本控制工具,travis-ci作为持续集成环境,gradle作为构建工具,Heroku作为应用的部署环境。这些工具都是免费服务,如果你需要更高级的功能(比如更多的并发数,更大的数据库),则可以选择付费套餐。不过对于我们平时的大部分side project来说,免费服务已经足够。

实例

我在《前后端分离了,然后呢?》这篇文章中,提到了一个叫做bookmarks的应用,这个应用是一个前后端分离的非常彻底的应用。

我们这里会再次使用这个应用作为实例,并采用不同的两个免费服务(travis-cisnap-ci)来完成持续部署环境的搭建。

bookmarks服务器

bookmarks-server是一个基于spring-boot的纯粹的API,它可以被打包成一个jar包,然后通过命令行启动运行。在本文中,我们我们将会将这个server部署到heroku平台上。

首先需要定义一个Procfile,这个是我们应用的入口,heroku根据这个文件来明确以何种方式来启动我们的应用:

1
web: java -Dserver.port=$PORT -jar build/libs/bookmarks-server-0.1.0.jar --spring.profiles.active=staging

由于我们在本地使用的使用mysql,而heroku默认的是postgres数据库,因此需要在application.yml中额外配置

1
2
3
4
5
6
7
8
9
10
11
12
13
spring:
  profiles: staging

  datasource:
    driverClassName: org.postgresql.Driver
    url: ${JDBC_DATABASE_URL}
    username: ${DATABASE_USER}
    password: ${DATABASE_PASS}

  jpa:
    database_platform: org.hibernate.dialect.PostgreSQLDialect
    hibernate:
      ddl-auto: update

有了这些配置后,我们需要创建一个heroku应用:

1
2
$ heroku create
Created http://quiet-atoll-8237.herokuapp.com/ | git@heroku.com:quiet-atoll-8237.git

创建之后,我们可以在界面上对这个应用进行一些配置(当然,也可以通过命令行,具体参看heroku help)。为了支持数据库,需要为我们的应用添加一个postgres的AddOn。添加之后,heroku会为我们提供一个postgres的连接地址,格式大概是这样:

1
postgres://username:password@host:port/database

然后我们需要在Heroku的配置界面中配置一些环境变量:

heroku env config

这样,当应用部署到Heroku上之后,我们的应用就可以读到这些配置了(注意application.yml中的环境变量JDBC_DATABASE_URL)。

搭建持续集成环境

持续集成环境,这里我们选用最简单的travis-ci,它可以很容易的与github集成。

  • 在项目X中定义一个.travis.yml的文件
  • 将你的代码push到github上
  • 绑定github帐号到travis
  • travis中启用项目X

这个.travis.yml因项目而异,我们这里的项目是spring-boot,所以只需要指定java即可:

1
language: java

如果是java项目,并且项目中有build.gradletravis-ci会自动执行gradle check任务。

自动化部署

当CI运行成功之后,我们需要travis-ci帮我们将应用程序发布到heroku上,这时候需要做一些修改。最简单的方式是直接安装travis-ci的命令行工具到本地:

1
$ gem install travis -v 1.8.0 --no-rdoc --no-ri

然后通过herokuauth:token命令获得heroku的token,在加密并写入.travis.yml

1
2
3
4
$ heroku auth:token
00xxxxxxxxxxxxx55d11dbd0cxxxxxxxxxxfe067

$ travis encrypt 00xxxxxxxxxxxxx55d11dbd0cxxxxxxxxxxfe067 --add

当然可以合并为一条命令:

1
$ travis encrypt $(heroku auth:token) --add

将加密过的token存入.travis.yml文件。最后的结果大致如下:

1
2
3
4
5
6
language: java
deploy:
  provider: heroku
  api_key:
    secure: ...
  app: quiet-atoll-8237

注意此处的app,正是我们的App的名字。另外,还需要给build.gradle添加一个名叫stage的task,travis在deploy时需要这个task

1
2
3
task stage {
    dependsOn build
}

travis deploy

这样,我们只需要在本地的一个提交,一切都会自动化起来:

  • travis会执行gradle check
  • gradle check会编译并运行自动化测试
  • travis会部署应用到heroku
  • heroku会自动重启服务

我们可以在本地进行简单的测试(注意此处我们的staging环境的URL):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ curl https://quiet-atoll-8237.herokuapp.com/api/feeds -s | jq .
[
  {
    "id": 1,
    "url": "http://icodeit.org/2016/01/how-to-summarize-privious-project/",
    "title": "如何持久化你的项目经历",
    "author": "icodit.org",
    "summary": "通常来说,下项目总是一件比较高兴的事(大部分团队还会一起吃个饭庆祝一下)。",
    "publishDate": "2016-01-07"
  },
  {
    "id": 2,
    "url": "http://icodeit.org/2015/11/get-started-with-reflux/",
    "title": "你为什么应该试一试Reflux?",
    "author": "icodit.org",
    "summary": "React在设计之初就只关注在View本身上,其余部分如数据的获取,事件处理等,全然不在考虑之内。",
    "publishDate": "2016-01-09"
  }
]

完整的代码在这里

其他

CI monitor

node-build-monitor是一个非常容易配置,使用的CI monitor,我们只需要进行简单地配置,就可以将travis的状态可视化出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "monitor": {
    "interval": 2000,
    "numberOfBuilds": 12,
    "debug": true
  },
  "services": [
    {
      "name": "Travis",
      "configuration": {
        "slug": "abruzzi/bookmarks-server"
      }
    }
  ]
}

不过这个工具会在有网络异常时自动终止,我们可以通过一个简单的脚本来在它终止时自动重启:

1
2
3
4
5
6
#!/bin/bash

until node app/app.js
do
    echo "restarting..."
done

CI Monitor

小结

通过travisheroku这样的免费服务,我们就可以轻松的将自己的项目做到持续集成+持续交付。我们后端的服务相对来说是比较容易的,但是涉及到一个前后端分离的架构,如何做到静态内容的托管,打包,部署,并和后端API集成起来,我会在下一篇文章中详细解释。

如何持久化你的项目经历

下项目之后

通常来说,下项目总是一件比较高兴的事(大部分团队还会一起吃个饭庆祝一下)。这里面既有终于摆脱了厌烦了的技术栈的解脱感,也有对新项目/新技术的向往,可能还有些在旧项目中做的不太满意的事情,可以在新项目重头再来的期望。

可能有点老生常谈了,不过这里我想说说下项目后如何做总结的事儿。对上一个项目的总结,其重要程度可能要远远超过你的想象。我是在2014年初,在一个客户现场的一个会议室和一位同事谈我的Annual Review的时候,才意识到这个问题的。

Annual Review会回顾我们过去一年的项目经历,有哪些方面的进步,同时也会展望未来的计划。但是这其中有几个问题:第一个问题是以年为单位的Review粒度太粗,有些经历已经淡忘,也有人可能一年会换好几个项目;第二个问题是对于大多数人来说,展望未来是一件比较容易的事儿,每个人或多或少都会有一些计划(比如学会前端的某些技术,尝试一些DevOps方面的工作,或者了解一下大数据等等);但是对于回顾过去,我们其实并不擅长;最后一个问题是即使做了回顾,回顾的层次非常浅,作用并不大。

说到回顾过去,更多时候我们关注的是从项目中学到了什么样的新技术,这种浅层次的回忆和记流水帐是容易的,但是对于我们的成长并不会有太大的收益。真正会为帮助我们在将来的项目中做决策,甚至会影响我们学习效率,解决问题能力的是:深度回顾

深度回顾练习

深度回顾可以帮助我们梳理知识,将实际的案例归纳总结为实际可用的知识。要获得这种能力,需要做一些针对性的练习。根据我自己的经验,这些练习大约可以归为3类,难度依次增加。

项目上的直接经验

这个练习比较简单,就是问问自己:“我在项目里学到了什么?

要回答这个问题是很容易的,项目中用到的技术如模板引擎,前端框架,自动化测试套件,build工具等等,总结这些内容的过程,对于我们的PS来说都是“自动”发生的,几乎不需要付出额外的efforts。这些回顾、总结可以帮助我们成为一个“熟手”,即当下一次遇到相同或者类似的场景时,我们可以很容易直接应用这些经验。

更进一步,再问问自己在项目中的其他收获。比如客户关系处理的经历,团队建设的经验,甚至是写英文邮件的技巧等等方面,看看做的有没有问题,有没有提升的可能?

人类最牛逼的技能是:可以审视自己的行为。也就是站在旁观者的角度来看待自己的行为,随波逐流式的在各种琐事中沉浮事实上无法得到提升的。可以经常性的将自己置身事外,以一个旁观者的角度来审视自己做过的事情。并从中找出做得好的地方和不足的地方,然后自己给过去的自己一些建议,并记录下来。这些刻意的练习会帮助你养成回顾,从经验中学习的习惯,而这个习惯正是一个人区别于另一个人的绝对“捷径”。

练习讲故事

这个练习是,假想你遇到了一个同一个办公室的同事,他对你刚做完的这个项目很感兴趣,你来给他描述一下这个项目。描述的内容包括但不限于这些方面:

  • 项目的背景介绍
  • 该项目以何种方式,为那些用户,带来了什么样的价值?(business model是什么)
  • 该项目的实际用户数量是什么级别?
  • 项目的部署,运维是如何操作的?
  • 项目的监控是怎样做的?
  • 当遇到系统故障,项目组是如何反应的?

能把一件事情描述清楚是一件非常了不起的能力。我见过很多的程序员,写起代码来好不含糊,但是却很难将一件简单的事情讲清楚。我们当然要提防那些夸夸其谈,华而不实的“嘴子”,但是也至少得要求自己做到清晰,准确的将自己经历过的事情描述清楚。

描述项目背景需要至少需要交代这样一些内容:客户是谁,最终的消费者是谁,项目以何种方式运作(离岸交付,本地,onsite,咨询,培训等),我们帮助客户为消费者带来了什么样的价值。客户的商业模式是什么,在我们周围有哪些类似的项目。

business canvas

即使在技术方面,也有很多被Dev忽略掉的信息,比如项目在产品环境中如何部署,数据中心建在何处,客户如何运维、监控等。实际的发布周期如何,发布流程如何,客户的内部论坛上都会有很多的这样的信息,但是很少有人关注。从一个项目roll off的时候,这些信息即使做不到了若指掌,至少也能描述清楚,否则难免有些“入宝山而空回”的遗憾。

回顾项目中的挑战

从简单的CRUD系统,到复杂的分布式计算,从企业内部的管理系统,到支持高并发、要求实时处理的交易平台,每个项目都会遇到一些挑战。除了技术上的挑战之外,还有陈旧而无文档的代码库,复杂的业务场景,不配和的客户接口人等等。挑战无处不在,那么作为项目中的一员,你是如何应对这些挑战的呢?最后又是如何解决的?

现实世界是一个充满了trade off的世界,我们需要做种种权衡,代码测试覆盖率和交付压力,性能和客户能负担的机器实例数量,框架A和框架B的优劣等等。我们在采取这个方案的时候,只能舍弃其他方案,由于谁也无法在事先准确预料采取某个方案一定是对的,那么在一个失败的方案背后,其实也是一个很好的教训,至少可以为未来的决策提供帮助。

  • 遇到的最大的挑战是什么?
  • 这个挑战是如何被解决的?
  • 如果有机会重做,你会如何考虑?

其他练习

这里列出了一些我常用的,辅助性的练习。它们可以帮助你更好的梳理项目上学到的技能、知识,并且转换成你自己的知识。这些练习未必一定要等到项目结束之后才做,事实上它们都可以应用在日常的工作中。

  • 记笔记
  • 写博客
  • 在办公室内演讲
  • 去社区贡献话题

很多人都会记笔记,但只有一小部分的人在记录之后会持续翻阅。很多人会使用Evernote/印象笔记之类的工具将一些临时的想法,问题的思路,知识点的细节等记录下来,但是仅仅记录是不够的,笔记需要不断的检索、整理、提炼、修正、总结和归纳。在不断的加工之后,这些笔记可能会得到沉淀,并升华形成一些更有意义的内容(比如个人博客,或者可以发表到InfoQ/IBM DeveloperWorks平台上的文章等)。

除了记录笔记之外,写博客也是一种很好的总结形式。通过将素材不断充实、整理、完善,最终形成一个可供别人直接消费的文章,不但可以锻炼到总结能力,还可以很好的提升表达能力,而且可以帮助你将已有的知识体系化。如果你的博客写成了系列,也很容易通过Gitbook等将其发布为一本电子书,从而影响更多人(说不定还可以赚点咖啡钱)。

写博客/电子书,终究是书面形式的。事实上一个人可以很容易的通过文字将自己的实际情况隐藏起来。举个极端的例子:如果有足够的动机(比如公司的KPI要求),即使不熟悉某种语言/工具,仅仅通过Google,一个人也可以通过这种“作弊”的方式写出一篇“专家级”的文章。但是对于演讲这种面对面的形式,则基本上无法作弊,从而也更具有挑战性。另一方面,对于一个新的知识、技能,自己掌握是一回事儿,要讲出来让别人也能听懂,并从中收益,则完全是另外一回事儿。作为咨询师,语言表达(包括书面和演讲)能力的重要性勿庸赘言。整理知识,并归纳为演讲,会帮助你将体系化后的知识更好的表达出来。

在办公室里讲session有一定的挑战,但受众毕竟是“自己人”,压力相对会小一些(比如在ThoughtWorks,我们非常鼓励员工为其他人讲session,具体可以参看我的这篇文章)。要在社区中演讲则要面临更大的挑战,通过将话题不断锤炼,不断归纳,最终形成可以在社区分享的话题,则不但可以提高内容的质量,也可以更好的锻炼表达能力和临场应变能力。

xian community

不过归根结底,这些活动的重要输入还是对之前项目中的知识、经历的深度回顾。

总结

从项目上下来之后,需要深入思考并总结之前的经验,这种深入思考会帮助你建立比较完整的知识体系,也可以让你在下一项目中更加得心应手,举一反三。如果只是蜻蜓点水般的“经历”了若干个项目,而不进行深入的总结和思考,相当于把相同的项目用不同的技术栈做了很多遍一样,那和我们平时所痛恨的重复代码又有什么不同呢?

我的2015

2015年总结

2015年在不经意间就过去了。又是一年。按照惯例,我会在年末回顾一下这一年自己的进步,收获以及一些感慨(牢骚)。然后对来年做一点展望,看看什么地方可以做的更好。

项目经历

今年基本上经历了三个项目,性质也都不一样:

  • 海外交付
  • 国内交付
  • 国内售前

3月前的项目以及没有什么印象了,依稀觉得项目上的所有实践都是“错”的,可能由于太过与荒诞,所以大脑自行将这段抹去了。

4月到6月在深圳的一个国内交付项目上,交付压力还挺大。不过也是在这个项目上,非常直接的体会到了其他角色的不容易。无论是客户的接口人,客户的项目经理,客户的领导,我们的开发,我们的UX,项目经理,还有交付的lead,所有人都不容易。

《项目经理是大傻逼吗》这篇文章就是为了纪念这个项目,或者说是被这个项目驱动出来的。

6月之后回到办公室,在一个海外交付项目上。说是一个项目,中间其实换了6,7个不同的工作内容(出钱的是同一个客户而已)。总之一片混乱,所幸我们在10月前就结束了和这个客户的合作。这个项目事实上除了锻炼项目上人的耐心外,基本毫无益处。甚至对于很多毕业生来说,是深刻的伤害。

10月之后,我难得的在beach上待了下来,而且一待就是3个月。中间在一个联合国的项目上工作了2周,然后就是为另外一个咨询项目准备了几周方案。当然,闲是不可能闲着的。在beach上,如果不出意外,肯定会比项目上更忙!比如打黑工啊,内部什么系统的改进啊,总是有好多事情。

由于有一些本地的项目机会,而我又不在具体项目上,就来做这个售前的角色。帮助客户梳理需求,分析问题,设计方案,计算工作量等等。但是这个过程往往循环往复,一轮接着一轮,在合同确认之前,需要很多次讨论和交流。这应该不会是我自己的一个方向,在项目上写代码,培养新人,分享自己的学习所得,和他人一起进步,是我自己比较有热情的方向。

技术方面

在海外交付项目上,乏善足陈,项目中用到的也是非常厚重,已经至少10年的技术。通常来看,这样的大组织,没有人对要做的事情真正关心。好不容易遇到一个特别靠谱的人,结果我们的项目又结束了。国内项目上倒是有很多有意思的东西:

  • 如何在前端代码中很好的使用MVC
  • 流畅的前端开发模式
  • 如何做前端的测试

上面这三点,我希望可以找时间整理出一本电子书,可以让没有工程级做过项目的前端工程师能有一个参考。

另一方面,由于项目的压力,和项目人员的特殊性(开发就俩人,一个做前端,一个做后端,要集成就pair一下),所以很多实践都没有应用,比如结对,自动化测试等,做的都不够好。虽然我们很推崇,强调CI/CD的实践,但是当和客户的后端系统集成时,就各种悲剧。

联合国的项目上,技术栈比较新颖,上一家的技术人员使用了他们当时能找到的所有酷炫的新技术,并用在了项目中,然后他们公司被收购。留给我们的在今年来看,依然是比较新的

  • React
  • Reflux
  • ES6
  • mocha/chai

而在国内售前,基本上没有写过一行代码,更多是更高层次(高不一定是好哦)的工作。确认需求,估算工作量,确定方案(前后端测试,开发方式,部署策略,自动化测试等等)。

2016年,我希望可以多学习一些具体的编程知识,比如:

  • mongodb
  • 数据分析,数据挖掘
  • 容器技术如docker

书籍

今年读了一些技术方面的书,更多的则是一些非技术类的。《自私的基因》是在2012年11月去墨尔本时,在广州白云机场买的,路上10个小时,读了几页。直到2015年才又拿起来,读了两章左右,基本上颠覆了我之前建立的对“进化论”的认识。

另外读了一些科普类的,比如《哲学家在干了些什么》,《上帝掷骰子吗》等,又扫了一次盲。再就是一些佛教相关的书籍,《西藏生死书》,《能断:金刚经》,《正见》等,人生观和价值观得到了刷新。

技术类的,主要是一些与具体技术关系不太大的,比如《恰如其分的软件架构》,《企业级应用架构模式》,《发布!》,《实例化需求》,《持续发布》之类。

虽然竣工于2014年,但是我的一本著作和一本译作都是2015年才发布出来,那就算作2015年的吧:

社区

今年在深圳的时候,有幸和ThoughtWorks的首席科学家马丁.福勒在同一次活动中作为讲师。

shenzhen

回到西安之后,在本地社区中还讲过一个前端工程师需要掌握的技能列表的session

xian

2016年希望可以做一些更加深入的topic,以及一些更有意思,可以帮助到更多已经在路上的工程师们。

People Development

这个不知道如何翻译了,但是确实做了一些具体的事情:

  • 给我的sponsee们安排了读书会
  • 组织了一次《编写可维护的JavaScript》的workshop
  • 组织sponsee来做一个side project
  • 组织《Web开发实战》的workshop

我自己总结出来了一套组织workshop的方式,其实很简单:

  1. 做好计划,做好课表,做好课件
  2. 找学员,同时从学员里找出有基础,而且有意愿接着run workshop的人
  3. 讲课,收集feedback,并改进课表,课件
  4. 将run workshop的任务传递给那些有基础,而且有意愿接着run workshop的人

2016年希望可以找到更多的候选人,并帮助他们成为更好的讲师,教练。

总的感悟

总体的感觉是很忙,各种事情千头万绪,没有了清晰的一个vision,也没有了指导,自身的发展方向也变得模糊起来。一部分原因可能是压力的方向变了,在项目上的单一的压力下总是可以找出一些事情来的,但是在项目之外,各种可见不可见的压力都慢慢浮现。对于这些隐形的,不可见的,但是又可以感受到的压力,需要认真思索对策,然后想办法搞定。

学习上有点“半瓶醋”的感觉,很多技术都接触了,但是不见得深入。缺乏空杯心态,这个是2016年希望自己能改进的地方。另一方面,希望在一个IT系统的架构层面有一些积累,以便在考虑项目的问题时,能够更加全面,而不至于陷入细节,只见树木,不见森林。

2016,当然还会很忙,只是希望忙的过程中可以多一些快乐。

使用graphviz绘制流程图(2015版)


2015年11月10日更新 在实践中,我又发现了一些graphviz的有趣的特性,比如时序图rank以及图片节点等。在这里一并更新。

前言

日常的开发工作中,为代码添加注释是代码可维护性的一个重要方面,但是仅仅提供注释是不够的,特别是当系统功能越来越复杂,涉及到的模块越来越多的时候,仅仅靠代码就很难从宏观的层次去理解。因此我们需要图例的支持,图例不仅仅包含功能之间的交互,也可以包含复杂的数据结构的示意图,数据流向等。

但是,常用的UML建模工具,如Visio等都略显复杂,且体积庞大。对于开发人员,特别是后台开发人员来说,命令行,脚本才是最友好的,而图形界面会很大程度的限制开发效率。相对于鼠标,键盘才是开发人员最好的朋友。

graphviz简介

本文介绍一个高效而简洁的绘图工具graphvizgraphviz是贝尔实验室开发的一个开源的工具包,它使用一个特定的DSL(领域特定语言): dot作为脚本语言,然后使用布局引擎来解析此脚本,并完成自动布局。graphviz提供丰富的导出格式,如常用的图片格式,SVG,PDF格式等。

graphviz中包含了众多的布局器:

  • dot 默认布局方式,主要用于有向图
  • neato 基于spring-model(又称force-based)算法
  • twopi 径向布局
  • circo 圆环布局
  • fdp 用于无向图

graphviz的设计初衷是对有向图/无向图等进行自动布局,开发人员使用dot脚本定义图形元素,然后选择算法进行布局,最终导出结果。

首先,在dot脚本中定义图的顶点和边,顶点和边都具有各自的属性,比如形状,颜色,填充模式,字体,样式等。然后使用合适的布局算法进行布局。布局算法除了绘制各个顶点和边之外,需要尽可能的将顶点均匀的分布在画布上,并且尽可能的减少边的交叉(如果交叉过多,就很难看清楚顶点之间的关系了)。所以使用graphviz的一般流程为:

  • 定义一个图,并向图中添加需要的顶点和边
  • 为顶点和边添加样式
  • 使用布局引擎进行绘制

一旦熟悉这种开发模式,就可以快速的将你的想法绘制出来。配合一个良好的编辑器(vim/emacs)等,可以极大的提高开发效率,与常见的GUI应用的所见即所得模式对应,此模式称为所思即所得。比如在我的机器上,使用Sublime Text 编辑dot脚本,然后将F7/Cmd-B映射为调用dot引擎去绘制当前脚本,并打开一个新的窗口来显示运行结果:

workspace

对于开发人员而言,经常会用到的图形绘制可能包括:函数调用关系,一个复杂的数据结构,系统的模块组成,抽象语法树等。

基础知识

graphviz包含3中元素,顶点。每个元素都可以具有各自的属性,用来定义字体,样式,颜色,形状等。下面是一些简单的示例,可以帮助我们快速的了解graphviz的基本用法。

第一个graphviz图

比如,要绘制一个有向图,包含4个节点a,b,c,d。其中a指向bbc指向d。可以定义下列脚本:

1
2
3
4
5
6
7
8
9
10
digraph abc{
  a;
  b;
  c;
  d;
 
  a -> b;
  b -> d;
  c -> d;
}

使用dot布局方式,绘制出来的效果如下:

dot-simple

默认的顶点中的文字为定义顶点变量的名称,形状为椭圆。边的默认样式为黑色实线箭头,我们可以在脚本中做一下修改,将顶点改为方形,边改为虚线

定义顶点和边的样式

digraph的花括号内,添加顶点和边的新定义:

1
2
node [shape="record"];
edge [style="dashed"];

则绘制的效果如下:

dot-simple2

进一步修改顶点和边样式

进一步,我们将顶点a的颜色改为淡绿色,并将cd的边改为红色,脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
digraph abc{
  node [shape="record"];
  edge [style="dashed"];
   
  a [style="filled", color="black", fillcolor="chartreuse"];
  b;
  c;
  d;
   
  a -> b;
  b -> d;
  c -> d [color="red"];
}

绘制的结果如下:

dot-simple3

应当注意到,顶点和边都接受属性的定义,形式为在顶点和边的定义之后加上一个由方括号括起来的key-value列表,每个key-value对由逗号隔开。如果图中顶点和边采用统一的风格,则可以在图定义的首部定义node, edge的属性。比如上图中,定义所有的顶点为方框,所有的边为虚线,在具体的顶点和边之后定义的属性将覆盖此全局属性。如特定与a的绿色,cd的边的红色。

以图片为节点

除了颜色,节点还可以使用图片。不过需要注意的是,在使用图片作为节点的时候,需要将本来的形状设置为none,并且将label置为空字符串,避免出现文字对图片的干扰。

1
2
3
4
5
6
7
8
9
10
11
12
13
digraph abc{
  node [shape="record"];
  edge [style="dashed"];
   
  a [style="filled", color="black", fillcolor="chartreuse"];
  b;
  c [shape="none", image="logos/browser-icon-chrome-resized.png", label=""];
  d;
   
  a -> b;
  b -> d;
  c -> d [color="red"];
}

image-node

子图的绘制

graphviz支持子图,即图中的部分节点和边相对对立(软件的模块划分经常如此)。比如,我们可以将顶点c和d归为一个子图:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
digraph abc{

  node [shape="record"];
  edge [style="dashed"];
   
  a [style="filled", color="black", fillcolor="chartreuse"];
  b;
 
    subgraph cluster_cd{
      label="c and d";
      bgcolor="mintcream";
      c;
      d;
    }
 
  a -> b;
  b -> d;
  c -> d [color="red"];
}

cd划分到cluster_cd这个子图中,标签为c and d,并添加背景色,以方便与主图区分开,绘制结果如下:

cluster

应该注意的是,子图的名称必须以cluster开头,否则graphviz无法设别。

数据结构的可视化

实际开发中,经常要用到的是对复杂数据结构的描述,graphviz提供完善的机制来绘制此类图形。

一个hash表的数据结构

比如一个hash表的内容,可能具有下列结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct st_hash_type {
    int (*compare) ();
    int (*hash) ();
};

struct st_table_entry {
    unsigned int hash;
    char *key;
    char *record;
    st_table_entry *next;
};

struct st_table {
    struct st_hash_type *type;
    int num_bins; /* slot count */
    int num_entries; /* total number of entries */
    struct st_table_entry **bins; /* slot */
};
绘制hash表的数据结构

从代码上看,由于结构体存在引用关系,不够清晰,如果层次较多,则很难以记住各个结构之间的关系,我们可以通过下图来更清楚的展示:

hash-datastruct

脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
digraph st2{
  fontname = "Verdana";
  fontsize = 10;
  rankdir=TB;
  
  node [fontname = "Verdana", fontsize = 10, color="skyblue", shape="record"];
  
  edge [fontname = "Verdana", fontsize = 10, color="crimson", style="solid"];
  
  st_hash_type [label="{<head>st_hash_type|(*compare)|(*hash)}"];
  st_table_entry [label="{<head>st_table_entry|hash|key|record|<next>next}"];
  st_table [label="{st_table|<type>type|num_bins|num_entries|<bins>bins}"];
  
  st_table:bins -> st_table_entry:head;
  st_table:type -> st_hash_type:head;
  st_table_entry:next -> st_table_entry:head [style="dashed", color="forestgreen"];
}

应该注意到,在顶点的形状为record的时候,label属性的语法比较奇怪,但是使用起来非常灵活。比如,用竖线”|”隔开的串会在绘制出来的节点中展现为一条分隔符。用<>括起来的串称为锚点,当一个节点具有多个锚点的时候,这个特性会非常有用,比如节点st_tabletype属性指向st_hash_type,第4个属性指向st_table_entry等,都是通过锚点来实现的。

我们发现,使用默认的dot布局后,绿色的这条边覆盖了数据结构st_table_entry,并不美观,因此可以使用别的布局方式来重新布局,如使用circo算法:

circo

则可以得到更加合理的布局结果。

hash表的实例

另外,这个hash表的一个实例如下:

hash-instance

脚本如下:

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
digraph st{
  fontname = "Verdana";
  fontsize = 10;
  rankdir = LR;
  rotate = 90;
  
  node [ shape="record", width=.1, height=.1];
  node [fontname = "Verdana", fontsize = 10, color="skyblue", shape="record"];
  
  edge [fontname = "Verdana", fontsize = 10, color="crimson", style="solid"];
  node [shape="plaintext"];
  
  st_table [label=<
      <table border="0" cellborder="1" cellspacing="0" align="left">
      <tr>
      <td>st_table</td>
      </tr>
      <tr>
      <td>num_bins=5</td>
      </tr>
      <tr>
      <td>num_entries=3</td>
      </tr>
      <tr>
      <td port="bins">bins</td>
      </tr>
      </table>
  >];
  
  node [shape="record"];
  num_bins [label=" <b1> | <b2> | <b3> | <b4> | <b5> ", height=2];
  node[ width=2 ];
  
  entry_1 [label="{<e>st_table_entry|<next>next}"];
  entry_2 [label="{<e>st_table_entry|<next>null}"];
  entry_3 [label="{<e>st_table_entry|<next>null}"];
  
  st_table:bins -> num_bins:b1;
  num_bins:b1 -> entry_1:e;
  entry_1:next -> entry_2:e;
  num_bins:b3 -> entry_3:e;
}

上例中可以看到,节点的label属性支持类似于HTML语言中的TABLE形式的定义,通过行列的数目来定义节点的形状,从而使得节点的组成更加灵活。

软件模块组成图

Apache httpd 模块关系

httpd

在实际的开发中,随着系统功能的完善,软件整体的结构会越来越复杂,通常开发人员会将软件划分为可理解的多个子模块,各个子模块通过协作,完成各种各样的需求。

下面有个例子,是某软件设计时的一个草稿:

idp

IDP支持层为一个相对独立的子系统,其中包括如数据库管理器,配置信息管理器等模块,另外为了提供更大的灵活性,将很多其他的模块抽取出来作为外部模块,而支持层提供一个模块管理器,来负责加载/卸载这些外部的模块集合。

这些模块间的关系较为复杂,并且有部分模块关系密切,应归类为一个子系统中,上图对应的dot脚本为:

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
digraph idp_modules{

  rankdir = TB;
  fontname = "Microsoft YaHei";
  fontsize = 12;
  
  node [ fontname = "Microsoft YaHei", fontsize = 12, shape = "record" ];
  edge [ fontname = "Microsoft YaHei", fontsize = 12 ];
  
      subgraph cluster_sl{
          label="IDP支持层";
          bgcolor="mintcream";
          node [shape="Mrecord", color="skyblue", style="filled"];
          network_mgr [label="网络管理器"];
          log_mgr [label="日志管理器"];
          module_mgr [label="模块管理器"];
          conf_mgr [label="配置管理器"];
          db_mgr [label="数据库管理器"];
      };
  
      subgraph cluster_md{
          label="可插拔模块集";
          bgcolor="lightcyan";
          node [color="chartreuse2", style="filled"];
          mod_dev [label="开发支持模块"];
          mod_dm [label="数据建模模块"];
          mod_dp [label="部署发布模块"];
      };
  
  mod_dp -> mod_dev [label="依赖..."];
  mod_dp -> mod_dm [label="依赖..."];
  mod_dp -> module_mgr [label="安装...", color="yellowgreen", arrowhead="none"];
  mod_dev -> mod_dm [label="依赖..."];
  mod_dev -> module_mgr [label="安装...", color="yellowgreen", arrowhead="none"];
  mod_dm -> module_mgr [label="安装...", color="yellowgreen", arrowhead="none"];
}

状态图

有限自动机示意图

fsm

上图是一个简易有限自动机,接受aa结尾的任意长度的串。其脚本定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
digraph automata_0 {
  size = "8.5, 11";
  fontname = "Microsoft YaHei";
  fontsize = 10;
  
  node [shape = circle, fontname = "Microsoft YaHei", fontsize = 10];
  edge [fontname = "Microsoft YaHei", fontsize = 10];
  
  0 [ style = filled, color=lightgrey ];
  2 [ shape = doublecircle ];
  
  0 -> 2 [ label = "a " ];
  0 -> 1 [ label = "other " ];
  1 -> 2 [ label = "a " ];
  1 -> 1 [ label = "other " ];
  2 -> 2 [ label = "a " ];
  2 -> 1 [ label = "other " ];
  
  "Machine: a" [ shape = plaintext ];
}

形状值为plaintext的表示不用绘制边框,仅展示纯文本内容,这个在绘图中,绘制指示性的文本时很有用,如上图中的Machine: a

OSGi中模块的生命周期图

OSGi中,模块具有生命周期,从安装到卸载,可能的状态具有已安装,已就绪,正在启动,已启动,正在停止,已卸载等。如下图所示:

osgi

对应的脚本如下:

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
digraph module_lc{
  rankdir=TB;
  fontname = "Microsoft YaHei";
  fontsize = 12;
  
  node [fontname = "Microsoft YaHei", fontsize = 12, shape = "Mrecord", color="skyblue", style="filled"];
  edge [fontname = "Microsoft YaHei", fontsize = 12, color="darkgreen" ];
  
  installed [label="已安装状态"];
  resolved [label="已就绪状态"];
  uninstalled [label="已卸载状态"];
  starting [label="正在启动"];
  active [label="已激活(运行)状态"];
  stopping [label="正在停止"];
  start [label="", shape="circle", width=0.5, fixedsize=true, style="filled", color="black"];
  
  start -> installed [label="安装"];
  installed -> uninstalled [label="卸载"];
  installed -> resolved [label="准备"];
  installed -> installed [label="更新"];
  resolved -> installed [label="更新"];
  resolved -> uninstalled [label="卸载"];
  resolved -> starting [label="启动"];
  starting -> active [label=""];
  active -> stopping [label="停止"];
  stopping -> resolved [label=""];
}

其他实例

一棵简单的抽象语法树(AST)

表达式 (3+4)*5 在编译时期,会形成一棵语法树,一边在计算时,先计算3+4的值,最后与5相乘。

ast-calc

对应的脚本如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
digraph ast{
  fontname = "Microsoft YaHei";
  fontsize = 10;
  
  node [shape = circle, fontname = "Microsoft YaHei", fontsize = 10];
  edge [fontname = "Microsoft YaHei", fontsize = 10];
  node [shape="plaintext"];
  
  mul [label="mul(*)"];
  add [label="add(+)"];
  
  add -> 3
  add -> 4;
  mul -> add;
  mul -> 5;
}

简单的UML类图

下面是一简单的UML类图,DogCat都是Animal的子类,DogCat同属一个包,且有可能有联系(0..n)

uml

脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
digraph G{
  
  fontname = "Courier New"
  fontsize = 10
  
  node [ fontname = "Courier New", fontsize = 10, shape = "record" ];
  edge [ fontname = "Courier New", fontsize = 10 ];
  
  Animal [ label = "{Animal |+ name : String\l+ age : int\l|+ die() : void\l}" ];
  
      subgraph clusterAnimalImpl{
          bgcolor="yellow"
          Dog [ label = "{Dog||+ bark() : void\l}" ];
          Cat [ label = "{Cat||+ meow() : void\l}" ];
      };
  
  edge [ arrowhead = "empty" ];
  
  Dog->Animal;
  Cat->Animal;
  Dog->Cat [arrowhead="none", label="0..*"];
}

状态图

status-chart

脚本:

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
digraph finite_state_machine {
  rankdir = LR;
  size = "8,5"
  
  node [shape = doublecircle];
  
  LR_0 LR_3 LR_4 LR_8;
  
  node [shape = circle];
  
  LR_0 -> LR_2 [ label = "SS(B)" ];
  LR_0 -> LR_1 [ label = "SS(S)" ];
  LR_1 -> LR_3 [ label = "S($end)" ];
  LR_2 -> LR_6 [ label = "SS(b)" ];
  LR_2 -> LR_5 [ label = "SS(a)" ];
  LR_2 -> LR_4 [ label = "S(A)" ];
  LR_5 -> LR_7 [ label = "S(b)" ];
  LR_5 -> LR_5 [ label = "S(a)" ];
  LR_6 -> LR_6 [ label = "S(b)" ];
  LR_6 -> LR_5 [ label = "S(a)" ];
  LR_7 -> LR_8 [ label = "S(b)" ];
  LR_7 -> LR_5 [ label = "S(a)" ];
  LR_8 -> LR_6 [ label = "S(b)" ];
  LR_8 -> LR_5 [ label = "S(a)" ];
}

时序图

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
digraph G {
    rankdir="LR";
    node[shape="point", width=0, height=0];
    edge[arrowhead="none", style="dashed"]

    {
        rank="same";
        edge[style="solided"];
        LC[shape="plaintext"];
        LC -> step00 -> step01 -> step02 -> step03 -> step04 -> step05;
    }

    {
        rank="same";
        edge[style="solided"];
        Agency[shape="plaintext"];
        Agency -> step10 -> step11 -> step12 -> step13 -> step14 -> step15;
    }

    {
        rank="same";
        edge[style="solided"];
        Agent[shape="plaintext"];
        Agent -> step20 -> step21 -> step22 -> step23 -> step24 -> step25;
    }

    step00 -> step10 [label="sends email new custumer", arrowhead="normal"];
    step11 -> step01 [label="declines", arrowhead="normal"];
    step12 -> step02 [label="accepts", arrowhead="normal"];
    step13 -> step23 [label="forward to", arrowhead="normal"];
    step24 -> step14;
    step14 -> step04 [arrowhead="normal"];
}

rankdir="LR"表示,布局从左L到右R。可以看到,在代码中有{}括起来的部分。

1
2
3
4
5
6
{
    rank="same";
    edge[style="solided"];
    Agency[shape="plaintext"];
    Agency -> step10 -> step11 -> step12 -> step13 -> step14 -> step15;
}

每一个rank="same"的block中的所有节点都会在同一条线上。我们设置了所有的线为虚线,但是在该block中,将线改为solided

seq

附录

事实上,从dot的语法及上述的示例中,很容易看出,dot脚本很容易被其他语言生成。比如,使用一些简单的数据库查询就可以生成数据库中的ER图的dot脚本。

如果你追求高效的开发速度,并希望快速的将自己的想法画出来,那么graphviz是一个很不错的选择。

当然,graphviz也有一定的局限,比如绘制时序图(序列图)就很难实现。graphviz的节点出现在画布上的位置事实上是不确定的,依赖于所使用的布局算法,而不是在脚本中出现的位置,这可能使刚开始接触graphviz的开发人员有点不适应。graphviz的强项在于自动布局,当图中的顶点和边的数目变得很多的时候,才能很好的体会这一特性的好处:

complex

比如上图,或者较上图更复杂的图,如果采用手工绘制显然是不可能的,只能通过graphviz提供的自动布局引擎来完成。如果仅用于展示模块间的关系,子模块与子模块间通信的方式,模块的逻辑位置等,graphviz完全可以胜任,但是如果图中对象的物理位置必须是准确的,如节点A必须位于左上角,节点B必须与A相邻等特性,使用graphviz则很难做到。毕竟,它的强项是自动布局,事实上,所有的节点对与布局引擎而言,权重在初始时都是相同的,只是在渲染之后,节点的大小,形状等特性才会影响权重。

本文只是初步介绍了graphviz的简单应用,如图的定义,顶点/边的属性定义,如果运行等,事实上还有很多的属性,如画布的大小,字体的选择,颜色列表等,大家可以通过graphviz的官网来找到更详细的资料。

文中的代码都已经在Github上。