I code it

Code and Life

无他,但手熟尔

高效幻象

通过对自己的行为观察,我发现在很多时候,我以为我掌握了的知识和技能其实并不牢靠。我引以为豪的高效其实犹如一个彩色的肥皂泡,轻轻一碰就会破碎,散落一地。

你可能只是精通搜索

我们现在所处的时代,信息爆炸,每个人每天都会接触,阅读很多的信息,快速消费,快速遗忘。那种每天早上起来如同皇帝批阅奏折的、虚假的误以为掌握知识的错觉,驱动我们进入一个恶性循环。

即使在我们真的打算解决问题,进行主动学习时,更多的也只是在熟练使用搜索引擎而已(在一个领域待久了,你所使用的关键字准确度自然要比新人高一些,仅此而已)。精通了高效率搜索之后,你会产生一种你精通搜索到的知识本身错觉

stack overflow

如何写一个Shell脚本

在写博客的时候,我通常会在文章中配图。图片一般会放在一个有固定格式的目录中,比如现在是2016年5月,我本地就会有一个名为$BLOG_HOME/images/2016/05的目录。由于使用的是markdown,在插入图片时我就不得不输入完整的图片路径,如:/images/2016/05/stack-overflow.png。但是我又不太记得路径中的images是单数(image)还是复数(images),而且图片格式又可能是jpg,jpeg,gif或者png,我也经常会搞错,这会导致图片无法正确显示。另外,放入该目录的原始文件尺寸有可能比较大,我通常需要将其缩放成800像素宽(长度无所谓,因为文章总是要从上往下阅读)。

为了自动化这个步骤,我写了一个小的Shell脚本。当你输入一个文件名如:stack-overflow.png后,它会缩放这个文件到800像素宽,结果是一个新的图片文件,命名为stack-overflow-resized.png,另外它将符合markdown语法的文件路径拷贝到剪贴板里:/images/2016/05/stack-overflow-resized.png,这样我在文章正文中只需要用Command+V粘贴就可以了。

有了思路,写起来就很容易了。缩放图片的命令我是知道的:

1
$ convert -resize 800 stack-overflow.png stack-overflow-resized.png

但是要在文件明上加入-resized,需要分割文件名和文件扩展名,在Bash里如何做到这一点呢?Google一下:

1
2
3
4
5
6
7
FULLFILE=$1

FILENAME=$(basename "$FULLFILE")
EXTENSION="${FILENAME##*.}"
FILENAME="${FILENAME%.*}"

convert -resize 800 $FULLFILE $FILENAME-resized.EXTENSION

难看是有点难看,不过还是可以工作的。接下来是按照当前日期生成完整路径,date命令我是知道的,而且我知道它的format格式很复杂,而且跟JavaScript里Date对象的format又不太一样(事实上,世界上有多少种日期工具,基本上就有多少种格式)。再Google一下:

1
$ date +"/images/%Y/%m/"

最后一步将路径拷贝到剪贴板也容易,Mac下的pbcopy我也会用:echo一下字符串变量,再管道到pbcopy即可:

1
2
PREFIX=`date +"/images/%Y/%m/"`
echo "$PREFIX$FILENAME-resized.EXTENSION" | pbcopy

但是将内容粘贴到markdown里之后,我发现这个脚本多了一个换行。我想这个应该是echo自己的行为吧,会给字符串自动加上一个换行符。Google一下,发现加上-n参数就可以解决这个问题。

好了,完整的脚本写好了:

1
2
3
4
5
6
7
8
9
10
11
#!/bin/bash
FULLFILE=$1

FILENAME=$(basename "$FULLFILE")
EXTENSION="${FILENAME##*.}"
FILENAME="${FILENAME%.*}"

convert -resize 800 $FULLFILE $FILENAME-resized.EXTENSION

PREFIX=`date +"/images/%Y/%m/"`
echo -n "$PREFIX$FILENAME-resized.EXTENSION" | pbcopy

嗯,还不错,整个过程中就用了我十几分钟时间而已,以后我在写博客时插入图片就方便多了!

不过等等,好像有点不对劲儿,我回过头来看了看这段脚本:7行代码只有1行是我独立写的!没有Google的话,查看man dateman echo也可以解决其中一部分问题,不过文件扩展名部分估计又得花较长时间。

仔细分析一下,之前的成就感荡然无存。

更多的例子

我相信,过几周我再来写这样一个简单的脚本时,上面那一幕还是会出现。开发者的IDE的外延已经将GoogleStack Overflow集成了。很难想象没有这两个IDE的插件我要怎样工作。

其实除此之外,日常工作中这样的事情每时每刻都在发生:

  1. Ansible里如何创建一个给用户robot读写权限的目录?
  2. Python 3中启动简单HTTPServer的命令是?
  3. Spring Boot的Gradle String是?
  4. Mongodb中如何为用户robot授权?
  5. Gulp里一个Task依赖另一个Task怎么写?

等等等等,这个列表可以根据你的技术栈,偏向前端/后端的不同而不同,但是相同的是在GoogleStack Overflow上搜索,阅读会浪费很多时间,而这些本来都是可以避免的。

肌肉记忆

大脑在对信息存储上有很高级的设计,如果某件事情不值得记忆,大脑会自动过滤掉(比如我们很容易获得的搜索结果)。而对于那些频繁发生,计算结果又不会变化的信息,大脑会将其下放到“更低级别”的神经去记忆。比如各种运动中的肌肉记忆,习武之人梦寐以求的“拳拳服膺”,“不期然而然,莫知之而至”。

这里也有两个小例子:

一个C语言的小程序

上周末我买了一个茶轴的机械键盘,打开包装之后我很兴奋,赶紧插在我的笔记本上,打开一个编辑器,心说敲一些代码体验一下。几秒钟后,我发现敲出来的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
# include <stdio.h>
# include <stdlib.h>

int main(int argc, char *argv[]) {
  if(argc != 3) {
      fprintf(stderr, "Usage: %s ip port\n", argv[0]);
      return -1;
  }

  fprintf(stdout, "Connecting to %s %d\n", argv[1], atoi(argv[2]));

  return 0;
}

然后在命令行里

1
2
3
4
5
6
$ gcc -o hello hello.c
$ ./hello
Usage: ./hello ip port

$ ./hello 10.180.1.1 9999
Connecting to 10.180.1.1 9999

整个过程极为流畅,上一次开发C代码已经是4年多前了。也就是说,我的手指已经记下了所有的这些命令:

  1. Linux下main函数的convention
  2. fprintf的签名
  3. stderr/stdout用法的区分
  4. main函数不同场景的返回值
  5. gcc命令的用法

另外一个小例子是vim编辑器。我使用vim已经有很多年了,现在在任何一个Linux服务器上,编辑那些/etc/nginx/nginx.conf之类的配置文件时,手指就会自动的找到快捷键,自动的完成搜索,替换,跳转等等操作。

刻意练习

对比这两个例子,一方面我惊讶于自己目前对搜索引擎、Stack Overflow的依赖;一方面惊讶于肌肉记忆力的深远和神奇。结合一下两者,我发现自己的开发效率有望得到很大的提升。

比如上面列出的那些略显尴尬的问题,如果我的手指可以自动的敲出这些答案,那么节省下的搜索、等待、阅读的时间就可以用来干别的事情,比如跑步啊,骑车啊,去驾校学车被教练骂啊等等,总之,去过自己的生活。

这方面的书籍,博客都已经有很多,比如我们在ThoughtWorks University里实践的Code KataJavaScript DojoTDD Dojo之类,都已经证明其有效性。

如果你打算做一些相关的练习,从Kata开始是一个不错的选择。每个Kata都包含一个简单的编程问题,你需要不断的去练习它(同一个题目做20遍,50遍等)。前几次你是在解决问题本身,慢慢就会变成在审视自己的编程习惯,发现并改进(比如快捷键的使用,语法的熟悉程度等等),这样在实际工作中你会以外的发现自己的速度变快了,而且对于重构的信心会变大很多。其实道理也很简单:如果你总是赶着deadline来完成任务,怎么会有时间来做优化呢?

这里有一些参考资料和Kata的题目,可供参考:

为故障和失败做设计

为故障和失败做设计

先来看一个笑话:

QA工程师走进酒吧,要了一杯啤酒,要了0杯啤酒,要了999999999杯啤酒,要了一只蜥蜴,要了-1杯啤酒,要了一个sfdeljknesv,酒保从容应对,QA工程师 很满意。接下来,一名顾客来到了同一个酒吧,问厕所在哪,酒吧顿时起了大火,然后整个建筑坍塌了。

事实上,无论我们事先做多么详尽的计划,我们还是会失败。而且大多数时候,失败、故障都会从一个我们无法预期的角度发生,令人猝不及防。

如果没有办法避免失败(事先要考虑到这么多的异常情况不太现实,而且会投入过多的精力),那么就需要设计某种机制,使得当发生这种失败时系统可以将损失降低到最小。

另一方面,系统需要具备从灾难中回复的能力。如果由于某种原因,服务进程意外终止了,那么一个watchdog机制就会非常有用,比如supervisord就可以用来保证进程意外终止之后的自动启动。

1
2
3
4
5
6
7
8
[program:jigsaw]
command=java -jar /app/jigsaw.jar
startsecs=0
stopwaitsecs=0
autostart=true
autorestart=true
stdout_logfile=/var/log/jigsaw/app.log
stderr_logfile=/var/log/jigsaw/app.err

在现实世界中,设计一个无缺陷的系统显然是不可能的,但是通过努力,我们还是有可能设计出具有弹性,能够快速失败,从失败中恢复的系统来。

错误无可避免

令人担心的错误处理

我们先来看两个代码片段,两段代码都是在实现一个典型的Linux下的Socket服务器:

1
2
3
4
5
6
7
8
9
10
int main (int argc, char *argv[]) {
  int serversock;
  struct sockaddr_in server;

  serversock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)
  setup_sockaddr(&server);
  bind(serversock, (struct sockaddr *) &server, sizeof(server));
  listen(serversock, MAXPENDING)
  //...
}

如果加上现实中可能出现的各种的处理,代码会变长一些:

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
int main (int argc, char *argv[]) {
  int serversock;
  struct sockaddr_in server;

  if (argc != 2) {
    fprintf(stderr, "USAGE: server <port>\n");
    exit(-1);
  }

  if ((serversock = socket(PF_INET, SOCK_STREAM, IPPROTO_TCP)) < 0) {
    fprintf(stderr, "Failed to create socket\n");
    exit(-1);
  }

  setup_sockaddr(&server);
  if (bind(serversock, (struct sockaddr *) &server,
                               sizeof(server)) < 0) {
    fprintf(stderr, "Failed to bind the server socket\n");
    exit(-1);
  }

  if (listen(serversock, MAXPENDING) < 0) {
    fprintf(stderr, "Failed to listen on server socket\n");
    exit(-1);
  }

  //...
}

早在上学的时候,我在编写程序时就非常害怕处理错误情况。一方面加入错误处理会导致代码变长、变难看,另一方面是担心有遗漏掉的点,更多的则是对复杂多变的现实环境中不确定性的担忧。

每当写这样的代码时,我都会陷入深深的焦虑:如果真的出错了怎么办?事实上我也经常会遇到错误,比如命令行参数没有写对,绑定一个 已经被占用的端口,磁盘空间不足等等。工作之后,这些烦人的问题其实也并不经常出现。偶尔出现时我们也有很好的日志来帮助定位,最后问题总会解决,不过那种对不确定性的担心仍然深藏心底。

UDP协议

早在大学网络课上,我就已经对不靠谱的UDP协议非常不满了:作为一个网络协议,竟然不能保证数据可靠的传送到网络的另一端,如果数据没有丢失,也无法保证次序。这种有点不负责任的协议我从来不用,甚至在做练习时都会将其自动过滤,不管那种编程语言,我都会优先考虑TCP

不过在学习网络视频传输的时候,我发现很多时候人们都会采用UDP。另外很多场景下,比如最早的QQ也都使用了UDP来作为内网穿透等设计者可能都没有考虑到的功能。

事实上,这种看似不靠谱的协议在很多IM软件中都在采用(混合模式),比如Skype:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
lsof -i -n | grep -i skype | awk '{print $1, $8, $9}'
Skype TCP 192.168.0.101:52093->91.190.219.39:12350
Skype UDP 127.0.0.1:50511
Skype TCP 192.168.0.101:53090->40.113.87.220:https
Skype UDP *:*
Skype TCP 192.168.0.101:52240->64.4.61.220:https
Skype TCP 192.168.0.101:14214
Skype UDP *:63639
Skype UDP 192.168.0.101:14214
Skype TCP 192.168.0.101:52544->168.63.205.106:https
Skype TCP 192.168.0.101:52094->157.55.56.145:40032
Skype TCP 192.168.0.101:52938->40.113.87.220:https
Skype TCP 192.168.0.101:53091->40.113.87.220:https
Skype TCP 192.168.0.101:53092->40.113.87.220:https

这种简单,不保证可靠性的协议有强大的适应性,在大部分网络环境都是适用的。在工程中,人们会将它和TCP混合适用,在诸如视频,语音的传输中,小规模的丢包,失序都是可以接受的,毕竟还有人类大脑这样的高级处理器负责纠正那些网络错误。

处理失败的模式

超时机制

对于网络上的第三方依赖,你无法预料它的响应延迟是什么样子的,它可能每秒钟可以处理10000次请求而游刃有余,也可能在处理100个并发时就会无限期阻塞,你需要为这种情况有所准备。

nginx通常被用作一个网关,它总是处于请求的最前端,因此其中有很多关于超时的设置:

1
2
3
4
5
6
7
8
9
10
location /api {
  proxy_pass http://api.backend;
  proxy_connect_timeout 500s;
  proxy_read_timeout 500s;
  proxy_send_timeout 500s;

  proxy_set_header        X-Real-IP $remote_addr;
  proxy_set_header        X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header        Host $http_host;
 }

比如上面/api这个虚拟host中就有连接超时,读超时,后端写超时等设置。在实际环境中,Fail Fast是对无法预料错误的最好处理方法。缓慢的处理会阻塞其他请求,并很快堆积,然后耗尽系统资源。

系统超时配置只是一部分,在你自己的代码中也应该为所有网络依赖加上合适的超时机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public Observable<Staff> fetchUserByName(String name) {
    String url = JIGSAW_ENDPOINT + name;

    Client client = ClientBuilder.newClient(new ClientConfig());
    client.property(ClientProperties.CONNECT_TIMEOUT, 10);
    client.property(ClientProperties.READ_TIMEOUT,    10);

    Invocation.Builder request = client.target(url).request(MediaType.APPLICATION_JSON);

    Observable<Staff> staff;

    try {
        staff = Observable.just(request.get(Staff.class));
    } catch (Exception ex) {
        staff = Observable.just(null);
    }

    return staff;
}

回退机制

如果应用程序无法获得正常的响应,那么提供优雅的回退机制在大多数情况下是必须的,而且这样做通常也不会很复杂。以Netflix的Hystrix库为例,如果一个异步命令失败(比如网络异常,超时等),它提供Fallback机制来返回客户端一个默认实现(或者一份本地缓存中的数据)。

1
2
3
4
5
6
7
8
@HystrixCommand(fallbackMethod = "getDefaultStaffInfo")
public Staff getStaffInfo(String loginName) {
  //fetch from remote server
}

public Staff getDefaultStaffInfo(String loginName) {
  return new Staff();
}

熔断器

Circuit Breaker

熔断器模式指当应用在依赖方响应过慢或者出现很多超时时,调用方主动熔断,这样可以防止对依赖方造成更严重的伤害。过一段时间之后,调用方会以较慢的速度开始重试,如果依赖方已经恢复,则逐步加大负载,直到恢复正常调用。如果依赖方还是没有就绪,那就延长等待时间,然后重试。这种模式使得系统在某种程度上显现出动态性和智能。

Netflix的Hystrix库已经提供了这种能力,事实上,如果你使用Spring Cloud Netfilx,这个功能是内置在系统中的,你只需要一些注解就可以让系统具备这样的能力:

1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableCircuitBreaker
public class Application {

    public static void main(String[] args) {
      SpringApplication.run(Application.class, args);
    }

}

如果5秒内连续失败了20次,Hystrix会进入熔断模式,后续的请求不会再发送。过一段时间之后,Hystrix又会逐步尝试恢复负载。

后记

扩展阅读:

技术文章:

保护你的API(下)

前后端分离之后

前后端分离之后,在部署上通过一个反向代理就可以实现动静态分离,跨域问题的解决等。但是一旦引入鉴权,则又会产生新的问题。通常来说,鉴权是对于后台API/API背后的资源的保护,即未经授权的用户不能访问受保护资源

要实现这个功能有很多种方式,在应用程序之外设置完善的安全拦截器是最常见的方式。不过有点不够优雅的是,一些不太纯粹的、非功能性的代码和业务代码混在同一个代码库中。

另一方面,各个业务系统都可能需要某种机制的鉴权,所以很多企业都会搭建SSO机制,即Single Sign-On。这样可以避免人们在多个系统创建不同账号,设置不同密码,不同的超时时间等等。如果SSO系统已经先于系统存在了很久,那么新开发的系统完全不需要自己再配置一套用户管理机制了(一般SSO只会完成鉴权鉴别的部分,授权还是需要各个业务系统自行处理)。

本文中,我们使用基础设施(反向代理)的一些配置,来完成保护未授权资源的目的。在这个例子中,我们假设系统由这样几个服务器组成:

系统组成

这个实例中,我们的系统分为三部分

  1. kanban.com:8000(业务系统前端)
  2. api.kanban.com:9000(业务系统后端API)
  3. sso.kanban.com:8100 (单点登录系统,登陆界面)

前端包含了HTML/JS/CSS等资源,是一个纯静态资源,所以本地磁盘即可。后端API则是一组需要被保护的API(比如查询工资详情,查询工作经历等)。最后,单点登录系统是一个简单的表单,用户填入用户名和密码后,如果登录成功,单点登录会将用户重定向到登录前的位置。

我们举一个具体场景的例子:

  1. 未登录用户访问http://kanba.com:8000/index.html
  2. 系统会重定向用户到http://sso.kanban.com:8100/sso?return=http://kanba.com:8000/index.html
  3. 用户看到登录页面,输入用户名、密码登录
  4. 用户被重定向回http://kanba.com:8000/index.html
  5. 此外,index.html页面上的app.jsapi.kanban.com:9000的访问也得到了授权

环境设置

简单起见,可以通过修改/etc/hosts文件来设置服务器环境:

1
2
3
127.0.0.1 sso.kanban.com
127.0.0.1 api.kanban.com
127.0.0.1 kanban.com

nginx及auth_request

反向代理nginx有一个auth_request的模块。在一个虚拟host中,每个请求会先发往一个内部location,这个内部的location可以指向一个可以做鉴权的Endpoint。如果这个请求得到的结果是200,那么nginx会返回用户本来请求的内容,如果返回401,则将用户重定向到一个预定义的地址:

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
server {
    listen 8000;
    server_name kanban.com;

    root /usr/local/var/www/kanban/;

    error_page 401 = @error401;

    location @error401 {
        return 302 http://sso.kanban.com:8100/sso?return=$scheme://$http_host$request_uri;
    }

    auth_request /api/auth;

    location = /api/auth {
        internal;

        proxy_pass http://api.kanban.com:9000;

        proxy_pass_request_body     off;

        proxy_set_header Content-Length "";
        proxy_set_header X-Original-URI $request_uri;
        proxy_set_header Host $http_host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        if ($http_cookie ~* "w3=(\w+)") {
            set $token "$1";
        }

        proxy_set_header X-KANBAN-TOKEN $token;
    }
}

比如上面这个例子中,auth_request的URL为/api/auth,它是一个内部的location,外部无法访问。在这个locaiton中,请求会被转发到http://api.kanban.com:9000,根据nginx的正则语法,请求将会被转发到http://api.kanban.com:9000/api/auth(我们随后可以看到这个Endpoint的定义)。

我们设置了请求的原始头信息,并禁用了request_body,如果cookie中包含了w3=(\w+)字样,则将这个w3的值抽取出来,并赋值给一个X-KANBAN-TOKEN的HTTP头。

权限Endpoint

对应的/api/auth的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
@RestController
@RequestMapping("/auth")
public class AuthController {

    @RequestMapping
    public ResponseEntity<String> simpleAuth(@RequestHeader(value="X-KANBAN-TOKEN", defaultValue = "") String token) {
        if(StringUtils.isEmpty(token)) {
            return new ResponseEntity<>("Unauthorized", HttpStatus.UNAUTHORIZED);
        } else {
            return new ResponseEntity<>("Authorized", HttpStatus.OK);
        }
    }
}

如果HTTP头上有X-KANBAN-TOKEN且值不为空,则返回200,否则返回401。

当这个请求得到401之后,用户被重定向到http://sso.kanban.com:8100/sso

1
2
3
4
5
error_page 401 = @error401;

location @error401 {
    return 302 http://sso.kanban.com:8100/sso?return=$scheme://$http_host$request_uri;
}

SSO组件(简化版)

这里用sinatra定义了一个简单的SSO服务器(去除了实际的校验部分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
require 'sinatra'
require 'uri'

set :return_url, ''
set :bind, '0.0.0.0'

get '/sso' do
    settings.return_url = params[:return]
    send_file 'public/index.html'
end

post '/login' do
  credential = params[:credential]
  # check credential against database
  uri = URI.parse(settings.return_url)
  response.set_cookie("w3", {
      :domain => ".#{uri.host}",
      :expires => Time.now + 2400,
      :value => "#{credential['name']}",
      :path => '/'
      })
  redirect settings.return_url, 302
end

/sso对应的Login Form是:

1
2
3
4
5
<form action="/login" method="post">
  <input type="text" name="credential[name]" />
  <input type="password" name="credential[password]" />
  <input type="submit">
</form>

当用户提交表单之后,我们只是简单的设置了cookie,并重定向用户到跳转前的URL。

前端页面

这个应用的前端应用非常简单,我们只需要将这些静态文件放到/usr/local/var/www/kanban目录下:

1
2
3
4
5
6
$ tree /usr/local/var/www/kanban

├── index.html
└── scripts
    ├── app.js
    └── jquery.min.js

其中index.html中引用的app.js会请求一个受保护的资源:

1
2
3
4
5
$(function() {
  $.get('/api/protected/1').done(function(message) {
      $('#message').text(message.content);
  });
});

从下图中的网络请求可以看到重定向的流程:

redirection

总结

本文我们通过配置反向代理,将多个Endpoint组织起来。这个过程可以在应用程序中通过代码实现,也可以在基础设施中通过配置实现,通常来讲,如果可以通过配置来实现的,就尽量将其与负责业务逻辑的代码隔离出来。这样可以保证各个组件的独立性,也可以使得优化和定位问题更加容易。

完整的代码可以在这里下载:

保护你的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集成起来,我会在下一篇文章中详细解释。