版权信息

书名：轻量级Web应用开发

ISBN：978-7-115-39152-0

本书由人民邮电出版社发行数字版。版权所有，侵权必究。

您购买的人民邮电出版社电子书仅供您个人使用，未经授权，不得以任何方式复制和传播本书内容。

我们愿意相信读者具有这样的良知和觉悟，与我们共同保护知识产权。

如果购买者有侵权行为，我们可能对该用户实施包括但不限于关闭该帐号等维权措施，并可能追究法律责任。

目    录

版权信息

内容提要

前言

简单就是美

代码先行

工具与方法论

本书组织结构

致谢

第1章 环境配置与工具准备

1.1 Shell

zsh的一些好用的特性

1.2 管道

1.3 几个常用命令

1.3.1 文件查找命令find

1.3.2 网络命令curl

1.3.3 文件搜索grep

1.3.4 定时任务crontab

1.3.5 JSON查询利器jq

1.4 编辑器

1.4.1 Vim编辑器

1.4.2 Sublime Text编辑器

1.5 程序启动器

1.5.1 Launchy

1.5.2 Alfred

1.6 关于Windows

第2章 Web应用服务器

2.1 Rack

2.1.1 rackup

2.1.2 Rack中间件

2.2 Sinatra

2.2.1 404页面

2.2.2 使用模板引擎

2.2.3 简单认证中间件

2.3 Grape

一个实例

第3章 数据库访问层

3.1 数据库的访问

3.2 数据库方案（schema）的修改

3.3 ActiveRecord

3.3.1 和Rails一起使用

表关联

3.3.2 独立使用（在既有数据库中）

查找记录

3.3.3 校验

3.4 DataMapper

使用DateMapper

第4章 客户端框架

4.1 富客户端

模块化RequireJS

4.2 Backbone.js简介

4.2.1 模型

4.2.2 视图

4.2.3 集合

4.2.4 与服务器交互

4.2.5 路由表

4.3 Angular.js

4.3.1 数据双向绑定

4.3.2 内置指令

4.3.3 AngularJS中的服务

4.3.4 与RESTFul的API集成

4.3.5 与moko集成

第5章 CSS框架简介

5.1 Foundation简介

常用组件

按钮

5.2 BootStrap简介

5.2.1 布局

5.2.2 常用组件

第6章 客户端测试框架

6.1 Jasmine简介

6.1.1 Spy功能

6.1.2 自定义匹配器

6.2 Mocha

6.2.1 Mocha的基本用法

6.2.2 测试异步场景

第7章 现代的前端开发方式

7.1 Karma简介

7.2 前端依赖管理

7.3 搭建工程

7.4 测试驱动开发

7.5 实例Todoify

7.5.1 underscore的一些特性

7.5.2 jQuery插件基础知识

7.5.3 Todoify

7.5.4 进一步改进

第8章 编写更容易维护的JavaScript代码

8.1 一个实例

8.2 重构：更容易测试的代码

8.2.1 搜索框

8.2.2 发送请求

8.2.3 结果集

点过赞的地方

8.2.4 放在一起

8.3 关注点分离：另一种实现方式

8.3.1 搜索服务

8.3.2 结果视图

8.3.3 搜索框视图

8.3.4 搜索逻辑

8.3.5 放在一起

8.3.6 更容易测试的代码

第9章 本地构建

9.1 Ruby中的构建

9.1.1 Rake

9.1.2 Guard

9.2 JavaScript中的构建

9.2.1 Grunt的使用

9.2.2 Gulp的使用

使用插件

第10章 持续集成

10.1 环境搭建

10.1.1 安装操作系统

10.1.2 安装Jenkins

10.1.3 安装rbenv

10.1.4 安装NodeJS

10.1.5 安装Xvfb

安装浏览器

10.2 持续集成服务器

Jenkins

10.3 与Github集成

10.3.1 Travis

10.3.2 Snap

第11章 单元测试与集成测试

11.1 RSpec单元测试

11.2 集成测试工具Selenium

11.2.1 Selenium-webdriver

11.2.2 Capybara

11.2.3 Cucumber

11.3 搭建Selenium独立环境

11.3.1 安装Selenium

11.3.2 服务脚本

第12章 环境搭建的自动化

12.1 自动化工具Chef

12.1.1 使用Berkshelf管理cookbook

12.1.2 自动创建用户

12.1.3 安装nginx服务器

12.1.4 配置nginx

配置Rbenv

第13章 应用程序发布

13.1 使用Heroku发布应用程序

发布第一个应用程序

13.2 发布到虚拟机环境

13.2.1 使用密钥登录

13.2.2 使用Mina

13.3 服务器典型配置

Web服务器

第14章 一个实例（前端部分）

14.1 线框图

14.2 搜索结果页面

14.2.1 模板页面

14.2.2 导航栏

14.2.3 走马灯

14.2.4 搜索框

14.2.5 目录侧栏

14.2.6 植物列表

14.2.7 分页器

14.3 详细信息页面

14.4 加入JavaScript

14.4.1 moko

14.4.2 AngularJS应用

14.4.3 细节页面

第15章 一个实例（后台部分）

15.1 第一个迭代

15.1.1 配置环境

15.1.2 定义数据

15.1.3 第一次提交

15.1.4 添加数据

15.2 发布到Heroku

15.2.1 环境准备

15.2.2 添加数据库插件

15.2.3 测试远程应用

15.2.4 访问远程数据

15.2.5 导出数据

15.3 更进一步

15.3.1 模块化的Sinatra应用

15.3.2 测试

第16章 一个实例（集成）

16.1 发布

16.1.1 添加植物页面

16.1.2 一个奇怪的bug

16.2 添加图片

16.2.1 后台API

16.2.2 客户端上传文件

16.3 新的问题

16.4 文件存储

16.4.1 创建分组及用户

16.4.2 创建S3中的bucket

16.4.3 存储到云端

16.4.4 部署到Heroku

附录A Web如何工作

A.1 CGI的相关背景

A.2 配置Apache支持CGI

A.3 更进一步

A.4 一个稍微有用的脚本

A.5 更进一步FastCGI

附录B Angular.js的测试

B.1 测试Controller

B.1.1 AngularJS的一个典型Controller

B.1.2 测试依赖于Service的Controller

B.1.3 在何处实例化Controller

B.1.4 如何mock一个service

B.2 测试Service

B.2.1 Service的典型示例

B.2.2 $httpBackend服务

B.2.3 Service的测试模板

B.2.4 服务器Moco

看完了

内容提要

CONTENT OF THE FEED

轻量级开发是一个很宽泛的主题，开发人员经常提到这个术语，但却少有人能够阐明它的内涵。本书介绍了轻量级Web应用开发背后的核心理念和方法。

全书共16章，介绍了环境配置与工具准备、Web应用服务器、数据库访问层、客户端框架、CSS框架、客户端测试框架、现代的前端开发方式、编写更容易维护的JavaScript代码、本地构建、持续集成、单元测试与集成测试、环境搭建的自动化、应用程序发布、前端实例、后台实例、集成实例。除此之外，附录部分还介绍了一些补充知识。

本书适合软件开发人员以及对轻量级Web开发感兴趣的读者阅读。

前言

简单就是美

2014年2月3日，我创建了一个新的Microsoft Word文档，开始编写本书。计划中，这本书会包含很多方面，一些工具的使用方法，一些软件开发的“哲学”或者方法论，一些公认为比较好的编程实践，以及尽可能真实地涉及一个Web应用开发中的所有点……从最初的简单需求，逐步演进成部署在真实环境中、可以被所有人访问到的真实程序。

2004年，我第一次接触到UNIX（一个运行在PC机上的FreeBSD），当看到一些各司其职的命令通过管道连接起来，然后流畅地处理很繁琐的任务时，就模糊地意识到“简单的工具组合起来，将会发挥出极大威力”。当然，在随后更加深入的学习中，我才知道这种体会只能算是处在“野蛮和蒙昧状态”。

但是也就是从那时候起，我就非常推崇轻量级的开发方式，包括轻量级的函数库、轻量级的工具、轻量级的框架每个程序/模块都应该只处理自己的份内之事，仅此而已。将一个艰巨而庞大的任务划分成小的模块，并对每个小的模块进行更精细的设计，得到的将是一系列相互独立、错误极少、更容易理解和维护的轻量级的工具集。

甚至，在最理想的情况下，这些轻量级的工具集可以应用在不同的项目中，而实际对于业务的编码则可能非常简单，只需要将这些工作良好的工具通过某种方式组合起来即可。

简单就是美（Simple is Beautiful），没有人不赞同这个观点，当我们看到简洁的界面设计、清晰的程序接口时，无不被那种简单性所打动、所折服。但是要做到简单这一点绝非易事，人们往往会自然地将事情复杂化。事先将各个模块的职责完全搞清楚几乎是不可能的，而当项目进行中，要在有交付压力的情况下对代码做大规模的重构也是具有很大风险的工作。

可能项目发起之初，项目的结构和代码会比较清晰简洁。但是当有多人合作开发，或者需求没有被预期地变更，一些临时的解决方案渗入到代码库时，一些权宜之计也会被采纳，代码库越来越庞大，越来越难以被理解。最后的结果可能是项目的失败，也可能是最终不得不留守多人来维护这个遗留的代码库。

代码先行

2013年1月的一个周末，我在ThoughtWorks西安的办公室，如以往很多个周末一样，享受着安静的巨大的独立办公室。从那时候开始，我就在为本书准备实例，我在之前长期的读、写技术文章的经验中得到的体会是：例子是最好的老师，甚至是跨越语言（甚至是自然语言）障碍的老师。作为程序员，你甚至可以读懂一份用法文写的关于如何使用Sinatra的文章——如果作者提供了足够清晰的例子的话。

断断续续地，我将项目中用到的技术实例抽取出来，做成足够小巧，而又能覆盖到足够多特性的demo。到了5月，我要为ThoughtWorks的欧洲AwayDay准备一个演讲，主题即为轻量级的Web应用程序开发。虽然这个演讲由于时间关系被取消了，但是我在背后做的很多计划和例子都固化了下来。10月，我在印度普内做完了一期ThoughtWorks大学的讲师，难得地有了两周的空闲时间，于是开始整理前端开发的工作流以及工具的介绍等，也产生了很多的例子。

到了12月，以及2014年的1月份，我在国内的一家公司做咨询服务，有了更多的时间和精力投入到纯粹前端的开发中。由于工作本身主要是做咨询服务，如何将一项技术很好地交付给团队的成员成了最关键的问题。所有的概念性的知识都是清晰而简洁的，但是这种清晰和简洁，唯有通过实例将技术本身掌握之后，才能体会到。因此，我需要很精心地准备每一个小例子，最终我得到了很多的例子。事后整理这些实例和心得时，我又发现这些与具体项目有一定关联的例子可以做进一步的抽取，并将它们作为本书的素材。

这样做的好处有很多：在介绍一个概念时，我无需再一次绞尽脑汁去发明一个场景来作为例子；另一方面，在介绍一项新技术时，Hello World（换言之，浅尝辄止）级别的介绍只会给读者一种错觉：误以为这项技术很简单，而忽略了在实施过程中可能遇到的问题。也就是说，我希望通过例子，以及对例子的解释，真正将这些技术实施起来，而不仅仅是看上去很美。

工具与方法论

我曾经观察自己，以及其他程序员的工作方式，特别是ThoughtWorks聪明的程序员们。虽然不至于单调到千篇一律，但是这些高效的程序员都有或多或少的相似性。

模块化、轻量级的根本原理来源于人类大脑的设计：每次只能关注一件事，某个时刻只能做好一件事。说来容易，但是事实上想要做到这一点是非常困难的，程序员需要在实践中不断积累，不断学习，才有可能发现简单的力量。完成一个软件的功能，对于一个熟练的软件开发者来说并非难事，但是要让这个软件足够简单，以适应随后的变化，且在适应的过程中保证软件的高质量，并不是一件容易的事情。

本书组织结构

如果粗略地划分一下，本书可以分为三部分：第1章至第6章为基础工具及框架的介绍，包括Web框架，数据库访问层以及一些前端的技术等；第7章至第13章是一些编程实践和Web应用周边的一些工具和框架的介绍，比如如何进行测试自动化，如何进行自动部署等；第14章至第16章是一个具体的实例，这个实例从头到尾介绍了一个Web应用从想法到实现，再到具体部署在一个真实的环境中的过程，其中包含了前后端开发、自动化测试、自动化部署以及云平台的使用。

本书的各个章节的简要描述如下：

第1章，介绍了一些常用的工具如Shell、编辑器、应用程序加速器等的使用，本书的其他章节会频繁地使用这些工具。

第2章，介绍了Ruby下的Web开发库Rack的原理、Sinatra框架的使用方法以及使用Grape创建RESTFul的API。

第3章，所有的动态的Web应用程序后台都有数据库持久层，如何将面向对象的世界和面向关系的数据库连接起来是每个Web框架都需要面对的主题。这一章讨论了ActiveRecord及DataMapper的使用方法。

第4章，介绍了前端的模块化框架Require.js、客户端的MVC框架Backbone.js以及Angular.js。

第5章，详细讨论了CSS框架，包括Foundation及Bootstrap，讨论了两个框架的布局方式、常用的组件等。

第6章，随着前端越来越重要，JavaScript代码在项目中占用的比重越来越高，相关的测试也越来越重要，这里讨论了前端的测试框架Jasmine和Mocha的基本用法。

第7章，前端开发的形式已经不是用编辑器简单地编辑几个文件就可以了，现在的前端开发已经有了完整的工作流：依赖管理，单元测试，合并并压缩JS/CSS，动态加载等等。这一章讨论现代的前端开发方式。

第8章，通过一个实例来介绍如何编写更容易维护、更容易扩展的前端代码，本章使用了两种不同的开发思路来编写同一个实例，以便读者更好地理解可维护性。

第9章，介绍了如何减少重复劳动，将常见的动作自动化起来。这一章讨论了Ruby和JavaScript中的构建工具的使用方法。

第10章，持续集成早已不是一个新的概念，事实上越来越多的项目都在使用持续集成服务器来保证不同团队的工作可以尽早集成，从而减少风险，加快发布的速度。持续集成已经成为开发项目时的标准配置。这一章我们讨论了Jenkins服务器，以及使用公开的Travis、Snap等持续集成服务。

第11章，一个最容易出错的地方是混淆不同类型的测试，很多初学者会不自觉地进行集成性质的测试，而忽略了更重要的单元测试；或者强调单元测试，却漏掉了集成测试。

第12章，我们着重于如何在本地搭建环境来完成自动化，我们使用Chef来自动化设置环境，这样当服务器环境发生故障之后，我们可以在数分钟之内就自动地设置好环境。

第13章，使用Heroku的云服务可以让我们快速地将应用程序在几分钟之内发布到互联网上，这样所有的人都可以访问我们的应用程序，使用我们的服务。这在原型开发，快速迭代中非常有用。

第14章，从这一章开始，我们开始一个实际的应用程序“奇葩”的开发。这一章使用Bootstrap、Angular.js进行前端的开发。

第15章，继续“奇葩”的开发，我们使用ActiveRecord和Sinatra作为后端，并介绍如何进行测试。

第16章，将前两章的开发结果进行集成，并发布到Heroku平台上，同时介绍如何使用亚马逊的S3存储服务，以及如何将S3服务于Heorku上的应用集成。

附录A，描述Web应用程序是如何工作的，HTTP协议本身是独立于具体的业务应用的，各个后台框架都使用了不同的方式来和HTTP服务器集成。

附录B，描述在AngularJS中如何进行测试，涵盖AngularJS中的控制器、指令以及服务的测试方式。

致谢

本书的一些示例代码开始于2013年2月，而文字工作则开始于2014年2月，当然期间有一些时候由于项目比较忙或者有其他活动等原因有所中断。总体来说，写作这本书是一个长达两年的大项目（当然，大部分写作时间是周末和节假日）。

在这冗长的写作与编辑过程中，感谢妻子孙曼思女士的不断督促和在生活上的悉心照料。很多时候，在项目上累了一周之后，自己难免会有懈怠的情绪，她则像一位良师益友一样在旁督促“你是不是该更新博客了？”“你是不是该更新你的书了？”而当我偶有所得时，她又会在旁鼓励赞赏。

本书编写期间，正好我们的房子开始装修，我几乎没有参与任何实质性的装修活动，感谢父母、岳父岳母，他们在背后帮我做了很多很多的事情，没有他们的支持，这本书至少会延期一年，甚至永无出版之日。

感谢贾玮对本书的大量编辑校对工作。她在2014年6月加入ThoughtWorks之后，我将这本书的草稿交给她，一方面促其自学，另一方面做一些校对。两个月后，当我看到她交还给我的加满了批注的草稿时，我非常震撼。其中包含了很多错别字的修改，不通顺语句的修改，尤为可贵的是，其中有一些从初学者视角给出的建议，这些建议使得我可以绕过“知识的诅咒”，更好地向读者传递最初的意图。

感谢ThoughtWorks的同事们，本书的第7章、第8章内容的原型是我在ThoughtWorks西安办公室进行的一次Workshop，这次Workshop有很多同事参加，并在参加之后给了我很多有用的反馈。另外，在书中内容的组织上，我在ThoughtWorks内部发起了一次调查问卷，同事们积极地给了我很多有益的反馈，在此一并感谢。

感谢本书的编辑陈冀康，他在我的第一本书《JavaScript核心概念及实践》出版之后，就鼓励我筹划这本书，期间给予了我很多指导和帮助，并持续指导直至本书最终完成。

邱俊涛

2015年1月18日于西安

第1章　环境配置与工具准备

这一章中，我列出了一些常用的可以提高工作效率的工具集。这些工具都符合体积小巧而功能强大的特点。学习这些工具，可能需要花费一些时间，但是一旦掌握其基本用法，你将会得到数倍的回报。简而言之，它们会节省你的时间。

虽然这些工具完成的具体功能各不一样，但是它们都展现出了一些共性：

（1）关注于一件事，并能很好地完成。

（2）可以很容易地和其他应用程序一起工作。

（3）体积小巧，支持众多选项。

（4）命令行程序。

命令行可以说是专业程序员最亲密的朋友，其重要性再怎么强调也不为过。事实上，当一个受过良好训练的程序员看到一个GUI应用程序时的第一反应就是：有没有对应命令行的工具？除了极个别的场景以外，比如海报绘制、广告设计等，大部分情况下，GUI应用可以做到的工作，命令行工具都可以更好地完成。从文本处理、软件下载、图片修改、到定时任务、系统监控、报表生成，再到即时通信、邮件收发等一切计算机可以做的事情，都可以通过命令行工具来完成。

1.1　Shell

UNIX世界中，有这样一句话：While there is a shell, there is a way，即如果有Shell，就有希望。Shell是下面将会讨论的很多工具赖以生存的环境，也是程序员赖以生存的环境。我每天花在Shell里的时间，占我工作总时间的70%左右（剩下的时间有20%在Chrome中）。在Shell中，编写代码，启动服务器，连接到远程机器，运行单元/集成测试，调试错误，查看日志，查找文件并处理，等等。所有有关开发的工作都可以在一个或者多个Shell窗口中完成，如图1-1所示。

图1-1　Mac OS下的Terminal应用

每个Shell都有各自的配置文件，比如最为流行的Bash（默认的所有主流的Linux发行版都安装了Bash）中，用户配置文件位于用户主目录~/.bashrc和~/.bash_profile中。这些配置文件会在用户登录时被加载，如果用户已经登录，又对这些文件做了修改，可以使用命令：

$ source ~/bash_profile

来使其生效。

而在众多的Shell中，zsh是我最喜爱的。

如果你是在Ubuntu系统中，通过命令：

$ apt-get install zsh

即可安装，或者通过编译源码的方式安装。在Mac OS X下，可以使用：

$ brew install zsh

来完成安装（需要你的系统中已经安装了homebrew）。

zsh的配置文件位于~/.zshrc中。事实上，有一个开源项目名叫oh-my-zsh，为zsh提供了众多便利的配置。使用它可以省去很多麻烦的配置。

安装非常简单：

$ curl -L https://raw.github.com/robbyrussell/oh-my-zsh/master/tools/ install.sh | sh

然后使用chsh zsh来切换到zsh，如图1-2所示。

图1-2　在Terminal中使用zsh替换默认的bash

oh-my-zsh拥有丰富的特性，比如大量的插件、主题等。这条命令可以查看已经安装好的插件：

$ ls ~/.oh-my-zsh/plugins

可以通过修改~/.zshrc来启用各种插件：

plugins=(git osx)

这条配置会启用git插件和osx插件。使用git插件， shell的提示符会发生变化：如果你正处于一个git的版本库中，那么提示符会显示你所处的分支：

➜　octopress git:(source) ✗

上面的输出说明你正处于source分支，而“x”则表示目前本地已经有代码的改动（这个小“x”会在代码被提交之后消失）。

另外一个常用zsh的插件是autojump。autojump是一个命令行工具，可以用来快速地在系统的目录中切换。它会记录用户通过cd命令去过的所有目录，并根据切换到该目录的频繁程度为每个目录加上权重。

安装autojump很容易。如果是在Mac系统下，下面这条命令就可以：

$ brew install autojump

如果是在Linix下，或者想要通过源码安装，需要确保系统中已经安装了python（python的2.6以上版本），然后执行：

$ git clone git://github.com/joelthelion/autojump.git
$ cd autojump
$ ./install.py

安装完成之后，需要在~/.zshrc中启用autojump的插件：为plugins这行加上autojump即可：

plugins=(git osx autojump)

使用起来极为简便，输入j加目录名关键字即可跳入该目录，比如想要跳入路径中含有ruby的目录：

$ j ruby
/Users/twer/develop/ruby

如果目录名关键字过于模糊，有多个路径一起命中的话，autojump会选择权重高的那一条。

$ j --stat

如图1-3所示。

图1-3　列出目前已经缓存的目录

$ j octopress
/Users/twer/blogs/octopress

如果这并不是你的本意，那就需要指定更具体的路径，如j blogs/octopress或者更简单的j b octopress。

可以通过j --stat查看当前已经存储的目录，以及各自的权重（如图1-4所示）：

$ j --stat

图1-4　每个目录都有对应的权重

zsh的一些好用的特性

列出当前目录下所有*.rb文件，深度可以是任意层次（如图1-5所示）：

$ ls -l **/*.rb

图1-5　列出当前目录下所有的以rb结束的文件

自动补全命令的参数（< TAB >表示tab键）：

$ git st<TAB>
stash　　　　　　 　-- stash away changes to dirty working directory
status　　　　　　　-- show working-tree status
stripspace　　　　 -- filter out empty lines

自动补全浏览过的网站（如图1-6所示）：

$ ssh <TAB>
192.30.252.131 s1.au.reastatic.net 50.19.85.132
...
$ curl https://www.<TAB>
www.casa.it.localhost　www.property.com.au
www.realestate.com.au.localhost　www.cba.realestate.com.au
...

图1-6　zsh的自动补全

zsh的智能补全的另一个例子是，你需要杀掉一个ruby进程，但是又不知道这个进程的id。传统的做法是：

$ ps -Af | grep ruby

如图1-7所示。

图1-7　查找所有的ruby进程

找到对应的进程id，再调用kill -9 id来终止该进程。而使用zsh，则可以简化为kill ruby< TAB >，然后会得到一个列表，持续地按< TAB >会在这个列表中切换，直到你选中需要杀掉的进程，然后回车即可，如图1-8所示。

图1-8　使用zsh的自动补全来终止ruby进程

批量重命名，比如当前目录有几个txt结尾的文件，我们需要将后缀修改为html：

$ ls
1.txt 2.txt 3.txt 4.txt
$ zmv '(*).txt' '$1.html'
$ ls
1.html 2.html 3.html 4.html

zmv是一个zsh的模块，我们需要将其加载进来：

$ autoload -U zmv

命令zmv '(*).txt' '$1.html'中，以txt为后缀的文件名部分被存到了一个分组中，这个分组可以通过$1来获取。这样我们就可以将文件命名成任意字符串了：

$ ls
1.html 2.html 3.html 4.html
$ zmv '(*).html' 'template_$1.html.haml'
$ ls
template_1.html.haml template_2.html.haml template_3.html.haml template_4.html.haml

1.2　管道

当各司其职的、又完全独立的工具组合在一起的时候，命令行的高效才得以体现。比如使用find命令查找所有的测试文件，如果我们更关心的是有多少个这样的文件，而不是文件的名称本身，应该怎么做呢？给find再加上一个统计选项？在UNIX世界里，我们有更好的选择：管道。

命令wc可以用于统计文件中的单词个数或者行的个数，比如文件file有100行，那么wc -l file会输出100。管道用于将多个命令连接起来，即将上一个程序的输出作为下一个程序的输入。

比如命令：

$ find . -name "*.rb" | wc -l

表示，从当前的目录起，查找所有的ruby文件，并统计其个数。通过管道|，find命令的输出变成了wc的输入，这样两个程序就连接了起来。通过管道可以将多个程序连接在一起：

$ find . -name "*.rb" | xargs basename
app.rb
notes.rb
mobile.rb
user.rb
factories.rb
feather_spec.rb
notes_spec.rb
spec_helper.rb
user_spec.rb

上面这条命令中，basename命令用于从文件的完整路径中获取最后一个“/”之后的内容，比如：

$ basename "/lightweight-web/sinatra/"
sinatra
$ basename "/lightweight-web/sinatra/lib"
lib
$ basename "/lightweight-web/sinatra/lib/products.rb"
products.rb

而xargs命令用于将前一条命令的输出作为后一条命令的参数，比如find命令查找出了当前目录中包含的所有ruby的相对路径：

$ find . -name "*.rb"
./app.rb
./lib/notes.rb
./lib/sinatra/mobile.rb
./lib/user.rb
./spec/factories.rb
./spec/feather_spec.rb
./spec/notes_spec.rb
./spec/spec_helper.rb
./spec/user_spec.rb

上边的那条带有xargs的命令就可以解释为，将查找出来的所有ruby文件的完整路径名通过xargs basename过滤，只得到文件名本身。得到了这样的一个列表之后，我们还可以再使用sort命令对这些文件进行排序：

$ find . -name "*.rb" | xargs basename | sort
app.rb
factories.rb
feather_spec.rb
mobile.rb
notes.rb
notes_spec.rb
spec_helper.rb
user.rb
user_spec.rb

还可以将这个命令再扩充为find . -name ".rb" | xargs basename | sort | xargs cat，即将sort产生的输出，又逐行地作为cat命令的参数，这样就可以查看所有".rb"文件的内容了。而如果文件比较多的话，我们还可以使用分页工具如more或者less命令来分页查看：

$ find . -name "*.rb" | sort | xargs cat | less

1.3　几个常用命令

1.3.1　文件查找命令find

著名的编辑器Vim的作者（Bram Moolenaar）曾在一次演讲中提到如何更高效地学习和使用Vim：

（1）观察自己的动作，并发现低效的一些操作。

（2）查看帮助或者请教周边的人，如何用更高效的方式来完成。

（3）不断地练习这种高效的方式，使之成为一种习惯。

事实上，这种方法可以用以学习其他一切工具。通过观察，我发现自己，以及其他程序员在工作中，很多时候都是在做各种各样的查询——查找文件、查找文件中的某些关键字、查找具有某种特征的目标。完成这项工作有很多种方式，图形界面无疑是最糟糕的一种。因为有太多可能的选项（按照文件名字的一部分，按照修改时间，按照大小，按照所有者等等），对于一个GUI程序来说，各种条件如何摆放便是一个巨大的挑战。

UNIX世界里经典的find命令可以使这个过程变得非常容易，甚至是一种享受。find命令遵循以下模式：

$ find where-to-lookcriteria[what-to-do]

即，从何处开始查找，查找的条件是什么，以及找到之后做什么动作（这一步是可选的，默认的find命令会打印文件的全路径）。举一个简单的例子：

$ find . -name "*.rb"

这条命令会从当前目录开始，递归遍历所有的子目录，查找名字中带有.rb的文件及目录（虽然将一个目录命名为xxx.rb有些奇怪，不过这是合法的）。此处的点号（.）表示当前目录，即告诉find从当前目录开始查找，-name参数指定按照名字查找，而正则表达式".rb"表示所有以“.rb”结尾的字符串。如果找到了匹配项，find命令会打印出该文件相对于当前目录的路径。

$ find . -name "*.rb"
./app.rb
./lib/notes.rb
./lib/sinatra/mobile.rb
./lib/user.rb
./spec/factories.rb
./spec/feather_spec.rb
./spec/notes_spec.rb
./spec/spec_helper.rb
./spec/user_spec.rb

我们还可以使用find . -size +100k来查找文件大小在100k以上的所有文件。最巧妙的是，这些条件是可以拼凑起来使用的：

$ find . -name "*.rb" -size +100k

表示从当前目录开始，查找名称中包含"*.rb"，并且大小在100k以上（注意100k前面的加号）的所有文件或目录。另外，用户还可以指定多个-size参数：

$ find . -size +50k -size -100k

这条命令表示查找所有大小在50k到100k之间的文件。如果加上-mtime 0，可以查找24小时之内修改的大小在50k到100k之间的文件：

$ find . -size +50k -size -100k -mtime 0

find支持众多的查询条件，可以通过查看手册man find来得到完整的索引。

$ find . -size +50k -size -100k -mtime 0 | xargs ls -lh
-rw-r--r--　1 twer　staff　　64K Feb　7 19:58 ./4.5_week.geojson

1.3.2　网络命令curl

curl是UNIX世界里另外一个经典的应用程序，它支持众多的网络协议，但是更多时候我们只是使用它实现HTTP协议部分的功能。借助curl的众多选项，测试基于HTTP的应用程序显得非常富有乐趣。

最简单的使用curl的场景是使用curl发送一次HTTP请求：

$ curl http://www.apple.com
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en-US" lang="en- US">
<head>
<meta charset="utf-8" />
<meta name="Author" content="Apple Inc." />
<meta name="viewport" content="width=1024" />
...

这条命令会获取到一个HTML文档（即apple.com这个站点上的index页面）。有时候我们仅仅需要获取HTTP的头信息，而无需关注页面本身。此时可以使用-I参数：

$ curl http://www.apple.com -I
HTTP/1.1 200 OK
Server: Apache
Content-Type: text/html; charset=UTF-8
Cache-Control: max-age=379
Expires: Fri, 07 Feb 2014 10:04:41 GMT
Date: Fri, 07 Feb 2014 09:58:22 GMT
Connection: keep-alive

这样我们会得到一个200 OK的响应。如果加上-v参数就可以看到详细的信息：比如服务器的IP地址，发往服务器的HTTP头信息，以及最终服务器的响应：

$ curl http://www.apple.com -I -v

以>开头的行是curl发往服务器的数据，以<开头的行是服务器的响应，而以*开头的则是一些日志消息。

curl命令的详细输出如图1-9所示。

图1-9　curl命令的详细输出

curl常常用于测试基于HTTP的RESTFul的API。比如通过POST方法，向服务器发送一段JSON数据：

$ curl -X POST http://application/resource -d "{\"name\": \"juntao\"}"

-X参数表示以何种HTTP动词来完成此次请求，curl支持所有的HTTP动词（GET、POST、PUT、DELETE、OPTION等）。-d参数用来表示有数据需要发送，这个数据可以是一段内联的字符串，也可以是一个文件。如果是文件，需要指明文件名-d @filename。

另外，使用curl可以设置HTTP头信息，这样在服务器看来，这个请求就好像是从浏览器发来的一样，此方式可以绕过一些对网络爬虫设置了屏障的站点：

$ curl -H "User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_9_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.63 Safari/537.36" http://www.apple.com

这里的User-Agent值是Google Chrome浏览器的用户代理字符串，也就是说，在服务器看来，这个请求就是通过Chrome浏览器发来的。当然，通过-H参数，我们还可以指定诸如Content-Type: application/json或者Accept: application/json等头信息，以便提供给服务器更多的信息（比如在服务器端，如果客户端的请求关注的是HTML，则返回HTML的内容，如果客户端关注JSON，则返回JSON内容）。

使用curl还可以做简单的登录操作，比如

$ curl https://api.github.com/users/abruzzi > abruzzi.nologin.json
$ curl --user "abruzzi:Password"https://api.github.com/users/abruzzi > abruzzi.login.json

选项-c cookie-file可以将网站返回的cookie信息保存到文件中，选项-b cookie-file可以使用这个cookie来做后续的请求，这样在服务器看来，这些独立的请求就变成了连续的了。

$ curl -L -c cookies http://application/resource
$ curl -L -b cookies http://application/resource

1.3.3　文件搜索grep

grep是用于搜索文件内容的一个命令行工具。它提供了很多参数使得用户可以以不同的方式做搜索。

比如查看文件spec/factories.rb中是否包含字符串"juntao"：

$ grep "juntao" spec/factories.rb
name 'juntao'
email 'juntao.qiu@gmail.com'

加上-n选项会打印出这个字符串所在的行号：

$ grep -n "juntao" spec/factories.rb
5:　　　 name 'juntao'
6:　　　 email 'juntao.qiu@gmail.com'

如果不知道想要搜索的字符串包含在哪些文件中，可以使用-R参数搜索当前目录下所有包含字符串“juntao”的文件，如图1-10所示。

图1-10　递归的搜索当前目录，查找字符串“juntao”

比如有些目录可能并不需要搜索，可以使用选项--exclude-dir来过滤，如图1-11所示。

图1-11　排除目录“spec”

进一步地，如果我们需要查找包含“juntao”或者“abruzzi”的行（这两个网络id在很多场合表示的都是同一个人），如图1-12所示。

图1-12　根据正则表达式查找

即通过-E参数来制定后边需要搜索的字符串是一个正则表达式（"juntao|abruzzi"表示，或者"juntao"，或者"abruzzi"）。

有些时候，仅仅显示匹配上的行可能还不够，用户可能需要该行周围的信息，这时候可以使用参数-C来启用上下文打印功能：

$ grep -n -E "juntao|abruzzi" -R . --exclude-dir=".git" -C 1
--
./abruzzi.nologin.json-22-　"location": "China",
./abruzzi.nologin.json:23:　"email": "juntao.qiu@gmail.com",
./abruzzi.nologin.json-24-　"hireable": false,
--

此处的参数-C 1表示，打印匹配行周围的一行，即上一行和下一行。这种用法在日志跟踪时非常有用。还可以使用参数-B 2打印匹配行之前的两行和参数-A 2打印匹配之后的两行。

1.3.4　定时任务crontab

crontab是UNIX下用来执行定时任务的一个守护进程。使用crontab可以定期地执行一些脚本，比如每天凌晨2点进行数据库备份；每个小时检查一次磁盘空间，如果空间小于某个阈值，就发邮件通知系统管理员；每隔10分钟启动笔记本电脑的前置摄像头，为正在专心解决问题的程序员拍张照片，等等。所有这些需要定期运行，又可以通过计算机程序来完成的任务，都可以交给crontab。

crontab的格式：

MIN HOUR DOM MON DOW CMD

列　　名

含　　义

取 值 范 围

MIN

分钟

0-59

HOUR

小时

0-23

DOM

每个月的第几天

1-31

MON

月份

1-12

DOW

每周的第几天

0-6

CMD

需要执行的命令或者脚本

可执行脚本

比如，8月20日下午4点30分，发一封邮件给smith.sun@sun.smith.com的任务描述起来就是：

30 16 20 8 * /home/juntao/bin/send_mail_to_smith

使用命令crontab -e会进入vi的编辑模式（此时用户实际上在编辑一个临时文件），将上面的命令写入，然后保存退出，就完成了一个任务的注册。可以使用crontab -l来列出所有当前已经注册的任务。crontab支持定义多个定时任务，当用户定义多个任务时，只需要再次进入crontab的编辑模式，将新建的任务追加进去即可。

crontab还支持定义某个指定范围的任务，比如在工作时间内，每一个小时检查一次邮件：

00 09-18 * * * /home/juntao/bin/check_my_email

如果觉得邮件太影响工作，可以设置成每天的11点半和4点半检查邮件：

30 11,16 * * * /home/juntao/bin/check_my_email

每过10分钟拍一张照片，但是周末除外：

*/10 * * * 0-4 /home/juntao/bin/take_a_photo

总之，使用crontab，可以将那些重复的，容易忘记或者容易犯错的任务都交给计算机来完成。

1.3.5　JSON查询利器jq

http://earthquake.usgs.gov/提供全球范围内的地震信息，它还提供了程序访问的接口，比如下列URL提供上周内，全世界范围内的震级在里氏4.5级以上的地震信息http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/4.5_week.geojson。

数据以JSON的形式提供，以方便各种编程语言解析和展现，如图1-13所示。

图1-13　浏览器中的地震信息（geojson格式）

但是问题是这种数据往往太大了，展现的时候，可能只需要其中的一小部分，比如我们更关注features这个数组中的一些内容。在做进一步的解析之前，我们先将远程的文件保存到本地：

$ curl -s http://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/ 4.5_week.geojson >4.5_week.geojson

保存之后，可以通过cat 4.5_week.geojson来查看该文件的内容，或者通过管道将文件内容交给jq来处理。jq可以处理自己的表达式，比如要查看文件中的features数组的第一个元素：

$ cat 4.5_week.geojson | jq '.features[0]'
{
"id": "usc000mjye",
"geometry": {
"coordinates": [
167.302,
-15.057,
111.24
],
"type": "Point"
},
"properties": {
"title": "M 6.5 - 27km E of Port-Olry, Vanuatu",
"type": "earthquake",
"magType": "mwp",
"gap": 53,
"rms": 1.17,
},
...
}

但是即使这样，内容也显得太多了，我们事实上只关心地理信息geometry和properties中的title属性，那么可以通过jq的过滤器来完成：

$ cat 4.5_week.geojson| jq '.features[0] | {geometry, title: .properties. title}'
{
"title": "M 6.5 - 27km E of Port-Olry, Vanuatu",
"geometry": {
"coordinates": [
167.302,
-15.057,
111.24
],
"type": "Point"
}
}

其中{geometry, title: .properties.title}定义了一个新的对象，这个对象中geometry保持使用features[0]中的geomeotry，而另一个属性title，则来源于features[0].properties.title。此时，得到的只是features数组的第一个元素，如果想要得到所有的元素，并且将最后的结果包装成一个新的数组，则需要下列表达式：

$ cat 4.5_week.geojson| jq '[.features[] | {geometry, title: .properties. title}]'
[
...
{
"title": "M 4.6 - 63km SSW of Fereydunshahr, Iran",
"geometry": {
"coordinates": [
49.8711,
32.4082,
10
],
"type": "Point"
}
}
...
]

这样，数据量就得到了大幅地减少，使得后续的操作可以更加快速。

1.4　编辑器

除了Shell，编辑器可能是程序员使用最为频繁的工具了。关于编辑器已经有过很多次战争了，我无意于挑起任何关于工具的论战，本节仅分享一些个人的使用经验。这里列举的所有工具，都是基于这样两个原则：功能强大且轻量小巧。

1.4.1　Vim编辑器

Vim是一个著名的、小巧的、高可配置性的编辑器，如图1-14所示。开始学习的时候，Vim中众多反直觉的操作方式会令人很不适应（hjkl键来进行导航，yy表示拷贝光标所在行等），但是一旦理解了这些怪异的命令背后的含义，一切就显得顺理成章了。

这里不讨论Vim的基本使用方法，这里仅仅列出一些非常好用的插件，以方便实际开发：

（1）目录树查看：nerdtree。

（2）查找文件：ctrlp。

（3）代码片段生成：vim-snipmate。

（4）代码注释：tcomment。

图1-14　配置好的vim编辑器

这里重点说明如何安装Vim的插件。很久之前，安装Vim插件的方法就是在官网上找到该插件，下载压缩包，然后解压到~/.vim目录中。换言之，就是纯手工操作，如果换一台机器，这些插件又需要重新找，重新下载，过程非常不便。

vim-pathogen是一个用来简化这个过程的工具。安装vim-pathogen和传统的插件安装方式类似：

$ mkdir -p ~/.vim/autoload ~/.vim/bundle
$ curl -Sso ~/.vim/autoload/pathogen.vim \
https://raw.github.com/tpope/vim-pathogen/master/autoload/pathogen.vim

这条命令会在你的Vim配置目录（通常位于~/.vim/）中创建两个新的目录autoload和bundle，然后下载pathogen.vim到autoload中。

安装完成之后，你需要在.vimrc中加入：

execute pathogen#infect()

这样，pathogen本身就安装完成了，下面我们来看几个例子，看看它如何快捷地安装Vim插件：

$ cd ~/.vim/bundle
$ git clone https://github.com/scrooloose/nerdtree.git

首先切换到~/.vim/bundle目录，然后将远程的git库https://github.com/scrooloose/ nerdtree.git复制到本地的nerdtree目录即可完成对nerdtree的安装。pathogen会检查~/.vim/bundle下的所有子目录，并加载其为Vim插件。

类似地，如果要安装ctrlp或者tcomment，都可以用同样的方式：

$ cd ~/.vim/bundle
$ git clone https://github.com/kien/ctrlp.vim.git
$ git clone https://github.com/tomtom/tcomment_vim.git

安装snipmate时步骤会多一些，但是绝对物超所值：

$ cd ~/.vim/bundle
$ git clone https://github.com/tomtom/tlib_vim.git
$ git clone https://github.com/MarcWeber/vim-addon-mw-utils.git
$ git clone https://github.com/garbas/vim-snipmate.git

其实本质上，vim-snipmate只负责在合适的时刻向文件中插入合适的片段，其本身并不存储片段。因此，我们还需要很多片段模板：

$ cd ~/.vim/bundle
$ git clone https://github.com/honza/vim-snippets.git

所谓片段，就是一个预定义的模板：

snippet def
def ${1:method_name}
${0}
end

上面的模板定义了当你在编辑器中输入def时，然后按一个扩展键（通常是Tab键），内容就会自动被替换成：

def method_name
end

而且，method_name处于选中状态，你可以将其修改为任意的方法名，然后再按Tab键，光标会置于方法体中，并进入编辑状态。这个功能可以节省很多编辑时间。vim-snippets定义了多种语言的片段，而snipmate负责在合适的时机使用这些片段（比如根据文件名后缀来判断到底使用哪种语言的片段）。

NERDTree是一个用于显示目录树的插件（如图1-15所示），在实际开发中，我们不可能只在一个文件中编码，通常是在一个目录中，而且这个目录往往会有数层。如何方便地将目录结构可视化？又如何方便地修改这个目录结构（比如创建新的文件夹，删除一个目录，移动一个文件到另一个文件夹等）？

图1-15　NERDTree插件

在vim的命令模式中，输入:NERDTree命令，可以看到上图左侧的显示的目录树结构。此时调用m命令得到一个菜单，如图1-16所示。

图1-16　NERDTree的菜单项

这个字符界面的菜单提供多种选项，比如我们可以用菜单中的a选项来新建一个文件夹，注意目录需要以“/”结尾，如图1-17所示。

图1-17　使用NERDTree创建一个目录

这样就创建了一个文件系统中的目录support。选择菜单中的r选项会将选中的目录用Finder程序打开，如图1-18所示。

图1-18　在Finder中打开目录

CtrlP插件用以快速地查找文件，尤其在大型的项目中会非常有用，而且CtrlP支持模糊查询，即使你只记得文件名的一部分，它也可以帮你找到需要的文件。

在Vim中，使用Ctrl+P快捷键进入CtrlP插件，这时候输入notes，可以看到一个命中了notes的文件列表，如图1-19所示。

图1-19　使用CtrlP快速查找文件

CtrlP还附带了一个很顺手的功能CtrlPMRU：最近最多使用的文件名列表，即CtrlP认为，如果需要切换文件，那么最近编辑次数最多的那个文件最可能是用户需要的文件。使用Vim，我们可以很容易为这个功能定义一个键映射：

map <C-X> :CtrlPMRU<CR>

将这行代码保存在你的Vim配置文件（通常为~/.vimrc）中。然后每次使用Ctrl-X就会看到一个最近最多编辑的文件的列表： {% img /images/2014/02/vim-ctrlp-recent.png %}

比如编辑JavaScript时，键入ajax，然后键入TAB：

snippet ajax
$.ajax({
url: "${1:url}",
data: "${2:data}",
success: function() {
${0}
}
});

在文件~/.vim/bundle/vim-snippet/snippets/javascript.snippets中，添加一个新的snippet的定义。定义好之后，在编辑JavaScript文件时，输入ajax< TAB >就会被自动补全为定义好的模板：

$.ajax({
url: "url",
data: "data",
success: function() {
}
});

并且，光标置于url中。对应地，定义一个ruby中测试的snippet，需要修改~/.vim/bundle/ vim-snippet/snippets/ruby.snippets：

snippet desc
describe "${1:test controller}" do
it "${2:should has route index}" do
${0}
end
end

1.4.2　Sublime Text编辑器

Sublime Text是一个小巧的编辑器，在Mac和Windows平台都有对应的版本。它本身是收费软件，但是其非注册版在功能上并没有限制，只是偶尔会在保存文档时弹出一个窗口，提示你去注册。但是这个弹出窗口并不会影响使用。

目前Sublime Text主要有V2和V3两个版本，V2为稳定版。这里的介绍都是基于V2版本。Sublime Text提供了丰富的功能，完整地介绍一个现代编辑器的功能已经远远超出此文的范围，这里仅列举其中几个非常高效的特性：

（1）自由跳转功能。

（2）多重选择模式。

（3）文件预览（无需在新标签中打开文件，就可以预览内容）。

（4）快速在已经打开的文件中切换。

（5）众多的插件支持。

事实上，第5项插件机制使得Sublime Text在理论上可以做任何事情，比如界面主题的改变，与外部的应用程序集成，等等。

Sublime Text很方便地支持跳转，这个功能的快捷键为Command+P，在Windows下为Ctrl+P输入Command+P进入跳转模式。

（1）输入文件名即可跳转到该文件，支持模糊匹配，如图1-20所示。

图1-20　在Sublime中根据文件名查找文件

（2）输入@加函数名跳转至该函数（对于markdown文档，可以跳转至指定标题），如图1-21所示。

图1-21　Sublime中根据函数名查找文件

（3）输入#加关键字可以跳转至出现该关键字的位置。

（4）输入:加行号可以跳转至指定行号。

这些跳转命令还可以组合使用，如app.rb:20跳转到app.rb的第20行。mixin#Products跳转到文件名包含mixin的文件中关键字Products所处的位置。

在选中一个词之后，按Command+Ctrl+G可以将当前文档/代码中所有出现这个词的地方都选中。当用户编辑当前选中的词时，所有其他的选中也都会随之改变，如图1-22所示。

图1-22　Sublime中的多处编辑

对于Sublime，首先需要安装的插件是Package Control，它类似于Vim中的pathogen，用以方便你安装其他的插件，Package Control更强大一些，它还可以帮助你管理其他的所有插件。

Package Control支持自动安装：在站点https://sublime.wbond.net/installation上选择Sublime版本，将对应的python代码复制下来，然后打开Sublime的控制台（视图->现实控制台）中，将内容粘贴进去即可。

重启Sublime之后，你就可以用工具->命令面板（Shift+Command+P）来安装插件了。在命令面板中输入install，会看到一个列表，如图1-23所示。

图1-23　安装插件

然后输入想要安装的插件名称，比如jshint（一个用于静态检查JavaScript语法的工具），如图1-24所示。

图1-24　搜索需要安装的插件

选择你需要的插件，然后安装即可。插件安装之后，可以通过命令面板来使用该插件，也可以通过插件本身提供的快捷键来使用。

1.5　程序启动器

应用程序启动器，或者称为程序的启动加速器，可以帮助你快速地找到需要启动的程序并启动，比如Mac OS下的Alfred或者Windows下的Launchy。

1.5.1　Launchy

Launchy可以快速地启动一个应用程序，比如敲入word即可将Microsoft Word启动起来，如图1-25所示。

图1-25　Launchy启动器

1.5.2　Alfred

Alfred是Mac下的一个程序启动加速器，它有两种版本。普通版完全免费，但是功能集合会小一些。收费版允许开发者自己开发工作流，从而更大程度地提高效率。

免费版本已经非常强大，我们来看它的一些基本的特性：

（1）找到并启动应用程序，如图1-26所示。

图1-26　查找并启动

（2）快速查找/打开文件，如图1-27所示。

图1-27　快速打开文件

（3）快速根据内容查找文件，如图1-28所示。

图1-28　在文件中查找

（4）可以使用option+command+c来启动粘贴板记录器（需要安装Alfred的付费包Powerpack），如图1-29所示，它可以记录最近使用的所有粘贴板记录，用户可以选择其中的一项，然后粘贴到指定位置：

图1-29　粘贴板记录器

比如当键入的markdown（一种用于快速编写结构化文档的标记语言，可以被转化成HTML等）时，本地的应用程序中没有对应的匹配，Alfred会提示是否要去网络上进行搜索，比如看看Google上有解释是什么等，如图1-30所示。

图1-30　快速启动谷歌搜索

1.6　关于Windows

从严格意义上来看，Windows系统不适合做开发。Windows是一个容易上手的操作系统，但是缺失了太多的支持高效开发的元素：没有shell环境，GUI程序间无法通信，不是一个真正支持多用户的系统，也很难将其作为服务器来使用。在Windows系统上做开发，常常需要付出数倍的艰辛，但是为了获得Linux或者Mac系统的自带功能（而有时根本无法实现）。

有很多热心的程序员为方便在Windows系统上开发做了很多事情，比如cygwin、mingw等工具。有了这些工具，可以为Windows的先天不足提供一些帮助，但是涉及到操作系统功能的时候，如进程管理、进程调度时，又会出现很多问题（比如如何在cmd中将一个进程放到后台运行，然后在5分钟之后杀死该进程）。

一个最直接的解决方案就是放弃Windows作为开发环境：安装Linux的虚拟机，宿主机Windows可以用来娱乐，而Linux虚拟机用来做开发；将系统换成Linux或者Mac OS，然后在系统中安装一个Windows的虚拟机用来娱乐等。

第2章 Web应用服务器

Web应用服务器是支持动态内容生成的Web服务器。通常来讲，Web服务器指那些可以提供静态内容的、建立在HTTP协议之上的服务器。Web服务器仅仅提供文档的映射或者说托管的能力，即客户端可以通过HTTP协议来请求一些文件资源，这些资源都是预先存在的，如果服务器需要根据客户端的请求来生成不同的资源，则需要应用程序来支持。

这里说的应用程序可以是任何语言，比如C、Java、Python、Ruby，等等，我们这里选择Ruby来进行描述。一来是Ruby非常简单，已经存在很多的库来简化Web应用程序开发，二来Ruby对DSL的支持非常好，可以写出非常易读的代码。

2.1　Rack

Rack是一个用于连接支持Ruby的Web服务器和Web框架的程序库，或者称为微框架。它非常小巧，可以说是轻量级框架/程序库中的典范。一方面，Rack包含了不同的Handler来和Web服务器连接（如WEBrick、Mongrel等，这些Web服务器类似于Apache httpd，但是又可以支持Ruby语言）。另一方面，Rack包含了适配器，用以连接Web框架（Sinatra、Rails等）。Rack程序通常会扮演中间人的角色：当HTTP请求从Web服务器上发往Web应用程序时，Rack可以做一些数据转化操作；同样，当响应从Web应用发往Web服务器时，Rack又可以做一些其他的工作。

最简单的Rack应用程序就是一个Ruby对象。根据Rack的规范，这个对象上需要有一个call方法，并且这个方法接收一个参数（一个Hash）。而call方法需要返回一个包含3个元素的数组：状态码、HTTP头信息（一个Hash）以及内容（通常这个内容是一个字符串数组，或者是一个文件对象）。

这个描述听起来非常拗口，但是用代码来表示的话就非常清晰了：

def proc(env)
[200, {"Content-Type" =>"text/plain"}, ["Hello, world"]]
end

可以看到，方法proc接收一个参数，并返回一个包含三个元素的数组。但是此处的proc是一个Ruby的方法，我们需要将这个方法剥离出来，使其成为一个可以响应call的对象：

rack_proc = method(:proc)

method方法会将一个Ruby的方法转换成一个对象，这个对象可以响应call方法。rack_proc对象就是一个Proc对象的实例，它可以响应call方法，接收一个参数（一个Hash），并且可以返回包含三个元素的数组。也就是说，它已经符合Rack应用程序的标准。事实上，使用Ruby的lambda表达式，我们可以以更简单的方式做到同样的事情：

rack_proc = lambda {|env| [200, {"Content-Type" =>"text/plain"}, ["Hello, world"]]}

Rack本质上只是一个简单的接口规范，我们此处要讨论得更多的是Rack这个gem：一个参考实现和一些方便开发的工具。Rack的gem中包含了众多的助手函数，使用这些助手，我们可以很快创建出Web应用程序。

安装rack非常容易，在Gemfile中添加rack，然后执行bundle install：

source 'http://ruby.taobao.org'
gem 'rack'

安装好rack之后，我们就可以来编写第一个简单的Rack应用了，在irb（Ruby自带的一个交互式环境，可以在其中直接编写Ruby代码，并求值运行）中输入：

require 'rack'
rack_proc = lambda {|env| [200, {"Content-Type" =>"text/plain"}, ["Hello, world"]]}
Rack::Handler::WEBrick.run rack_proc

语句Rack::Handler::WEBrick.run rack_proc会启动一个HTTP服务器，并在8080端口监听。当请求到达时，Rack会调用我们的应用程序，也就是函数rack_proc来产生响应。

[2014-02-05 23:54:44] INFO　WEBrick 1.3.1
[2014-02-05 23:54:44] INFO　ruby 1.9.3 (2012-10-12) [x86_64-darwin12. 2.0]
[2014-02-05 23:54:44] INFO　WEBrick::HTTPServer#start: pid=45429 port= 8080

此时在浏览器地址栏键入“http://localhost:8080”就可以看到“Hello, world”的响应了。更方便的方法是可以通过命令行curl命令来查看：

$curl http://localhost:8080
Hello, world

相比之下，我更喜欢命令行方式，速度也更快。

2.1.1　rackup

好了，实验成功，接下来我们需要将这些代码保存到文件中，以便下次使用。上边提到过，Rack的gem中提供了一个很好用的工具rackup，可以用来启动符合Rack规范的应用程序。rackup需要一个配置文件，这个配置文件一般由.ru结尾。

我们可以将上面在irb中的实验代码存入config.ru文件：

require'rack'
rack_proc = lambda {|env| [200, {"Content-Type" =>"text/plain"}, ["Hello, world"]]}
run rack_proc

注意此处，我们去掉了run方法前面的对象Rack::Handler::WEBrick，rackup自己会知道需要运行在何种环境，默认的即为WEBrick。保存文件之后，执行rackup config.ru即可启动应用程序了。

$ rackup config.ru
[2014-02-06 00:15:07] INFO　WEBrick 1.3.1
[2014-02-06 00:15:07] INFO　ruby 1.9.3 (2012-10-12) [x86_64-darwin12. 2.0]
[2014-02-06 00:15:07] INFO　WEBrick::HTTPServer#start: pid=45801 port=9292

更进一步，我们可以简化config.ru，将应用程序从文件中移出去：

require'rack'
class App
def call env
[200, {"Content-Type" =>"text/plain"}, ["Hello, world"]]
end
end

移出之后，我们定义了一个新的类App，这个类的所有实例上都会包含方法call，而且是符合Rack规范的call方法，因此config.ru就被简化成了：

require'./app'
run App.new

到目前为止，我们的应用程序还没有任何具体的功能，但是它已经具备了一个Rack应用程序所需的所有要素了。我们来对其做一下简单的扩展：返回客户端的请求中的所有HTTP头信息。

require'rack'
require'json'
class App
def call env
[200, {"Content-Type" =>"application/json"}, [env.to_json]]
end
end

使用rackup config.ru启动服务之后，通过命令行的curl来进行测试，并通过jq命令将输出的json字符串格式化：

$ curl http://localhost:9292 | jq .
{
"REQUEST_PATH": "/",
"HTTP_VERSION": "HTTP/1.1",
"rack.url_scheme": "http",
"rack.run_once": false,
"rack.multiprocess": false,
"rack.multithread": true,
"rack.errors": "#<Rack::Lint::ErrorWrapper:0x007fd1a2830380>",
"rack.input": "#<Rack::Lint::InputWrapper:0x007fd1a2830448>",
"SCRIPT_NAME": "",
"REQUEST_URI": "http://localhost:9292/",
"REQUEST_METHOD": "GET",
"REMOTE_HOST": "localhost",
"REMOTE_ADDR": "127.0.0.1",
"QUERY_STRING": "",
"PATH_INFO": "/",
"GATEWAY_INTERFACE": "CGI/1.1",
"SERVER_NAME": "localhost",
"SERVER_PORT": "9292",
"SERVER_PROTOCOL": "HTTP/1.1",
"SERVER_SOFTWARE": "WEBrick/1.3.1 (Ruby/1.9.3/2012-10-12)",
"HTTP_USER_AGENT": "curl/7.30.0",
"HTTP_HOST": "localhost:9292",
"HTTP_ACCEPT": "*/*",
"rack.version": [
1,
2
]
}

可以看到，env中包含了客户端请求的所有信息，比如请求类型、客户端的User-Agent、查询字符串（Query String）以及一些rack自身的信息（这些属性都以rack.开头）。

Rack还提供了将这些信息解析为Request对象（类似于Java EE中的HTTPRequest对象）的功能，根据这个对象，我们可以获取请求类型，以及客户端发送请求时的参数信息：

class App
def call env
req = Rack::Request.new(env)
p req.request_method
p req.params['name']
p req.params['address']
[200, {"Content-Type" =>"application/json"}, [env.to_json]]
end
end

此处使用函数p将解析出来的请求对象中的一些信息打印到控制台上。比如request_method可以得到请求类型，params['name']可以得到请求字符串中name对应的值。

$ curl http://localhost:9292?name=juntao&address=China

在服务器运行的窗口可以看到这样的输出：

$ rackup config.ru
[2014-02-06 00:45:05] INFO　WEBrick 1.3.1
[2014-02-06 00:45:05] INFO　ruby 1.9.3 (2012-10-12) [x86_64-darwin12. 2.0]
[2014-02-06 00:45:05] INFO　WEBrick::HTTPServer#start: pid=46453 port=9292
"GET"
"juntao"
"China"

事实上，一旦我们可以获得这些信息，就可以做很多事情，比如查询数据库，生成用于校验的图片信息，或者删除某一个资源文件，等等。

对应的，Rack提供了对响应的包装对象Response，通过对Respnose对象的修改，可以生成最终应用程序的响应。

class App
def call env
resp = Rack::Response.new
resp['Content-Type'] = 'application/json'
resp.write "{}"
resp.status = 200
resp.finish
end
end

上例中，我们先创建了一个空的Rack::Response对象，然后设置了头信息、状态码以及响应内容，最后调用resp.finish将生成最终的结果。事实上，与上例的代码相对应的，我们可以将整个过程内联到一行：

[200, {'Content-Type': 'application/json'}, [{}]]

但是如果我们写的不是空对象，或者需要添加的HTTP头信息又不止一个时，使用Response会更加地便利，而且代码的可读性也会提高很多。

2.1.2　Rack中间件

Rack中间件，本质上就是一个个的Rack应用程序，但是Rack支持将这些应用程序组成一个链（一个先进后出的栈结构）来调用。即请求经过一个中间件处理后，紧接着传递给下一个中间件处理，然后再传递给下一个，依此类推。这个机制从纵向将请求分离开，每个中间件都相互独立，而当请求从最后一个中间件流出之时，数据已经经过了完整的处理加工（编码转换、权限校验，等等）。

定义一个新的中间件事实上非常简单，无非就是对一个Rack应用程序的包装而已：

class MyMiddleware
def initialize(app)
@app = app
end
def call(env)
status, headers, body = @app.call(env)
new_body = []
new_body <<"prefix..."
new_body << body.to_s
new_body <<"...suffix"
[status, headers, new_body]
end
end

这个中间件会为经过它的应用程序的内容加上头尾信息，在初始化这个中间件的时候，我们将一个Rack应用程序传递进来，并保存到实例变量@app上，而MyMiddleware本身又是一个合法的Rack应用程序（call方法，call方法的参数以及最后的返回值）。在中间件内部，先调用了@app.call(env)获得原始的输出，然后再修改这个结构，最后返回包装过的结果。

使用一个中间件非常简单，仅仅需要在config.ru中加入use语句即可：

require'./app'
require'./my_middleware'
use MyMiddleware
run App.new

使用curl测试，会得到这样的输出：

$ curl http://localhost:9292
prefix...["Hello, world"]...suffix

rackup本质上会将config.ru中的内容包装成一个大的Rack应用程序，而完成这个动作的是Rack::Builder，比如上例中定义的config.ru：

use MyMiddleware
run App.new

会被rackup转换成类似于这样的代码：

app = App.new
builder = Rack::Builder.new do
use MyMiddleware
run app
end
Rack::Handler::WEBrick.run builder, :Port =>9292

当然，你可以同时使用多个中间件：

use MyMiddleware1
use MyMiddleware2
use MyMiddleware3
run App.new

在rackup中，另外一个强大的功能是路由，即将匹配到某个路径的请求分发到对应的Rack应用程序上：

require'./app'
require'./my_middleware'
use MyMiddleware
map '/' do
run lambda {|env| [200, {}, ["root"]]}
end
map '/todos' do
run lambda {|env| [200, {}, ["todo list"]]}
end

上例中，发送到/的请求会返回“root”，而/todos会返回“todo list”。而且这些请求都会经过MyMiddleware这个中间件：

$ curl http://localhost:9292/
prefix...["root"]...suffix
$ curl http://localhost:9292/todos
prefix...["todo list"]...suffix

同样，在内部，上例中的config.ru会被rackup转换为：

builder = Rack::Builder.new do
use MyMiddleware
map '/' do
run lambda {|env| [200, {}, ["root"]]}
end
map '/todos' do
run lambda {|env| [200, {}, ["todo list"]]}
end
end
Rack::Handler::WEBrick.run builder, :Port =>9292

Rack::Cascade可以将多个Rack应用程序串联起来。Rack::Cascade会接受一个数组，数组中的每个元素都是一个Rack应用程序，请求会首先被第一个元素处理，如果第一个应用程序返回404；请求会被转发给第二个元素（应用程序），并依此类推。

require'./app'
StaticApp = Rack::File.new("static")
MyApp = App.new
run Rack::Cascade.new [StaticApp, MyApp]

Rack::File是一个Rack自带的应用，用以将一个目录作为HTTP服务器的根。Rack::File.new("static")即表示将当前目录下的static目录变为HTTP服务器的根，如果通过浏览器访问http://localhost:9292/file.html，事实上访问的就是static/file.html。这样上例中的代码表示，如果访问的文件存在，则返回文件内容，否则使用我们的App来响应。

Rack的这些特性相对而言都比较底层，有很多框架都是构建在Rack之上的，它们有的关注于通用功能，如权限管理、用户管理、日志记录；有些关注于简化开发流程，比如如何快速搭建一个可用的Web应用程序，如何快速搭建基于RESTFul的API，等等。我们在后边的小节中都会看到。

2.2　Sinatra

Sinatra是构建在Rack之上的程序库，提供完全HTTP化的DSL（领域特定语言），用Sinatra写出来的Web应用程序读起来非常流畅，就好像是读自然语言一样。

require 'sinatra'
get '/' do
"Hello, world"
end

比如上面这个代码片段，读起来就是：当在/这个URL上接收到HTTP的GET请求时，返回一个字符串“Hello, world”作为响应。将代码保存为app.rb，然后运行它：

$ ruby app.rb
== Sinatra/1.4.4 has taken the stage on 4567 for development with backup from Thin
>> Thin web server (v1.5.1 codename Straight Razor)
>> Maximum connections set to 1024
>> Listening on localhost:4567, CTRL+C to stop
127.0.0.1 - - [06/Feb/2014 13:26:46] "GET / HTTP/1.1" 200 12 0.0065

默认的Sinatra会在4567端口上监听。只需要4行代码，我们就创建了一个Web应用程序！而且是一个完全可用的Web应用程序！这个应用当然会响应我们发往/的GET请求，那么如果请求一个不存在的URL会怎么样呢？

2.2.1　404页面

Sinatra会返回404错误码，并提供一个默认的404页面（如图2-1所示），页面中还给出了可能解决此错误的指示：

图2-1　Sinatra默认的404页面

get'/notfound' do
"Hello World"
end

更进一步，我们看看如何用Sinatra处理来自客户端的数据请求。最简单的，如何获取请求字符串：

require'sinatra'
get '/' do
info = "#{params[:name]} from #{params[:address]}"
"Hello, #{info}"
end

Sinatra会将请求解析，并将请求字符串中的参数信息保存在params中。这样就可以通过param[:name]来引用请求字符串中的name值了。

$ curl http://localhost:4567/?name=juntao&address=China

将会产生这样的输出：

Hello, juntao from China

使用Sinatra，还可以将请求URL中的一部分解析为参数。比如目前很多支持RESTFul的URL/users/1，表示访问编号为1的用户记录：

get '/users/:id' do
"Detail for user #{params[:id]}"
end

注意此处，路由中的/users/:id，:id表明它是一个参数，即/users/后边跟的具体值会被解析出来，并赋值给:id。在代码中，可以通过params[:id]来获取此处的参数。这样，下面的请求就会得出这样的输出了：

$ curl http://localhost:4567/users/1
Detail for user 1

处理POST请求，也是一样容易，只需要定义这样一条路由：

post'/' do
data = JSON.parse request.body.read
data.to_json
end

这个路由的程序会将客户端的请求原封不动地返回，并且是以JSON的形式。我们这里还是可以使用curl来测试，curl命令功能十分强大，对HTTP协议的各种请求都有很好的支持，比如-X POST参数表示本次请求将使用HTTP POST来完成，如果不指定，默认按照HTTP GET请求来发送。参数-d ...表示需要被传输到服务器的数据，如果数据较小的话，可以使用一个字符串。而大的数据可以存储到一个本地文件中，然后使用-d @filename来传输到服务器。

此处我们使用-d加上json字符串来发送请求，可以看到，得到的响应正是我们发送过去的数据：

$ curl -X POST -d "{\"name\": \"juntao\", \"address\": \"Xi'an, China\"}" http://localhost:4567/
{"name":"juntao","address":"Xi'an, China"}

另外，Sinatra提供丰富的过滤条件，根据请求中带有的不同HTTP头信息，做不同的处理。比如，如果发现请求来自IE浏览器，则简单地返回一个拒绝服务响应：

get '/', :agent =>/.*MSIE.*/do
"looks like you're using the stupid IE, get out of here."
end
get '/' do
info = "#{params[:name]} from #{params[:address]}"
"Hello, #{info}"
end

此处的:agent引用的正是浏览器发来的请求中带的User-Agent头信息，我们可以通过curl来模拟这个请求：

$ curl -H "User-Agent: Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.1; WOW64; Trident/6.0)" http://localhost:4567/?name=juntao&address= China
looks like you're using the stupid IE, get out of here.

如果不带这个头信息的话，Sinatra会执行第二个路由/的程序，即返回“Hello, …”。

Sinatra还支持根据客户端请求中的Accept头来响应不同格式的内容。如果客户端需要消费json格式的数据，则在发送的HTTP请求中包含这样的头Accept: application/json，或者通过Accept: text/html来请求HTML格式的数据。这个功能通过provides来实现，:provides => html对应的即为Accept: text/html。

get '/', :provides =>'html' do
"<div>This is root</div>"
end
get '/', :provides =>'json' do
{:content =>"This is root"}.to_json
end
get '/' do
"This is root"
end

可以通过curl来测试我们预设的这些条件，应该注意的是，默认情况下，HTTP的客户端发送的请求中都会附加Accept: /，即接受任何形式的响应，我们可以显式地指定：

$ curl -H "Accept: application/json" http://localhost:4567/
{"content":"This is root"}
$ curl -H "Accept: text/html" http://localhost:4567/
<div>This is root</div>
$ curl -H "Accept: text/plain" http://localhost:4567/
This is root

毫无意外地，Sinatra可以作为一个静态文件的Web服务器使用。也就是说，我们可以将一些静态内容（HTML文件、JavaScript文件、样式表、图片等）托管在Sinatra上。依照默认的配置，Sinatra会在当前目录下的public子目录查找这些静态内容并响应，而用于展现的模板则默认放在当前目录下的views子目录中。

首先，我们创建一个名为index.html的HTML文件，注意其中包含了一张图片，图片的路径为/images/logo.png，Sinatra实际上会在当前目录的public/images/logo.png来访问此图片。

比如创建一个新的HTML文件views/index.html，内容如下：

<!DOCTYPE html>
<html>
<head>
<title>The index page of Sinatra demo</title>
</head>
<body>
<img src="/images/logo.png">
<h2>Hello, I'm the index page of Sinatra demo</h2>
</body>
</html>

对应的，在Sinatra应用程序中，加上下面这条路由：

get'/index' do
File.read("views/index.html")
end

这样，当我们在浏览器中请求http://localhost:4567/index时，就会看到浏览器中的页面如图2-2所示。

图2-2　浏览器中的页面

2.2.2　使用模板引擎

Sinatra提供与很多模板引擎集成的功能，比如原生的erb，haml等。我们在这里可以看一下haml的一个小例子。haml是一个很小巧的模板引擎，通过缩进来表达元素间的层次关系：

haml使用%加上标签名称来定义一个新的标签，比如%span定义一个span，%img定义一个img。然后通过一个Hash来定义元素的属性%span{:id => "container", :class => "container"}。另外由于class属性太过于频繁，因此可以简写为%span.container，这样就定义了<span class="container"></span>了。

!!!
%html
%head
%title The index page of Sinatra demo
%body
%img{:src => '/images/logo.png'}
%h2 Hello, I'm the index page of Sinatra demo

上边的haml与HTML比起来，层次关系更加清晰。重要的是，你再也不需要写闭标签了！当然，haml需要一个额外的gem来解析，我们在Gemfile加上gem "haml"然后执行bundle install即可：

require'haml'
get '/index' do
haml :index
end

使用模板的好处远远不止于层次变得更加清晰或者无需写闭标签，模板的本质是将静态页面变成动态。通过在Sinatra中传入不同的数据，模板引擎会将两者结合，生成最终的HTML页面：

get'/index' do
@user = {
:name =>"Juntao",
:address =>"Xi'an, China"
}
haml :index
end

而在haml中，使用这里定义好的实例变量@user：

!!!
%html
%head
%title The index page of Sinatra demo
%body
%img{:src => '/images/logo.png'}
%h2 Hello, I'm the index page of Sinatra demo
%div
%h3== Hello #{@user[:name]}, from #{@user[:address]}

haml文件渲染结果如图2-3所示。

图2-3　haml文件渲染结果

注意此处使用==来将后边的字符串作为Ruby字符串来处理，即此处的#@user将会被当做Ruby字符串，而不是静态的字符串。

@user:name

另外一个Sinatra提供的功能是过滤器（filter），借助于过滤器，我们可以在每个请求被处理之前，及每个请求被处理之后而还没有发送到客户端之前对数据做一些加工。

比如，对于所有的包含admin的URL，我们需要做权限校验：

before '/admin/*' do
unless authenticate @current_user
halt 401, "Go away!"
end
end

上面的例子中，我们介绍了Sinatra本身的一些有意思的特性。下面我们来讨论一下如何将Sinatra应用写的更模块化一些，也就是说，看起来更像一个专业的应用程序。上边的例子中，我们的路由信息都定义在应用程序的外部，事实上Sinatra默认的为我们生成了一个应用程序，然后将路由附加在这个应用上。

所以，我们的第一步就是定义自己的应用程序。这个步骤非常容易，只需要从Sinatra::Base上继承，然后将定义的路由移动到类MyApplication内即可：

class MyApplication <Sinatra::Base
#...
end

同时需要添加一个config.ru：

require'./app'
run MyApplication

通常情况下，我们还会将这个类包装在一个模块中（module）中：

module MyModule
class MyApplication <Sinatra::Base
#...
end
end

对应的，config.ru也需要更新为：

require './app'
run MyModule::MyApplication

在Sinatra中，我们可以使用任意的基于Rack的中间件。比如用于记录日志的中间件，做用户权限校验的中间件等等。

2.2.3　简单认证中间件

来看一个最简单权限校验中间件Rack::Auth::Basic。Rack::Auth::Basic仅仅需要一组合法的（和预定义的相匹配）用户名、密码即可：

use Rack::Auth::Basic do |username, password|
username == 'admin' && password == 'admin'
end

这样，当我们首次访问应用程序上的任何资源时，浏览器都会弹出一个登录框，如图2-4所示。

除非填入了正确的用户名和密码，否则用户将看不到应用程序的内容。如果通过curl来访问index页面，此处的-I参数表示仅实现响应中的头信息，而不显示请求到的文档本身。会得到401未授权的错误。

$ curl http://localhost:9292/index -I
HTTP/1.1 401 Unauthorized
Content-Type: text/plain
Content-Length: 0
WWW-Authenticate: Basic realm=""
X-Content-Type-Options: nosniff
Connection: keep-alive
Server: thin 1.5.1 codename Straight Razor

图2-4　基本验证页面

2.3　Grape

在Web前端越来越独立的现代，越来越多的应用程序倾向于以这样一种形式部署：后台提供RESTFul的API，前端独立出一个基于JavaScript的应用程序，两者通过HTTP协议来进行通信，数据交换使用最为通用的JSON格式。

这个模式有很多好处：

（1）接口统一，传输的数据人和机器都可以理解。

（2）前后端完全独立，各自可以使用相应的构建工具，测试框架等。

（3）部署互不依赖，前端代码完全可以放在静态的服务器上，访问速度会非常快。

（4）分工更加明确，前后端各自关注于不同的点上，更便于各自专注于自身的优化。

Grape也是一个基于Rack的框架。它为生成RESTFul形式的API提供了极大的便利，Grape通过提供一套领域特定语言来使这个过程更为简单。更为重要的是，Grape的设计初衷之一是，可以容易地和已有的系统进行集成（如Rails应用、Sinatra应用等）。

一般而言，一个Web应用程序在设计之初很难预见要以后到支持RESTFul的API。所以更多的情况是，Web应用程序会使用模板技术将一部分展现工作放到服务器上来做，比如使用erb或者haml模板来将数据和展现在服务器端拼装成最终的页面。而当应用程序发展到一定规模，比如需要支持移动设备的时候，这种设计就出现了问题：如何将现有的展现层移动到屏幕尺寸各自不同的移动设备上（智能手机，平板电脑等）。如果从头开发一套新的系统，无疑会耗费大量的人力和财力，但是如果将这些与展现无关的业务处理都形成API，使得不同的客户端都消费同一组API，无疑可以节省很多的成本。

从头开发一套支持RESTFul的API，本来是一个很大的挑战。比如以何种数据形式来展现，如何容易地与既有的系统集成，如何在不同版本的API切换等等。这些通用的问题，都可以使用Grape来解决。它可以很容易地和已有的系统集成在一起工作，在对既有功能毫无影响的前提下，为将业务逻辑通过API的形式暴露给消费者提供了很大的便利，而且对各种展现形式的支持已经非常完善，支持多版本并存。

一个实例

我们可以通过一个例子来探索一下如何使用Grape进行API的设计和发布。比如我们已经有了一个应用程序：一个对商品信息进行管理的系统。这个管理系统使用了ActiveRecord作为ORM层来进行数据库访问，数据库为sqlite3，简单起见，数据库中仅有一张表products。在引入Grape之前，系统具有本地的增删改查的功能（当然，仅能通过irb来进行操作）。

现在我们需要将这个系统通过HTTP暴露出去，为外部提供RESTFul形式的API。有了这套API，不同的客户端可以自行开发对应的应用程序，比如一个纯前端的MVC框架可以使用这组API，从而开发出一个全新的站点。而移动开发小组，也可以使用这套API，来开发基于手机设备的应用程序。

我们将使用Grape来完成这个工作。grape本身也是一个gem包，首先需要安装它，定义好Gemfile，然后执行bundle install即可：

source"http://ruby.taobao.org"
gem 'grape'

安装完成之后，我们就可以来定义API本身了。一个最简单的API：当访问/products时，返回所有的商品信息，用Grape实现如下：

module MySys
class API <Grape::API
format :json
resource :products do
desc "get all prodcuts information"
get do
Product.limit(20)
end
end
end
end

首先定义一个模块MySys，这在Grape中并非必须，不过它可以使我们的代码更容易管理。MySys::API继承了Grape::API，这样我们的API就拥有了众多的Grape的强大功能。然后使用format :json来指定了这个API将返回json格式的数据。

接下来定义了一个资源（resource）products。在RESTFul的术语中，所有的需要暴露给用户的称为资源（resource），每个资源都提供增删改查四种操作，而这些操作对应于HTTP的动词中，分别为：

（1）查询操作对应于GET请求，不带参数的GET请求将返回所有的资源，带有ID的则返回指定的资源。

（2）创建操作对应于POST请求。

（3）更新操作对应于PUT请求。

（4）删除操作对应于DELETE请求。

如上例中，定义了一个名为products的资源，当收到get请求时，返回Product资源，且最多返回20条。定义资源很容易，使用resource :resource_name block即可。在这个块（block，可以简单理解为Ruby中的匿名函数）中，定义对资源的所有可能的操作。desc是一个说明性的文字，用以描述这个端点（endpoint）的作用。然后在get中，返回了Product.limit(20)，这是ActiveRecord提供的一个查询语句，相当于SELECT * FROM products LIMIT 20;。

这样我们的第一个API的连接点就完成了。下面我们来启动这个API服务器，使其为外部提供服务。由于Grape是一个基于Rack的应用，在第一小节我们已经看到如何启动一个Rack应用，定义一个rackup的配置文件：

require './mysys'
run MySys::API

保存为config.ru，再在命令行执行：

$ rackup config.ru
[2014-02-06 16:13:36] INFO　WEBrick 1.3.1
[2014-02-06 16:13:36] INFO　ruby 1.9.3 (2012-10-12) [x86_64-darwin12. 2.0]
[2014-02-06 16:13:36] INFO　WEBrick::HTTPServer#start: pid=52898 port=9292

这样，API服务器就启动了。假设这时数据库中已经有了两条Products的数据，那么使用curl来请求我们的API，会得到这样的结果：

$ curl http://localhost:9292/products -s | jq .
[
{
"user_id": 1,
"updated_at": "2014-02-05T05:29:50Z",
"created_at": "2014-02-04T14:04:29Z",
"price": 12345.67,
"name": "Mac Book Pro",
"id": 1
},
{
"user_id": 1,
"updated_at": "2014-02-05T05:39:01Z",
"created_at": "2014-02-05T05:38:54Z",
"price": 4567.89,
"name": "iPhone 5s",
"id": 2
}
]

此处我使用了jq .来对返回的JSON数据进行了格式化，使其更加清晰易读。

在实际的业务场景中，我们还需要提供对某一个特定产品的查询，即根据请求中的条件来查询：

desc "return a product"
params do
requires :pid, :type =>Integer, :desc =>"product id"
end
route_param :pid do
get do
Product.find(params[:pid])
end
end

我们只需要在resource :prodcuts中定义一个新的端点（endpoint），这个端点需要一个整数作为参数，当得到这个参数后，Grape会将其传递给Product.find，并从数据库中获得该记录返回。

$ curl http://localhost:9292/products/2 -s | jq .
{
"user_id": 1,
"updated_at": "2014-02-05T05:39:01Z",
"created_at": "2014-02-05T05:38:54Z",
"price": 4567.89,
"name": "iPhone 5s",
"id": 2
}

例子中的代码使用了requires来声明，这个端点需要一个类型为整数的参数，这个参数被解析出来之后，会赋值给名为:pid的变量以便使用。如果客户端发送的是一个字符串，那么Grape会报告一个错误：

$ curl http://localhost:9292/products/hello
{"error":"pid is invalid"}

那么，如何保存一条新的记录呢？一样简单，只需要定义一个新的端点，然后使用ActiveRecord来完成数据库操作即可。但是应该注意的是，此处我们定义的是post方式的请求端点，这样才符合RESTFul的约定。

desc"create a product"
params do
requires :name, :type =>String, :desc=>"Product name"
requires :price, :type =>Float, :desc=>"Product price"
end
post do
product = Product.new(:name => params[:name], :price => params[:price])
if product.save
product
else
error! product.errors, 500
end
end

上边的代码中，首先从请求中获取两个请求参数params[:name]和params[:price]。根据这两个参数创建出一个新的Product对象（这个对象是ActiveRecord对象，因此可以用于和数据库交互）。然后调用product.save进行保存。如果保存成功，则返回对象本身，否则如果ActiveRecord校验失败，则返回ActiveRecord的错误信息。比如当前的Product模型中设置的校验条件如下：

class Product <ActiveRecord::Base
validates :name, :price, presence: true
validates :name, length: { maximum: 128 }
validates :price, numericality: true
end

即，保存记录时，name和price都必须存在，并且，name的长度必须小于128，而price必须为数字类型。

同样我们可以使用curl来进行测试，我们在本地建一个新的文本文件，内容如下：

{
"name": "iPad mini 3G",
"price": 3456.78
}

将这个json保存到名字为new_product的文件中，然后使用curl来请求API：

$ curl -X POST -H "Content-Type: application/json" -d @new_product http://localhost:5678/products/ -s
{
"user_id": null,
"updated_at": "2014-02-06T06:30:38Z",
"created_at": "2014-02-06T06:30:38Z",
"price": 3456.78,
"name": "iPad mini 3G",
"id": 5
}

注意此处的-H "Content-Type: application/json"告诉服务器，请求中的数据为JSON格式，否则服务器会无法解析。保存成功之后，服务器会返回新创建的产品信息。

而如果我们将文件new_post中的信息修改一下，比如将name改为空字符串，则会得到服务器报告的错误：

$ curl -X POST -H "Content-Type: application/json" -d @new_product http://localhost:5678/products/ | jq .
{
"error": {
"name": [
"can't be blank"
]
}
}

作为API的提供者，每次的更新都需要精心设计。因为你无法预料客户端事实上以何种方式使用API，当发生大的变化的时候，我们肯定不想影响API既有的消费者。解决这个问题的一个常见做法是为API加上版本信息，老的API用户不会受到任何影响，而新的用户又能得到最新的功能。Grape提供了众多的版本方式，比如将版本信息加在路径，或者加在请求的HTTP头信息中：

module MySys
class API <Grape::API
format :json
version 'v1', :using =>:path
end
end

注意我们此处声明，本APIMySys::API为版本1（v1），使用将版本号加入路径的方式来区别不同的请求，当发送请求到：

$ curl http://localhost:9292/v1/products/ -s | jq .
[
{
"user_id": 1,
"updated_at": "2014-02-05T05:29:50Z",
"created_at": "2014-02-04T14:04:29Z",
"price": 12345.67,
"name": "Mac Book Pro",
"id": 1
},
...
]

而发送到不带有版本信息的请求http://localhost:9292/products/会得到Not Found的错误提示。另外一种方式是通过HTTP头来区分：

module MySys
class API <Grape::API
format :json
version 'v1', :using =>:header, :vendor =>"mysys", :strict =>true
end
end

通过:using => :header指定通过HTTP头来区分，而请求对应的URL则不变。这样，请求中需要带有这样的信息Accept: application/vnd.mysys-v1+json，与默认的Accept: application/json不同的是此处加上了vnd.mysys-v1+。vnd.表示提供者（vendor），随后是版本号。

这样，只有约定好的客户端才可以访问此资源。最后的选项:strict => true表明，这个头是必填选项。这是因为，默认的，如果请求中没有头信息，Grape会返回第一个找到的版本号并使用之，:strict选项会禁用此默认行为，并严格匹配HTTP头中的版本号。

我们在Rack那一小节已经讲过，如果要将Sinatra应用和Grape联合在一起使用，实现起来也非常简单。只需要使用Rack::Cascade即可：

require'./mysys'
require'./myapp'
run Rack::Cascade.new [MyModule::MyApplication, MySys::API]

其中myapp.rb的内容为：

require 'sinatra'
require 'json'
require 'haml'
module MyModule
class MyApplication <Sinatra::Base
get '/index' do
@user = {
:name =>"Juntao",
:address =>"Xi'an, China"
}
haml :index
end
end
end

这样，当客户端访问http://localhost:9292/index时，响应的为MyModule::MyApplication，对应的当客户端访问http://localhost:9292/products时，响应的是MySys::API模块。

第3章 数据库访问层

3.1　数据库的访问

早期，在一个稍具规模的Web应用程序中，开发人员需要自己定义数据库中表的结构，比如有哪些字段，字段有哪些含义，每个字段的类型是什么等等。开发人员需要熟知至少一种数据库的语法及特性，如具体的SQL语句如何编写，如何在数据库系统中使用函数（各个数据库系统都会实现很多助手类的函数，比如数据库日期的获取，将一个扁平的表结构生成一个树结构的对象等）。

大多数情况下，业务逻辑会使用一门面向对象的语言编写，如Java、C++或者Ruby等。业务逻辑所使用的面向对象的语言和数据本身的关系型数据库语言之间存在着一条难以逾越的鸿沟：业务代码中会嵌入很多的数据库操作字符串。换言之，我们需要以字符串拼接的形式在面向对象语言中嵌入另外一门语言的源码！

con = Database.connect("mysql://user:password@hostname/database")
product = OpenStruct.new(:name =>'MyProduct', :price =>22.3)
sql = "insert into products(name, price) values('#{product.name}', #{product.price})"
if con.execute(sql)
puts "record inserted"
end

这无疑会引入很多的问题，比如表结构的修改会直接导致业务代码的修改，而且毫无疑问，手工的字符串操作会引入很多细小的问题，而这些问题只有在运行时才能被发现。后来，根据经验，人们在业务层和数据库层之间引入了一个额外的ORM层（Object Relational Mapping，对象-关系映射层）来解决此问题。这个层次对于业务逻辑而言，屏蔽了数据库的存取操作，使得业务逻辑中对数据的访问变得完全符合面向对象的编码风格，代码更加可读，比如下面的代码会完成相同的功能：

con = Database.connect("mysql://user:password@hostname/database")
product = Product.new(:name =>'MyProduct', :price =>22.3)
if product.save
puts "record inserted"
end

代码的可读性得到了显著的提升，毕竟像new和save这样的方法看起来更加流畅。使用ORM之后，对于业务逻辑的开发人员而言，数据库的一切复杂性都被屏蔽了，代码编写也变得非常顺畅，而且犯错误的概率也降低了很多（相信我，纯手工的字符串拼接会产生大量的奇异的错误）。

比如粗心的程序员经常会犯的一个错误：

sql = "insert into products(name, price)"
sql += "values('#{product.name}', #{product.price})"
if con.execute(sql)
puts "record inserted"
end

此处的字符串拼接的错误很难一眼看出来（关键字values前面少一个空格），而某些数据库仅仅会简单地报告一句第1行有错误，然后就退出会话，这给调试带来了极大的不便。在一个拼凑出来的、长达数行的SQL中找错误绝对不是一个好的体验。将这些反复而且容易出错的工作交给一个专司其职的层次来完成，则是一个很好的选择。如果这个层的实现比较好，并且经过良好的测试，那么可以将其复用在所有适合的场景下。

3.2　数据库方案（schema）的修改

ORM解决的另外一个问题是对数据库方案修改的追踪。传统基于数据库的应用程序的开发模式中，数据库的设计需要非常谨慎，这个设计过程可能先于项目的实际开发而独立进行，参与的人员要么是业务专家，要么是经验丰富的程序员。一旦到了开发后期，如果系统中的某个表或者某些表的设计不合理，那么将会导致很多额外的工作（可能会有数个或者数十个文件需要改动，每一次的改动都可能会引入潜在的新的bug），数据库的结构和上层的应用程序耦合在一起，而且结构十分的脆弱。

无论设计阶段如何仔细，设计人员经验如何丰富，数据库方案（schema）还是有可能在未来发生改变。所以，为这种变化设计一个新的解决方案才能真正解决问题。一种思路是，将对数据库方案的所有操作历史以某种形式追踪下来，然后通过工具可以很容易地将这些历史记录还原：

（1）创建了表T1，T1包含了F1和F2两个字段。

（2）字段F1的数据类型发生了变化（varchar(32)修改为了varchar(64)）。

（3）添加了新的字段F3，F4。

（4）删除了字段F2。

这些记录可以分别保存在不同的文件中，如果有工具可以将其翻译为数据库能识别的语言，那么就完成了数据库方案的迁移：

create_table :t1 do |t|
t.string f1
t.string f2
end

会被翻译成：

CREATE TABLE T1(
F1 varchar(255),
F2 varchar(255)
);

而

add_column :t1, f3, :float
add_column :t1, f4, :float

被翻译为：

alter table t1 add f3 float, f4 float

那么只需要保证这些历史记录按照时间的顺序执行，就可以在任何地方重新建立该数据库，理论上也可以将数据库方案回退到任何一个历史时刻。借助于版本控制，业务逻辑部分的代码可以很容易地回退到一个历史版本。这样，我们可以随时随地建立一个环境，使得代码和底层的数据库保持同步，毫无差错。有了这个保证，对数据库的修改才不至于传播到业务逻辑中，导致难以追踪的bug。

3.3　ActiveRecord

ActiveRecord是一个Ruby的ORM实现，最初ActiveRecord是Rails框架的一个组件。随着Rails的发展，ActiveRecord被独立出来，作为一个通用的ORM层实现来使用。也就是说，我们可以在任意的Ruby程序中使用ActiveRecord来提供具体数据库层的屏蔽。

ActiveRecord的功能十分强大，它提供了众多对开发人员友好的接口，完全屏蔽了数据库间的差异。即使底层的数据库实现更换了，上层应用程序也不需要做任何修改。ActiveRecord支持众多的数据库系统，如MySQL，Sqlite，Postgres，SQL Server, Oracle等等。ActiveRecord支持数据校验，数据迁移（Migration）脚本的追踪等，使得开发人员可以完全将精力集中在业务逻辑上。

3.3.1　和Rails一起使用

生成模型和数据迁移脚本。

ActiveRecord是Rails框架的一个组件，我们可以先来看看在Rails中如何方便地使用它。可以使用Rails自带的生成器来生成一个新的模型rails generate model ModelName：

$ rails generate model Product
create　　db/migrate/20140204134014_create_products.rb
create　　app/models/product.rb

上边的命令用以生成一个名叫Product的模型（Model），这个命令会生成两个文件，一个为数据迁移脚本，一个是模型本身的定义。先来看迁移脚本20140204134014_create_products.rb，这个文件名包含两部分：时间戳和表示迁移动作的名字。它的内容如下：

class CreateProducts <ActiveRecord::Migration
def change
create_table :products do |t|
t.timestamps
end
end
end

这个脚本定义了一个新类CreateProducts，这个类继承自ActiveRecord::Migration。方法change是用以定义数据表的地方，如果change被执行，Rails会创建一个新表products，这个表包含一个id的字段和两个关于时间的字段created_at和updated_at，这个魔法是由t.timestamps完成的。一个真实的数据表当然不会不包含任何业务字段，我们可以在此处加上一些字段：

class CreateProducts <ActiveRecord::Migration
def change
create_table :products do |t|
t.string :name
t.float :price
t.timestamps
end
end
end

定义好迁移脚本之后，可以执行Rails提供的一个Rake任务db:migrate来进行数据库迁移：

$ rake db:migrate
==　CreateProducts: migrating
======================================
-- create_table(:products)
-> 0.0010s
==　CreateProducts: migrated (0.0011s)
======================================

这样，表products就被创建了。根据Rails的设计哲学，惯例优于配置，这个新生成的数据库位于db/development.sqlite3中，这个魔法是在config/database.yml中完成的：

development:
adapter: sqlite3
database: db/development.sqlite3
pool: 5
timeout: 5000

默认的，Rails会去读config/database.yml中读取数据库配置信息，并根据当前环境来决定使用哪个数据库配置，默认的环境为development，因此生成的数据库位于db/development.sqlite3。其他可用的环境还有test和production，可以为它们分别指定不同的数据库配置。

如果使用sqlite3来查看刚才迁移的结果，可以看到products表的结构：

$ sqlite3 db/development.sqlite3
sqlite> .schema
CREATE TABLE "products" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(255), "price" float, "created_at" datetime, "updated_at" datetime);

而对于生成的第二个文件product.rb，内容更为简单：

class Product <ActiveRecord::Base
end

类Product仅仅继承了ActiveRecord::Base，就自然拥有了众多的超能力。我们可以通过rails console对生成的模型做一些测试。

$ rails console
Loading development environment (Rails 4.0.1)
1.9.3-p286 :001 > prod = Product.new(:name => "Mac Book Pro", :price => 12345.67)
=>#<Product id: nil, name: "Mac Book Pro", price: 12345.67, created_at: nil, updated_at: nil>
1.9.3-p286 :002 > prod.save
(0.2ms)　begin transaction
SQL (9.4ms)　INSERT INTO "products" ("created_at", "name", "price", "updated_at") VALUES (?, ?, ?, ?)　[["created_at", Tue, 04 Feb 2014 14:04:29 UTC +00:00], ["name", "Mac Book Pro"], ["price", 12345.67], ["updated_at", Tue, 04 Feb 2014 14:04:29 UTC +00:00]]
(0.7ms)　commit transaction
=>true
1.9.3-p286 :003 > Product.all
Product Load (0.5ms)　SELECT "products".* FROM "products"
=>#<ActiveRecord::Relation [#<Product id: 1, name: "Mac Book Pro", price: 12345.67, created_at: "2014-02-04 14:04:29", updated_at: "2014-02-04 14:04:29">]>

rails console会加载rails应用程序所需要的一切依赖包，如ActiveRecord，以及我们的模型等。有了这些模块，我们就可以很方便地做相关测试了。比如可以通过Product.new来创建一条记录，prod.save来将这条记录持久化到数据库中，Product.all来查询prodcuts表中的所有记录。

而且重要的是，通过控制台上的输出，我们可以清晰地看到每个ActiveRecord方法调用的背后会做哪些数据库相关操作：比如Product.all会执行底层的SELECT "products".* FROM "products"。这正是ORM层帮助开发人员做的事情。

如果这时去查看数据库，会看到数据库已经被更新：

sqlite> select * from products;

1|Mac Book Pro|12345.67|2014-02-04 14:04:29.712155|2014-02-04 14:04:29.712155

表关联

关系型数据库的强大之处在于表和表之间的关联上，比如我们对上述例子做一个小的扩展，添加一个新的表User：

$ rails generate model User name:string email:string
create　　db/migrate/20140205051751_create_users.rb
create　　app/models/user.rb

此处我们为新表添加了两个字段name和email，且类型都是string。生成的迁移脚本中自然会包含：

class CreateUsers <ActiveRecord::Migration
def change
create_table :users do |t|
t.string :name
t.string :email
t.timestamps
end
end
end

假设我们想要关联User表和Product表，即每个User可以拥有多个Product，那么可以使用rails generate migration来生成一个迁移脚本：

$ rails generate migration AddUserRefToProducts user:references
create　　db/migrate/20140205051910_add_user_ref_to_products.rb

注意此处迁移脚本的名字AddUserRefToProducts，Rails足够聪明，知道这个名字的含义！如果查看生成的脚本，可以看到，这个新的迁移脚本为products添加了对user的引用：

class AddUserRefToProducts <ActiveRecord::Migration
def change
add_reference :products, :user, index: true
end
end

此时，运行rake db:migrate，即可完成对底层数据库的迁移操作：

$ rake db:migrate
==　CreateUsers: migrating
=================================
-- create_table(:users)
-> 0.0089s
==　CreateUsers: migrated (0.0090s)
=================================
==　AddUserRefToProducts: migrating
=================================
-- add_reference(:products, :user, {:index=>true})
-> 0.0016s
==　AddUserRefToProducts: migrated (0.0018s)
=================================

使用sqlite3的客户端打开db/development.sqlite3，从数据库的方案（schema）可以看出，users表被创建了，而且products表多了一个user_id列，并且数据库中多了一条INDEX。

sqlite> .schema
CREATE TABLE "products" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(255), "price" float, "created_at" datetime, "updated_at" datetime, "user_id" integer);
CREATE TABLE "users" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(255), "email" varchar(255), "created_at" datetime, "updated_at" datetime);
CREATE INDEX "index_products_on_user_id" ON "products" ("user_id");

这样在数据库层面，我们定义的两个模型就关联起来了，但是我们还需要Ruby代码知道两个类之间的关系。我们可以修改生成的模型，使得它们被关联起来：

class User <ActiveRecord::Base
has_many :products
end

定义User包含多个（has_many）products，对应的

class Product <ActiveRecord::Base
belongs_to :user
end

定义Product属于（belongs_to）user。这样，我们就可以在rails console中进行一些简单的测试：

$ rails console
Loading development environment (Rails 4.0.1)
1.9.3-p286 :001 > user = User.create(:name => "juntao", :email => "juntao.qiu@gmail.com")
=>#<User id: 1, name: "juntao", email: "juntao.qiu@gmail.com", created_at: "2014-02-05 05:26:15", updated_at: "2014-02-05 05:26:15">
1.9.3-p286 :002 > user.products << Product.find(1)
=>#<ActiveRecord::Associations::CollectionProxy [#<Product id: 1, name: "Mac Book Pro", price: 12345.67, created_at: "2014-02-04 14:04:29", updated_at: "2014-02-05 05:29:50", user_id: 1>]>
1.9.3-p286 :005 > Product.find(1)
=>#<Product id: 1, name: "Mac Book Pro", price: 12345.67, created_at: "2014-02-04 14:04:29", updated_at: "2014-02-05 05:29:50", user_id: 1>

我们首先创建了一个用户User实例，然后为这个实例添加了一个Product实例，这样，这个产品就属于该用户了。反过来，我们可以创建一个新的产品，然后将该产品的用户设置为某个用户：

1.9.3-p286 :001 > prod = Product.new(:name => "iMac", :price => 13243)
=>#<Product id: nil, name: "iMac", price: 13243.0, created_at: nil, updated_at: nil, user_id: nil>
1.9.3-p286 :002 > user = User.new(:name => "Qiu Juntao", :email => "juntao.qiu@gmail.com")
=>#<User id: nil, name: "Qiu Juntao", email: "juntao.qiu@gmail.com", created_at: nil, updated_at: nil>
1.9.3-p286 :003 > prod.user = user
=>#<User id: nil, name: "Qiu Juntao", email: "juntao.qiu@gmail.com", created_at: nil, updated_at: nil>
1.9.3-p286 :004 > prod.save
=>true
1.9.3-p286 :006 > prod
=>#<Product id: 4, name: "iMac", price: 13243.0, created_at: "2014-02-05 07:54:40", updated_at: "2014-02-05 07:54:40", user_id: 3>

应该注意的是，当调用prod.save的时候，ActiveRecord会先保存User，成功后得到新的id，然后保存prod，此时的user_id即为新生成的id。

3.3.2　独立使用（在既有数据库中）

Rails非常强大，配置也非常灵活，但是并不是适应所有场合，比如一个更轻量级的应用，或者一个建立在既有数据库上的RestfulAPI。在这些场景下，单独的ActiveRecord就已经足够。轻量级的框架搭配起来，往往会发挥出非常巨大的威力。

独立使用ActiveRecord也非常方便，最简单的方式是通过gem install来安装：

$ gem install activerecord

安装完成之后，我们需要定义迁移脚本，以及数据模型本身。一个迁移（Migration）动作的命名通常会与此次迁移动作本身的行为一致，命名方法与程序开发中变量的命名相似，需要与其做的事情相关联，比如此处的CreateProducts就是一个很表意的好名字。我们需要手动为其添加建表语句：

class CreateProducts <ActiveRecord::Migration
def change
create_table :products do |t|
t.string :name
t.float :price
t.text :detail
t.timestamps
end
end
end

应该注意的是，这个文件的名字需要以一个时间戳开头，比如20140204030536_ create_products.rb。这样ActiveRecord才能知道应该以何种顺序执行多个迁移脚本。

定义完迁移脚本之后，我们还需要定义数据模型。定义一个数据模型的方法非常简单，仅仅需要将类名和数据库中表名对应起来，然后继承自ActiveRecord::Base即可。这样我们的模型就具有了一切魔力，使得对模型实例的操作会被映射到数据库中去。

比如定义Product模型，保存到product.rb中：

require'active_record'
class Product <ActiveRecord::Base
end

有了模型，我们就可以测试一下ActiveRecord给我们带来的方便。要测试数据库，首先当然需要一个数据库系统，此处我们使用最简单的sqlite3：

$ gem install sqlite3

安装完成之后，进入irb环境（由于这里是独立的ActiveRecord，所以就没有rails console的支持）：

require'./products'

先加载当前目录下的products文件，然后建立对数据库的连接：

ActiveRecord::Base.establish_connection(
:adapter =>'sqlite3',
:database =>'development.sqlite3'
)

数据库连接建立之后，执行数据迁移ActiveRecord::Migrator.migrate "./"，这个语句会在当前目录（“./”）下查找所有的迁移脚本。这些脚本都以一个表示日期的时间戳作为开头，Migrator会根据日期的顺序依次执行这些迁移脚本。我们这个例子里只有一个脚本。从控制台上的log可以看出，ActiveRecord会创建products表：

1.9.3p286 :012 > ActiveRecord::Migrator.migrate "./", nil
==　CreateProducts: migrating
=============================
-- create_table(:products)
-> 0.0020s
==　CreateProducts: migrated (0.0026s)
=============================
=> [#<struct ActiveRecord::MigrationProxy name="CreateProducts", version=20140204030536, filename=".//20140204030536_create_products.rb", scope="">]

当然，所有的这些手工的动作ActiveRecord::Base.establish_connection及ActiveRecord::Migrator.migrate "./"，在Rails中都是自动完成的。

我们可以通过sqlite3的命令行工具来查看这个表的结构：

$ sqlite3 development.sqlite3
SQLite version 3.7.13 2012-07-17 17:46:21
Enter ".help" for instructions
Enter SQL statements terminated with a ";"
sqlite> .schema
CREATE TABLE "products" ("id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "name" varchar(255), "price" float, "detail" text, "created_at" datetime, "updated_at" datetime);

当然，我们还可以将这次对数据库的修改回滚（rollback）掉：

1.9.3p286 :014 > ActiveRecord::Migrator.rollback "./"
D, [2014-02-04T16:16:19.123988 #17370] DEBUG -- :　 ActiveRecord:: SchemaMigration Load (0.2ms)　SELECT "schema_migrations".* FROM "schema_ migrations"
D, [2014-02-04T16:16:19.125358 #17370] DEBUG -- :　 ActiveRecord:: SchemaMigration Load (0.1ms)　SELECT "schema_migrations".* FROM "schema_ migrations"
I, [2014-02-04T16:16:19.125577 #17370]　INFO -- : Migrating to CreateProducts (20140204030536)
D, [2014-02-04T16:16:19.140509 #17370] DEBUG -- :　　(0.1ms)　begin transaction
==　CreateProducts: reverting =====================================
-- drop_table(:products)
D, [2014-02-04T16:16:19.147268 #17370] DEBUG -- :　　(1.7ms)　DROP TABLE "products"
-> 0.0022s
==　CreateProducts: reverted (0.0067s) =============================
D, [2014-02-04T16:16:19.153609 #17370] DEBUG -- :　 SQL (2.8ms)　DELETE FROM "schema_migrations" WHERE "schema_migrations"."version" = '20140204030536'
D, [2014-02-04T16:16:19.155136 #17370] DEBUG -- :　　(1.2ms)　commit transaction
=> [#<struct ActiveRecord::MigrationProxy name="CreateProducts", version=20140204030536, filename=".//20140204030536_create_products.rb", scope="">]

从日志中可以看出，我们刚才创建表的操作被回滚，对应的数据库中的表结构也被删除了：DROP TABLE "products"。

有了数据库，也有了表结构，就可以创建模型实例了：

1.9.3p286 :013 > prod = Product.new(:name => "Mac Book Pro", :price => 12345.67)
=>#<Product id: nil, name: "Mac Book Pro", price: 12345.67, detail: nil, created_at: nil, updated_at: nil>
1.9.3p286 :015 > prod.save
=>true
1.9.3p286 :016 > prod
=>#<Product id: 1, name: "Mac Book Pro", price: 12345.67, detail: nil, created_at: "2014-02-04 03:08:28", updated_at: "2014-02-04 03:08:28">

当调用了prod.save之后，如果查看数据库，就会看到新的记录已经被插入了：

sqlite> select * from products;
1|Mac Book Pro|12345.67||2014-02-04 03:08:28.033256|2014-02-04 03:08:28.033256

使用ActiveRecord创建模型有两种方式，一种就是上边例子中的创建然后保存的方式，另一种是使用create方法直接创建同时保存。如果查看控制台上打印出来的log信息，就会发现ActiveRecord会先创建一个事务，然后执行映射过的SQL语句：

INSERT INTO "products" ("created_at", "detail", "name", "price", "updated_at") VALUES ...
1.9.3p286 :018 > Product.create(:name => "iPhone 5s", :price => 4567.89, :detail => "iPhone 5s")
=>#<Product id: 2, name: "iPhone 5s", price: 4567.89, detail: "iPhone 5s", created_at: "2014-02-04 03:11:34", updated_at: "2014-02-04 03:11:34">

一般而言，使用Model.new之后，用户还有机会对模型做一些可能的修改，然后再在适当的时机调用save来存储到数据库中。而create则将这些动作包装在同一个调用中。

查找记录

当然，ActiveRecord提供的便利性远不止于此，比如我们可以通过Product.first从已有的记录中取出第一条记录：

1.9.3p286 :022 > Product.first
=>#<Product id: 1, name: "Mac Book Pro", price: 12345.67, detail: nil, created_at: "2014-02-04 05:25:40", updated_at: "2014-02-04 05:25:40">

可以使用Product.all来获取所有的记录。

1.9.3p286 :021 > Product.all
=>#<ActiveRecord::Relation [#<Product id: 1, name: "Mac Book Pro", price: 12345.67, detail: nil, created_at: "2014-02-04 05:25:40", updated_at: "2014-02-04 05:25:40">, #<Product id: 2, name: "iPhone 5s", price: 4567.89, detail: "iPhone 5s", created_at: "2014-02-04 05:25:43", updated_at: "2014-02-04 05:25:43">]>

当然，实际中我们使用更频繁的是查找符合某些条件的记录，比如id为5的记录，产品名称为iPhone 5s的记录等。

1.9.3p286 :024 > Product.find(1)
=>#<Product id: 1, name: "Mac Book Pro", price: 12345.67, detail: nil, created_at: "2014-02-04 05:25:40", updated_at: "2014-02-04 05:25:40">

默认地，可以通过find函数来查找记录，条件为数据库中的id。事实上，借助于Ruby的元编程特性，ActiveRecord会根据数据库中的字段名称，生成很多的助手函数。比如我们可以使用find_by_name方法（即使这个方法在某种程度上来说并不存在）来按照名称查找记录：

1.9.3p286 :027 > Product.find_by_name("iPhone 5s")
=>#<Product id: 2, name: "iPhone 5s", price: 4567.89, detail: "iPhone 5s", created_at: "2014-02-04 05:25:43", updated_at: "2014-02-04 05:25:43">

类似地，你也可以使用find_by_price来根据价格查找记录。甚至于在某些情况下，开发人员可以使用更加灵活的where方法来加入SQL语句本身：

1.9.3p286 :030 > Product.where("name like '%iPhone%'")
=>#<ActiveRecord::Relation [#<Product id: 2, name: "iPhone 5s", price: 4567.89, detail: "iPhone 5s", created_at: "2014-02-04 05:25:43", updated_at: "2014-02-04 05:25:43">]>

不过在大多数情况下，我们都不需要使用where来编写SQL语句，ActiveRecord提供的方法就已经足够。

3.3.3　校验

数据校验无疑是一个非常重要的问题。在一个健全的应用程序中，我们需要保证数据在存入数据库时符合业务需求，比如一个没有名称和价格的产品记录并不会有真正的业务价值。所以我们需要加入校验条件，如：

require'active_record'
class Product <ActiveRecord::Base
validates :name, :price, presence: true
end

这里的presence: true表明，:name和:price字段是必须有的，如果该模型在保存时，这两个字段中任意一个为空，ActiveRecord都会报错，记录不会被存储。类似地，如果我们需要的数据是数字型的，而客户端请求保存的是字符串类型，或者业务上需要某字段的长度必须控制在某一范围内等，ActiveRecord也会报错，记录不会被存储。同样地，数据在存入数据库时也需要得到校验，ActiveRecord同样支持这样的校验：

class Product <ActiveRecord::Base
validates :name, :price, presence: true
validates :name, length: { maximum: 128 }
validates :price, numericality: true
end

这里限定了:name字段的最长长度不能超过128，:price字段必须为数字类型。

比如当我们创建一条仅包含name字段的产品记录时，ActiveRecord会报错，数据不会被保存：

1.9.3p286 :016 > prod = Product.create(:name => 'iPhone 4s')
=>#<Product id: nil, name: "iPhone 4s", price: nil, detail: nil, created_at: nil, updated_at: nil>

错误信息被附加到了prod对象的errors属性上，:price不能为空，并且:price不是一个数字：

1.9.3p286 :017 > prod.errors.messages
=>{:price=>["can't be blank", "is not a number"]}

1．更进一步

实际Web应用开发中，经常会遇到多个Ruby版本并存的问题，比如某个项目在Ruby-1.8上工作良好，并且开发团队之前就是在该版本做的测试。而另一个项目则依赖于Ruby-2.0的某些特性。在开发者的机器上，这种情形再正常不过了。那么如何处理这个问题呢？

好在我们有rvm！简而言之，rvm是一个用以管理多个Ruby环境的工具（使用rvm，你可以很容易地安装、卸载，在不同版本的Ruby环境间切换）。rvm的安装非常简单：

$ curl -sSL https://get.rvm.io | bash -s stable

如果你的机器上没有安装Ruby，可以使用下面的命令安装rvm和一个稳定版本的Ruby：

$ curl -sSL https://get.rvm.io | bash -s stable --ruby

安装完成之后，可以通过rvm list命令来查看当前系统被rvm管理起来的Ruby环境：

$ rvm list
rvm rubies
ree-1.8.7-2012.02 [ i686 ]
ruby-1.8.7-p334 [ i686 ]
ruby-1.9.3-p194 [ x86_64 ]
=* ruby-1.9.3-p286 [ x86_64 ]
ruby-2.0.0-p0 [ x86_64 ]
# => - current
# =* - current && default
#　* - default

你会得到一个类似的输出列表。注意输出中的说明：如果某一行以=>开头，说明该行显示的Ruby版本为当前的环境；如果以=开头，则表明该行的版本不但是当前的环境，而且是默认的环境；如果以开头，表示默认的Ruby版本。

可以使用rvm use ruby-2.0.0-p0这样的命令来切换Ruby环境：

$ rvm use ruby-2.0.0-p0
Using /Users/twer/.rvm/gems/ruby-2.0.0-p0
$ rvm list
rvm rubies
ree-1.8.7-2012.02 [ i686 ]
ruby-1.8.7-p334 [ i686 ]
ruby-1.9.3-p194 [ x86_64 ]
* ruby-1.9.3-p286 [ x86_64 ]
=> ruby-2.0.0-p0 [ x86_64 ]
# => - current
# =* - current && default
#　* - default

可以看到，默认的Ruby为ruby-1.9.3-p286，而当前的Ruby为ruby-2.0.0-p0。

当创建一个新项目时，如果需要指定特定的Ruby版本，可以定义一个.rvmrc文件，在这个文件中写入项目使用的Ruby版本号。rvm会读取这个文件，并根据这个文件中的配置自动切换到相应的Ruby版本。如果该版本的Ruby不存在，则可以通过rvm install ruby-x.x.x来进行安装。

具体做法是：在当前目录下创建一个rvm的配置文件.rvmrc，每次当你从其他路径下切换到当前目录的时候，rvm都会检查这个文件是否存在，如果存在，那么rvm会根据其中的配置来切换不同版本的Ruby，并且重新设置gemset的搜索路径，比如一个.rvmrc的内容如下：

rvm use --create ruby-1.9.3-p286@orm

这条配置告诉rvm做两件事：

如果存在版本ruby-1.9.3-p286的Ruby，则使用，否则rvm会报告该版本的Ruby没有找到并退出；

如果orm这个gemset存在，则使用这个gemset，否则创建一个空的gemset。

创建此文件之后，可以尝试切换到当前目录，你会得到一个下面的提示：

$ cd .

rvm探测到.rvmrc之后的提示信息如图3-1所示。

图3-1　rvm探测到.rvmrc之后的提示信息

本质上.rvmrc是一个shell脚本，它里面可以包含任意的命令组合，所以会有一些潜在的风险。你可以选择查看.rvmrc内容（选择v），如果没有问题，就可以选择使用该.rvmrc（选择y）。

当然，这个选择过程会发生在.rvmrc被创建或者被修改时。如果没有修改，再次进入该目录时，rvm只会提示当前的ruby版本和gemset的位置信息：

$ cd standalone
Using /Users/twer/.rvm/gems/ruby-1.9.3-p286 with gemset orm

2．bundler

在Ruby世界中，函数库或者工具通过gem来发布和管理，gem类似于Java世界中的jar包，是相对独立的功能单元。每个gem都符合一个统一的规范。规范定义了gem本身的元数据信息，如版本、描述信息、项目主页等等。重要的是，如果本gem依赖于其他的gem，那么依赖关系也会记录在这个规范文件中。

比如，ActiveRecord这个gem依赖于：

<activesupport>, ["= 4.0.2"]
<activemodel>, ["= 4.0.2"]
<arel>, ["~> 4.0.0"]
<activerecord-deprecated_finders>, ["~> 1.0.2"]

而activemodel又依赖于：

<activesupport>, ["= 4.0.2"]
<builder>, ["~> 3.1.0"]
...

而activesupport又依赖于：

<i18n>, [">= 0.6.4", "~> 0.6"]
<multi_json>, ["~> 1.3"]
<tzinfo>, ["~> 0.3.37"]
<minitest>, ["~> 4.2"]
<thread_safe>, ["~> 0.1"]

有了这些元数据的定义，我们可以很容易地将一个给定gem的所有依赖关系找出来。换言之，我们可以很容易地安装一个gem，而且无需考虑关于这个gem的依赖（可以借助一个工具来追溯所有依赖，并依次安装）。

这个帮助我们的工具正是bundler。bundler本身就是一个gem，因此它的安装需要使用gem命令：

$ gem install bundler

安装完成之后，我们就可以使用bundler来安装开发所需要的gem了。bundler会在当前目录查找一个名为Gemfile的文件，并根据这个文件的描述来安装所需的gem。

比如下面这个例子，我们要使用bundler安装ActiveRecord，可以在Gemfile中做以下定义：

source 'http://ruby.taobao.org'
gem 'activerecord'

首先通过source "http://ruby.taobao.org"来告诉bundler从“http://ruby.taobao.org”下载所有的gem。然后指定我们需要的gem名字为activerecord。另外，你还可以指定该gem的版本，如果不指定，则默认下载最新版本。

定义好这个文件之后，就可以使用bundle install来完成安装：

$ bundle install
Fetching gem metadata from http://ruby.taobao.org/.
Fetching full source index from http://ruby.taobao.org/
Installing i18n (0.6.9)
Installing minitest (4.7.5)
Installing multi_json (1.8.4)
Installing atomic (1.1.14) with native extensions
Installing thread_safe (0.1.3)
Installing tzinfo (0.3.38)
Installing activesupport (4.0.2)
Installing builder (3.1.4)
Installing activemodel (4.0.2)
Installing activerecord-deprecated_finders (1.0.3)
Installing arel (4.0.1)
Installing activerecord (4.0.2)
Using bundler (1.2.1)
Your bundle is complete! Use `bundle show [gemname]` to see where a bundled gem is installed.

可以看到，bundler会帮助我们查找依赖链，然后依次安装，最后安装activerecord本身，至此安装过程就完成了。作为一个数据库应用程序，我们需要数据库的支持，简单起见，我们选择了sqlite3作为数据库系统。很简单，只需要在Gemfile中加一行：

source 'http://ruby.taobao.org'
gem 'activerecord'
gem 'sqlite3'

然后再次执行bundle install即可完成安装。

Gemfile中还可以定义分组信息。比如在本地开发时，我们使用sqlite3作为数据库系统，而在产品环境中，数据库系统为MySQL。另外，在本地开发时，我们需要一些用于测试的gem，而在产品环境里，这些gem则完全不需要。对于这种情况，我们可以在Gemfile中做以下定义：

Source 'http://ruby.taobao.org'
gem 'activerecord'
group :development, :test do
gem 'sqlite3'
gem 'rspec'
end
group :production do
gem 'mysql'
end

然后在产品环境中，只需要执行：

$ bundle install --without test development

bundler就会跳过test和development分组中的所有gem。

3.4　DataMapper

DataMapper是另一个功能强大的ORM，使用起来也非常方便。和ActiveRecord一样，DataMapper提供对多种数据库的支持，上层业务逻辑开发时，开发人员可能完全感觉不到它的存在。和ActiveRecord不同的是，在DataMapper中，表结构定义在模型本身上，而不是单独的迁移脚本中：

class User
include DataMapper::Resource
property :id, Serial
property :name, String, :required =>true
property :email, String, :format =>:email_address, :required =>true, :unique =>true
property :created_at, DateTime
property :updated_at, DateTime
end

模型User定义了5个字段，这5个字段也就定义了底层数据库的结构。

使用DateMapper

使用DataMapper非常简单，只需要在Gemfile中指定需要datamapper即可：

source'http://ruby.taobao.org'
gem 'datamapper'
group :test, :development do
gem 'sqlite3'
gem 'dm-sqlite-adapter'
end

我们同时指定了，在本地开发环境和测试环境中，使用sqlite3作为底层的数据库系统。

1．指定底层的数据库类型

在DataMapper中，对底层数据库的设置非常容易，比如，我们需要对sqlite3的支持，只需要使用DataMapper.setup即可：

DataMapper.setup(:default, 'sqlite:///path/to/project.db')

同样，如果需要支持MySQL，则需要指定：

DataMapper.setup(:default, 'mysql://user:password@hostname/database')

2．定义模型

定义一个模型很简单，如果不包含include DataMapper::Resource语句，模型就可以看作一个简单的Ruby类。引入了DataMapper::Resource之后，这个模型就有了神奇的魔力：

class User
include DataMapper::Resource
property :id, Serial
property :name, String, :required =>true
property :email, String, :format =>:email_address, :required =>true, :unique =>true
property :created_at, DateTime
property :updated_at, DateTime
end

在DataMapper中，使用property方法来定义模型的属性，同时也是在定义数据库中的表字段。Serial类型表明:id为一个序列，换言之，就是一个可以自增的主键。另外还有String和DateTime等数据类型。而使用:require => true表明该列是必填项，如果使用该模型时，其值为空，保存这条记录会报错。类似地，:format => :email_address表明:email列的格式应该符合邮件格式。

3．使用模型

定义好模型之后，我们需要建立底层真正的数据表，可以在irb中测试一下：

require'data_mapper'
require'./models/user'
DataMapper.setup(:default, "sqlite3://#{Dir.pwd}/notes.db")
DataMapper.finalize.auto_upgrade!

注意此处的DataMapper.finalize，调用之后，DataMapper会检查模型的依赖关系，比如表T1的F1引用了T2的F2，则T2必须限于T1初始化等。随后我们调用了auto_upgrade!，这个调用会在数据表不存在的情况下创建表结构，如果存在则跳过创建过程。另外，DataMapper还提供一个auto_migrate!的方法，这个方法会清空已有的数据库结构，重新建立新的结构。

User.create(:name =>"juntao", :email =>"juntao.qiu@gmail.com")
p User.all

会打印：

[#<User @id=1 @name="juntao" @email="juntao.qiu@gmail.com" @created_at =#<DateTime: 2014-02-05T17:42:38+11:00 ((2456694j,24158s,0n),+39600s, 2299161j)> @updated_at=#<DateTime: 2014-02-05T17:42:38+11:00 ((2456694j, 24158s,0n),+39600s,2299161j)>>]

4．表关联

和ActiveRecord一样，DataMapper也支持表关联。比如每个用户User都可以拥有多个笔记Note：

class User
include DataMapper::Resource
property :id, Serial
property :name, String, :required =>true
property :email, String, :format =>:email_address, :required =>true, :unique =>true
property :created_at, DateTime
property :updated_at, DateTime
has n, :notes
end

与此相对应，笔记Note是属于用户User的：

class Note
include DataMapper::Resource
property :id, Serial
property :content, Text, :required =>true, :lazy =>false
property :complete, Boolean, :required =>true, :default =>false
property :created_at, DateTime
property :updated_at, DateTime
belongs_to :user
end

5．数据校验

上例中已经看到，像:required => true，:format => :email_address以及:unique => true等都是数据校验的条件，当然，DataMapper提供了很多常用的校验条件，如长度校验、格式校验等。

我们可以通过测试来验证：

user = User.new(:name =>'name')
res = user.save
p user.errors[:email]

会得到：

["Email must not be blank"]

第4章 客户端框架

4.1　富客户端

客户端是指浏览器端，浏览器是一个最为流行的HTTP客户端应用程序，它是所有应用程序中最为通用的软件之一。在2000年之后，软件开发的模式由之前的C/S结构不断地向B/S结构演化，浏览器端的技术不断完善、进化。

随着浏览器能力的增强（JavaScript脚本引擎、CSS渲染引擎等），有越来越多的计算可以放到前端来处理，客户端可以展现炫目的用户界面，而且可以处理复杂的应用逻辑，客户端的概念随之诞生。

（1）前端代码的模块化。

（2）前端MVC框架Backbone.js/Angular.js。

模块化RequireJS

随着前端代码量的增多，已经不可能将众多的逻辑放在一个或者几个文件中，我们需要和众多文件打交道。如何组织文件，使得代码的结构更加清晰，维护/扩展更加方便变成了一个很重要的问题。而另一方面，由于浏览器对资源的请求是串行的，因此在编写这样的代码时

<script src="ModuleA.js" type="text/javascript"></script>
<script src="ModuleB.js" type="text/javascript"></script>
<script src="ModuleC.js" type="text/javascript"></script>

需要注意文件的次序，如果ModuleB对ModuleA有依赖关系，那么它必须出现在ModuleA模块之后。另外一个问题是：浏览器请求这些资源时，虽然会有一些并发的线程，但是总体上来说是阻塞式的，即假设有10个JavaScript文件需要下载，而浏览器一般会启用2～6个线程（此处假设为6个）来并发下载，那么第7个资源需要等到之前的6个线程中的任意一个完成之后才能开始。

这样会造成页面空白等待。当模块增多时，这些问题也会更加明显。

RequireJS正是用来解决这个问题的一个库。简而言之，RequireJS可以定义模块间的依赖关系，并异步地加载这些模块，当所有的依赖都满足之后，RequireJS会执行预定义的回调函数。

我们可以通过一个小例子来获得RequireJS的基本概念。

首先假设我们有一个不依赖于任何其他模块的模块，这个模块中保存了一些城市的信息（城市名称和人口信息），假设这个文件名为city-population.js:

define(function() {
return [
{
address: "Melbourne, VIC 3121",
population: 3707530
},
{
address: "Sydney, Australia",
population: 4336374
}
]
});

此处的define是一个函数，它用来定义一个模块。默认地，RequireJS会使用文件名（不带扩展名）作为模块的名称，因此这个模块在RequireJS的上下文中就是city-population。

然后，我们的应用程序入口app.js依赖于city-population这个模块：

require(['underscore', 'city-population'], function(_, population) {
var numbers = _.pluck(population, "population");
console.log(numbers);
});

require函数用来加载依赖，require的第一个参数是一个数组，第二个参数是一个函数。数组中的每一项都是一个模块的名称，比如此处的underscore和 city-population。而第二个参数是一个函数，这个函数本身的参数和前边的数组相对应，比如underscore模块被加载之后，就是此处的_，而 city-population则对应的是population。

应该注意的是，require会保证所有的依赖（数组中的模块）被加载完成之后才会执行回调函数。

如果说RequireJS可以通过文件名找到city-population的话，那么underscore这个模块是如何被找到的呢？其实RequireJS提供了config方法来方便配置模块名称和实际模块之间的映射关系：

require.config({
paths: {
'underscore': 'libs/underscore/underscore'
},
shim: {
'underscore': {
exports: '_'
}
}
});

关于这段配置，有两点需要注意：在paths中定义了underscore这个模块对应的脚本为libs/underscore/underscore.js，但是依照RequireJS的惯例，文件的扩展名是省略不写的。shim中则定义了underscore会导出“_”这个符号。事实上，shim的主要作用是将那些不符合AMD规范的JavaScript库加上一个垫层，使之符合AMD规范。

AMD规范是指异步模块定义（Asynchronous Module Definition），所有的模块都是异步加载，且不影响后续代码的执行。当模块完成异步加载之后，通过回调的方式来执行，而不是同步的阻塞方式。

有了上边的定义之后，我们就可以使用RequireJS来启动整个应用程序了：

<script src="libs/requirejs/require.js" data-main="app"></script>

RequireJS会查找data-main属性指定的文件名（省略扩展名）。启动之后，我们可以在浏览器的加载顺序中看到：

app.js先被加载，但是直到所有的依赖加载之前，打印人口信息的代码并不会执行。当然，此处的例子是一个非常简单的场景，当你的工程开始变得复杂，使用RequireJS来管理模块是一个很好的选择。它可以帮助你节省很多用来调试“诡异”错误的时间，也可以帮助你更好地规划自己的代码结构。

Chrome开发者工具中看到的请求列表如图4-1所示。

图4-1　Chrome开发者工具中看到的请求列表

4.2　Backbone.js简介

Backbone是一个非常轻量级的前端MVC框架，Backbone可以帮助开发人员编写结构化更加良好的代码。它使用Model/Collection来管理数据，使用Views来响应事件，渲染模板等。

Backbone还提供了路由、模板编译、同步数据到后台等的支持，使得编写轻量级的前端应用变得非常方便。结合RESTFul的后端API，使用Backbone可以很容易地编写出非常健壮，并且易于扩展、维护的客户端应用。

4.2.1　模型

Backbone中使用模型来管理基本的数据，模型中定义了数据的基本属性，如何校验，如何获取数据——与后端的服务器同步等。另外，模型还提供了“发布-订阅”模式的支持，即当模型上的数据发生变化时，可以注册对该变化的监听器，从而更新视图。

在Backbone中定义一个模型非常容易，只需要使用Backbone.Model.extend方法即可：

var Book = Backbone.Model.extend({
defaults: {
name: 'Notitle',
author: 'Nobody',
keywords: []
}
});

这里定义了一个名字为Book的模型，这个模型有几个属性：书的名称，书的作者，书的相关的关键字列表。

有了这个模型，我们就可以创建一些书名的实例了，就像使用传统面向对象语言中的类一样：

var placeholder = new Book();
var jscp = new Book({
name: "JavaScript Core Concepts and Practices",
author: "Juntao Qiu",
keywords: ["JavaScript", "Node.js"]
});
console.log(placeholder.toJSON());
console.log(jscp.toJSON());

将会分别打印：

{
"name": "Notitle",
"author": "Nobody",
"keywords": []
}

和

{
"name": "JavaScript Core Concepts and Practices",
"author": "Juntao Qiu",
"keywords": ["JavaScript", "Node.js"]
}

另外，还可以通过get方法来获取某个属性的值，通过set来修改某个属性的值：

console.log(placeholder.get('author'));
placeholder.set('author', 'Bill');
console.log(placeholder.get('author'));

每个模型都有一个initialize的方法，在初始化模型时，这个方法会被调用：

var Book = Backbone.Model.extend({
defaults: {
name: 'Notitle',
author: 'Nobody',
keywords: []
},
initialize: function() {
console.log("Book is initialized");
}
});

在这个方法中，我们可以注册一些事件监听器：

var Book = Backbone.Model.extend({
defaults: {
name: 'Notitle',
author: 'Nobody',
keywords: []
},
initialize: function() {
console.log("Book is initialized");
this.on("change", function() {
console.log("model is changed");
})
}
});

这样当模型的值发生变化时，模型本身会探测到这一变化：

var placeholder = new Book();
placeholder.set("name", "Python programming language");
//model is changed

4.2.2　视图

在Backbone中，用来负责操作DOM元素，以及绑定事件的部分被包装到了视图中，视图提供了丰富的特性来简化对用户界面的开发。定义一个视图非常容易，和定义模型非常类似，只需要调用Backbone.View.extend方法，然后传入一个有一定格式的JSON即可：

var BookView = Backbone.View.extend({
tagName: "div",
initialize: function() {
this.render();
},
render: function() {
this.$el.html("<h1>I'm book view</h1>");
return this;
}
});

tagName属性表示该视图最后的展现会是一个div元素，initialize方法会在初始化的时候被自动调用，而render方法就是实际的绘制。Backbone提供了两种方式来在视图中引用自身的DOM元素，this.el表示自身的DOM元素，而this.$el表示被jQuery包装过的this.el。比如下面的例子中，我们可以看到el的用法：

var bv = new BookView();
console.log(bv.el);

这段代码会打印：

<div>
<h1>I'm book view</h1>
</div>

如果视图只是对DOM的操作的包装，那还不足以展示Backbone的强大之处，它与数据模型结合起来才渐渐展现出MVC的威力，在视图中使用模型非常容易，只需要将其作为初始化参数传入即可：

var BookView = Backbone.View.extend({
tagName: "div",
initialize: function() {
this.render();
},
render: function() {
this.$el.html("<h1>"+this.model.get('name')+"</h1>");
return this;
}
});
var book = new Book({
name: "Python programming language"
});
var bv = new BookView({model: book});
console.log(bv.el);

会生成：

<div>
<h1>Python programming language</h1>
</div>

Backbone的一个依赖库underscore提供了一个简洁的模板功能，使用这个模板工具，可以很容易地将视图扩展为更加简洁清晰的形式，比如我们在页面上插入这样一段模板：

<scripttype="text/template" id="book-template">
<h4 class="media-heading"><%= name %></h4>
<p><%= author %></p>
<% _.each(keywords, function(keyword) { %>
<spanclass="badge"><%= keyword %></span>
<% }); %>
</script>

注意此处的script的type是text/template，遇到这种type，浏览器不会尝试解析其中的内容，换言之，这些内容会被当作纯粹的文本，而不会展示在页面上。

“<%=”和“%>”之间的内容会被动态内容替换掉，注意此处的_.each，它是underscore提供的迭代器，可以遍历一个列表，列表中的每个元素被取出来，并传递给一个匿名函数。

另一方面，我们在视图类中可以通过这段模板的ID来获取：

var BookView = Backbone.View.extend({
tagName: "div",
tmpl: _.template($('#book-template').html()),
initialize: function() {
this.render();
},
render: function() {
this.$el.html(this.tmpl(this.model.attributes));
return this;
}
});

这样，当调用render的时候，Backbone会获取模板内容，并将model的值传入，最后生成最终的HTML片段，然后插入到this.$el中。

在入口的JavaScript中，我们会有这样的代码：

$(function() {
var jscp = new Book({
name: "JavaScript Core Concepts and Practices",
author: "Juntao Qiu",
keywords: ["JavaScript", "Node.js"]
});
var view = new BookView({
model: jscp
});
$("#books").html(view.el);
});

对应的HTML文档是这样的：

<body>
<scripttype="text/template" id="book-template">
<h4 class="media-heading"><%= name %></h4>
<p><%= author %></p>
<% _.each(keywords, function(keyword) { %>
<spanclass="badge"><%= keyword %></span>
<% }); %>
</script>
<div class="container">
<div class="row">
<div class="col-lg-12 col-md-12" id="books"></div>
</div>
</div>
<script type="text/javascript" src="lib/jquery/dist/jquery.min.js"> </script>
<script type="text/javascript" src="lib/underscore/underscore.js"> </script>
<script type="text/javascript" src="lib/backbone/backbone.js"></script>
<script type="text/javascript" src="lib/bootstrap/dist/js/bootstrap. min.js"></script>
<script type="text/javascript" src="scripts/app.js"></script>
</body>

最终效果如图4-2所示。

图4-2　页面上的一条书籍记录

当我们需要创建多个视图对象时，框架的作用才真正可以显现出来：

$(function() {
var jscp = new Book({
name: "JavaScript Core Concepts and Practices",
author: "Juntao Qiu",
keywords: ["JavaScript", "Node.js"]
});
var qpb = new Book({
name: "The Quick Python Book",
author: "Naomi R. Ceder"
});
var refactor = new Book({
name: "Refactoring",
author: "Martin Fowler",
keywords: ["Agile", "Clean code", "Refactoring"]
});
var view1 = new BookView({
model: jscp
});
var view2 = new BookView({
model: qpb
});
var view3 = new BookView({
model: refactor
});
$("#books").append(view1.el);
$("#books").append(view2.el);
$("#books").append(view3.el);
});

页面上的多条书籍记录如图4-3所示。

图4-3　页面上的多条书籍记录

当然，对于这种多条记录的情况，Backbone提供了更为自然，也更加强大的方式：集合。

4.2.3　集合

集合是一组模型，很多情况下，我们都需要将模型整组地操作，比如一个书籍的列表比单独的书模型的应用场景更加广泛。集合同样可以注册对感兴趣的事件的监听等。定义一个集合非常容易，只需要指定集合中存储的模型是哪种类型即可：

var Books = Backbone.Collection.extend({
model: Book
});

使用集合也非常容易，将一个平坦的JSON对象传入：

var books = new Books([
{
name: "JavaScript Core Concepts and Practices",
author: "Juntao Qiu",
keywords: ["JavaScript", "Node.js"]
}, {
name: "The Quick Python Book",
author: "Naomi R. Ceder"
}, {
name: "Refactoring",
author: "Martin Fowler",
keywords: ["Agile", "Clean code", "Refactoring"]
}
]);

在视图中使用集合同样容易，我们可以定义一个新的视图BooksView，然后在该视图中调用子视图BookView的render，最后展现为完整的结果：

var BooksView = Backbone.View.extend({
tagName: "ul",
className: "media-list",
initialize: function() {
this.render();
},
render: function() {
var view;
this.collection.forEach(function(book) {
view = new BookView({model: book});
this.$el.append(view.el);
}, this);
}
});

注意此处的className，该属性表示创建的元素会自动添加一个名叫“media-list”的CSS类。而对应的每个子视图，我们做了一些简单的修改如下：

var BookView = Backbone.View.extend({
tagName: "li",
className: "media",
tmpl: _.template($('#book-template').html()),
initialize: function() {
this.render();
},
render: function() {
this.$el.html(this.tmpl(this.model.attributes));
return this;
}
});

最后，入口代码变得更加简洁：

$(function() {
var books = new Books([
{
name: "JavaScript Core Concepts and Practices",
author: "Juntao Qiu",
keywords: ["JavaScript", "Node.js"]
}, {
name: "The Quick Python Book",
author: "Naomi R. Ceder"
}, {
name: "Refactoring",
author: "Martin Fowler",
keywords: ["Aigle", "Clean code", "Refactoring"]
}
]);
var view = new BooksView({
collection: books
});
$("#books").html(view.el);
});

我们还对页面上的模板做了一些修改，使得可以展示一个64×64像素的占位图片，如图4-4所示。

图4-4　多条书目记录（使用BootStrap添加样式）

<scripttype="text/template" id="book-template">
<a class="pull-left"href="#">
<img class="media-object"src="http://placehold.it/64x64">
</a>
<divclass="media-body">
<h4class="media-heading"><%=name%></h4>
<div>
<spanclass="label label-success"><%=author%></span>
</div>
<%_.each(keywords,function(keyword){%>
<spanclass="badge"><%=keyword%></span>
<%});%>
</div>
</script>

4.2.4　与服务器交互

现实世界中，数据不可能仅仅来源于一个静态的JavaScript数组，大多数情况下，数据都会以HTTP方式从后台获取，对于这种情况，我们如何处理呢？

现在假设后台已经有了一个非常完善的服务器，你可以通过向/books这个URL发送HTTP的GET请求，就可以获得一个JSON格式的数据，如图4-5所示。

图4-5　后台服务提供的数据

这种情况下，我们如何使得前后台一起协同工作呢？非常简单，我们需要修改的地方仅仅只有两处，首先为Books集合添加一个url的属性，该属性指向从何处获取数据：

var Books = Backbone.Collection.extend({
model: Book,
url: "/books"
});

然后，在入口处，我们修改了获取数据的方式：

$(function() {
var books = new Books();
books.fetch({
success: function(data) {
var view = new BooksView({
collection: data
});
$("#books").html(view.el);
}
});
});

注意此处的books.fetch，这个操作是一个异步操作，当获取数据之后，我们再将集合传入BooksView来创建视图。如果代码写成同步的方式

$(function() {
var books = new Books();

books.fetch();

var view = new BooksView({
collection: books
});
$("#books").html(view.el);
});

则会得到一个空白的页面，因为当创建BooksView视图时，数据还没有实际返回。

4.2.5　路由表

在构建较为复杂的前端应用程序时，我们往往需要在多个页面之间切换。产品列表页面中的每个条目都应该可以连接到一个详情页面，由于引用逻辑都在前端，我们就没有理由非要通过请求服务器才能完成页面的跳转。使用Backbone.js的路由表可以完全在客户端控制应用程序的状态，而无需真正发送请求来刷新页面。

创建路由表只需要扩展Backbone.Router即可，路由表本质上来说就是一张Hash表，键为路由，值为当匹配到该路由时需要调用的函数。

我们来创建一个简单的路由表，其中有两项规则：

var BookRouter = Backbone.Router.extend({
routes: {
"": "books",
"about": "about",
"search/:query": "search"
},

books: function() {
var books = new Books();
books.fetch({
success: function(books) {
var view = new BooksView({
collection: books
});
$("#about").hide();
$("#books").html(view.el).show();
}
});
},
about: function() {
var view = new AboutView();
$("#books").hide();
$("#about").show();
},
search: function(query) {
console.log(query);
}
});

routes中定义了一个Hash表，当URL为根路径时，调用books函数；当请求about时，调用about函数，最后当请求search/时，调用search函数。注意此处的:query，它表示将search/之后的字符串作为参数传递给search函数，参数名称为query。

这里我们定义了一个关于页面：

var AboutView = Backbone.View.extend({
el: "#about",
tmpl: _.template($('#about-template').html()),

initialize: function() {
this.render();
},

render: function() {
this.$el.html(this.tmpl());
return this;
}
});

模板内容为：

<scripttype="text/template" id="about-template">
<h3>This is a book shelf</h3>
<p>...</p>
</script>

这样当访问index.html#/about时，浏览器上可以看到如下的页面，如图4-6所示。

注意此处的URL，哈希符（#）之后的内容浏览器不会将其发送到服务器端，而Backbone.js可以感知到哈希符（#）之后内容的变化，然后查找路由表，并执行相关的动作。

这样我们的入口部分代码就变成了：

$(function() {
var bookRouter = new BookRouter();
Backbone.history.start();
});

图4-6　“关于”页面

Backbone.history.start()函数会启动Backbone.js对页面URL的监听。你还可以通过调用Backbone.history.stop()来停止监听。

4.3　Angular.js

AngularJS是另一个非常强大，非常灵活的前端MVC框架。AngularJS提供双向绑定（操作数据模型会自动刷新DOM元素，反之亦然）、路由控制、拦截器。另外，AngularJS对RESTFul的后端也有非常好的支持。

AgnularJS的强大之处在于关注点分离。事实上这个原则贯穿在所有开发的过程中，但是AngularJS在机制上对关注点分离做了集成。比如Angular中将负责数据提供的组件抽象为Service，页面展现则包装在指令（Directive）中，视图之间的逻辑被抽象为Controller。所有的DOM操作，事件处理都会放入指令，与展现无关的逻辑放在Service中，而Controller则负责展现相关的数据组织。

4.3.1　数据双向绑定

AngularJS支持数据的双向绑定：当JavaScript上的变量发生变化时，对应的HTML元素中的内容会发生同步更新，而HTML元素中的变化也会使得JavaScript上的变量同步更新。

一个简单的例子：

<!DOCTYPE HTML>
<html ng-app="SimpleApp">
<head>
<meta charset="UTF-8">
<title>Simple App</title>
</head>
<body>
<div ng-controller="SimpleController">
<input type="text" name="name" ng-model="name" />
<span>{{name}}</span>
</div>
<script type="text/javascript" src="libs/angular.min.js"></script>
<script type="text/javascript" src="scripts/app.js"></script>
</body>
</html>

这是一个典型的包含了AngularJS指令的HTML文档，粗略地看一眼，几乎就是一篇正常的HTML。但是仔细看就会发现html元素的ng-app属性，body里的第一个div的ng-controller属性，input元素的ng-model属性，以及span元素中的{{name}}。

属性ng-app定义了一个AngularJS的应用程序，它是AngularJS中顶级的对象类型。而ng-controller则定义了一个Controller，Controller上可以附带供展现的数据，也可以定义一些事件响应的函数等。AngularJS中的Controller比较轻量级，它主要负责与展现相关的一些状态信息。ng-model指定定义在Controller上的一个变量名。表达式{{name}}表示将变量name的值输出。

我们来看一下Controller的定义：

var app = angular.module('SimpleApp', []);
app.controller('SimpleController', ['$scope', function($scope) {
$scope.name = "Juntao QIU";
}]);

第一句定义了一个AngularJS的模块，模块名为SimpleApp。然后第二行定义了一个名字叫SimpleController的Controller。注意此处的：

['$scope', function($scope) {
$scope.name = "Juntao QIU";
}]

是AngularJS中，用来注入依赖关系的一个简写。首先它是一个数组，数组的最后一个元素是函数，在AngularJS中，这种写法表示，将数组的前几项作为依赖，当这些依赖都完成时，执行最后一项的函数，并将之前的依赖项作为参数传递给该函数。

比如此处，$scope是一个AngularJS内置的一个服务。当这个服务被找到并加载之后，执行匿名函数，并将$scope传入。

事实上，当AngularJS解析页面时，会动态地创建一个scope，并为scope的name属性赋值。这样页面上就可以获得该变量的值了，如图4-7所示。

图4-7　AngularJS中的数据双向绑定

页面加载之后，可以看到两处的name的值都为Juntao QIU。此时，如果修改输入框中的内容，则输入框后边的span中的文本会同时变化，如图4-8所示。

图4-8　AngularJS中的数据双向绑定

4.3.2　内置指令

AngularJS内置的一些指令可以方便日常的前端开发，比如使用频率很高的ng-repeat。ng-repeat接受一个迭代表达式：

<div ng-controller="SimpleController">
<ul ng-repeat="framework in frameworks">
<li>{{framework}}</li>
</ul>
</div>

如果对应的Controller中定义了frameworks为这样的内容：

$scope.frameworks= [
'Backbone.js',
'Angular.js',
'Ember.js',
'Knockout.js'
];

那么，ng-repeat生成的列表，如图4-9所示。

图4-9　ng-repeat指令

4.3.3　AngularJS中的服务

如果一个前端应用完全不和后台交互，那么会非常无聊。AngularJS完成与后端的交互非常容易，只需要定义一个服务（Service）即可。虽然Service的用途不局限在与后端交互，但是通常人们会这样做。

定义一个Service非常容易，和定义Controller类似：

app.factory('FrameworksService', ['$http', '$q',
function($http, $q) {
return {
getFrameworks: function() {
return [
'Backbone.js',
'Angular.js',
'Ember.js',
'Knockout.js'
];
}
};
}]);

factory是AngularJS支持的Service定义的一种。我们定义的这个Service名叫FrameworksService，它依赖于$http服务和$q服务（当然，目前这一步还不需要），它定义了一个方法getFrameworks，当调用时，它会返回一个静态的数组。

要在Controller中使用这个Service，只需要“注入”它即可：

app.controller('SimpleController', ['$scope', 'FrameworksService',
function($scope, FrameworksService) {
$scope.frameworks = FrameworksService.getFrameworks();
}]);

注入的方式和注入AngularJS的内置服务一样。

更进一步，可以将FrameworksService修改为从后台获取数据，这时候就需要用到$http服务和$q服务：

app.factory('FrameworksService', ['$http', '$q',
function($http, $q) {
return {
getFrameworks: function() {
var deferred = $q.defer();
var param = {
type: "client"
};
$http.post('/action.do', param).success(function(result){
deferred.resolve(result);
}).error(function(result) {
deferred.reject(result);
});
return deferred.promise;
}
};
}]);

$http服务用以向后台发送请求，并获取响应。这个与jQuery的Ajax方法非常类似。这里的$q是AngularJS提供了另一个服务。用以简化异步事件的编写，$q可以用来创建一个defer对象，defer有两个状态：接受（resolve）和拒绝（reject）。这里的$http不直接返回异步操作的结果，而是返回一个对defer对象的引用。这样，当实际的事件（网络请求返回）发生之后，FrameworksService的调用者可以用一个相对优雅的方式来处理结果：

app.controller('SimpleController', ['$scope', 'FrameworksService',
function($scope, FrameworksService) {
FrameworksService.getFrameworks().then(function (frameworks) {
$scope.frameworks = frameworks;
});
}]);

如果采取上例中直接赋值的方式，则$scope.frameworks会为空（因为网络调用是异步的），所以需要将赋值操作放到一个回调函数中。

这个操作模型被称为promise模型，简而言之，可以分为这样几步：

（1）异步请求发生时，直接返回一个promise对象。

（2）调用者在promise上调用then。

（3）当实际的事件发生时，then中的回调会被执行。

在测试环境中，我们无需真正创建一个后台的服务器应用程序。可以使用moco来快速搭建一个假的服务器。

要运行moco，需要为其提供一个简单的配置文件：moco.conf.js

{
"request": {
"method": "post",
"uri": "/action.do",
"json_paths": {
"$.type": "client"
}
},
"response": {
"status": 200,
"file": "spec/fixtures/frameworks.json"
}
}

这个配置文件定义了：对于发往/action.do的POST请求中，如果请求里包含了type=client这样的参数，则返回状态码200，并将spec/fixtures/frameworks.json的内容作为响应返回给客户端。

由于我们的前端应用需要请求后台服务，那么前端应用本身需要被托管在一个Web服务器上，moco也提供静态文件的托管：

{
"mount": {
"dir": "./",
"uri": "/"
}
}

mount指令表示将当前目录挂载（映射）为Web根目录，这样如果我们在项目的根目录启动moco，则所有的AngularJS脚本都可以正确地寻址。

moco的独立服务器是一个Jar包，可以通过下列命令来启动：

$ java -jar ./libs/moco-runner-1.0-SNAPSHOT-standalone.jar start \
-p 12306 -c conf/moco.conf.json

启动之后，我们可以在12306端口来访问整个应用程序，如图4-10所示。

图4-10　与moco集成

默认的，moco会在控制台上打印详细的请求/响应信息，这些信息有时候会有用，但是大部分时候我们不需要看到它，因此可以通过下列命令将moco变为一个后台进程，这样我们就可以重用这个控制台了：

$ nohup java -jar $MOCO_BIN start -p 12306 -c moco.conf < /dev/null > moco.std.log 2>&1 &

其中环境变量MOCO_BIN为moco的standalone版本的jar包的路径即可。这样moco的输出被重定向到了moco.std.log文件中，以备我们调试时使用。

4.3.4　与RESTFul的API集成

当后端已经有一个现成的RESTful的API时，使用AngularJS可以快速地搭建出一个前端应用程序。比如在第2章使用Grape搭建了一个支持RESTful的API应用，如图4-11所示。

图4-11　与真实的RESTFul服务器集成

首先我们需要定义一个基于这个API的service：

app.factory('ProductsService', ['$resource', function($resource) {
return $resource('/products', {}, {
query: { method: 'GET', isArray: true }
});
}]);

AngularJS提供的$resource服务用来与标准的RESTFul形式的API结合。比如此处的query操作，会使用GET方法来从/products获取数据，通过isArray选项来选择是否可以获取多条记录。

我们在Controller中来获取该API暴露的数据：

var app = angular.module('ProductsApp');
app.controller('ProductsController', ['$scope', 'ProductsService',
function($scope, ProductsService) {
$scope.products = ProductsService.query();
}]);

对应的页面上的元素为：

<div ng-controller="ProductsController" class="row">
<div ng-repeat="product in products" class="col-xs-12">
<h4>{{product.name}}</h4>
<span>{{product.price}}</span>
<button>Save</button>
</div>
</div>

由于我们一次性可以获取多条记录，因此使用了ng-repeat来迭代products列表，如图4-12所示。

图4-12　ng-repeat迭代products列表

当然，这里只是一个简单的query操作，我们可以很容易地将对应的服务扩展，使其支持增删查改所有操作，而使其变成一个完整的应用程序。

4.3.5　与moko集成

有时候，当我们做前端开发时，关注点主要集中在如何使用后端的RESTFul的API上，也就是说，我们并不想被后台的bug所阻塞，或者有时候当开始开发前端时，后台的服务还没有完全就绪，这时候我们就需要一个“假的”后端服务器。

moko是一个Ruby的gem，是对moco服务器的一个简单包装，使用moko可以在几分钟内搭建一个假的服务器。

安装moko非常容易：

$ gem install moko

安装之后，本地会有一个mokoup的命令：

$ mokoup
Commands:
mokoup generate　　　　# generate moco configuration and restful resources
mokoup help [COMMAND]　# Describe available commands or one specific command
mokoup server　　　　　# startup the underlying moco server

你需要在本地目录创建一个moko.up的配置文件，这个文件定义了一个简单的DSL。比如你需要暴露的RESTFul的API提供对资源user的访问，那么可以这样定义：

resource :user do |u|
u.string :name
u.integer :age
u.string :type
u.datetime :created_at
u.datetime :updated_at
end

这样资源就定义好了，这时候如果执行：

$ mokoup generate

就可以生成moco服务器的配置文件了，然后启动moco服务器：

$ mokoup server

同样，如果你不想看到命令行打印的请求/响应信息，只需要带上-d选项：

$ mokoup server -d

启动之后，我们可以通过curl来测试：

$ curl -X POST -d "{}" http://localhost:12306/users | jq .
{
"updated_at": "2014-02-12 00:16:29 +1100",
"created_at": "2014-02-12 00:16:29 +1100",
"type": "default String Value",
"arg": 1,
"name": "default String Value"
}

有了后台的API，编写AngularJS来访问这些API就变得容易多了，这些“静态”的数据可以认为是没有bug的真实服务器程序，后期的集成也会变得容易。

第5章 CSS框架简介

在前人工作的基础上工作是一件好事情，你可以避免很多可能犯的错误，做更少量的工作，得到更好的结果。

优秀的设计师和前端开发人员从大量项目界面中发现了很多共性，比如页面常用的布局方式，基本的组件抽象（页眉，页脚，分页器，多标签页等等），常用的色彩搭配等等。

为了避免进入一个新项目时又将这些工作重新做一遍，人们发布了很多框架。这些框架都有良好的结构，漂亮的界面，容易定制的代码，使得开发人员在一个新项目上工作时，只需要少量的修改即可得到一套完整而且漂亮的界面。

随着多种显示终端的流行，响应式界面设计成为CSS框架的一个必备特性，人们需要页面在不同分辨率的终端设备上都可以很好地展现。页面在大显示器上展示为大的字体，大的图片，大的列表；在平板电脑上则显示较小的字体，较小的图片，列表则被折叠起来；而在诸如手机等小屏幕上，则将会使用更小的图片，并且将很多不重要的元素隐藏起来。

目前业界已经有很多CSS框架，这一章将介绍其中非常优秀的两个框架：Foundation和Bootstrap。

5.1　Foundation简介

Foundation是一个非常简洁的响应式设计的CSS框架，它强调移动设备优先，又可以完美地运行在平板设备以及桌面上。Foundation可以帮助设计、开发人员快速搭建原型，迅速调整布局等。

安装Foundation有两种方式，一种是直接下载主页上的zip包，然后解压使用，另一种方式是使用命令行工具安装。第二种方式更加方便，对开发人员更加友好，这里我们使用第二种方式。首先安装Foundation的Gem包：

$ gem install foundation

安装之后，可以运行foundation命令来创建一个新的工程：

$ foundation new project

如图5-1所示。

图5-1　使用foundation命令行工具创建工程

在新创建的目录中，会有很多文件，如图5-2所示。

图5-2　生成的新目录结构

这是一个简单的Ruby工程，其中包含了Gemfile，我们可以执行bundle install来安装项目的依赖。由于这个应用默认发布的是SCSS格式的样式文件，所以我们需要将其预编译为CSS文件。完成预编译工作只需要执行：

$ compass compile

这个命令可以把scss/目录中的app.scss文件编译到stylesheets目录中的app.css文件。有了这个文件，我们就可以在命令行来启动一个HTTP服务，并在浏览器中查看：

$ python -m SimpleHTTPServer 9999

基础元素展现页面如图5-3所示。

图5-3　基础元素展现页面

首先需要学习的是Foundation提供的布局功能，和BootStrap一样，每行分为12列。Foundation的CSS类非常语义化，读起来也非常自然：

<div class="row">
<div class="large-6 medium-6 columns">
<div class="callout panel">
<p>Six columns</p>
</div>
</div>
<div class="large-6 medium-6 columns">
<div class="callout panel">
<p>Six columns</p>
</div>
</div>
</div>

每一个行有对应的row作为CSS类，在每一个行内，类名被定义为“尺寸-列数”的格式，比如large-6表示当页面展示在大的屏幕时，该元素占用6列，而medium-4则表示在较小的设备中，该元素占用4列。

上边的片段绘制之后，看起来如图5-4所示。

图5-4　12列布局（两个块各占6列）

如果略做修改，将布局变为第一个元素占4列，第二个元素占8列：

<div class="row">
<div class="large-4 medium-6 columns">
<div class="callout panel">
<p>Six columns</p>
</div>
</div>
<div class="large-8 medium-6 columns">
<div class="callout panel">
<p>Six columns</p>
</div>
</div>
</div>

则页面效果如图5-5所示。

图5-5　12列布局（6列-6列和4列-8列）

常用组件

Foundation提供了比较常用的组件的包装，比如按钮、输入框、面板、列表、表单、导航栏等等。这些基础组件可以极大地简化开发者的工作量，也方便开发者很容易从头搭建一个美观、专业的页面。

按钮

按钮是最常见的页面元素之一，Foundation对它有很好的支持。我们来看一些按钮的例子：

<div class="row">
<div class="large-12 columns">
<a href="#" class="small button">Simple Button</a>
<a href="#" class="small radius button">Radius Button</a>
<a href="#" class="small round button">Round Button</a>
<a href="#" class="medium success button">Success Btn</a>
<a href="#" class="medium alert button">Alert Btn</a>
<a href="#" class="medium secondary button">Secondary Btn</a>
</div>
</div>

如图5-6所示。

图5-6　各种样式的按钮

1．图标栏

图标栏可以用做页面上的单级菜单，也可用做工具栏。

<div class="row">
<div class="large-12 columns">
<div class="icon-bar five-up">
<a class="item">
<imgsrc="assets/img/images/fi-home.svg" >
<label>Home</label>
</a>
<a class="item">
<imgsrc="assets/img/images/fi-bookmark.svg" >
<label>Bookmark</label>
</a>
<a class="item">
<imgsrc="assets/img/images/fi-info.svg" >
<label>Info</label>
</a>
<a class="item">
<imgsrc="assets/img/images/fi-mail.svg" >
<label>Mail</label>
</a>
<a class="item">
<imgsrc="assets/img/images/fi-like.svg" >
<label>Like</label>
</a>
</div>
</div>
</div>

渲染效果如图5-7所示。

图5-7　横向排列的图标栏

Foundation还提供选项可以将图标栏以垂直方式布局，如图5-8所示。

图5-8　纵向排列的图标栏

要做到这一点只需要为整个图标栏加上一个vertical的类即可：

<div class="icon-bar vertical five-up">
...
</div>

2．走马灯

走马灯/画廊是一个非常常见的组件，大多数网站会用它来展示多个产品，通常是一些拍摄得非常吸引人的图片，每隔数秒，图片都会切换一次，而且用户可以自己点击来查看图片。在Foundation中实现这种效果需要使用orbit类。

只需要找出合适的图片，然后定义如下结构的HTML即可：

<div class="row">
<div class="large-6 columns">
<ul class="example-orbit" data-orbit>
<li>
<imgsrc="images/cube-resized.jpg" alt="cube" />
<div class="orbit-caption">
Cube
</div>
</li>
<liclass="active">
<imgsrc="images/clover-resized.jpg" alt="clover" />
<div class="orbit-caption">
Clover
</div>
</li>
<li>
<imgsrc="images/light-resized.jpg" alt="light" />
<div class="orbit-caption">
Light
</div>
</li>
</ul>
</div>
</div>

走马灯效果如图5-9所示。

图5-9　走马灯效果

另一个相关的有趣的特性是可缩放的视频，使用Foundation的flex-video包装起来的视频会根据浏览器尺寸自动调节画面，比如：

<divclass="flex-video">
<iframewidth="420" height="315" src="//localhost/~jtqiu/
bossgao/index. html" frameborder="0" allowfullscreen></iframe>
</div>

这样，即使页面缩小到比较小的页面，也可以看到完整的视频，如图5-10所示。

图5-10　可缩放的视频

3．块网格（block-grid）

很多时候，我们需要展现很多重复的块，比如多张图片、多个产品的描述等。又需要将这些块根据不同的屏幕尺寸进行不同的排列，那么就可以使用Foundation提供的块网格来实现。

<div class="row">
<div class="large-12 columns">
<ul class="small-block-grid-3 medium-block-grid-4 large-block-
grid-5">
<li><img src="images/cube-resized.jpg" /></li>
<li><img src="images/leaf-resized.jpg" /></li>
<li><img src="images/luban-resized.jpg" /></li>
<li><img src="images/light-resized.jpg" /></li>
<li><img src="images/umbrella-resized.jpg" /></li>
</ul>
</div>
</div>

上面这个列表中有5张图片，当屏幕较大时，我们可以展示5张图片，如图5-11所示。

图5-11　大屏幕下的块网格

而缩小屏幕，则每行显示4张，另外一个换行显示，如图5-12所示。

图5-12　较小屏幕下的块网格

继续缩小，每行显示三张，另外两个换行显示，如图5-13所示。

图5-13　小屏幕下的块网格

5.2　BootStrap简介

Bootstrap是Twitter推出的一个开源框架，也是目前最为流行的CSS框架。使用Bootstrap可以让开发人员在很短的时间内搭建出非常专业的界面。

Bootstrap可以通过bower来安装：

$ bower install bootstrap

下载的包中包含了这样一些文件，如图5-14所示。

图5-14　基本文件布局

其中，css目录中包含了bootstrap定义的基本样式以及主题样式，fonts目录中是一些字体图标，默认样式中的很多小图标其实就是使用这些字体的特殊字符渲染的。

打开该字体文件，可以看到其中的内容正是一个个图标，如图5-15所示。

图5-15　子图图标

这种图标使得页面可以无限制放大，而不会产生模糊。js目录中是JavaScript代码，Bootstrap中的一些效果，比如下拉菜单等，需要JavaScript的支持。

将Bootstrap加入到页面非常容易，只需要引入CSS和对应的JavaScript即可：

<!DOCTYPE HTML>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=
1">
<title>Page title</title>
<link rel="stylesheet" href="src/vendor/bootstrap/dist/css/bootstrap.
css" />
</head>
<body>
<div class="container">
</div>
<script src="src/vendor/jquery/dist/jquery.js" type="text/javascript"> </script>
<script src="src/vendor/bootstrap/dist/js/bootstrap.js" type="text/ javascript"> </script>
</body>
</html>

我们在文件头部引入了bootstrap.css，在底部引入了JavaScript代码，然后就可以在body中使用Bootstrap来样式化我们的界面了。

5.2.1　布局

CSS框架中使用最为频繁的应该就是布局功能了，之前有很多CSS框架使用的是960网格布局（12列）方式，这也是众多设计师从长期的工作中找到的一种非常灵活易用的网格系统。

基本上来说，这种布局方式定义页面的宽度为960像素，然后页面分为12列，每一列为60像素，列之间的空白为20像素，而两边分别有10像素的空白。在这种布局方式下，页面可以很容易地分为6-6（左右各一半）、7-5（左边占7，右边占5）、8-4、9-3等划分，而只要页面上的其他元素也按照这种方式计算，则元素自然是对齐的，如图5-16所示。

图5-16　960网格布局

在Bootstrap中，每一行也分为12列，行由CSS的类row来表示，然后列由col-size-number形式来表示，比如col-sm-6表示在小屏幕下，该元素占6列，而col-lg-8则表示在大屏幕中该元素占8列，以此类推。

比如我们要在页面上的一行并列展示4个元素，则可以使用这样的代码：

<div class="row">
<p class="col-lg-3">Bootstrap</p>
<p class="col-lg-3">Bootstrap</p>
<p class="col-lg-3">Bootstrap</p>
<p class="col-lg-3">Bootstrap</p>
</div>

绘制在页面上如图5-17所示。

图5-17　4个三列的段落元素

这时候，如果缩小窗口，则界面会自动变化为一列一条，如图5-18所示。

图5-18　缩小窗口之后，每行均占用12列

我们当然不愿意看到这样的极端情况，因此可以使用这样的代码来完成响应式的设计：

<div class="row">
<p class="col-lg-3 col-md-4">Bootstrap</p>
<p class="col-lg-3 col-md-4">Bootstrap</p>
<p class="col-lg-3 col-md-4">Bootstrap</p>
<p class="col-lg-3 col-md-4">Bootstrap</p>
</div>

这样，当页面缩小为较小尺寸时，页面元素会自动调整自己的位置，如图5-19所示。

图5-19　响应式的段落布局

同样，我们可以为小屏幕（col-sm-）和极小屏幕（col-xs-）分别配置：

<div class="row">
<p class="col-lg-3 col-md-4 col-sm-6 col-xs-12">Bootstrap</p>
<p class="col-lg-3 col-md-4 col-sm-6 col-xs-12">Bootstrap</p>
<p class="col-lg-3 col-md-4 col-sm-6 col-xs-12">Bootstrap</p>
<p class="col-lg-3 col-md-4 col-sm-6 col-xs-12">Bootstrap</p>
</div>

5.2.2　常用组件

Bootstrap不但提供了极为方便的布局支持，而且提供了非常多的常用组件，比如页眉页脚、走马灯、表单、导航栏、分页器、进度条等等。这些元素都非常容易使用，大部分仅仅需要标记为某个CSS的类即可。

1．分页器

当页面元素太多时，我们需要分页显示。分页器在Bootstrap中非常容易实现：

<ul class="pagination">
<li><a href="#">&laquo;</a></li>
<li><a href="#">1</a></li>
<li><a href="#">2</a></li>
<li><a href="#">3</a></li>
<li><a href="#">4</a></li>
<li><a href="#">5</a></li>
<li><a href="#">&raquo;</a></li>
</ul>

展现在页面上的效果如图5-20所示。

图5-20　分页器效果

如果要禁用某个页签，比如当前为第一页，向前的按钮不可用，只需要为该元素加上disabled的类即可：

<li class="disabled"><a href="#">&laquo;</a></li>
<li><a href="#">1</a></li>
<li class="disabled"><a href="#">2</a></li>

这样当鼠标移动到该元素之上时，鼠标会显示为一个不可点击的图标。

2．缩略图

缩略图是一个常用的表示横向列表的组件，比如展示同类产品的页面，如图5-21所示。

图5-21　缩略图效果

代码如下：

<div class="row">
<div class="col-lg-3 col-md-3">
<a href="#" class="thumbnail">
<img src="http://placehold.it/170x180" alt="170x180">
</a>
</div>
<div class="col-lg-3 col-md-3">
<a href="#" class="thumbnail">
<img src="http://placehold.it/170x180" alt="170x180">
</a>
</div>
<div class="col-lg-3 col-md-3">
<a href="#" class="thumbnail">
<img src="http://placehold.it/170x180" alt="170x180">
</a>
</div>
<div class="col-lg-3 col-md-3">
<a href="#" class="thumbnail">
<img src="http://placehold.it/170x180" alt="170x180">
</a>
</div>
</div>

注意此处的图片的src属性，我们指向了一个专门提供图片占位符的服务，只需要指定图片的尺寸：以“宽度×长度”的格式，就可以得到一张缩略图，比如访问地址：http://placehold.it/320×100会得到如图5-22所示的效果。

图5-22　占位图片服务

当然，缩略图略经修改，就可以变为一个产品卡，如图5-23所示。

图5-23　多个占位符组成的产品页面

代码非常简单，对于每一个条目，我们添加了一个抬头和一个简短的描述信息：

<div class="col-lg-3 col-md-3">
<a href="#" class="thumbnail">
<img src="http://placehold.it/170x180" alt="170x180">
</a>
<h4>This is product 1</h4>
<p>This is the detail description of product 1</p>
</div>

3．实体元素

实体元素是另外一种用以展示同类元素的组件，比如Gtalk中的联系人列表，邮件中的邮件列表等，在Bootstrap很容易实现实体元素，如图5-24所示。

实现列表在Bootstrap中也非常容易，只需要将列表元素定义在HTML的列表中即可：

<ul class="media-list">
<li class="media">
<a class="pull-left" href="#">
<!-- image -->
</a>
<div class="media-body">
<!-- content -->
</div>
</li>
</ul>

图5-24　Bootstrap中的列表

image部分填写一个图片描述，content部分可以是任意复杂的HTML片段，事实上Bootstrap只是保证将图片显示在左边，内容显示在右边而已。图5-24对应的代码为：

<ul class="media-list">
<li class="media">
<a class="pull-left" href="#">
<img class="media-object" src="http://placehold.it/64x64" alt="64x64">
</a>
<div class="media-body">
<h4>This is product 1</h4>
<p>This is the detail description of product 1</p>
</div>
</li>
<!---->
</ul>

4．列表

一个最简单的列表只需要包含本身的文字即可，为ul元素加上list-group类，然后li元素加上list-group-item类：

<ul class="list-group">
<li class="list-group-item">Inbox</li>
<li class="list-group-item">Very Important</li>
<li class="list-group-item">Already Sent</li>
<li class="list-group-item">Draft</li>
<li class="list-group-item">Junk Mails</li>
</ul>

列表效果如图5-25所示。

图5-25　列表效果

而事实上列表元素的内容区完全可以是任何其他内容，比如添加一些标签和徽标，如图5-26所示。

图5-26　加入徽标和标签的列表

<ul class="list-group">
<li class="list-group-item">
<span class="badge">32</span>Inbox
</li>
<!--...-->
<li class="list-group-item">Already Sent
<div class="pull-right">
<span class="label label-danger">Already Sent</span>
</div>
</li>
<!--...-->
</ul>

5．表单

表单应该算是应用最为广泛的组件了。几乎所有的Web应用上都会有一些表单：用户注册、用户登录、商品信息录入、将细节分享给好友、高级搜索等。

使用Bootstrap可以快速而轻松地创建很漂亮的表单，如图5-27所示。

图5-27　多个输入框组成的表单

表单中的输入框（input）元素，当焦点切换到表单中的input元素上之后，整个框会高亮起来。

<form role="form">
<div class="form-group">
<label for="username">Your Name</label>
<input type="email" class="form-control" id="username" placeholder="Name">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" class="form-control" id="password" placeholder= "Password">
</div>
<div class="form-group">
<label for="password-confrim">Confrim Password</label>
<input type="password" class="form-control" id="password-confrim" placeholder="Password">
</div>
<div class="checkbox">
<label>
<input type="checkbox"> Remember me for 2 weeks
</label>
</div>
<button type="submit" class="btn btn-primary">Submit</button>
</form>

另一方面，如果输入有非法值，input元素还可以很容易地通过不同的颜色来标注为非法。比如一个长度不够的密码输入框，当该元素失焦之后，可以通过设置has-error类来标注这个错误：

<div class="form-group has-error">
<label for="password">Password</label>
<input type="password" class="form-control" id="password" placeholder= "Password">
</div>

效果如图5-28所示。

图5-28　高亮的输入框

6．表格

表格元素是最基本的元素之一，可以将多条类似的记录以直观的形式展现出来。Bootstrap对表格的支持也非常全面，最基本的用法就是给table元素加上table类：

<table class="table">
<thead>
<tr>
<th>Framework</th>
<th>Vendor</th>
<th>Responsive</th>
<th>Less/Sass Support</th>
<th>license</th>
</tr>
</thead>
<tbody>
<tr>
<td>Foundation</td>
<td>ZURB</td>
<td>Yes</td>
<td>Yes</td>
<td>MIT</td>
</tr>
<tr>
<td>Bootstrap</td>
<td>Twitter</td>
<td>Yes</td>
<td>Yes</td>
<td>MIT</td>
</tr>
<tr>
<td>Semantic-ui</td>
<td>Twitter</td>
<td>Yes</td>
<td>Kind of</td>
<td>MIT</td>
</tr>
</tbody>
</table>

Bootstrap会设置基本的属性，比如去掉边框，调整下边框的线条粗细等，如图5-29所示。

图5-29　基本表格样式

当表格中的数据稍微多一些的时候，加上不同背景色的条纹会更加易读，这时候只需要为table添加一个新的类table-striped即可，如图5-30所示。

图5-30　带有条纹的表格样式

这里列出的组件只是Bootstrap提供的强大组件集合中的一小部分，其余的组件我们在用到的时候再做进一步的介绍。

第6章 客户端测试框架

从软件的最终交付的角度来看，软件测试是最重要的开发技能之一。如果代码不经过验证，那谁又有信心将其发布呢？

前端之所以称之为前端，就是因为它在部署中处在整个系统的最前端，它是用户与系统交互的接口，如果系统有任何错误或者异常，总是要通过前端展现出来的。所以对前端代码的测试就显得更加重要。

6.1　Jasmine简介

Jasmine是一个JavaScript的BDD（行为驱动开发，Behavior Driven Development）测试框架。它在代码编写上与Ruby的RSpec非常类似，用Jasmine运行的测试代码看起来是这样的：

describe("Book list", function() {
it("should return empty if none matches", function() {
//...
});
it("should return matched items", function() {
$("#query").val("JavaScript");
$("#search").click();
expect($("#results").find("li")).toEqual(5);
});
});

这样的测试代码直接可读，不熟悉代码的人可以从类似于注释一样的文本中获得足够的说明，从而理解代码的逻辑。事实上，使用Jasmine的HTML插件，在运行时会生成HTML报表，可以非常清晰地看到哪些功能通过了测试，哪些没有，以及错误发生的原因。

这里先简单介绍一下Jasmine中的一些基本概念，在随后的章节中我们会使用Jasmine编写前端测试代码。

Jasmine中，测试用例被组织成测试套件。简而言之，就是把相关的测试归为一组，比如测试登录页面的逻辑就应该放在一个套件中：

describe("login page", function() {
//...
});

在每一个套件中，需要编写一个个特定用例：

describe("login page", function() {
it("should show error when no password given", function () {
});
it("should enable login button after validation", function() {
});
});

注意这里的describe和it函数的第一个参数中的字符串只是说明性的文字，不会影响代码的运行。但是一个好测试一定要注意这些文字的编写，这样一旦发生错误就可以快速定位，而且可以立刻得知这个错误会影响哪些功能点。

6.1.1　Spy功能

作为一个完善的测试框架，Jasmine除了基本的断言和用例的支持外，它提供更为强大的spy功能。

比如实际代码中有对外部应用的依赖：

var realBusiness = function(callback) {
$.ajax({
url: 'business.do',
success: function(data) {
callback(data);
}
})
};
realBusiness(function(list) {
$('#map').html(list);
});

RealBusiness函数依赖于后台的business.do，它会发送请求到business.do，得到数据之后回到传入的callback来更新id为map的DOM元素的内容。

但是在测试中，我们肯定不期望真正发送这个请求，因为后台未必实时都是就绪的，并且后台的依赖也可能不能实时满足（比如数据库中没有数据）。这时候可以使用spy来解决这个问题：

describe('realBuesiness', function() {
it('should send request to server', function() {
spyOn($, 'ajax');
realBusiness(undefined);
expect($.ajax).toHaveBeenCalled();
});
});

此处spy在$.ajax上，那么在后边的代码中，无论何处调用$.ajax，jasmine都不会真实地调用ajax，而是调用这个spy，并且会记录所有的调用记录。

因此这里可以预期$.ajax被调用了（toHaveBeenCalled）。当然，仅仅被调用了还不足以说明程序的正确性，因此jasmine提供了更多的方法：

it('should call callback when success', function() {
var callback = function() {};
spyOn($, 'ajax').andCallFake(function(e) {
e.success({});
});
realBusiness(callback);
expect($.ajax.mostRecentCall.args[0].url).toEqual('business.
do');
});

6.1.2　自定义匹配器

这一小节我们来看一个实例：如何自定义一个匹配器，以及匹配器是如何使得代码的可读性更高，更容易理解。

JSONPath是类似于XPath的一种表达式，用来定位JSON中的一个或者一组节点，比如对于这样的一个对象：

obj= {
languages: [
{
type: "Dynamic",
samples: ["Ruby", "Python"]
},
{
type: "Static",
samples: ["Java", "C"]
},
{
type: "Dynamic",
samples: ["JavaScript"]
}
]
};

对应的JSONPath：

$.languages[?(@.type=="Dynamic")]

表示，从languages数组中，找到type为Dynamic的节点，可以看到，这个表达式会找到两个节点。

我们来编写一个自定义的匹配器，以方便写出这样的代码：

it("should be able to deteming a path is existing", function() {
expect(obj).toHasJsonPath('$.languages[0].type');
expect(obj).toHasJsonPath('$.languages[?(@.type=="Static")]');
});

在Jasmine中完成这个操作很容易，只需要在所有的测试之前调用addMatchers即可：

beforeEach(function () {
this.addMatchers({
toHasJsonPath: function (path) {
var actual = this.actual;
var result = jsonPath(actual, path);
this.message = function () {
return 'Expected ' + JSON.stringify(actual, null, 2) + ' to has path ' + path;
};
return result || result === null;
}
});
});

这样，在实际执行每个测试用例时，toHasJsonPath这个匹配器已经被定义好了。注意在匹配器中，我们可以通过this.actual来引用传入的那个对象。比如：

expect(obj).toHasJsonPath('$.languages[0].type');

obj就是这个的this.actual。而如果出错的话，可以定义一个this.message的函数，这个函数通常打印详细的错误信息，如图6-1所示。

图6-1　控制台中打印的错误提示

最后，匹配器需要返回一个boolean值，表示是否匹配。

6.2　Mocha

Mocha是另一个测试框架，它对异步事件测试的支持更好，也更简单。Mocha是一个基于Node.js的模块，也就是说，你的本地机器上需要安装Node.js。

如果是Mac OSX，安装过程十分简单：

$ brew install node

如果是Linux，可以通过安装预编译包，或者源代码编译的方式。但是一般来说，预编译包的版本都会比较低，所以推荐使用源码编译的方式安装。安装完成之后，你会得到一个npm的可执行脚本。npm是Node的包管理器（Node Package Manager），用来安装基于Node的程序包。

可以通过npm来安装mocha：

$ npm install mocha

Mocha通常会和Chai搭配使用，Chai是一个断言库，它使得测试用例在语法上更易读。Chai也是一个Node.js模块，同样使用npm安装：

$ npm install chai

安装之后，我们就可以在本地建立一个测试环境来进行测试。

6.2.1　Mocha的基本用法

首先创建源代码目录和测试目录：

$ mkdir -p src
$ mkdir -p test

然后编写测试和源代码。我们这里举一个很简单的例子来查看如何运行mocha，并生成HTML格式的报告。

首先可以定义一个HTML文档，其中包含了mocha.css样式文件，mocha.js以及chai.js文件：

<html>
<head>
<meta charset="utf-8">
<title>Mocha Tests</title>
<link rel="stylesheet" href="node_modules/mocha/mocha.css" />
</head>
<body>
<div id="mocha"></div>
<script src="node_modules/mocha/mocha.js"></script>
<script src="node_modules/chai/chai.js"></script>
<script>
mocha.setup('bdd');
mocha.reporter('html');
</script>
<!-- Tests -->
<script src="src/calc.js"></script>
<script src="test/calc-spec.js"></script>
<script>
mocha.run();
</script>
</body>
</html>

注意此处的node_modules目录就是npm安装完mocha和chai生成的目录。在中间的script标签中，我们加载了src/calc.js和test/calc-spec.js文件，calc.js为计算器的实现，calc-spec.js为测试代码。

其中，calc-spec.js的代码如下：

var expect = chai.expect;
describe("Calculator", function() {
it("should return 5 when add 2 and 3", function() {
var calc = new Calculator();
expect(calc.add(2, 3)).to.equal(5);
});
it("should return -1 when sub 2 and 3", function() {
var calc = new Calculator();
expect(calc.sub(2, 3)).to.equal(-1);
});
});

其实，如果不是第一行的expect，这个测试看起来和Jasmine别无二致。Mocha会根据执行情况生成报告，如图6-2所示。

图6-2　Mocha的页面输出

这个失败的用例是因为sub函数并没有实现：

var Calculator = function() {
this.add = function(a, b) {
return a + b;
}
}

事实上，Mocha的优势在于异步测试。

6.2.2　测试异步场景

比如上例的计算器中有一个耗时很长的计算longAdd：

this.longAdd= function(a, b, cb) {
var self = this;
setTimeout(function() {
cb(self.add(a, b));
}, 1000);
}

mocha提供一个简单的方式来完成对异步函数的测试，即在it()函数的回调函数指定一个参数，然后在代码中调用这个回调函数。

it("should return 5 after a while", function(done) {
var calc = new Calculator();
calc.longAdd(2, 3, function(x) {
expect(x).to.equal(5);
done();
});
});

可以看到，mocha等待了1001ms之后得到了结果，如图6-3所示。

图6-3　异步测试

默认地，Mocha会等待2000ms的超时时间。如果需要更长的时间，可以在测试套件中配置：

describe("Calculator", function() {
this.timeout(6000);
it("should return 5 after a while", function(done) {
var calc = new Calculator();
calc.longAdd(2, 3, function(x) {
expect(x).to.equal(5);
done();
});
});
});

实际开发中，会有一些已经想到但是没有实现的功能点。对于这种情况，一种做法就是编写一些说明性的测试：

it("should return 6 when mul 2 and 3");
it("should return 1.5 when div 3 and 2");
it("should throw exception when div by 0");

比如我们发现做出可以计算乘法除法的计算器很重要，但是又不知道如何实现，可以写成这样的形式，这些“测试”会执行，但是在最终的报告中，会以一种不同的形式展现出来，如图6-4所示。

图6-4　测试报告

另外，对于每个测试，还可以在最终的报告中看到代码，只需要点击对应的测试描述即可，如图6-5所示。

图6-5　运行结果报告及对应代码

第7章 现代的前端开发方式

在这一章结束的时候，你会学会下面这些技术：

（1）使用Karma测试运行器来自动执行JavaScript测试。

（2）使用bower作为前端JavaScript库的依赖管理工具。

（3）使用Gulp作为构建脚本，使得很多构建任务自动化。

（4）简单的测试驱动开发的方式。

（5）如何开发一个jquery插件。

7.1　Karma简介

Karma是一个JavaScript的测试运行器，使用Karma可以很方便地运行测试（方便到你感觉不到它的实际存在）。运行器的意思是，Karma本身不会执行具体的测试，而是用来启动浏览器和测试框架，然后由测试框架去执行预先定义好的测试套件。

Karma的优势在于：

（1）基于真实的浏览器，并且支持多个浏览器。

（2）自动监听测试/实现文件的变化，并在变化之后运行测试。

（3）支持多种测试框架，比如Jasmine、Mocha等。

（4）容易调试。

（5）可以方便地与持续集成服务器集成。

本章会使用Jasmine作为测试框架和Karma一起运行。当然，首先需要安装Karma。Karma是一个Node.js包，可以通过npm来安装Karma：

$ npm install karma

安装完成之后，当前目录下会多出一个node_modules目录，里边会有Karma包。我们可以再安装一个karma-cli包，-g参数表示将Karma安装在全局环境中，以便其他项目使用。

$ npm install karma-cli -g

karma-cli会在当前目录的node_modules中查找karma包，并尝试启动这个Karma。如果当前目录没有，则会向全局目录查找。这样做的好处是，每个项目都可以使用不同版本的Karma。

安装完成之后可以通过下面这条命令来查看Karma的版本：

$ karma --version
Karma version: 0.10.10

7.2　前端依赖管理

Bower是一个基于Node.js的依赖管理工具，它也是一个npm包，安装十分简单，由于我们的其他项目也会用到bower，因此将其安装在全局环境中：

$ npm install -g bower

安装完成之后，可以通过bower search来搜索需要的包，比如：

$ bower search backbone.js

通常你会得到类似于这样的结果：

Search results:
jquery.backbone.js git://github.com/fanlia/jquery.backbone.js.git

典型应用场景可能会是这样的，首先新建一个项目目录，然后在该目录中运行bowerinit命令：

$ mkdir -p listing
$ cd listing
$ bower init

Bower会问你一些问题，比如项目名称，项目入口点，作者信息之类，然后生成一个bower.json文件：

{
"name": "listing",
"version": "0.0.0",
"authors": [
"Qiu Juntao <juntao.qiu@gmail.com>"
],
"main": "src/app.js",
"license": "MIT",
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"test",
"tests"
]
}

如果项目中需要安装jQuery和underscore.js，简单的运行bower install命令即可：

$ bower install jquery
$ bower install underscore

而且，使用bower可以安装指定版本的包，比如先通过info子命令查看

$ bower info jquery#1.10.*
bower cached　　　　git://github.com/jquery/jquery.git#1.10.2
bower validate　　　1.10.2 against git://github.com/jquery/jquery. git#1.10.*
{
name: 'jquery',
version: '1.10.2',
description: 'jQuery component',
keywords: [
'jquery',
'component'
],
main: 'jquery.js',
license: 'MIT',
homepage: 'https://github.com/jquery/jquery'
}

然后执行：

$ bower install jquery#1.10.*

即可安装1.10.2版本的jQuery，默认的bower会下载安装最新版本的jQuery。

如果需要团队中的其他成员在本地恢复我们的环境，需要在bower.json中指定dependencies小节：

"dependencies": {
"jquery": "1.10.*",
"underscore": "~1.5.2"
}

默认地，所有JavaScript包都被安装到本地的bower_components目录下。当然可以通过下面的方式来修改。

在当前目录创建一个.bowerrc文件，修改该文件的内容为：

{
"directory": "vendor"
}

然后执行bower install，bower会自动创建目录vendor，并将内容下载到该目录中。

有了bower.json文件，即使本地的bower_components目录（或者在.bowerrc中自定义的目录）不存在，或者其中的包内容过期了，也可以使用bower install将其更新。

7.3　搭建工程

首先创建基本的目录结构，我们将第三方库放到一个单独的目录vendor中，将测试文件放到另一个spec目录中：

$ mkdir –p todo
$ cd todo
$ mkdir -p src/vendor
$ mkdir -p spec/

然后创建一个基本的HTML文件，这个文件将作为最终展示的入口：

$ touch index.html

在使用Karma之前，需要告诉Karma，哪些文件是源代码，哪些文件是测试代码，这样Karma才可以动态地加载这些文件，并监听文件的改动。这个动作可以通过init子命令来完成：

$ karma init

Karma会问一些问题，比如使用哪种浏览器，哪种测试框架等，如图7-1所示。

图7-1　使用Karma命令行工具初始化

填写完成之后，Karma会在当前目录下生成一个karma.conf.js文件，这是一个可以被Node.js执行的JavaScript文件。事实上，karma.conf.js中只是为config设置了一些配置：

module.exports= function(config) {
config.set({
});
};

Karma会在执行时读取这些定义，并应用这些配置。比如这里有一些常见的选项：

{
frameworks: ['jasmine'],
files: [
"src/vendor/underscore/underscore.js",
"src/vendor/jquery/jquery.js",
'src/vendor/jasmine-jquery/lib/jasmine-jquery.js',
"src/todoify.js",
"spec/**/*-spec.js"
],
exclude: [
],
reporters: ['progress'],
port: 9876,
colors: true,
logLevel: config.LOG_INFO,
autoWatch: true,
browsers: ['Chrome'],
singleRun: false
}

frameworks来配置测试框架（比如Jasmine、Mocha等）；files来指定测试中需要加载哪些文件；autoWatch选项指定是否监听文件变化，值为true时，当files中指定的任何文件有变化时，karma会被触发，然后执行所有的测试。你可以在随后编辑这个文件的内容。

我们可以做一个简单的测试，来验证Karma的安装情况。执行start子命令，如图7-2所示。

图7-2　启动Karma服务器

这时候会看到一个错误，Karma抱怨找不到任何测试，我们暂时不用管这个错误。Karma还会启动配置过的（通过browsers选项）浏览器，如图7-3所示。

图7-3　Karma在浏览器中的页面

接下来，我们需要安装jquery和underscore到当前目录的src/vendor文件夹下，因此需要编辑.bowerrc文件：

{
"directory": "src/vendor"
}

然后运行install：

$ bower install jquery underscore

当前的目录结构如图7-4所示。

图7-4　目录结构

有了这些基础设施之后，我们就可以开始真正的开发工作了。

7.4　测试驱动开发

简而言之，测试驱动开发是这样一种开发模式：

（1）在编写任何产品代码之前，先编写测试代码。

（2）运行该测试，这时候测试必然会失败。

（3）用最简单的方式来修复这个失败。

（4）重构代码，使得代码更加清晰，容易扩展。

这种开发方式的初衷是以最小的付出来完成功能，并且会获得很多额外的好处：

（1）编写完善的测试。

（2）避免过度设计。

（3）在修复bug的时候更加有信心，你可以立刻知道修改会引起哪些其他的影响。

通常来看，测试驱动开发可以很大程度上提高代码质量，提高开发效率，降低修复bug的成本。但是测试驱动开发对开发人员的要求也比较高，特别是在环境配置上需要花费一些“额外”的时间。

比如：

（1）如何快速运行测试。

（2）如何在发生错误时快速定位。

（3）测试代码需要用哪种语言编写。

（4）在持续集成环境中是否容易执行测试。

这些问题在服务器端开发中已经得到了很好的解决，但是前端开发，直到最近才有了改善。我们这里使用的Karma运行器、Jasmine测试框架等，都为在前端使用测试驱动开发提供了极大的便利。

7.5　实例Todoify

Web前端现在正处在类似于生物学上的寒武纪：众多框架不断被开发出来，有偏重于大而全的“重量级”框架，有强调只做一件事情的工具包，有严格遵循MVC的小巧框架，又有为客户端做过适配改进的MVVM。

在进入这些令人眼花缭乱的世界之前，我们一起看一个简单的实例，在这个例子中，我们关注于功能以及小巧两个方面。我们将开发一个jQuery插件，这个插件可以将一个普通的HTML输入框变成一个待办事项控件，使用这个控件，用户可以输入一条待办事项，或者删除待办事项列表中的一条记录等。如果用户预先已经有一组待办事项的数据（一个数组或者列表对象），这个控件还可以用可视化的方式将他们展现出来。

在这个实例中，我们会用到最常用而且非常轻量级的两个JavaScript库：jQuery和underscore。

比如用户已经有了这样几条数据：

var mydata = [
"Hello, darkness",
"Tomorrow is another day",
"Never say never"
];

那么，当使用了这个插件之后：

$("#item-input").todoify({
data: mydata,
container: "#item-todos"
});

会得到这样一个列表，如图7-5所示。

如果你之前没有任何的jQuery开发经验，不必担心，我们会从头开始介绍所有的知识。

图7-5　待办事项列表

jQuery是一个JavaScipt函数库，体积小巧且功能强大，它提供非常简洁的方式来选择或者操作DOM元素，注册事件，实现元素的动画，发送ajax请求等。事实上，jQuery已经成为前端开发的标准，我们已经很难见到一个不使用jQuery的网站了。

underscore是另一个小巧的函数库，它更关注JavaScript中对数据的操作。使用underscore可以极大程度上精简对集合的操作，比如从集合中选择出符合某个条件的子集，去掉数组中的重复项，将数组中的所有元素都转化成另外一个形式等。

7.5.1　underscore的一些特性

underscore对外暴露的对象名是一个下划线（_），也就是underscore这个单词本身的意义。

抽取出对象数组中的某个属性，并组成一个新的数组，这个特性在很多情况下非常有用。一般后台返回的数据可能包含很多与展现无关的部分，或者对当前组件无关的数据：

var vendors = [
{
id: 1,
name: "Dell",
address: "U.S"
}, {
id: 2,
name: "HP",
address: "U.S"
}, {
id: 3,
name: "Lenovo",
address: "China"
}
];
var vendorNames = _.pluck(vendors, "name");
//["Dell", "HP", "Lenovo"]
var vendorIds = _.pluck(vendors, "id");
//[1, 2, 3]

取出一个对象的所有键组成的数组：

var vendor = {
id: 3,
name: "Lenovo",
address: "China"
};
var keys = _.keys(vendor);
var values = _.values(vendor);
_keys(vendor)会得到["id", "name", "address"]，而_.values(vendor)会得到[3, "Lenovo", "China"]。

tempalte方法提供一个简单的模板：模板定义HTML的结构，以及一些占位符。_.template()会将这个静态模板编译成一个函数。在这个生成函数上，将数据以参数的形式传入，就会得到最终的字符串（也就是视图）：

var tmpl = "<p>Vendor <%= name %>, from <%= address %></p>";
var tmpl_func = _.template(tmpl);
tmpl_func({name: "Lenovo", address: "China"});

比如，这个例子中，模板tmpl中定义了一个<p>元素，而且预期传入的对象中包含name和address两个属性。调用template之后得到的函数为tmpl_func，将data传入函数会得到：

<p>Vendor Lenovo, from China</p>

而用下列数据调用tmpl_func：

tmpl_func({name: "Dell", address: "U.S"});

则得到输出：

<p>Vendor Dell, from U.S</p>

默认地，template使用ERB的模板语法，如果你更喜欢自己熟悉的模板技术，比如Mustache，只需要简单的配置即可：

_.templateSettings= {
interpolate: /\{\{(.+?)\}\}/g
};
var tmpl = "<p>Vendor {{ name }}, from {{ address }}</p>";
var tmpl_func = _.template(tmpl);
tmpl_func({name: "Lenovo", address: "China"});

当这两个极为轻巧的函数库结合在一起时，会发挥出强大的威力。

7.5.2　jQuery插件基础知识

1．简单流程

通常使用jQuery的流程是这样的：通过选择器选择出一个jQuery对象（集合），然后为这个对象应用一些预定义的函数。例如：

$(".artile .title").mouseover(function(){
$(this).css({
"background-color": "red",
"color": "white"
});
});

如果要定义自己的插件，预期其被调用的方式和此处的mouseover一致，需要将我们定义的函数附加到jQuery对象的fn属性上：

$.fn.hltitle= function() {
this.mouseover(function(){
$(this).css({
"background-color": "red",
"color": "white"
})
})
};
$('.article .title').hltitle();

jQuery的一个很明显的特点是其链式操作，即每次调用完成一个函数/插件之后仍然会返回jQuery对象本身，这个需要我们在插件函数的最后一行返回this。这样插件的使用者会像使用其他函数/插件一样方便地调用。

另外一个问题是注意命名冲突。在JavaScript中，符号“$”是一个被众多的JavaScript库使用的标识符（在JavaScript中，这是一个合法的标识符），可能与此处jQuery中的$冲突，所以可以通过匿名执行函数来避免：

(function($){
$.fn.hltitle = function() {
//...
}
}(jQuery));

2．需要注意的问题

上面是一个最简单的插件定义，为了插件更加灵活，我们需要尽可能多地将配置项暴露给插件用户，比如提供一些默认选项，如果用户不提供配置，则插件按照默认配置来工作，但是用户可以通过修改配置来定制插件的行为：

(function($){
$.fn.hltitle = function(options) {
var defaults = {
"background-color": "red",
"color": "white"
};
var settings = $.extend(defaults, options);
return this.mouseover(...);
};
}(jQuery));

7.5.3　Todoify

使用jQuery的插件机制来完成Todoify的功能，是我们目前唯一明确的背景，因此我们的第一个测试可以从这一点出发：

1．测试：运行起来

根据上面的背景，第一个测试很明确：

describe('todoify', function() {
it('should have been defined', function() {
expect($.fn.todoify).toBeDefined();
});
});

将这个片段保存为todoify-spec.js，该片段测试$.fn上已经附加了我们的插件函数。这样当别人使用插件时，就可以直接使用选择器选中元素，然后调用todoify即可。

此时，karma.conf.js中的配置应该是这样的：

files: [
"src/vendor/jquery/jquery.js",
"src/vendor/underscore/underscore.js",
"src/todoify.js",
"spec/**/*-spec.js"
],

当然，这时候运行Karma，会看到一个错误，如图7-6所示。

图7-6　控制台上的错误信息

提示todoify-spec.js的第3行有错误，期望一个undefined的值为defined。这个错误很容易修复，我们在src/todoify.js中加入这样的代码：

$.fn.todoify= function(){}

再次运行测试，如图7-7所示。

图7-7　测试通过（注意最后一行的SUCCESS）

可以看到最后一行中，测试已经执行成功了。应该注意的是，这个过程中，Karma一直运行在后台，一旦我们修改了测试或者实现代码，Karma都会自动运行一次测试。这样我们可以得到实时的反馈：哪些测试成功了，哪一行代码以何种方式运行失败了。

2．测试：链式操作

jQuery的一个强大特性是方便的链式操作，我们需要todoify也支持这个特性，可以写一个新的测试：

it('should be chainable after invoke', function() {
var jq = $.fn.todoify();
expect(jq.jquery).toBeDefined();
});

Karma会报告一个错误，如图7-8所示。

图7-8　又一个错误

第8行报错了，这是因为我们第一步的实现仅仅是简单地让测试通过而已，并没有实质性的代码，所以需要加上一点真实的功能：

$.fn.todoify= function(){
return this;
}

3．测试：显示一个条目

接下来我们需要做一些“实际”的事情了。首先我们的插件需要显示一个条目，那么可以先写如下的测试：

it('should render an one-item list', function() {
$('input').todoify({
data: ['one-item'],
to: '#todo-container'
});
expect($('#todo-container').find('.todo').length).toBe(1);
expect($('#todo-container').find('.todo').text()).toBe('one- item');
});

给定一个input元素，传入一个数组数据，并且传入一个HTML元素作为容器，我们预期这个元素中会多出一个class为todo的元素，并且这个元素中的文本是传入的数据one-item。

此时运行测试，会看到报告的错误，我们进一步加入实现代码：

$.fn.todoify= function(options){
var settings = options || {};
var todo = $('<span></span>').addClass('todo');
todo.text(options.data[0]);
$(options.to).append(todo);
return this;
}

即使代码上看不出问题，但测试仍然是失败的。追踪原因之后，我们会发现，测试中的input不知道从何而来。

理论上，这个input是我们在实际调用时传入到页面上的input元素，那么在测试中，我们又如何得知这个元素的名字呢？这里需要在测试页面上预先加入一些用于测试的元素，这些用于测试目的的数据被称为fixture。

这里需要另外一个JavaScript库：Jasmine-jquery。使用Jasmine-jquery可以很容易地加载外部文件作为fixture，以方便单元测试。我们可以通过bower来安装这个库：

$ bower install jasmine-jquery#1.6.0

另外在karma.conf.js中加入对jasmine-jquery的引用：

files: [
"src/vendor/jquery/jquery.js",
"src/vendor/underscore/underscore.js",
'src/vendor/jasmine-jquery/lib/jasmine-jquery.js',
"src/todoify.js",
"spec/**/*-spec.js",
{pattern: 'spec/fixtures/*.html', included: false, served: true}
],

注意此处，我们加入了pattern这样一行代码来告诉Karma加载spec/fixtures目录下HTML文件作为fixture。

而在spec/fixtures目录下，我们会创建一个todo.html，内容为：

<div>
<input type="text" name="" id="" value="" />
<div id="todo-container" />
</div>

这样，jasmine-jquery就会先将这个片段插入到测试页面上，然后测试就可以找到input元素，并将其变成一个todo。这时，我们需要在测试中加入加载HTML片段的代码：

beforeEach(function(){
var fixtures　= jasmine.getFixtures();
jasmine.getFixtures().fixturesPath = 'base/spec/fixtures/';
fixtures.load('todo.html');
});

对应的实现代码也调整为：

$.fn.todoify= function(options){
var settings = $.extend({
data: [],
to: "body"
}, options);
var todo = $('<span></span>').addClass('todo');
todo.text(settings.data[0]);
$(settings.to).append(todo);
return this;
}

4．测试：显示多个条目

对于多个条目，测试非常类似：

it('should render multiple items', function() {
$('input').todoify({
data: ['one-item', 'two-item', 'three-item'],
to: '#todo-container'
});
expect($('#todo-container').find('.todo').length).toBe(3);
});

此时，测试会抱怨，如图7-9所示。

图7-9　多个条目时测试失败

因此我们需要调整实现代码：

$.fn.todoify= function(options){
var settings = $.extend({
data: [],
to: "body"
}, options);
var render = function(item) {
var todo = $('<span></span>').addClass('todo');
todo.text(item);
$(settings.to).append(todo);
};
settings.data.forEach(render);
return this;
};

这时候，测试代码显得有些凌乱了，我们需要为各个测试用例分组，所有关于初始化的测试归为一组：

describe("initialize", function() {
it('should have been defined', function() {
expect($.fn.todoify).toBeDefined();
});
it('should be chainable after invoke', function() {
var jq = $.fn.todoify();
expect(jq.jquery).toBeDefined();
});
});

对应的，关于数据渲染的归为一组：

describe("with static data", function() {
beforeEach(function(){
var fixtures　= jasmine.getFixtures();
jasmine.getFixtures().fixturesPath = 'base/spec/fixtures/';
fixtures.load('todo.html');
});
it('should render an one-item list', function() {
$('input').todoify({
data: ['one-item'],
to: '#todo-container'
});
expect($('#todo-container').find('.todo').length).toBe(1);
expect($('#todo-container').find('.todo').text()).toBe ('one-item');
});
it('should render multiple items', function() {
$('input').todoify({
data: ['one-item', 'two-item', 'three-item'],
to: '#todo-container'
});
expect($('#todo-container').find('.todo').length).toBe(3);
});
});

5．测试：添加条目

我们刚刚完成了一个测试失败-测试通过-代码重构的完整周期，相信读者已经对测试驱动开发的方式有了一些认识，所以很容易就可以想到如何编写添加条目的测试：

it('should be able to add new item to empty list', function() {
$('input').todoify({
data: [],
to: '#todo-container'
});
expect($('#todo-container').find('.todo').length).toBe(0);
$('input').val('new item').pressEnter();
expect($('#todo-container').find('.todo').length).toBe(1);
expect($('#todo-container').find('.todo').text()).toBe('new item');
});

假设我们有一个空的列表，当在输入框中添加了新项目之后，列表应该会增加一个条目，并且条目的内容恰好为填入的内容“new item”。

注意此处的pressEnter，这个函数模拟了用户按下回车键的动作，其实现为：

$.fn.pressEnter= function () {
var e = $.Event("keypress");
e.keyCode = 13;
$(this).trigger(e);
};

同样，测试会报错，我们加入实现代码使得测试通过：

var eventHandler = function(event) {
if(event.keyCode === 13) {
var item = $(this).val();
render(item);
$(this).val('').focus();
}
};
$(this).keypress(eventHandler);

6．测试：添加额外的条目

这个测试事实上是一个用例的扩展，如果我们在一个已有的列表中插入新的条目，应该不会影响已有的条目：

it('should be able to add new item to list has items', function() {
$('input').todoify({
data: ['one-item'],
to: '#todo-container'
});
expect($('#todo-container').find('.todo').length).toBe(1);
$('input').val('new item').pressEnter();
expect($('#todo-container').find('.todo').length).toBe(2);
});

同样，这个测试与已有的添加单独条目到空表是有一定关系的，因此可以归为一组：

describe('manipulate todos', function() {
it('should be able to add new item to empty list', function() {
//...
});
it('should be able to add new item to list has items', function() {
//...
});
});

7.5.4　进一步改进

读者可以进一步使用测试驱动开发的方式来完成：

（1）删除一个条目。

（2）用户自定义模板（目前是一个简单的span元素，如果用户需要更复杂的呢？），如图7-10所示。

（3）与后台服务通信。

图7-10　用户自定制样式

目前，todoify还没有与后台进行任何的通信，如果可以和后台的RESTFul的API集成，这个插件将会有更多的使用场景。

简单来讲，只需要为插件提供更多选项，并提供回调函数即可，比如：

$("#input").todoify({
resource: 'http://app/todos',
onadd: function(item){
//...
},
ondelete: function(item){
//...
}
});

然后加入对应的ajax调用。就目前来说，这个例子已经足够。

第8章 编写更容易维护的JavaScript代码

在Web技术正处于井喷状态的今天，各种新的库、新的框架、新的工具层出不穷。但是对于一些小型项目而言，可能并不一定要启用“重量级”的框架。

一直以来，对于小型或者微型项目，开发人员倾向于使用jQuery来完成对页面的控制，例如DOM操作，事件绑定，发送网络请求等。大量的jQuery插件被开发出来，使得人们编写JavaScript的方式完全改变。但是随着代码量的增加和前端代码复杂性的提升，一个明显的问题被人们重视起来：如何测试这些JavaScript代码。

我们这里将讨论一个具体的例子，先查看传统的实现方式。随后变换一个视角，从如何更方便测试的角度去重构代码，从而编写出更加清晰易读、容易维护的代码。

8.1　一个实例

现在有这样一个应用，系统中录入了很多地点信息，用户可以搜索自己感兴趣的地点，还可以标记搜索结果中的地点。编辑为“喜欢”的地点会显示在右边栏中，如图8-1所示。

图8-1　搜索结果页面

在代码编写过程中，我们暂时不需要考虑用户注册、用户登录、保存数据到数据库等实际功能，而把目光仅仅关注在前端界面上。

基本功能可以分解为：

（1）用户填写地点名称，点击查找按钮时，发送请求到后台，并获得匹配的数据。

（2）根据这些数据，动态地生成新条目。

（3）为每个新条目注册事件，当点击这些条目时，将条目添加到页面的另一个位置。

如果页面的结构是这样的:

<div id="container">
<div id="searchForm">
<input type="text" id="locationInput" value="" placeholder="location for search"/>
<input type="button" class="submit" id="searchButton" value="search" />
</div>
<div id="searchResults">
<h4>Search results:</h4>
<ul />
</div>
<div id="likedPlaces">
<h4>Places I liked:</h4>
<ul />
</div>
</div>

那么一个对应的实现很自然地与前面列出的几点相匹配。首先，选中几个主要的元素：

var loc = $("#locationInput");
var searchResults = $("#searchResults ul");
var liked = $("#likedPlaces ul");

然后为提交按钮注册事件：

$("#searchButton").on("click", function() {
var location = $.trim(loc.val());
$.ajax({
url: '/locations/'+location,
dataType: 'json',
success: function(locations) {
getTemplate('location-detail.tmpl').then(function(template) {
var tmpl = _.template(template);
searchResults.html(tmpl({locations: locations}));
});
},
error: function(xhr, status, error) {
console.log("err: " + error);
}
});
});

当用户点击search按钮时，这段代码会发送请求到后台。如果成功，则先获取一个名为location-detail.tmpl的模板，然后将请求到的数据locations与这个模板结合，并将结果设置为searchResults元素的内容。

获取模板事实上也是发送一次请求，然后将请求到的文件内容缓存到前端：

var cache = {};
function getTemplate(template) {
if(!cache[template]) {
cache[template] = $.get("templates/" + template);
}
return cache[template];
}

当第二次点击search按钮的时候，就无需再发送一次请求，而直接从本地的cache对象中获取。

这里的模板location-detail.tmpl的内容为：

<% _.each(locations, function(location) { %>
<li>
<div class="title">
<span>
<%= location.name %>
</span>
<span>
<a href="#" class="like">Like</a>
</span>
</div>
</li>
<% })%>

这是一段underscore的模板，内嵌的_.each语句会遍历locations数组变量，然后将每一个元素的name字段取出来，添加到title下的span中作为输出。

在完成了对searchResults内容的填充之后，还需要绑定事件，这样当用户点击条目时，才可以将条目标记为“喜欢”：

searchResults.on('click', '.like', function() {
var loc = $(this).closest('.title').find('span:nth(0)').text();
$('<li></li>', {text: loc}).appendTo(liked);
});

整体上来说，这段代码还算清晰，很容易读懂代码的意图。那么如何测试这段代码呢？

it("should return locations based on the search critria",
function() {
$("#locationInput").val("Melbourne");
$("#searchButton").click();
waitsFor(function() {
return $("#searchResults ul li").length >0;
}, 2000);
runs(function() {
expect($("#searchResults ul li").length).toEqual(4);
expect($("#likedPlaces ul li").length).toEqual(0);
});
});

假设后台会返回4个与Melbourne这个地方相关的数据，那么这个测试是有意义的，但是它明显已经是一个端到端测试了。除此之外，我们还可以看到这段代码有几个明显的缺点：

（1）所有的逻辑都写在$(document).ready中。

（2）DOM操作和逻辑混合在一起。

（3）如果有进一步的扩展，比如在页面的底部也放一个search按钮，代码不能重用。

（4）无法完成单元测试。

8.2　重构：更容易测试的代码

我们再来看看如图8-2所示的页面：

图8-2　最终结果页面

如果要划分组件的话，很自然地可以划分为三个，如图8-3所示。

（1）搜索框（search box）。

（2）结果集（search results）。

（3）点过赞的地方（liked）。

图8-3　一个合理的划分

8.2.1　搜索框

搜索框组件可以由一个输入框和一个按钮组成，当点击按钮时，该组件会发起一个请求。如果再进一步，点击按钮时，会触发一个事件，而事件的响应则交给具体的监听器来完成。

那么搜索框的实现就非常简单了：

var SearchForm = function(form) {
this.$element = $(form);
this.$element.on('click', '.submit', _.bind(this._bindSubmit, this));
};
SearchForm.prototype._bindSubmit = function(e) {
var text = this.$element.find('input[type="text"]').val();
$(document).trigger('search', [text]);
};

而且重要的是，我们可以很容易地编写对这个组件的单元测试：

describe("search form", function() {
var form =
$("<div><input type='text' /><input class='submit'/></div>");
var searchForm;
beforeEach(function() {
searchForm = new SearchForm(form);
});
it("should construct a new form", function() {
expect(searchForm).toBeDefined();
});
it("should trigger search when I click submit", function() {
var spyEvent = spyOnEvent(document, 'search');
form.find('.submit').click();
expect(spyEvent).toHaveBeenTriggered();
});
});

当在“页面”上点击submit时，可以看到它触发了一个search的全局事件。至于谁来捕获这个事件，则可以交由下一个组件来处理。

8.2.2　发送请求

我们完全可以将发送请求的动作独立出来，作为一个组件来进行测试。

it("should create new search", function() {
expect(search).toBeDefined();
});
it("should fetch data from remote", function() {
var r = search.fetch("Melbourne");
expect($.ajax).toHaveBeenCalled();
expect($.ajax.mostRecentCall.args[0]).toContain("Melbourne");;
});

在第二个用例中，我们预期jQuery的ajax方法被调用了，并且当fetch的参数为Melbourne的时候，我们对ajax的调用也包含了该字符串。

当然，我们不需要真实的调用后台服务，只需要做一个简单的spy即可：

var search;
beforeEach(function() {
spyOn($, 'ajax').andCallFake(function(e) {
return {
then: function(){}
}
});
search = new Search();
});

即当调用$.ajax的时候，jasmine实际上会调用这个假的函数，这个函数会返回一个对象，对象中有一个名为then的空方法。

实现中，我们使用了jQuery的promise对象，使得代码更加简洁，这也是上边的测试中为何会出现then的原因。这里不深入讨论promise异步模型，我们会在后边的章节讲解。

var Search = function() {};
Search.prototype.fetch = function(query) {
var dfd;
if(!query) {
dfd = $.Deferred();
dfd.resolove([]);
return dfd.promise();
}
return $.ajax('/locations/' + query, {
dataType: 'json'
}).then(function(resp) {
return resp;
});
};

完成了搜索之后，我们来看看结果集。

8.2.3　结果集

结果集作为一个独立的组件，可以被设置值。既然是结果集，它自然接受一个数组作为参数，并将数组包装成DOM元素。另外，它还需要响应事件，当用户点击列表中的任意一个元素时，会触发一个全局的事件。

我们可以先从单元测试来看结果集组件需要哪些接口，首先是设置：

var ul;
var searchResults;
var results = [
{name: "Richmond"},
{name: "Melbourne"},
{name: "Dockland"}
];
beforeEach(function() {
ul = $("<ul></ul>");
searchResults = new SearchResults(ul);
});

下面的几个用例清楚地体现了结果集组件的对外接口：

it("should constructor a new search result", function() {
expect(searchResults).toBeDefined();
});
it("should set search result", function() {
searchResults.setResults(results);
waitsFor(function() {
return ul.find("li").length >0;
}, 1000);
runs(function() {
expect(ul.find("li").length).toBe(3);
var location = $.trim(ul.find("li").eq(0).find(".title").text
());
expect(location).toContain("Richmond");
});
});
it("should like one of the search results", function() {
searchResults.setResults(results);
var spyEvent = spyOnEvent(document, 'like');
waitsFor(function() {
return ul.find("li").length >0;
}, 1000);
runs(function() {
ul.find('li').first().find('.like').click();
expect(spyEvent).toHaveBeenTriggered();
});
});

注意，我们现在可以在任何时刻设置结果集：setResults([])。这里的结果可能来源于一个静态数组，或者来源于网络上的一个JSON片段，可以是任意的数据源！结果集组件和其数据源完全解耦合了。

结果集组件的实现也变得非常高内聚：

var SearchResults = function(element) {
this.$element = $(element);
this.$element.on('click', '.like', _.bind(this._bindClick, this));
};
SearchResults.prototype._bindClick = function(e) {
var name = $(e.target).closest('.title').find('h4').text();
$(document).trigger('like', [name]);
};
SearchResults.prototype.setResults = function(locations) {
var template = $.get('templates/location-detail.tmpl');
var that = this;
template.then(function(tmpl) {
var html = _.template(tmpl, {locations: locations});
that.$element.html(html);
});
};

可以看到，加载模板的代码被放在了SearchResults内部。外界不再，也无需知道其内部的实现机制。

点过赞的地方

同样，我们可以从测试代码入手：

describe("like list", function() {
var ul;
var like;
beforeEach(function() {
ul = $("<ul></ul>");
like = new Like(ul);
});
it("should constructor a new list", function() {
expect(like).toBeDefined();
});
it("should add new item", function() {
like.add("juntao");
expect(ul.find("li").length).toBe(1);
expect(ul.find("li").eq(0).text()).toEqual("juntao");
});
});

它有一个add的接口，可以供外部调用，将元素添加到自身上。在实现上，它将一个ul包装起来，然后当add被调用时，包装一个li元素，并添加在自身的ul上。

var Like = function(element) {
this.$element = $(element);
return this;
};
Like.prototype.add = function(item) {
var element = $("<li></li>", {text: item}).addClass('like');
element.appendTo(this.$element);
};

这样，当我们为like注册事件时，就完全不需要修改别的文件，影响范围也非常小。事实上，只要对外的接口：add方法不变，Like可以被实现成任意的形式。

8.2.4　放在一起

如果我们将所有的组件放在一起，会得到这样一段代码：

$(function() {
var searchForm = new SearchForm("#searchForm");
var searchResults = new SearchResults("#searchResults ul");
var liked = new Like("#likedPlaces ul");
var search = new Search();
$(document).on('search', function(event, query) {
search.fetch(query).then(function(locations) {
searchResults.setResults(locations);
});
});
$(document).on('like', function(e, name) {
liked.add(name);
});
});

代码当然比刚开始的时候更加清晰，每个部分都是独立的组件，互相之间不会直接依赖。但是，你可能已经发现了，代码量反而增多了！由1个文件变成了5个文件，而且有了众多的测试代码。运行这些测试，结果如图8-4所示。

但是这一切都是值得的，当你需要重用某个组件的时候，拿去用就是了！而且有了测试的保证，每个组件都可以做到更加健壮，更加灵活。

图8-4　Jasmine测试报告

最后，我们获得了9个测试，4个组件，而且这些组件完全可以灵活地组装起来。

有了基本的组件，修改过的实现可以更快速地响应需求的变化。比如，我们需要在界面的左边添加另外一个“喜欢的地方”的组件，如图8-5所示。

图8-5　执行结果

只需要调整对应的DOM元素：

<div>
<div id="likedPlaces1">
<h4>Places I liked:</h4>
<ul />
</div>
<div id="searchResults">
<h4>Search results:</h4>
<ul />
</div>
<div id="likedPlaces2">
<h4>Places I liked:</h4>
<ul />
</div>
</div>

然后在JavaScript中加入：

var liked1 = new Like("#likedPlaces1 ul");
var liked2 = new Like("#likedPlaces2 ul");
//...
$(document).on('like', function(e, name) {
liked1.add(name);
liked2.add(name);
});

即可完成新的需求。

8.3　关注点分离：另一种实现方式

用面向对象的方式将界面元素抽象为独立的组件，使得每一个组件都可以独立测试，并完成自管理，这种方式当然比将所有内容混在一起要好得多。但是我们还可以从另外的角度出发，继续改善既有代码。

关注点分离是一种常用的系统解耦方式，即将不同职责的代码归类起来。既然我们无法避免对DOM元素的操作，那么就将这些与DOM相关的操作封装到表示视图（view）的抽象中。

在应用程序中，我们事实上并不是非常关心对页面元素的操作，比如我们在例子中关注的是：当用户输入关键字，点击搜索，得到结果集这个过程。这个过程也正是这个应用程序存在的核心意义。我们可以把这个过程单独抽取出来，形成一个对象。

最后，我们需要依赖后端的服务程序。这个服务程序是独立存在的，它可以给任何需要的地方提供搜索服务。

一个简单的示意图如图8-6所示。

图8-6　关注点分离之后的结构

SearchLogic将这些不同的部分组装起来，而这些组件之间可能并不知道对方的存在。

8.3.1　搜索服务

搜索服务应该是最简单，而且最独立的模块，只需要指定一个搜索的端点（数据的提供者），然后暴露一个search的接口即可：

function SearchService(url) {
this.search = function(location, successcb, errorcb) {
$.ajax({
url: url + location,
dataType: 'json',
success: successcb,
error: errorcb
});
};
}

由于搜索服务是一个异步调用，我们将搜索成功以及搜索失败的函数作为参数传入，这样既便于测试，也可以获得更大的灵活性。

8.3.2　结果视图

结果视图作为一个视图，仅仅需要正确的渲染即可。另外，当发生意外错误时，结果视图需要显示一个错误信息。

function SearchResultView(container) {
this.render = function(data) {
$(container).html('');
$(data).each(function(index, loc) {
var li = $("<li></li>").html(loc.name);
$(container).append(li);
li.on('click', function(e) {
var loc = $(e.target).text();
$(document).trigger('like', [loc]);
});
});
};
this.renderError = function() {
$(container).text("something went wrong");
};
}

应该注意的是，我们将所有DOM相关的操作封装在此处，而至于何时展现，则应该剥离到别的对象中。

render方法可以遍历传入的data，然后创建新的li元素，绑定事件。注意此处，我们只是简单地自定义了一个事件，并在点击时将这个事件触发到document上。

8.3.3　搜索框视图

搜索框视图的实现非常简单，它需要一个获取框中内容的接口，这样调用者可以在任何时刻调用这个接口来获得搜索条件。另外，它需要公开一个接口，方便别人注册在其上，当点击搜索按钮时，调用这个注册过的回调函数。

function SearchLocationView() {
this.getLocation = function() {
return $("#location").val();
};
this.addSearchHandler = function(callback) {
$("#search").on('click', callback);
};
}

8.3.4　搜索逻辑

有了这些简单逻辑之后，该应用的核心代码就会变成：

function SearchLocationLogic(formView, resultView, service) {
this.launch = function() {
formView.addSearchHandler(this.updateSearchResults);
};
this.updateSearchResults = function() {
var location = formView.getLocation();
if(location) {
service.search(location,resultView.render,resultView.
renderError);
}
};
}

当逻辑部分启动时，它会为搜索框注册一个回调函数，当点击搜索框的搜索按钮时，这个回调会被调用。将这个函数定义为一个命名函数（而不是一个匿名函数）的好处是，我们可以在不触发点击事件的情况下测试这个代码块。

这个函数会先从搜索框视图中获取关键字，然后发起一次对搜索服务的调用，调用的回调则分别指向结果视图的render和renderError。

8.3.5　放在一起

这时候，应用程序的入口将会变为：

$(function() {
var searchResults = new SearchResultView("#searchResults ul");
var searchLocation = new SearchLocationView();
var searchService = new SearchService("http://localhost:9292/ locations/");
var searchLogic = new SearchLocationLogic(searchLocation, search Results, searchService);
searchLogic.launch();
var liked = new LikeView("#liked ul");
$(document).on('like', function(e, loc) {
liked.render(loc);
});
});

此处的LikeView是一个更加简单的独立视图：

function LikeView(container) {
this.render = function(data) {
var li = $("<li></li>").text(data);
$(container).append(li);
};
}

代码越小巧，犯错的可能也越小，而且一旦出现错误，也可以很容易定位并修复。

8.3.6　更容易测试的代码

由于视图的分离，应用程序的核心逻辑被包装到了搜索逻辑部分，如果我们可以保证这部分代码的质量，视图部分事实上是无需测试的（视图已经被简化为简单的值-对象，类似于Java中的POJO）。

对于逻辑部分的测试，我们需要创建一些mock对象：

var formView;
var searchResultView;
var searchService;
beforeEach(function() {
formView = jasmine.createSpyObj('SearchLocationView', ['getLoca- tion']);
searchResultView = jasmine.createSpyObj('SearchResultView', ['render', 'renderError']);
searchService = jasmine.createSpyObj('SearchService', ['search']);
});

然后，需要验证各个组件间的交互是正确的：

it("do search logic", function() {
var logic = new SearchLocationLogic(formView, searchResultView, search Service);
logic.updateSearchResults();
expect(formView.getLocation).toHaveBeenCalled();
});

即当调用updateSearchResults时，需要保证搜索框视图的getLocation被调用了。另外一个测试场景是：

it("search for something", function() {
formView = jasmine.createSpy('SearchLocationView');
formView.getLocation = jasmine.createSpy('getLocation').andCallFake (function() {
return "Melbourne";
});
var logic = new SearchLocationLogic(formView, searchResultView, searchService);
logic.updateSearchResults();
expect(searchService.search).toHaveBeenCalled();
expect(searchService.search.mostRecentCall.args[0])
.toEqual("Melbourne");
expect(searchService.search.mostRecentCall.args[1])
.toEqual(searchResultView.render);
expect(searchService.search.mostRecentCall.args[2])
.toEqual(searchResultView.renderError);
});

即确保调用updateSearchResults时，传递的参数是正确的。

测试搜索服务这种独立的模块则更加容易：

describe("search service", function() {
it("call ajax underline", function() {
var spy = spyOn($, 'ajax');
var service = new SearchService("http://whatsoever.service");
service.search("terms");
expect($.ajax).toHaveBeenCalled();
});
});

应该注意的是，此处我们无需测试任何视图代码。在视图中，对DOM的增删查改无需特别测试，而关于事件的触发等可以移至更高层级的测试中，比如基于Selenium的测试。

第9章 本地构建

构建是指通过自动化的方式，将软件由源码变成可发布的包。传统意义上，这个过程仅仅包含对源代码的预编译、编译、链接、混淆等，变成可执行的应用程序。而在现代的开发流程上，这个过程还应该包括编译、测试（单元测试、集成测试、端到端测试等），打包等一系列的构建动作。

构建动作由来已久，比如传统UNIX世界的make程序。Java世界的Ant，Maven，Gradle等。每个主流的编程语言都会有相关的构建工具。

9.1　Ruby中的构建

9.1.1　Rake

Rake是Ruby世界中的Make。它和Make的工作原理类似，它会在当前目录寻找一个名为Rakefile的文件，然后根据文件中的指令来运行。

Rakefile中定义任务，这些任务可以依赖于其他任务。这和开发过程是自然吻合的，比如发布任务依赖于测试任务的通过，测试任务依赖于编译、链接的通过，编译任务依赖于预编译的通过等等。

Rake是一个Ruby的gem，因此安装Rake需要使用：

$ gem install rake

使用Rake提供的DSL，在Rake中定义一个任务非常容易，并且读起来也非常顺畅：

task :default do
puts "Default task here"
end

这段代码定义了名叫default的任务，这个任务并不依赖与其他的任何任务，当执行时，它只是打印一行“Default task here”。

执行这个任务的命令为：

$ rake default

default为任务的名称。事实上，如果不指定任何的任务名，Rake默认地会执行名字为default的任务，因此上边的命令可以简化为：

$ rake

1．任务依赖

Rake在定义任务之间依赖时，只需要一个“=>”，虽然是一个Ruby中内置的操作符号，但是读起来更加地有意义：

task :run do
puts "launch application"
end
task :default => :run do
puts "Default task here"
end

此处的default任务依赖于run任务。执行时，run任务会先执行，如果成功，才会执行default任务：

$ rake
launch application
Default task here

如果一个任务依赖于多个任务，只需要提供一个数组即可：

task :start_nginx do
puts "start nginx on port 80"
end
task :connect_db do
puts "connect database MySQL"
end
task :run => [:start_nginx, :connect_db] do
puts "launch application"
end
task :default => :run do
puts "Default task here"
end

run任务依赖于启动nginx任务start_nginx，还依赖于连接数据库的任务connect_db，运行结果如下：

$ rake
start nginx on port 80
connect database MySQL
launch application
Default task here

2．查看任务描述

如果一个Rakefile中定义了很多任务，那么如何快速查看它提供的任务以及相应任务的描述呢？

只需要在任务上用desc定义一个用以描述任务的字符串即可：

desc "start nginx on port 80"
task :start_nginx do
puts "start nginx on port 80"
end
desc "connect to database"
task :connect_db do
puts "connect database MySQL"
end
desc "launch the application"
task :run => [:start_nginx, :connect_db] do
puts "launch application"
end

然后以-T选项来执行Rake，就可以看到详细的描述：

$ rake -T
rake connect_db　 # connect to database
rake run　　　　　# launch the application
rake start_nginx　# start nginx on port 80

注意，对于某些临时任务，我们无需编写desc描述，这样当rake -T时就不会看到关于这个任务的描述了。

3．带有参数的任务

实际开发中，任务很少是只有一种情况的，比如Web服务器可能选择Nginx也可能是Apache，数据库可能是MySQL也可能是Postgres。在Rake中，定义这种带有参数的任务非常容易：

desc "connect to database"
task :connect_db, [:db_type] do |t, args|
args.with_defaults(:db_type => "MySQL")
puts "connect database #{args.db_type}"
end

任务connnect_db可以传入一个表示数据库类型的参数，如果不传入参数，with_defaults会将其值填为MySQL。可以通过args.db_type来引用到这个参数的值。

$ rake connect_db["postgres"]
connect database postgres

4．命名空间

为了将逻辑上的一组任务归类起来，Rake提供了命名空间的支持，我们可以将之前定义的任务归类到myapp的组中：

namespace :myapp do
desc "start nginx on port 80"
#...
desc "connect to database"
#...
desc "launch the application"
#...
end

而default任务需要通过名字空间来引用run任务：

task :default =>"myapp:run" do
puts "Default task here"
end

这时候如果查看任务列表的话，会看到所有的任务都带有了名字空间：

$ rake -T
rake myapp:connect_db[db_type]　# connect to database
rake myapp:run　　　　　　　　　　# launch the application
rake myapp:start_nginx　　　　　# start nginx on port 80

5．文件任务

开发中会遇到很多文件操作，比如将一个模板文件和数据文件合并为一个配置文件，将所有的JavaScript文件合并为一个大的JavaScript文件，预编译SCSS为CSS文件等。Rake也提供了对这个过程的支持，这种特殊的任务可以称之为文件任务。

比如，定义一个文件任务，这个任务会生成一个nginx.conf文件，而这个文件依赖于其他两个文件的存在：nginx.conf.templ和nginx.conf.yaml。

desc "generate nginx configure file"
file 'nginx.conf' => ['nginx.conf.templ', 'nginx.yaml'] do
puts "combine template and yaml together"
end

文件任务看起来和普通的任务区别不大，不过它有两个特点：

（1）如果依赖的文件不存在，那么这个任务不会执行。

（2）如果依赖的文件没有这个文件新，那么这个任务也不会被执行。

如果nginx.conf.templ或者nginx.yaml不存在：

$ rake nginx.conf
rake aborted!
Don't know how to build task 'nginx.conf.templ'
Tasks: TOP => nginx.conf

当两个文件都存在，并且nginx.conf没有两个文件中的任何一个新（也就是说，它可能已经过期了）时，这个任务才会执行：

$ rake nginx.conf
combine template and yaml together

我们再来看一个任务，这个任务将当前目录下的templates里的所有haml模板合并成一个文件：

templates_files = FileList['templates/*.haml']
all_in_one = 'all_in_one.haml'
templates_files.each do |template|
file all_in_one => template do
puts "combine #{template} into #{all_in_one}"
end
end

FileList接受通配符，因此很容易得到所有符合该通配符的文件的列表，有了这个列表我们就可以定义一个文件任务来完成合并等操作：

$ rake all_in_one.haml
combine templates/1.haml into all_in_one.haml
combine templates/2.haml into all_in_one.haml

9.1.2　Guard

Guard是一个Ruby的Gem，它可以捕获到操作系统中文件的改变事件，并根据这些事件来执行相关动作。这种功能在很多场景下都非常有用。比如在前端开发中，我们需要不断刷新浏览器来查看对于Javascript、css以及HTML文件的修改是否生效，使用Guard可以使这个过程更加容易，一旦文件内容发生修改，它可以调用相关程序来执行预定义的动作（预编译scss文件，刷新浏览器等）。

安装Guard非常容易，从命令行里执行：

$ gem install guard

或者在Gemfile中定义：

group :development do
gem 'guard'
end

然后执行bundle即可。有了Guard，就有了感知文件系统修改的能力。我们还需要安装一些其他的Guard插件来完成实际的动作，比如重新刷新浏览器中的页面等。

想要刷新浏览器，首先需要为Chrome浏览器安装LiveReload 插件，如图9-1所示。

安装之后，需要安装guard-livereload的Gem：

group :development do
gem 'guard'
gem 'guard-livereload'
end

安装之后，通过命令：

$ guard init

图9-1　LiveReload插件

来生成Guard的配置文件：

# A sample Guardfile
# More info at https://github.com/guard/guard#readme
guard 'livereload' do
watch(%r{app/views/.+\.(erb|haml|slim)$})
watch(%r{app/helpers/.+\.rb})
watch(%r{public/.+\.(css|js|html)})
watch(%r{config/locales/.+\.yml})
# Rails Assets Pipeline
watch(%r{(app|vendor)(/assets/\w+/(.+\.(css|js|html|png|jpg))).*}) { |m| "/assets/#{m[3]}" }
end

watch语句中指定了一个正则表达式，当匹配到这个正则表达式的文件被修改时，livereload插件就会被执行。

通过命令：

$ bundle exec guard start

来启动guard，然后就可以在编辑器中进行实际开发了。从现在开始，我们每一次保存都会触发一次livereload。当然，我们还需要在浏览器中连接guard服务器，点击浏览器中如图9-2所示的按钮来连接后台的guard服务。

图9-2　LiveReload在Chrome中的按钮

这时候，在命令行中会看到诸如浏览器已连接（Browser connected）的字样，如图9-3所示。

图9-3　命令行启动guard

9.2　JavaScript中的构建

9.2.1　Grunt的使用

Grunt是一个基于JavaScript的构建工具。和其他构建工具类似，grunt主要用于将一些繁琐的工作自动化，比如运行测试，代码的静态检查，压缩JavaScript源代码等等。

如果要在命令行运行grunt，需要安装grunt的命令行工具：

$ npm install -g grunt-cli

grunt-cli本身不会提供Grunt构建工具，它只是一个Grunt的调用器。-g参数表示将grunt-cli安装在全局路径中，这样我们可以在不同的项目中使用grunt-cli。由于grunt-cli只是一个调用器，所以对于不同的项目，真正运行的Grunt可以是不同的版本，而命令行的接口则完全一致。

grunt-cli提供的命令行可执行文件的名称为grunt，这个工具每次运行时都会检查当前目录下的grunt安装。

1．使用Grunt

在一个既有的npm模块中，可以很容易地加入对grunt的支持，只需要修改package.json文件，加入依赖，然后运行npm install来完成依赖的安装即可。

如果是一个新启动的项目，那么需要在项目中添加两个文件：package.json和Gruntfile。其中package.json用来定义当前项目是一个npm的模块，而Gruntfile用来定义具体的任务，以及加载Grunt的其他插件（Grunt提供丰富的插件，比如运行测试，代码静态检查等功能都可以通过插件来完成）。

2．package.json

package.json定义了一个工程的元数据，这些数据被npm管理器使用，npm本身提供的init参数可以很容易地生成一个package.json文件：

$ npm init

根据提示可以很容易地生成一个新的package.json：

{
"name": "chapter-testing",
"version": "0.0.0",
"description": "This is the demo for how to use grunt.js",
"main": "my.conf.js",
"directories": {
"test": "test"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"author": "Juntao",
"license": "BSD-2-Clause"
}

一般来说，package.json文件中有一个devDependencies小节，定义了本项目的外部依赖。

可以通过运行：

$ npm install grunt --save-dev

来为工程文件package.json添加devDependencies小节的定义：

"devDependencies": {
"grunt": "~0.4.1"
}

该命令会为工程添加一条依赖关系，如果别人拿到这个文件，就可以在本地“复原”你的开发环境，以保证整个团队使用同样的库文件。

执行之后，该命令会在本地生成一个目录（如果没有的话）：node_modules，其中包括了Grunt的可执行文件，这时候在命令行运行grunt（由grunt-cli提供的命令行工具），就会尝试在此目录中查找Grunt的可执行文件。

3．Gruntfile

要运行Grunt，还需要定义自己的任务，默认的任务定义在Gruntfile中，Gruntfile有一定的格式。

所有的任务需要定义在一个函数中：

module.exports= function(grunt) {
// task defination
};

一般而言，使用Grunt会读取一些项目的信息(定义在package.json中)：

grunt.initConfig({
pkg: grunt.file.readJSON('package.json')
});

也可以在这个时刻指定一些其他插件的选项：

grunt.initConfig({
pkg: grunt.file.readJSON('package.json'),
jshint: {
all: ['Gruntfile.js', 'lib/**/*.js', 'test/**/*.js']
}
});

然后加载其他的插件（如果需要的话）

grunt.loadNpmTasks('grunt-contrib-jshint');

最后，需要指定一个grunt的入口任务(default任务)：

grunt.registerTask('default', function() {
console.log("default task");
});

然后运行grunt，我们此处定义的default任务仅仅在控制台上打印一行字符串：

$ grunt
Running "default" task
default task
Done, without errors.

4．Grunt插件

Grunt得到了很多开源软件贡献者的支持，已经有众多的插件被开发出来。比如：

（1）grunt-contrib-jshint；

（2）grunt-contrib-uglify；

（3）grunt-contrib-qunit；

（4）grunt-karma。

使用这些插件可以为项目开发提供很多便利，以grunt-jshint为例（grunt-contrib-jshint是一个用于JavaScript静态语法检查的工具，它会帮助开发者进行较为严格的语法检查），首先需要安装此插件：

$ npm install grunt-contrib-jshint --save-dev

然后在grunt.initConfig中指定jshint需要的参数：

grunt.initConfig({
jshint: {
files: ['js/*.js'],
options: {
ignores: ['js/jquery*.js']
}
}
});

然后加载此插件：

grunt.loadNpmTasks('grunt-contrib-jshint');

最后，可以将jshint加入到默认的任务中：

grunt.registerTask('default', ['jshint']);

运行结果如图9-4所示。

图9-4　命令行执行grunt命令

5．Grunt的几个常用插件

grunt-karma是一个karma的Grunt插件，上一篇文章中已经介绍了karma的基本用法。这里简单介绍如何在Grunt中使用karma。

首先需要安装grunt-karma插件：

$ npm install grunt-karma --save-dev

然后在Gruntfile.js中加载该插件：

grunt.loadNpmTasks('grunt-karma');

在使用karma之前，需要生成一个karma的配置文件karma.conf.js:

$ karma init karma.conf.js

然后在Gruntfile.js中，加入初始化karma的参数，并指定karma需要使用karma.conf.js文件作为配置来运行：

grunt.initConfig({
karma: {
unit: {
configFile: 'karma.conf.js'
}
}
});

大多数情况下，如果要把karma作为持续集成过程的一部分，应该启动单次运行模式：

singleRun: true

这样karma会启动浏览器，运行所有的测试用例，然后退出。

将karma加入默认的任务中：

grunt.registerTask('default', ['jshint', 'karma']);

然后执行：

$ grunt

运行结果可能如图9-5所示。

注意此处的default后边带了一个任务数组，其中每个任务会按照声明的顺序依次被执行。事实上“default”是后边整个列表的一个别名（alias）。

图9-5　执行jshint及karma任务

除此之外，还有几个常用插件分别为：grunt-uglify，grunt-concat。其grunt-uglify用以最小化Javascript源代码，grunt-concat用以连接所有的Javascript源代码为一个独立的文件。

和其他的Grunt插件一样，它们是以npm的包的形式发布的，因此安装非常容易:

$ npm install grunt-contrib-uglify--save-dev
$ npm install grunt-contrib-concat--save-dev

然后在Gruntfile.js中加载这些插件:

grunt.loadNpmTasks('grunt-contrib-concat');
grunt.loadNpmTasks('grunt-contrib-uglify');

即可。

6．自定义插件

grunt-init是一个帮助开发人员快速搭建基于Grunt项目的工具，比如开发jQuery插件，Gruntfile，或者Grunt插件本身。安装方式很简单:

$ npm install -g grunt-init

-g表示安装在全局路径下，因为其他项目中也有可能用到grunt-init，所以我们将其安装在全局路径下。开发Grunt插件需要一个基本的模板，我们可以将这个模板clone到home下的.grunt-init目录下：

$ git clone git://github.com/gruntjs/grunt-init-gruntplugin.git ~/.grunt-init/gruntplugin

然后新建一个目录，并在该目录下运行：

$ mkdir beautify
$ cd beautify
$ grunt-init gruntplugin

grunt-init会让你回答一些问题，比如插件名称、版本号、github链接等。之后，grunt-init会生成一个基本的模板，开发者只需要完成自己插件的逻辑代码即可。逻辑代码实现在tasks/<plugin-name>.js中。

完成后可以通过npm publish来发布，发布之后，你的插件就可以像上边提到的grunt-jshint等常用插件那样被其他开发者使用了。

9.2.2　Gulp的使用

Gulp是继Grunt之后出现在JavaScript世界中的另一个轻量级构建工具。由于它使用了管道，而不是写临时文件的形式，因此它比Grunt更快，而且代码也比较切合程序员的阅读习惯。

Gulp作为一个npm的包，可以很方便地安装：

$ npm install -g gulp

Gulp运行时，会在当前目录中查找gulpfile.js文件，并执行该文件中的内容，因此快速开始的方式就是创建一个gulpfile.js，然后在其中编写任务：

var gulp = require('gulp');
gulp.task('default', function() {
// place code for your default task here
});

使用插件

一个最简单的例子是使用Gulp的JSHint插件来检查JavaScript代码的格式，要使用这个插件，可以通过npm来进行安装：

$ npm install gulp-jshint

然后在gulpfile.js中加载jshint插件：

var gulp = require('gulp'),
jshint = require('gulp-jshint');
gulp.task('js', function() {
return gulp.src(['src/*.js', 'test/*.js'])
.pipe(jshint('.jshintrc'))
.pipe(jshint.reporter('default'));
});
gulp.task('default', function() {
gulp.start('js');
});

此处我们先使用gulp.task定义了一个名为js的任务。这个任务中，gulp.src的参数是一个支持通配符的数组，这个数组包含了所有的src目录下的JavaScript文件和test目录下的JavaScript文件。

多个子任务通过pipe连接起来，这里的pipe概念和UNIX世界中传统的管道的概念一样，即当数据从管道的前一个节点流出之后，会流入下一个节点，而且数据在此过程中并不会写入文件。

jshint需要一个.jshintrc的规则文件来定义如何检查代码，一个典型的规则文件为：

{
"bitwise": true,
"camelcase": true,
"curly": true,
"eqeqeq": true,
"indent": 4,
"newcap": true,
"noarg": true,
"noempty": true,
"nonew": true,
"quotmark": "single",
"unused": true,
"strict": true,
"trailing": true,
"maxparams": 5,
"maxcomplexity": 10,
"maxlen": 80,
"asi": true,
"loopfunc": true,
"browser": true,
"jquery": true
}

经过这些规则校验之后，我们使用了reporter来生成报告。

最后，我们定义了一个default任务，这个任务中会启动js任务，从而串接起来。除此之外，我们可以定义多个任务，然后在default中依次执行。

在命令行中执行gulp，即可看到jshint的检查结果，如图9-6所示。

图9-6　执行gulp来进行jshint检查

可以看到，gulp的jshint插件报告了很多错误。不过这些错误信息并没有很好的格式化，看起来比较费力。这个问题可以通过安装jshint插件jshint-stylish来解决：

$ npm install jshint-stylish

然后修改reporter为jshint-stylish：

gulp.task('js', function() {
return gulp.src(['src/*.js', 'test/*.js'])
.pipe(jshint('.jshintrc'))
.pipe(jshint.reporter('jshint-stylish'));
});

运行gulp之后，可以看到之前没有格式化的问题得到很好的解决，如图9-7所示。

图9-7　发现错误

修复这些错误之后，再次运行gulp，则可以看到如图9-8所示的运行结果。

图9-8　修复错误并重新执行

第10章 持续集成

持续集成是敏捷开发的一种实践，即频繁地集成系统的各个模块。在开发大规模应用的时候，我们会自然地将系统分解为小型的，可以独立度量的模块，而这些小模块之间可能会有依赖、干扰等。如果能尽量早地集成，并在集成过程中发现问题，无疑可以为后期的发布节省很多时间，也减少了浪费。

持续集成事实上是一系列工具支持的一种实践，一个典型的持续集成环境需要：

（1）一台或者多台服务器。

（2）持续集成工具（Jenkins，GO）。

（3）构建脚本（Make，Maven，Grunt等）。

（4）环境依赖的工具（浏览器，虚拟桌面等）。

如果项目规模比较大，或者已经运行了很长时间，系统自然会形成很多物理模块，这些模块的构建需要很长时间。如果采取串行的方式进行集成，那么整个过程会非常耗时，因此可以搭建一个集群进行并行的持续集成。

在本章，我们将继续上一章的实例，在本地通过虚拟机软件搭建一个持续集成环境，并以此为例说明如何搭建环境，如何配置，如何在实际项目中使用Jenkins作为持续集成工具。

10.1　环境搭建

10.1.1　安装操作系统

在本章，我们将使用虚拟机来搭建集成测试服务器。当然，我们会依赖一些轻量级的工具来完成环境的搭建和配置。

VirtualBox是一个开源的虚拟机软件，类似于商业的VMWare、VirtualPC等。VirtualBox功能非常强大，比如对网络的支持、与宿主机之间的共享等功能都非常好用。另外，VirtualBox还开放API，并且提供插件机制，使得对其进行脚本化成为可能。

使用VirtualBox的桌面版来安装一个Linux虚拟机非常简单，但是开发人员可能仍然觉得这个有些琐碎，如果能将安装过程自动化就好了。Vagrant正好可以完成这个功能。

Vagrant是一个建立在VirtualBox之上的工具，它可以非常方便地搭建出虚拟机环境。我们可以通过命令行来创建、配置、删除一个虚拟机。在Vagrant中，虚拟机被称为box，所以我们的所有操作对象都是box。你可以使用别人创建好的box，也可以将自己的环境打包成一个box分享给别人使用。

在我们的例子中，首先需要下载一个ubuntu的box：

$ wget http://files.vagrantup.com/precise64.box

下载之后，使用vagrant的命令：

$ vagrant box add precise64 precise64.box

来添加这个box，并将其命名为precise64。随后，我们会使用这个名字来引用这个虚拟机。执行完成之后，会看到诸如这样的输出：

$ vagrant box add precise64 precise64.box
Downloading box from URL: file:/Users/jtqiu/develop/bigdata/ precise64.box
Extracting box...e: 0/s, Estimated time remaining: --:--:--)
Successfully added box 'precise64' with provider 'virtualbox'!

这表示我们已经成功地将precise64导入，vagrant发现这个box是一个virtualbox格式的虚拟机。

导入之后，我们就可以配置自己的环境了。

$ mkdir -p ci
$ cd ci
$ vagrant init

vagrant init命令会在当前目录生成一个Vagrantfile，这是一个ruby文件，用来指定Vagrant使用哪一个box，网络配置是什么等等。当然Vagrantfile的作用不仅如此，我们这里先介绍其基本用法。

一个典型的Vagrantfile配置如下：

VAGRANTFILE_API_VERSION = "2"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.box = "precise64"
config.vm.network "private_network", :ip =>"192.168.2.100"
end

它指定了我们将使用precise64这个box作为虚拟机，然后为这个虚拟机指定了一个IP地址。我们随后可以通过这个IP来访问虚拟机中的Web应用。

配置好以后，使用下面这条命令来启动虚拟机：

$ vagrant up

首次启动，会稍微有些耗时，然后就可以看到Vagrant的报告，如图10-1所示。

图10-1　启动虚拟机

一旦完成启动，我们可以通过：

$ vagrant ssh

登录到该虚拟机中：

$ vagrant ssh
Welcome to Ubuntu 12.04 LTS (GNU/Linux 3.2.0-23-generic x86_64)
* Documentation:　https://help.ubuntu.com/
Welcome to your Vagrant-built virtual machine.
Last login: Sat Jun 14 07:01:33 2014 from 10.0.2.2
vagrant@precise64:~$

通过lsb_release来查看输出：

vagrant@precise64:~$ lsb_release -a
No LSB modules are available.
Distributor ID: Ubuntu
Description: Ubuntu 12.04 LTS
Release: 12.04
Codename: precise

这时候，如果打开VirtualBox，可以看到我们通过Vagrant启动的box，如图10-2所示。

图10-2　VirtualBox界面

我们现在有了一个运行良好、干净的虚拟机环境，下面就可以在其上安装其他软件了。

10.1.2　安装Jenkins

最简单的安装Jenkins的方式是使用ubuntu的安装命令apt-get：

$ wget -q -O - http://pkg.jenkins-ci.org/debian/jenkins-ci.org.key | sudo apt-key add -
$ sudo sh -c 'echo deb http://pkg.jenkins-ci.org/debian binary/ > /etc/apt/sources.list.d/jenkins.list'
$ sudo apt-get update
$ sudo apt-get install jenkins

安装完成之后，jenkins会被作为一个Linux的服务安装到系统中，即在目录/etc/init.d下会有一个名字为jenkins的服务。这个服务被加入到开机自启动的列表中，因此每当虚拟机重启之后，jenkins都会随之启动。

另外，安装过程会创建一个jenkins用户，这个用户的主目录为：/var/lib/jenkins，我们创建的任务都会以这个用户的身份执行。

10.1.3　安装rbenv

我们上一章例子中应用的后台是基于Ruby的，而通常在持续集成服务器上会运行多个工程，每个工程都可能使用不同的Ruby版本。因此我们需要安装rbenv或者rvm这样的工具来管理多个Ruby版本。

首先登录到jenkins用户，这个用户已经在apt-get install的时候自动创建了：

$ sudo -Hiu jenkins

安装rbenv非常简单，只需要将其在Github上的代码克隆到本地：

$ git clone https://github.com/sstephenson/rbenv.git ~/.rbenv

然后需要将rbenv的可执行文件加入到系统的环境变量中：

$ echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
$ echo 'eval "$(rbenv init -)"' >> ~/.bashrc
$ source ~/.bashrc

随后，可以验证rbenv是否已经安装成功：

$ type rbenv | head -n 1
rbenv is a function

如果看到提示rbenv is a function，说明rbenv已经安装成功，这时候我们已经可以尝试安装多个版本的Ruby了。不过，我们不需要手工做这些工作，rbenv有一个插件ruby- build来简化这个过程。

安装ruby-build和rbenv一样，克隆到本地即可：

$ git clone https://github.com/sstephenson/ruby-build.git ~/.rbenv/ plugins/ruby-build

此时如果执行：

$ rbenv install -l | head -n 10
Available versions:
1.8.6-p383
1.8.6-p420
1.8.7-p249
1.8.7-p302
1.8.7-p334
1.8.7-p352
1.8.7-p357
1.8.7-p358
1.8.7-p370

会看到一个列表，我们可以安装任意一个版本。安装完成之后，可以设置全局的Ruby版本：

$ rbenv global 1.9.3-p547

或者为应用程序指定特定的版本：

$ cd app
$ rbenv local 1.9.3-p547

事实上，要完整地配置rbenv环境，另一个rbenv插件也需要安装：

$ git clone git://github.com/carsomyr/rbenv-bundler.git ~/.rbenv/ plugins/bundler

这个插件的作用是简化使用bundler的过程。在Ruby中，我们使用bundler来安装Ruby包（gem），bundler本身是一个gem，对于每个版本的Ruby都需要安装一个对应的bundler。

一般我们会使用：

$ bundle exec rake ...

这样的形式，而使用这个插件之后就无需再指定bundle exec了。另外，这个插件在后边rbenv与Jenkins集成时也需要使用。

10.1.4　安装NodeJS

我们前一章中使用的Karma需要NodeJS环境。使用ubuntu默认的apt-get的方式安装NodeJS，会有版本过低的问题，因此我们需要安装较高版本。

先安装一些外部的依赖程序包，以及一个apt的源地址：

$ sudo apt-get install software-properties-common
$ sudo apt-get install python-software-properties
$ sudo add-apt-repository ppa:chris-lea/node.js

然后再安装NodeJS：

$ sudo apt-get update
$ sudo apt-get install python g++ make nodejs

得到较新版本的Node之后，可以通过npm来安装Karma和Bower：

$ sudo npm install karma-cli -g
$ sudo npm install bower -g

10.1.5　安装Xvfb

我们有一部分测试需要启动浏览器，但是我们又是运行在一个没有安装图形界面的环境中。

所以我们需要有一个程序，可以在其中运行浏览器，又不需要真正安装图形界面，这个程序就是Xvfb。

Xvfb可以理解为运行在内存中的图形界面，这一点借助于Linux系统设计的卓越之处。事实上这一点是从UNIX系统借鉴而来的：图形界面完全可以独立启动，也就是说，你可以选择不安装，也不使用图形界面。

使用Xvfb可以完成所有真实的图形界面可以完成的事情，当然，除了它是“看不见的”之外！它正是为自动化测试而诞生的。

安装Xvfb很容易，使用ubuntu默认的安装方式即可：

$ sudo apt-get install xvfb

由于Xvfb会作为一个服务启动，因此我们需要为启动Xvfb编写一个小的服务脚本，然后将其放置于/etc/init.d/目录中。

将这个脚本保存为Xvfb，然后修改其权限：

$ sudo chown root:root /etc/init.d/Xvfb
$ sudo chmod a+x /etc/init.d/Xvfb
$ sudo update-rc.d Xvfb defaults

安装浏览器

为了在集成测试时运行与浏览器相关的动作，我们还需要安装与浏览器相关的一些包：

$ sudo apt-get install x11-xkb-utils xfonts-100dpi xfonts-75dpi
$ sudo apt-get install xfonts-scalable xserver-xorg-core
$ sudo apt-get install dbus-x11

然后安装浏览器（Chrome和Firefox）本身：

$ sudo apt-get install chromium-browser firefox

可以为chrome浏览器创建一个符号链接：

$ sudo ln -s /usr/bin/chromium-browser /usr/bin/google-chrome

安装Xvfb之后，Xvfb将会在:10上启动，也就是说，我们需要告诉Firefox或者Chrome，当启动时，要尝试去连接:10上的图形服务器，而不是默认的:0。因此需要在环境变量中声明这一点：

$ export DISPLAY=:10

配置完成之后，我们就可以启动Xvfb服务了（如图10-3所示），和其他服务一样：

$ sudo service Xvfb start

图10-3　启动Xvfb服务

10.2　持续集成服务器

Jenkins是业界使用非常广泛的开源持续集成工具。对于持续集成，业界已经有很多的支持工具，比如开源的Jenkins，ThoughtWorks发布的GO，商用的Bamboo，与Github集成的如Travis，ThoughtWorks发布的Snap。

Jenkins配置方便，支持并行，有很多插件来做扩展。作为一个通用的持续集成服务器，Jenkins可以配置运行任意类型的项目，比如Java、Ruby、JavaScript，甚至C语言的项目。

Jenkins

Jenkins提供的安装包是一个J2EE标准的war包，需要部署在一个Web容器中，比如Tomcat，GlassFish等。如果你项目的技术栈并不是建立在Java上，那么最简单的方式是使用Jenkins包中内置的Jetty容器：

$ java -jar jenkins.war

来启动。启动之后，你可以通过浏览器访问Jenkins的界面，如图10-4所示。

默认地，Jenkins会在8080端口启动。启动之后，我们需要定义一些任务，在Jenkins中，任务一般由这样几部分组成：

（1）从版本库中获取最新的源码。

（2）运行一些先置动作（比如建立数据卡连接，启动图形界面等）。

（3）执行Jenkins中定义的脚本。

（4）判断构建脚本的执行结果。

一个常见的做法是将构建脚本放入版本库中，然后当Jenkins从版本库中获取到代码之后，就可以直接运行构建脚本了。

图10-4　Jenkins启动页面

另一方面，Jenkins一般会运行在Linux平台下，如果你的工程是在Windows下做的开发，则需要自己编写一个Linux下的脚本，然后同步到版本库中。由于Jenkins需要执行构建脚本，那么Jenkins运行的环境就需要和本地构建环境一致。

比如Java项目中，你需要在Jenkins运行的服务器上安装JDK，并且要安装构建工具如Maven、Gradle等，对应地，在Ruby项目中，我们又需要安装ruby，以及运行构建需要的一些gems。

1．定义任务

我们下面来创建一个任务，这个任务会执行上一章中的测试，如图10-5所示。

图10-5　定义新任务

我们的工程是一个基于Ruby的项目，因此可以创建一个自由格式的软件工程。名称可以是任意的字符串，当然最好给它一个合理的名字。比如，上一章中代码的功能是让用户可以标记自己喜欢的地方，因此可以命名为“liked-locations”。

然后需要配置版本库的路径。有了路径，Jenkins就可以按照一定的周期去检查代码库，一旦发现代码有变动，Jenkins就会触发一个动作，这个动作就是执行我们的构建脚本！

我们的代码放在Github上，因此先要安装Jenkins的Git插件，如图10-6所示。

图10-6　Git插件

安装之后，可以编辑刚才创建的任务“liked-locations”，此时该任务的编辑界面会多出一个Git选项，如图10-7所示。

图10-7　与Git集成

指定版本库路径为Github中的路径：

git@github.com:abruzzi/testable-js-listing.git

另外一个问题是，Github需要认证当前用户，因此需要配置虚拟机和Github之间的通信。

首先通过ssh-keygen生成一对密钥，此处的邮箱地址是你在Github上注册的邮箱地址：

$ cd ~/.ssh
$ ssh-keygen -t rsa -C "juntao.qiu@gmail.com"

生成的两个文件分别为：

$ ls ~/.ssh
id_rsa
id_rsa.pub

然后需要将公钥上传至Github，如图10-8所示。

图10-8　在Github中添加公钥

接下来，需要在Jenkins中配置私钥。Jenkins提供丰富的验证机制，我们只需要创建一个简单认证，如图10-9所示。

图10-9　创建认证

这个证书的名称为vagrant，使用的是私钥，即上一步生成的私钥。有了这个配置，Jenkins就可以直接从Github上拉新的代码。

下面我们配置何时触发构建过程，如图10-10所示。

图10-10　定义触发周期

注意此处的Build periodically和Poll SCM是有不同的，前者是每隔一个固定的周期都会进行构建，后者虽然也是周期性的检查，但是如果代码没有改变的话，则不会触发构建过程。

好了，我们终于到达了有意思的部分。上一章中，运行一次构建采用的命令是：

$ karma start

这样的话，Karma会一直停留在控制台上，等待文件的改变，然后重新运行测试。Karma原生地就支持持续集成模式。如果执行：

$ karma start --singleRun

Karma会启动浏览器，运行所有的测试，并退出。Karma在退出时会报告执行状态，这样Jenkins就可以根据这个状态来判断测试是否通过，如图10-11所示。

图10-11　定义构建脚本

2．运行任务

好了，我们可以先手工触发一次测试，点击Build Now按钮，如图10-12所示。

图10-12　Jenksin的构建输出

这个红色的大圆点标志着测试失败，Jenkins会打印出控制台上的所有信息，来说明测试失败的原因：比如某个命令没有找到，在命令行里启动图形界面失败等等。

修复这个问题之后，我们再次运行测试，会得到一个通过的标志，如图10-13所示。

图10-13　构建历史

默认地，Jenkins会把控制台上所有的信息以纯文本的格式打印出来。而目前有很多的命令行工具为了提供更加醒目的提示，都采用了彩色的命令行。这样Jenkins就会打印出一些“乱码”，如图10-14所示。

图10-14　命令行输出

实际的输出应该如图10-15所示。

图10-15　带有颜色的命令行输出

这个需要安装Jenkins的AnsiColor插件来支持。另外一个常用的插件是Green Balls，它可以将表示“通过”的标志变成绿色。这是软件开发中的一个惯例：红色表示失败，绿色表示通过，如图10-16所示。

图10-16　红绿灯效果

3．监视器与通知

如果关注系统集成状态的人需要频繁打开网页来查看状态将非常不方便，而且不容易操作。如果在办公室放置一个大的监视器来显示集成状态则足够醒目，可以提醒系统中的所有人员及时关注集成状态。

Jenkins的插件Build Monitor可以将Build的状态生成一个页面，如图10-17所示。

图10-17　监视器效果

安装Build Monitor之后，可以在Jenkins中创建一个视图（View），然后选择需要监控的任务列表，如图10-18所示。

图10-18　监视器效果配置

CCMenu是另外一个开源的桌面应用程序，可以安装在开发人员的机器上，以得到实时的状态更新。

首先，指定需要监控的工程以及任务列表，如图10-19所示。

图10-19　CCMenu配置

这样，每当Jenkins运行完测试之后，开发者的桌面状态栏就会得到一个通知，如图10-20所示。

图10-20　CCMenu在桌面上的效果

每个任务运行之后，开发者还会得到一个实时的弹出信息，如图10-21所示。

图10-21　实时通知效果

Windows平台下也有类似的工具CCTray。这些小工具可以很好地报告集成环境的状态，并提示开发人员及时进行修复。监控工具虽然不能直接帮助开发人员定位问题，解决问题，但是可以尽早地将问题反映出来。

10.3　与Github集成

现在，越来越多的项目被托管到Github上，Github事实上已经成为托管项目的首选。它不但提供对开源项目的托管，已经有很多企业通过购买付费的服务，将私有的项目也托管在Github上。

由于Github提供丰富而完善的API，已经有很多工具可以和它集成。比如这里要介绍的Travis和Snap，这些服务会从Github上获取代码，并自动查找构建脚本，执行构建，就像本地搭建的持续集成服务器一样。

10.3.1　Travis

Travis是一个免费的与Github集成的服务，使用非常容易，有非常多的开源软件选择它作为持续集成工具。运行完成之后，Travis会生成一个小图标的URL，当构建失败，该图标为红色，如果成功则为绿色。你可以在自己项目的Readme中引用这个URL，这样当别的开发人员在Github上打开该项目时，就会看到构建的状态，如图10-22所示。

图10-22　Travis界面

使用Travis非常容易，首先需要在Github上为Travis授权，允许Travis安全地访问Github来获取代码。

有了这个授权之后，Travis就可以获得你的Github上所有代码仓库的列表，这时候你可以选择在哪些项目中启用Travis，如图10-23所示。

图10-23　定义启动哪些仓库的构建

授权之后，在你的本地工作目录中创建一个名为.travis.yml的文件，这个文件中会定义项目的类型，构建前需要执行的脚本等。一个典型的例子是：

language: node_js
node_js:
- "0.10"
before_script:
- npm install -g bower
- bower install

上面的yml描述了当前项目类型为node_js，这样Travis运行时会去执行package.json中指定的测试脚本。比如当前项目中的package.json定义如下：

{
"devDependencies": {
"karma": "~0.12.16",
"gulp": "~3.8.0",
"karma-jasmine": "~0.1.5",
"karma-chrome-launcher": "~0.1.4",
"gulp-jshint": "~1.6.2",
"jshint-stylish": "~0.2.0",
"lodash": "~2.4.1",
"karma-phantomjs-launcher": "~0.1.4"
},
"scripts": {
"test": "./node_modules/karma/bin/karma start --single-run --browsers PhantomJS"
}
}

正如scripts.test指定的那样，Travis会尝试执行这个命令。而在执行该命令之前，Travis会先执行before_script中的命令。

如果你的项目是Java，Travis默认的会执行诸如mvn test或者gradle test这样的任务。

Travis的另外一个非常好的特性是它可以很好地与ccmenu集成使用，只需要在ccmenu中配置对应的URL即可：

https://api.travis-ci.org/repositories/abruzzi/tw-testable-javascript/cc.xml

这条URL用来表示用户名为abruzzi、仓库名为tw-testable-javascript的代码的状态。

10.3.2　Snap

Snap（https://snap-ci.com/）是ThoughtWorks公司开发的一款持续集成工具，它很容易与Github集成。和Travis一样，Snap支持众多的技术栈。ThoughtWorks在持续集成方面有着丰富的经验，Snap事实上是最为专业、最为全面的一个集成测试工具。

在Snap中，用户可以定义很多个构建阶段，每个构建阶段中可以定义各自的命令集。比如一般持续集成环境会包含：单元测试，集成测试，打包，部署等等阶段，有些阶段需要自动触发下一个阶段，而如部署这样的阶段则需要手工触发。这些在Snap中都有非常好的支持。

经过Github的授权之后，Snap就可以访问Github中的代码仓库了。接下来需要在Snap中添加一个仓库，如图10-24所示。

图10-24　在Snap中添加代码仓库

添加仓库之后，需要定义项目所使用的技术栈，以及所使用平台的版本等，比如Java版本，NodeJS版本等。

随后需要定义一个阶段（Stage），并且定义在该阶段中需要执行的命令集，如图10-25所示。

图10-25　定义阶段及命令

保存之后，Snap会自动执行，并在最后报告执行结果，如图10-26所示。

图10-26　执行结果页面

对于每个构建，可以查看详细信息，如图10-27所示。

图10-27　构建细节及日志

和Travis一样，Snap可以很容易地将build的状态和CCMenu集成，如图10-28所示。Snap的URL格式为：

<a>https://snap-ci.com/<用户名>/<项目名>/branch/<分支名>/cctray.xml</a>

比如上边例子中项目的状态路径为：

https://snap-ci.com/abruzzi/jasmine-jsonpath/branch/master/cctray.xml

图10-28　与CCMenu集成

第11章 单元测试与集成测试

11.1　RSpec单元测试

RSpec是Ruby下的单元测试的工具，RSpec提供BDD的DSL，使得其编写的代码可读性很高。RSpec可以很好地和其他测试工具（如Capybara）集成，这样就可以很轻松地编写端到端测试。

RSpec是一个gem，安装非常容易，可以安装在系统的全局环境中：

$ gem install rspec

或者安装到指定的项目中，在Gemfile中加入

gem 'rspec'

然后运行bundle install即可。

RSpec的可执行程序为rspec，按照惯例，rspec会在当前目录下查找specs目录。specs中的文件需要以 _spec.rb 结尾。

我们来看一个小例子，一个用户模型的测试：

require File.dirname(__FILE__) + '/../lib/user.rb'
describe "User" do
it "converts Western name to Chinese name" do
user = User.new("Juntao", "Qiu")
expect(user.name).to eq "QIU Juntao"
end
end

每一个测试都会放到一个测试套件中，RSpec使用describe来表示一个套件。describe接受一个描述性信息和一个块（block）。每个具体的测试用例定义在it的块中。it也接受一个描述信息和一个块（block），块中的代码即为具体的测试。

比如上述测试中，创建一个新用户，并期望user.name返回的是将姓转化为大写的形式。

测试代码对应的实现非常简单：

class User
def initialize first, last
@first = first
@last = last
end
def name
"#{@last.upcase} #{@first}"
end
end

运行RSpec只需要：

$ rspec
.
Finished in 0.00144 seconds (files took 0.10848 seconds to load)
1 example, 0 failures

运行结果中的点（.）表示一个测试用例，然后RSpec会报告时间和总的情况（运行了多少个用例，失败了多少个），如图11-1所示。

默认的rspec没有颜色，显示的结果可能不够直观，因此可以带上参数：

$ rspec --color

图11-1　RSpec执行结果

如果需要更加详细的报告，比如运行了哪些用例，可以使用：

$ rspec -fd --color
User
converts Western name to Chinese name
Finished in 0.00078 seconds (files took 0.08135 seconds to load)
1 example, 0 failures

这里的fd表示输出的格式（format）为文档（documentation）。除此之外，还有json、html格式的输出。

对应的，如果有错误，rspec会详细地报告错误的位置，比如文件、行号、错误类型等等。如果我们新添加了一个测试，但是没有编写对应的实现：

require File.dirname(__FILE__) + '/../lib/user.rb'
describe "User" do
it "converts Western name to Chinese name" do
user = User.new("Juntao", "Qiu")
expect(user.name).to eq "QIU Juntao"
end
it "says hi when greeting" do
user = User.new("Juntao", "Qiu")
greeting = user.greeting
expect(greeting).to eq "Hello, My name is QIU Juntao"
end
end

运行rspec，会得到这样的错误，如图11-2所示。

如果编写更多的测试，我们会发现每次都需要创建一个新的User实例，代码会有冗余。RSpec提供了更高级的DSL来简化这个过程，使得测试代码更加易读：

require File.dirname(__FILE__) + '/../lib/user.rb'
describe "User" do
subject(:user) {User.new("Juntao", "Qiu")}
it "converts Western name to Chinese name" do
expect(user.name).to eq "QIU Juntao"
end
it "says hi when greeting" do
expect(user.greeting).to eq "Hello, My name is QIU Juntao"
end
end

图11-2　带有详细报告的RSpec执行结果

使用subject，可以将初始化的工作提取出来，放到更外层。这样，每个测试用例都可以访问到这个实例。当然，这个变量在每个测试中都是重新初始化的，比如我们在user.rb里加入以下的打印信息：

class User
def initialize first, last
@first = first
@last = last
p "ininialize User class"
end
end

然后运行测试：

$ rspec -fd --color
User
"ininialize User class"
converts Western name to Chinese name
"ininialize User class"
says hi when greeting
Finished in 0.00107 seconds (files took 0.07983 seconds to load)
2 examples, 0 failures

可以看到，每运行一个it（测试用例），User都会被创建一次，这样也保证了每个测试之间互不干扰。

我们将这个例子扩展，看看如何使用RSpec测试Sinatra应用。首先我们需要安装几个gem来支持：

source "http://ruby.taobao.org"
gem 'sinatra'
group :test do
gem 'rack-test'
end

然后，假设sinatra应用会放在app.rb中，那么对应的测试需要编写为：

require File.dirname(__FILE__) + '/../app.rb'
require 'rack/test'
set :environment, :test
def app
Sinatra::Application
end
describe "User service" do
include Rack::Test::Methods
it "should load the user page" do
get '/'
expect(last_response).to be_ok
end
it "should create new user" do
post '/', params = {:first =>"Mansi", :last =>"Sun"}
expect(last_response).to be_ok
expect(last_response.body).to eq "Hello, My name is SUN Mansi"
end
end

首先，我们require了app.rb文件，这样就将这个sinatra应用引入进来了，然后设置:enviroment为测试。Rack::Test::Methods提供了诸如get、post之类的方法，用来发送请求（当然，只是模拟发送，不需要真正启动服务器）。

然后对于测试代码，我们可以模拟用户的行为，比如第一个测试：

it "should load the user page" do
get '/'
expect(last_response).to be_ok
end

请求了根路径，并预期状态码为200。对于第二个场景，我们发送了一组参数到服务器端，并期望服务器返回一句问候："Hello, My name is SUN Mansi"。因此对应的服务器实现为：

require 'sinatra'
require './lib/user'
get '/' do
juntao = User.new("juntao", "qiu")
juntao.greeting
end
post '/' do
user = User.new(params[:first], params[:last])
user.greeting
end

结果页面如图11-3所示。

图11-3　结果页面

11.2　集成测试工具Selenium

Selenium最早是由ThoughtWorks的Jason Huggins开发的一个用于自动化测试网页的工具，它本身是一个JavaScript库，可以侵入到浏览器中，发送指令给浏览器，比如选中一个元素，填写一些值，然后点击一个按钮等。这些动作可以模拟用户的实际操作。

Selenium最早出现时令人惊叹，但是随着Web应用程序的复杂化，Selenium的一些弊端开始变得严重。浏览器对JavaScript的安全限制导致Selenium无法直接请求外部资源，因此Selenium引入了代理机制，但是这样又导致了Selenium体积变大，而且在一个项目中编写的代码很难在另外一个项目中重用。

2006年Google的一位工程师（Simon Stewart）开发了WebDriver来弥补这一缺陷，WebDriver试图使用原生的方式来访问浏览器。这种方式需要在系统中安装一个driver程序，这个程序通过操作系统来模拟用户的行为，但是需要为每个系统分别开发不同的Driver。

2008年，Selenium和WebDriver合并为一个项目：Selenium 2。Selenium 2支持两种模式，它可以在本地使用driver来与浏览器通信，模拟用户的输入，验证页面元素的存在性等，也可以与远程的Selenium服务器交互，操作远程的浏览器来进行元素校验等。

操作远程的浏览器需要在该服务器上部署Selenium的独立版。这个功能用于将多个测试分发到不同的机器上运行，这样可以并发地执行端到端测试。由于端到端测试往往耗时较长，因此远程方式是运行验收测试时经常采用的方式。

围绕着Selenium，有众多的工具包被开发出来。事实上，每个语言都有自己的相关绑定，这些绑定或多或少遵循了本语言的惯例，使得编写测试更加地易读，也更容易维护。我们这里会介绍Ruby绑定selenium-webdriver。

11.2.1　Selenium-webdriver

安装selenium-webdriver和其他的gem一样：

$ gem install selenium-webdriver

或者在Gemfile中加入：

group:test do
gem 'selenium-webdriver'
end

我们可以使用selenium-webdriver来访问一个Web应用，然后获取该页面的标题：

require "selenium-webdriver"
driver = Selenium::WebDriver.for :firefox
driver.navigate.to "http://localhost:9292/index.html"
puts driver.title
driver.quit

这里指定使用firefox作为浏览器，并访问localhost:9292/index.html。运行这段代码时，selenium-webdriver会启动一个firefox，并会自动在地址栏填入url，然后在控制台上打印该页面的标题。

$ ruby nav.rb
Locations

进一步，我们看一个更有意思的例子：

require "selenium-webdriver"
driver = Selenium::WebDriver.for :firefox
driver.navigate.to "http://localhost:9292/index.html"
input = driver.find_element(:id, 'locationInput')
input.send_keys "Melbourne"
button = driver.find_element(:id, 'searchButton')
button.click
driver.quit

在这个例子中，我们首先找到id为locationInput的元素，填入Melbourne，然后找出id为searchButton的元素，点击这个元素，最后退出，如图11-4所示。

图11-4　Selenium启动浏览器

11.2.2　Capybara

使用selenium-webdriver操作DOM，触发事件的时，代码会比较冗长，特别是和异步事件交互时，代码会更加难读。因此又有一些构建在其上的轻量级包装，比如webrat，capybara。

Capybara构建在selenium-webdirver之上，有一些额外的好处：

（1）更友好的API。

（2）可以自由切换底层的driver（比如对有些无需JavaScript的测试，可以不启动浏览器）。

（3）自动处理异步事件。

Capybara的DSL形式的API可以使得代码非常易读。

访问某个URL，可以使用visit：

visit "/locations"
visit "http://www.google.com"

点击某个按钮或者某个超链接，使用click：

click('#searchButton')
click('Submit')
click_link('Save me')

应该注意的是，Capybara足够聪明，知道你要点击的是元素需要按照名字，还是按照id，或者是按照页面元素上的文字来查找。你既可以使用click，也可以分别使用click_link和click_button。

Capybara支持标准的CSS选择器，可以很容易地找到需要的元素：

find('#inputButton').click
all('ul li.like').each { |a| a[:href] }

Capybara提供了很多匹配器，来判断页面上的某些元素是否存在，或者某个元素的文本中是否包含某个关键字等：

page.has_selector?('table tr')
page.has_selector?(:xpath, '//table/tr')
page.has_xpath?('//table/tr')
page.has_css?('table tr.foo')
page.has_content?('foo')

对于异步事件来说，Capybara足够聪明，它会一直等待预期的内容出现。比如一个常见的场景是，填写条件，点击按钮，然后发送一个Ajax调用，当Ajax返回后，更新页面。

这个过程在测试时通常不会发生问题，但是如果在一个网络较差的环境中，测试也有可能失败。Capybara会等待异步事件的完成：

fill_in 'locationInput', :with => location
click_on 'searchButton'
page.should have_content("Melbourne")

如果等待超时，Capybara会报告超时错误。这里的异步等待时间是可以配置的：

Capybara.default_wait_time = 5

11.2.3　Cucumber

Cucumber是一个BDD（Business Driven Development业务驱动开发）测试工具。BDD是作为TDD的补充出现的，即从业务价值出发编写测试，编写的测试应该足够描述一个业务场景。

比如对用户付费场景的描述就是一个典型的业务：

特性：注册用户应该可以通过我们的站点来购买商品

场景：登录用户通过网站买手机

作为一个登录用户

我在网站上选择了一部iPhone 4S

我填写了投递地址和手机号码

我应该看到网站提示的成功下单的消息

BDD的方式会强迫开发人员从业务价值出发，即编写的功能必然会为系统带来实际价值。另一方面，用BDD的方式编写出来的测试对于业务人员（非技术人员）也应该是可读的。

Cucumber使你可以以编写文本的方式来编写测试：它的输入是一个英文文档。当然这个英文文档有具体语法，不过读起来非常直观。

比如下面这个场景描述的是，假设我在主页上，当我在搜索框里输入了Melbourne，并且点击搜索，然后应该看到4条结果。

Scenario: Search for a location
Given I am on the home page
When I type "Melbourne" in search box
And I click search button
Then there are 4 locations show up

Cucumber使用Given-When-Then的格式来描述一个场景，这种格式的可读性好，可以很好地定义一个场景的前提条件、行为的触发方式以及结果验证，被广泛采用。

Cucumber通过定义step文件来解析这些场景描述，事实上仅仅需要简单的正则表达式就可以做到这点。当正则表达式匹配到场景中的一行时，你可以定义自己的对该行的解释。比如，第一行Given I am on the home page，就可以解释为：

Given /^I am on the home page$/do
visit "http://#{BASE_URL}/index.html"
end

即，当遇到I am on the home page时，调用Capybara的visit方法来打开浏览器并跳转到index.html页面。

我们来看一个使用Cucumber的实例，以前面可测试的JavaScript代码中一个“喜欢的地方”的应用为例，如图11-5所示。

我们定义这样两个场景：

（1）用户搜索一个地方。

（2）用户赞了一个地方。

Cucumber中用以描述场景的文件叫feature文件，以.feature结尾，如图11-6所示。它需要这样一个目录结构。

图11-5　需要被测试的页面

图11-6　典型的Cucumber测试目录结构

search.feature是我们的特性描述文件，step_definitions中是steps定义，也就是我们编写代码的地方。而support目录中是一些支持运行测试的文件，比如Capybara的配置等。

首先来看search.feature文件，我们定义了两个特性：

Feature: Search locations
As a consumer
I want to do search on liked locations site
So I can find some interesting places
Scenario: Search for a location
Given I am on the home page
When I type "Melbourne" in search box
And I click search button
Then there are 4 locations show up
Scenario: Like an item
Given I am on the home page
When I type "Melbourne" in search box
And I click search button
Then there are 4 locations show up
When I liked the "1st" location
Then I should see it in liked locations

注意这个额外的Feature，它描述这个特性的用户是谁，以及这个特性提供了什么样的商业价值。Feature不参与解析，Cucumber会完全忽略它。

特性文件对开发人员和业务人员都非常友好，下面我们来看在step_definitions中的实现：

Given /^I am on the home page$/do
visit "http://#{BASE_URL}/index.html"
end
Then /^I should see the search box$/do
page.should have_css('#locationInput')
end
When /^I type "([^"]*)" in search box$/do |location|
@location = location
fill_in 'locationInput', :with => location
end
And /^I click search button$/do
click_on 'searchButton'
end
Then /^there are (\d)+ locations show up$/do |number|
page.should have_content(@location)
all('ul li .title').length.should eq number
end
def convert pos
{
"1st" =>0,
"2nd" =>1,
"3rd" =>2,
"4th" =>3
}[pos.to_s]
end
When /^I liked the "([^"]*)" location$/do |pos|
num = convert(pos)
all("ul li .title a")[num].click
end
Then /^I should see it in liked locations$/do
page.should have_selector('#likedPlaces .like')
end

此时运行Cucumber来执行测试，如图11-7所示。

图11-7　Cucumber测试结果

可以看到一个失败，这是因为Cucumber从正则表达式中解析出来的是字符串4，而我们预期的是数字4。我们需要修复这个问题：

Then /^there are (\d)+ locations show up$/do |number|
page.should have_content(@location)
all('ul li .title').length.should eq number.to_i
end

好了，再次运行，如图11-8所示。

图11-8　执行成功

注意函数convert，它完全是一个Ruby的函数，是的，step_definitions中的就是简单的Ruby文件，因此你可以定义任意的Ruby函数。当然，作为一个好的实践，你可以将这些函数隔离到helper类的文件中。

我们再来看看support中的env.rb文件：

require 'capybara/cucumber'
require 'capybara/session'
BASE_URL = "localhost:9292"
Capybara.default_driver = :selenium
Capybara.run_server = false
Capybara.default_selector = :css
Capybara.default_wait_time = 30
Capybara.ignore_hidden_elements = false
Capybara.app = BASE_URL

这个文件中，我们对Capybara进行了一些设置，比如默认的驱动使用Selenium，使用CSS选择器，默认异步等待时间是30秒等。这里定义的可以被step中的Ruby文件访问到，比如此处的BASE_URL。

11.3　搭建Selenium独立环境

在本地运行Selenium非常方便，但是如果集成测试的数目变多时，你需要将这些测试分布到不同的机器上运行，此时就需要部署独立的Selenium环境。

搭建Selenium的独立环境可以分为下面几个步骤。

11.3.1　安装Selenium

Selenium的独立版以Jar包的形式发布，需要下载到本地：

$ wget http://selenium.googlecode.com/files/selenium-server-standal- one-2.39.0.jar

然后为Selenium创建独立的用户，并设置相关权限：

$ sudo mkdir –p /usr/local/share/selenium
$ sudo mv selenium-server-standalone-2.39.0.jar /usr/local/share/ selenium
$ sudo /usr/sbin/useradd -m -s /bin/bash -d /home/selenium selenium
$ sudo chown -R selenium:selenium /usr/local/share/selenium

我们需要为Selenium创建单独的日志目录：

$ sudo mkdir -p /var/log/selenium
$ sudo chown selenium:selenium /var/log/selenium

另外，还需要下载ChromeDriver，并将其放置在/usr/local/bin/目录下。配置完成之后，我们需要编写一个服务脚本，以便系统启动时自动启动Selenium。

11.3.2　服务脚本

服务脚本是一个特殊的Shell脚本，它由特殊的头注释信息和主体组成。这个头信息是LSB（Linux Standard Base）对标准服务的一个规范：

### BEGIN INIT INFO
# Provides:　　　　　selenium-standalone
# Required-Start:　　$local_fs $remote_fs $network $syslog
# Required-Stop:　　 $local_fs $remote_fs $network $syslog
# Default-Start:　　 2 3 4 5
# Default-Stop:　　　0 1 6
# Short-Description: Selenium standalone server
### END INIT INFO

这里的注释中，Provides为该服务的名字，Required-Start中列出了一些服务，它们表示当前服务需要等到该列表中的所有服务都可用之后再启动。由于Selenium需要文件系统，记录日志，网络服务等，因此你可以看到此处配置的$network, $syslog等。同样的，Required-Stop中列出的服务需要在停止本服务之前停止。

随后我们需要定义一些环境变量，这些变量是我们的脚本会访问的：

DESC="Selenium standalone server"
USER=selenium
JAVA=/usr/bin/java
PID_FILE=/var/run/selenium.pid
JAR_FILE=/usr/local/share/selenium/selenium-server-standalone-2.39. 0.jar
LOG_FILE=/var/log/selenium/selenium.log
CHROME_DRIVER=/usr/local/bin/chromedriver
DAEMON_OPTS="-Xmx500m -Xss1024k -Dwebdriver.chrome.driver=$CHROME_ DRIVER -jar $JAR_FILE -log $LOG_FILE"
DAEMON_OPTS="-Djava.security.egd=file:/dev/./urandom $DAEMON_OPTS"
# The value for DISPLAY must match that used by the running instance of Xvfb.
export DISPLAY=:10
# Make sure that the PATH includes the location of the ChromeDriver binary.
# This is necessary for tests with Chromium to work.
export PATH=$PATH:/usr/local/bin

注意此处的DISPLAY指定为:10，它是系统X 启动的编号，如果你使用了Xvfb之类的虚拟桌面，那么这里需要和Xvfb所在的编号一致。

最后是启动、停止、重启三个选项，这对于每个服务都是必须的：

case "$1" in
start)
echo "Starting $DESC: "
start-stop-daemon -c $USER --start --background \
--pidfile $PID_FILE --make-pidfile --exec $JAVA -- $DAEMON_ OPTS
;;
stop)
echo　"Stopping $DESC: "
start-stop-daemon --stop --pidfile $PID_FILE
;;
restart)
echo "Restarting $DESC: "
start-stop-daemon --stop --pidfile $PID_FILE
sleep 1
start-stop-daemon -c $USER --start --background \
--pidfile $PID_FILE --make-pidfile --exec $JAVA -- $DAEMON_ OPTS
;;
*)
echo "Usage: /etc/init.d/selenium-standalone {start|stop|restart}"
exit 1
;;
esac
exit 0

编写完脚本之后，将其保存为selenium，路径为/etc/init.d/selenium。接下来，还需要修改其执行权限：

$ sudo chown root:root /etc/init.d/selenium
$ sudo chmod a+x /etc/init.d/selenium
$ sudo update-rc.d selenium defaults

测试一下：

$ sudo service selenium start

我们可以通过ps –Af来查看其运行情况，也可以通过selenium.log查看更详细的信息。

第12章 环境搭建的自动化

在软件世界中，将软件开发出来只是整个生命周期中的一个步骤，软件只有发布在实际的服务器上才能被最终用户访问，也才能为企业带来真正的价值。

人们在软件开发周期中做了很多工作，比如通过构建系统来自动化重复的编译、链接、测试、集成等工作。但是在软件部署方面，仍然存在很多手工劳动，特别是当应用程序的规模变大之后，企业一般会有多个环境：开发环境，测试环境，验收环境，仿真环境，生产环境。对于每个环境，又可能由多台物理机器或者虚拟机器组成：数据库服务器，Web服务器，负载均衡服务器，应用容器等等。

每当软件新版本需要发布时，运维人员需要花费大量的时间来清理、安装、配置各种软件。手工配置的一个明显的弊端就是容易出错，比如IP地址配置错误，修改配置时漏掉了某个配置文件等。

随着云平台的逐渐普及，以及虚拟化技术的成熟，人们开始为这些过程开发自动化工具。这些工具可以将下载、安装、配置的工作自动化，使得人们可以在数分钟之内将所有的机器重置，并安装一个全新的环境。

随着这些工具的出现，人们管理基础设施的方式变成了编写代码，因为借助虚拟化技术使得所有的机器都可以认为是一样的，对于机器的描述变成了：

（1）创建一个App的用户组。

（2）在该组下创建一个App用户。

（3）安装nginx的1.6.0版本。

（4）创建一个新的nginx配置文件。

（5）以用户App来启动nginx。

这些自然语言（或者类似于自然语言）的描述将被工具解释并执行，比如安装nginx的动作会变成：

（1）下载nginx1.6.0的源码。

（2）检查nginx的依赖（比如openssl等）。

（3）下载并安装各个依赖。

（4）安装nginx。

（5）启动nginx服务。

这些解释是在工具内部完成的，对于最终用户来说完全透明。本章将以Chef为例，自动化地完成一个Web应用服务器的配置。

12.1　自动化工具Chef

Chef是一个开源的、基于Ruby的自动化部署工具。Chef定义了一套用以描述部署的DSL，这套DSL使得用Chef编写的代码非常易读。

Chef中有一些非常有趣的概念，这里做一些必要的解释：

Chef这个词的意思是厨师，Cookbook是菜单，Recipe是具体的菜谱食谱。一个厨师可以有一大本菜单，每个菜都有对应的菜谱，用户需要什么菜，厨师只需要按照菜谱就可以完全制作出来。

12.1.1　使用Berkshelf管理cookbook

Berkshelf是一个Cookbook的依赖管理工具，它可以用来定义一个Cookbook，新的Cookbook可以依赖于其他已有的Cookbook，比如我们的应用程序依赖于nginx，那么就需要在我们的Cookbook中导入nginx的Cookbook。

Berkshelf是一个Ruby的gem，可以通过以下命令安装：

$ gem install berkshelf

安装之后，你会获得一个命令行工具berks，这个工具可以用来创建新的Cookbook，或者安装被依赖的Cookbook。

我们用它来创建一个Cookbook：

$ berks cookbook liked
create　liked/files/default
create　liked/templates/default
create　liked/attributes
create　liked/libraries
create　liked/providers
create　liked/recipes
create　liked/resources
create　liked/recipes/default.rb
create　liked/metadata.rb
create　liked/LICENSE
create　liked/README.md
create　liked/CHANGELOG.md
create　liked/Berksfile
create　liked/Thorfile
create　liked/chefignore
create　liked/.gitignore
create　liked/Gemfile
create　liked/Vagrantfile

可以看到，berks会生成一个新目录，并且在该目录下生成很多的目录和文件，这些目录和文件都是Chef的Cookbook的标准布局。比如具体的recipe会放在recipe目录下，而一些相关的常量则定义在attributes目录下等。

注意此处生成了一个Vagrantfile，这个文件是Vagrant 的配置文件，其中定义了虚拟机的一些配置。我们需要对其做一些修改，然后让Chef来配置这个虚拟机。

VAGRANTFILE_API_VERSION = "2"
Vagrant.require_version ">= 1.5.0"
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
config.vm.hostname = "liked-berkshelf"
config.omnibus.chef_version = "11.10.4"
config.vm.box = "precise64"
config.vm.network "private_network", :ip =>"192.168.2.105"
config.berkshelf.enabled = true
config.vm.provision :chef_solo do |chef|
chef.run_list = [
"recipe[liked::default]"
]
end
end

我们配置基本的box为precise64，并为虚拟机指定了虚拟的IP地址。注意这里的config.berkshelf.enable=true指定启用berkshelf插件，这个插件将Berkshelf和Vagrant联系起来。另外一个需要的插件是omnibus，它用来查看具体的Cookbook有没有被安装。这两个插件的安装非常简单：

$ vagrant plugin install vagrant-omnibus
Installing the 'vagrant-omnibus' plugin. This can take a few minutes...
Installed the plugin 'vagrant-omnibus (1.4.1)'!

以及：

$ vagrant plugin install vagrant-berkshelf --plugin-version '>= 2.0.1'
Installing the 'vagrant-berkshelf --version '>= 2.0.1'' plugin. This can take a few minutes...
Installed the plugin 'vagrant-berkshelf (2.0.1)'!

安装插件之后，我们就可以开始编写第一个配置脚本了。

12.1.2　自动创建用户

第一步非常简单，我们将在虚拟机中创建一个组，并在该组下创建一个用户。

首先，需要编辑recipes目录下的default.rb，它是Vagrantfile中run_list指定的位置：recipe[liked::default]。

在该文件中加入：

group 'liked'
user 'liked' do
group 'liked'
system true
shell '/bin/bash'
end

这些代码是Chef提供的DSL，group表示创建一个组，名称为liked。类似的，user用来创建一个用户，然后指定该用户所属组，以及登录后的sehll。

配置之后，运行：

$ vagrant provision

这个命令会先启动Berkshelf来下载依赖的Coobook（我们在第一步没有任何依赖），然后在虚拟机上安装Chef的客户端，并开始按照配置设置虚拟机。这一步运行结束之后，我们可以通过下面命令检查运行结果：

$ vagrant ssh -c "getent passwd liked"
liked:x:998:1003::/home/liked:/bin/bash
Connection to 127.0.0.1 closed.

ssh　-c可以用来执行一条命令，getent passwd加用户名用来查看该用户在系统中的信息。从执行结果可以看出，我们的脚本已经生效！用户被正确创建了。

上边的代码还可以改进，比如将常量“liked”抽取到一个公共的地方，事实上这也是Chef推荐的方式。通常这些常量需要被放置到attributes目录下的default.rb文件中：

default['liked']['user'] = 'liked'
default['liked']['group'] = 'liked'

然后在recipes目录下的default.rb中，可以这样引用这些值：

group node['liked']['group']
user node['liked']['user'] do
group node['liked']['group']
system true
shell '/bin/bash'
end

12.1.3　安装nginx服务器

Web应用需要一个服务器来对外提供服务，这里选择轻量级的nginx。nginx已经被别人创建成一个Cookbook，我们只需要将其依赖进来即可。添加依赖需要修改metadata.rb文件：

name　　　　　　  'liked'
maintainer　　   'juntao.qiu'
maintainer_email  'juntao.qiu@gmail.com'
license　　　　　  'All rights reserved'
description　　　 'Installs/Configures liked'
long_description  'Installs/Configures liked'
version　　　　　 '0.1.0'
depends "nginx", "~> 2.6"

前几行都是一些元数据信息，最后一行指定了这个依赖关系。这时候如果执行

$ berks install

nginx的Cookbook就会被安装到本地的~/.berkshelf/default/vagrant/berkshelf-20140702- 69590-17me9nj-default目录下。注意，路径的最后一部分是根据时间戳生成的，在你的环境中可能会不同。

然后，在recipes/default.rb中，加入对这个依赖的引用：

group node['liked']['group']
user node['liked']['user'] do
group node['liked']['group']
system true
shell '/bin/bash'
end
include_recipe 'nginx'

然后再次执行

$ vagrant provision

Chef会再次运行，安装并启动nginx，如图12-1所示。

图12-1　Chef自动安装nginx

启动之后，可以通过下面命令来查看其运行情况：

$ ssh -c "service nginx status"
* nginx is running
Connection to 127.0.0.1 closed.

或者从浏览器中查看，如图12-2所示。

图12-2　访问虚拟机中的nginx

12.1.4　配置nginx

目前为止，这个nginx还不是那么有用。想让其正常工作，还需要一些额外的配置。

nginx使用自己的配置文件，比如：

upstream moco_server {
server localhost:12306;
}
server {
listen 9999;
server_name _;
location / {
proxy_pass http://moco_server;
index index.html;
}
location ~ ^/(scripts/|css/|style/) {
root "/Users/twer/develop/design/todos/";
}
location = /index.html {
root "/Users/twer/develop/design/todos";
}
}

upstream节指定了一个运行在本地12306端口的另一个网络程序。server节中指定启动一个nginx进程，监听9999端口，当用户访问除了index.html和scripts/css/style之外的资源时，将请求转发到12306上。对于发往index.html的请求，返回/Users/twer/develop/ design/todos目录下的index.html，对于发往scripts/css/style的请求，都返回/Users/twer/ develop/design/todos目录下相应的内容。

这是一个典型的动态、静态分离的配置：将对动态内容的请求原封不动地转发到后台，而对于静态内容则直接从文件（缓存）中读取。

但是，在我们的应用中，这些目录肯定是动态的。也就是说，我们希望这个Cookbook有一定的通用性，如果别人需要使用这个Cookbook，只需要配置一下静态内容的路径就可以了。

这一点可以通过模板来实现，首先在recipes/default.rb中指定template动作和directory动作：

# generate nginx configuration
template "#{node['nginx']['dir']}/sites-
enabled/#{node['liked'] ['config']}" do
source "liked.conf.erb"
notifies :restart, "service[nginx]"
end
# create application folder
directory "#{node['liked']['app_root']}" do
action :create
recursive true
end

template会根据source中指定的模板来生成具体的内容，并将其放在指定的位置。notifies则用以在该模板发生变化时通知相关组件。

directory用以操作目录，此处我们用它来生成一个目录。recursive表示创建时如果路径中有不存在的目录，也一起创建，比如创建路径“/opt/apps/liked”时，如果apps不存在，则先创建apps，然后再创建“apps/liked”。

这里的常量还是定义在attributes/default.rb中：

default['liked']['config'] = 'liked.conf'
default['liked']['app_root'] = '/var/apps/liked'

模板liked.conf.erb的内容为：

upstream unicorn_server {
server unix:<%= node['liked']['app_root']
%>/tmp/sockets/unicorn.sock
fail_timeout=0;
}
# configure the virtual host
server {
# replace with your domain name
server_name _;
# replace this with your static Sinatra app files, root + public
root <%= node['liked']['app_root'] %>;
# port to listen for requests on
listen 9527;
# maximum accepted body size of client request
client_max_body_size 4M;
# the server will close connections after this time
keepalive_timeout 5;
location / {
try_files $uri @app;
}
location @app {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
# pass to the upstream unicorn server mentioned above
proxy_pass http://unicorn_server;
}
}

注意这里的<%=%>中的内容将会被Chef替换，然后拷贝到指定目录。配置完成之后，再次运行：

$vagrant provision

这一次，我们的模板被替换，并安装在正确的目录下：

$ ls /etc/nginx/sites-enabled/liked.conf
/etc/nginx/sites-enabled/liked.conf

配置Rbenv

首先在metadata.rb中加入对rbenv的依赖：

depends 'rbenv'

然后，在recipes/default.rb 中使用该Cookbook：

include_recipe "rbenv::default"
include_recipe "rbenv::ruby_build"
rbenv_ruby "#{node['liked']['rbenv']['version']}" do
global true
end
rbenv_gem "bundler" do
ruby_version "#{node['liked']['rbenv']['version']}"
end

其中，ruby的版本定义在attributes/default.rb:

default['liked']['rbenv']['version'] = '1.9.3-p194'

此时可以再次执行vagrant provision来重新配置虚拟机，执行之后可以在虚拟机中看到：

$ rbenv -v
rbenv 0.4.0-98-g13a474c
$ rbenv global
1.9.3-p194

此时，我们的Cookbook对其他Cookbook的依赖关系如图12-3所示。

图12-3　Cookbook的依赖关系

这个图事实上是通过下面命令生成的：

$ berks viz

第13章 应用程序发布

在过去，发布应用程序是一个极为复杂的过程：你需要一个域名，一个独立的/虚拟的主机，一个应用程序的运行环境（JVM、Ruby、Apache、PHP等）。环境的搭建往往要耗费很多的时间和精力，而且一旦出现问题，就需要重新配置。

依赖于云平台，部署应用程序到公共网络几乎变成了一个没有成本的过程，帮助我们节省出大量时间来做其他的任务。云平台的一个核心技术是虚拟化：大量基础设施可以通过软件来自动化，例如自动创建虚拟机，自动安装操作系统，自动安装Web系统，自动配置数据库等等。有了这些工作的自动化，我们可以在数秒之内配置出一个完全按照需求定制的“机器”。

一般来说，如果一个Web应用不能被实际用户看到，我们就无法真正知道这个应用程序是否可以解决用户的问题。越早将用户包含进来，就可以越早调整方向，使得软件向用户所需要的方向演进。

13.1　使用Heroku发布应用程序

Heroku是一个非常轻量级的云平台，它提供免费的应用程序托管服务（每个免费用户可以获得5个应用程序的托管），同时，付费用户可以获得更多的服务，比如更多的存储空间、更多的应用程序数量等。

使用Heroku可以部署多种环境的应用程序，比如Java，Ruby，Python，NodeJS等。Heroku的底层操作系统使用了Ubuntu，不过它提供了客户端工具，使得开发者已经感觉不到底层操作系统的存在。

要使用Heroku提供的服务，首先需要注册一个用户，如图13-1所示。

然后，需要安装平台特定的命令行工具，我们后续的所有操作都需要使用这个命令行工具。

图13-1　Heroku首页

安装之后，可以在命令行里执行heroku来查看，如图13-2所示。

$ heroku --help

图13-2　Heroku命令行工具

首次使用时，需要先登录：

$ heroku login
Enter your Heroku credentials.
Email: kmustlinux@gmail.com
Password (typing will be hidden):
Authentication successful.

验证之后，我们需要添加一个公钥到Heroku，以便以后与Herohu服务器通信：

$ heroku keys:add ~/.ssh/id_rsa_github_juntao.pub
Uploading SSH public key /Users/jtqiu/.ssh/id_rsa_github_juntao.pub... done

这样，基本环境就配置好了，我们可以很容易地将一个本地的git版本库中的代码部署到Heroku上。

发布第一个应用程序

这一小节会做一个Ruby应用程序，然后将其发布在Heroku提供的虚拟机上，这样任何知道URL的人都可以访问我们的应用。

首先需要将我们的应用程序上传至Heroku，这样Heroku才能找到并启动它。Heroku使用git来同步代码，因此需要在本地创建一个git的仓库：

$ mkdir myapp
$ cd myapp
$ git init
Initialized empty Git repository in /Users/jtqiu/develop/ruby/myapp/. git/

然后，建立一个Gemfile来描述应用程序的依赖：

source 'http://ruby.taobao.org'
gem 'sinatra'

执行：

$ bundle install

安装完成之后，我们开始编写应用程序。创建一个app.rb：

require 'sinatra'
languages = [
"Bash",
"C/C++",
"Java",
"Ruby",
"Python",
"JavasSript",
"CoffeeScript"
]
get '/' do
fav = languages.shuffle[0]
"I like <b>#{fav}</b> the most!"
end

这个程序运行之后，每次访问根目录都会得到一行文字，如图13-3所示。

图13-3　随机编程语言页面

不过这行文字中的语言是随机的，每次访问都可能会不同。当然，这个应用程序到目前为止还没有什么有意思的功能。

我们可以很容易地在本地启动它：

$ ruby app.rb

但是如果将其同步到Herohu上，Heroku怎么能认识并运行它呢？我们需要告诉Heroku这是一个Ruby的Web应用程序。这个可以通过提供一个Procfile来实现，基本上来说，Procfile描述了应用的类型及启动方式。

我们可以创建一个Procfile，里边的内容为：

web: bundle exec ruby app.rb -p $PORT

web表示应用程序的类型，冒号后边为启动应用程序的命令，$PORT是对环境变量PORT的引用，Heroku会负责设置这个值。

完成这一步之后，我们需要先在本地测试一下Procfile是否可以工作。Heroku的工具包中提供了一个叫做foreman的工具用来测试，如图13-4所示。

图13-4　foreman命令行工具

foreman在本地的5000端口启动了我们的应用程序。看来一切正常，这时候就可以开始部署。

首先，将本地的修改作为一次commit添加到git中:

git add .
git commit -m "myapp first version"

然后创建应用：

$ heroku create
Creating fathomless-headland-3090... done, stack is cedar
http://fathomless-headland-3090.herokuapp.com/ | git@heroku.com: fathomless-headland-3090.git
Git remote heroku added

这一步中，heroku会在自己的服务器上为我们创建一个git版本库，然后将本地版本库的远端（remote）指向这个版本库，这样我们就可以向其中push代码了：

$ git remote -v
heroku git@heroku.com:fathomless-headland-3090.git (fetch)
heroku git@heroku.com:fathomless-headland-3090.git (push)

可以看到，我们已经将远端添加成功了，下一步就是将本地的commit同步到这个远端，之后Heroku会完成所有其他的事情：

$ git push heroku master

这样我们的部署工作就完成了！可以使用下面的命令来打开浏览器并访问该应用，如图13-5所示。

$ heroku open

图13-5　部署到Heroku

对于一个已有的版本库，我们应该如何部署呢？毕竟我们可能已经编写了很多代码，事实上部署已有的版本库同样非常容易。假设有一个已有的版本库ExistingApp，我们可以先在Heroku的服务器为代码创建一个版本库：

$ heroku create

然后需要登录到Heroku的后台查看刚才创建出来的应用，并在设置中找到代码库的URL，如图13-6所示。

图13-6　获取应用的Git路径

有了这个URL之后，需要将其添加为remote：

$ git remote add heroku git@heroku.com:desolate-savannah-6640.git

然后再将本地的版本库同步上去即可：

$ git push heroku master

13.2　发布到虚拟机环境

日常开发中，我们还可以借助一些工具，将应用程序发布到自己的虚拟机环境中。一种典型的做法是：将测试通过的代码提交到版本库中，然后执行一个部署脚本将应用程序安装到服务器上。

部署脚本首先会从代码库中获得代码，然后配置一些基本的环境（安装依赖包等），最后执行启动脚本，将应用程序启动起来。

这个小节将使用Mina来实现自动化部署。Mina是一个Ruby的gem，它事实上只是一个Shell的包装，提供了基于Rake的DSL，写出来的代码清晰易读。

部署是在一台机器上操作另一台机器，为了方便操作，我们需要配置无密码登录。这样部署时就可以完全无人值守了。

13.2.1　使用密钥登录

可以通过ssh-keygen生成一对密钥：

$ ssh-keygen -t rsa

rsa是一种密钥生成的算法。ssh-keygen会提示你输入密钥文件的名称和路径，默认会生成到用户目录下的 .ssh目录中，生成的key有两个：

$ ls ~/.ssh
id_vagrant_liked
id_vagrant_liked.pub

有了公钥私钥之后，我们需要将公钥上传到虚拟机环境中，比如我们的虚拟机地址为192.168.2.105，而且需要以用户App来登录：

$ cat ~/.ssh/id_vagrant_liked.pub | ssh app@192.168.2.105 \
"mkdir -p ~/.ssh; cat >> ~/.ssh/authorized_keys"

即，将本地的公钥~/.ssh/id_vagrant_liked.pub的内容拷贝到远程机器上的~/.ssh/authorized_keys文件中。

这样，当我们后续要通过ssh登录到远端时，只需要指定本地的私钥即可：

$ ssh -i ~/.ssh/id_vagrant_liked app@192.168.2.105

-i选项指定了本地私钥文件的路径，这样两个密钥就可以匹配成功。

13.2.2　使用Mina

Mina是一个Ruby的gem，安装非常简单。这里采用Gemfile加bundle的方式安装，在Gemfile中加入：

gem 'mina'

然后执行bundle install即可。安装完成之后，通过下面命令来初始化：

$ mina init
-----> Created ./config/deploy.rb
Edit this file, then run `mina setup' after.

这时候mina会创建一个config目录，并在该目录下生成一个deploy.rb文件。我们要做的就是编辑这个deploy.rb。

在deploy.rb中，首先需要引入一些依赖的工具包：

require 'mina/bundler'
require 'mina/git'
require 'mina/rbenv'

mina/bundler用来在目标机器中执行bundle，mina/git从git获取代码，rbenv负责配置多版本的Ruby版本环境。之后需要设置一些基本的配置：

set :domain, '192.168.2.105'
set :deploy_to, '/var/apps/liked'
set :repository, 'git@github.com:abruzzi/testable-js-listing.git'
set :branch, 'master'

我们的应用程序将部署在192.168.2.105上的/var/apps/liked目录中，另外我们指定了代码在github上的路径和分支。

set :user, 'liked'　　# Username in the server to SSH to.
set :identity_file, '/Users/jtqiu/.ssh/id_vagrant_liked'
set :forward_agent, true
set :rbenv_path, '/opt/rbenv'

这里我们定义了以liked用户来完成部署动作，部署过程需要刚才生成的密钥文件。

下面我们来定义一些任务：

task :environment do
# If you're using rbenv, use this to load the rbenv environment.
# Be sure to commit your .rbenv-version to your repository.
invoke :'rbenv:load'
queue "export PATH=/opt/rbenv/bin:/opt/rbenv/shims:$PATH"
end

我们使用rbenv管理Ruby版本，因此在environment任务中调用rbenv:load，并设置环境变量，将rbenv的一些工具加入到环境变量PATH中。queue是Mina提供的对Shell命令的一个简单包装。

这个environment任务会被其他任务依赖：

task :setup =>:environment do
queue! "mkdir -p #{deploy_to}/current/tmp/pids
#{deploy_to}/current/ tmp/sockets"
queue! "mkdir -p #{deploy_to}/current/log"
end

setup任务依赖于environment，在setup阶段，我们创建了三个目录。而在部署任务中：

desc "Deploys the current version to the server."
task :deploy =>:environment do
deploy do
invoke :'git:clone'
invoke :'bundle:install'
end
end

我们先从版本库中获取最新的包，然后调用bundle:install来完成依赖包的安装。这些任务接近自然语言，非常易读。

定义好这些任务之后，就可以通过mina工具来执行了：

$ mina setup
-----> Loading rbenv
-----> Setting up /var/apps/liked
total 32
drwxr-xr-x 7 liked root　4096 Jul　3 09:17 .
drwxr-xr-x 3 root　root　4096 Jul　3 06:09 ..
lrwxrwxrwx 1 liked liked　 26 Jul　3 09:17 current -> /var/apps/ liked/releases/5
-rw-rw-r-- 1 liked liked　　2 Jul　3 09:12 last_version
drwxrwxr-x 2 liked liked 4096 Jul　3 08:32 log
drwxrwxr-x 7 liked liked 4096 Jul　3 09:17 releases
drwxrwxr-x 7 liked liked 4096 Jul　3 08:34 scm
drwxrwxr-x 3 liked liked 4096 Jul　3 08:32 shared
drwxrwxr-x 4 liked liked 4096 Jul　3 09:17 tmp
-----> Done.
Elapsed time: 0.00 seconds

可以看到，mina加载了rbenv的一些配置，然后在应用目录中创建了一些目录结构，然后退出。整个过程非常快，1秒以内就已经完成。

如果在上条命令中加上--verbose，就可以看到更详细的信息，如图13-7所示。

图13-7　使用mina部署

setup任务完成之后，就可以进行实际的deploy了：

$ mina deploy --verbose
-----> Loading rbenv
-----> Creating a temporary build path
-----> Fetching new git commits
$ (cd "/var/apps/liked/scm" && git fetch "git@github.com: abruzzi/testable-js-listing.git" "master:master" --force)
-----> Using git branch 'master'
$ git clone "/var/apps/liked/scm" . --recursive --branch "master"
Cloning into '.'...
done.
...
-----> Done. Deployed v1
Elapsed time: 4.00 seconds

deploy命令首先从Github上获取代码，然后运行bundle install来安装一些依赖包。我们还需要定义启动、停止、重启的动作：

task :start =>:environment do
queue! "cd #{deploy_to}/current && bundle exec unicorn –c
#{unicorn_conf} -E development -D"
end
task :restart =>:environment do
queue! "if [ -f #{unicorn_pid} ]; then kill -USR2 'cat
#{unicorn_pid}'; else cd #{deploy_to}/current && bundle exec unicorn -c #{unicorn_conf} -E #{env} -D; fi"
end
task :stop =>:environment do
queue! "if [ -f #{unicorn_pid} ]; then kill -QUIT 'cat
#{unicorn_pid}'; fi"
end

这些命令只需要是简单的Shell命令的包装即可。

13.3　服务器典型配置

这一节介绍服务器的典型部署结构，这种结构被大多数Web应用采用，它有很多优点，比如：

（1）动态，静态内容分离。

（2）高性能。

（3）负载均衡。

（4）热部署。

在生产环境中，硬件当然是必备条件，但是在相同的硬件环境中，软件的选择和配置也会给性能带来极大的影响。

我们这一节将介绍Nginx加Unicorn的组合来配置我们的服务器，以获得上面描述的这些优点。

Web服务器

Web服务器可以帮助应用服务器承担负载。通常我们还会将Web服务器配置成一个反向代理，这样它既可以提供静态内容给客户端，还可以转发动态内容给后端的应用服务器，并从应用服务器获得响应。

这里选择Nginx作为Web服务器，并且使用Nginx来配置反向代理，这样可以轻松地将动态请求和静态请求分流。

Nginx的配置文件默认的位于其安装目录的nginx.conf中。除了基本配置以外，Nginx会自动加载sites-enabled目录下的所有配置：

include /etc/nginx/conf.d/*.conf;
include /etc/nginx/sites-enabled/*;

这样我们可以将应用程序的相关配置写在sites-enabled目录中，比如创建一个配置文件liked.conf：

# use the socket we configured in our unicorn.rb
upstream unicorn_server {
server unix:/var/apps/liked/current/tmp/sockets/unicorn.sock
fail_timeout=0;
}

Nginx不会自己处理动态请求，我们要使用Unicorn来处理这些请求。Unicorn使用UNIX域套接字来进行通信。域套接字和命名管道非常类似，就是进程间的通信需要一个进程向该文件写入内容，另一个进程从其中读取。

定义了这个upstream，我们就可以定义一些规则，当用户请求和这些规则匹配时，nginx就自动将请求转发到这个upstream中。

server {
server_name _;
root /var/apps/liked/current;
# port to listen for requests on
listen 9527;
# maximum accepted body size of client request
client_max_body_size 4M;
# the server will close connections after this time
keepalive_timeout 5;
location / {
try_files $uri @app;
}
location @app {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
proxy_redirect off;
proxy_pass http://unicorn_server;
}
}

然后定义了一个server节，其中指定了server在9527端口上监听，这个端口就是应用程序对外的接口。然后定义root为一个本地路径，这样当请求到达之后，Nginx会从该路径中读取，并将找到的文件返回给客户端。

注意这里定义了一个@app的location，一旦请求到达这个location，就会被设置一些头信息，并被转发到unicorn_server这个upstream上。

而第一条locaiton：

location / {
try_files $uri @app;
}

则定义了，请求先通过root查找，如果找不到，就转发到@app这个location中去。如果都找不到，Nginx会返回默认的404页面。

1．应用服务器

应用服务器可以加载Ruby应用，并公开一个HTTP格式的接口，外部的进程（比如Web服务器）可以通过HTTP协议和应用服务器通信，应用服务器负责将HTTP信息翻译成Ruby对象，然后调用应用程序。应用程序处理完之后又会将这些处理后的对象翻译成HTTP并返回给调用者。

一般而言，应用服务器还负责负载均衡，重新加载应用程序等职责。Ruby有众多的应用服务器可供使用。WEBrick是一个Ruby自带的应用服务器，但是它本身有一些缺陷，无法在生产环境使用。Thin是一个轻量级，支持高并发的应用服务器，不过对监控的支持很弱。本章使用的Unicorn是另一个轻量级，支持高并发的服务器。Unicorn自动重启死掉的应用进程，并且可以很方便地热切换（发布新版本的应用程序时无需停止服务器，可以做到零宕机时间）。

Unicorn是一个Ruby的Gem，安装非常方便：

$ gem install unicorn

运行Unicron，需要指定配置文件，这个配置文件事实上也是一个Ruby文件，比如一个典型的配置是这样的：

@dir= "/var/apps/liked/current/"
# Preload our app for more speed
preload_app true
worker_processes 4
working_directory @dir
timeout 30
listen "#{@dir}tmp/sockets/unicorn.sock", :backlog =>64
# Set process id path
pid "#{@dir}tmp/pids/unicorn.pid"
# Set log file paths
stderr_path "#{@dir}log/unicorn.stderr.log"
stdout_path "#{@dir}log/unicorn.stdout.log"

可以看到，我们在文件unicorn.sock上监听，并使用pid来记录unicorn的进程ID，这样在停止unicorn时会非常方便。最后我们指定了日志文件的位置，如果启动失败，我们可以在日志中查找原因并修复。

working_directory指定了应用程序所在的目录，Unicorn会在启动时自动加载应用程序并执行。配置之后，可以通过下面命令来启动Unicorn：

$ unicorn -c unicorn.rb -E development -D

其中，-c选项指定配置文件，-E选项指定运行环境，这个值可能在我们的应用程序中使用（比如是否启用某些开关等），-D指定该进程在后台运行，这样我们就可以继续使用Shell。

2．热切换

使用Unicorn可以很容易地做到热切换。当应用程序更新之后，我们可以发送USR2信号给Unicorn，它会自动创建一个新的master进程，然后新的master会创建新的工作进程，每个工作进程会去加载应用程序，并开始服务。而老的工作进程会在处理完当前请求之后退出。

我们还需要将老的master进程kill掉，但是又不知道应该何时kill。事实上，unicorn有一个钩子，可以注册一个回调函数来处理这种场景：

before_fork do |server, worker|
# Before forking, kill the master process that belongs to the .oldbin PID.
# This enables 0 downtime deploys.
old_pid = "#{@dir}tmp/pids/unicorn.pid.oldbin"
if File.exists?(old_pid) && server.pid != old_pid
begin
Process.kill("QUIT", File.read(old_pid).to_i)
rescue Errno::ENOENT, Errno::ESRCH
# someone else did our job for us
end
end
end

这样，重启Unicorn时只需要发送信号USR2到master：

$ kill -USR2 'cat /var/apps/liked/current/tmp/pids/unicorn.pid'

第14章 一个实例（前端部分）

从本章起，我们将从头到尾开发一个应用程序。从最基本的环境配置，到代码开发，到持续集成，再到发布到公共网络上。我们将采用迭代的方式来完成这个应用程序的搭建，即先完成一组最小的功能，然后逐步细化，逐步优化，直到最后发布一个相对完整，而且可用的系统出来。

植物世界是一个丰富多彩的世界，千百万年的进化使得它们具有各种各样的外形，为了获取更多的阳光，它们进化出了更大/更多的叶子；为了更好地繁衍后代，它们进化出了千姿百态的花朵、果实；有的则完全另辟蹊径，依附于其他的植物之上。

但是不是每个人都有机会去野外欣赏丰富多彩的植物世界，我们将创建一个应用程序。这个应用中将收集许许多多的奇异植物，应用将包含植物的名称、产地、喜好等信息，另外还附有实际的照片，将它们的美丽展现给其他人。

用户可以上传、编辑自己熟知的奇异植物，也可以从其他用户那里获取自己不熟悉的其他奇异植物。当然，系统可能会有很多的其他功能，但是让我们先将需求缩减到最小、最简单，然后逐步完善。

14.1　线框图

线框图是一个非常方便、简单的工具。很多时候，设计师甚至可以通过使用纸笔完成线框图的设计。线框图粗略地描述了页面元素的大致位置，以及在何处放置何种组件等等。

我们这里画出的线框图表示了一个对网站的初步设想，如图14-1所示。

图中，页面的头部是一个大的展示区，此处有数张最为用户喜爱的植物图片。接下来是一个搜索框，用户可以按照关键字来查找系统中已有的植物信息。然后在内容区的左边是一些分类信息，比如草本植物、木本植物等。内容区则是一个个的植物卡，每个卡上有一种植物的名称和简要描述，还有一个可以让用户保存的按钮。最后页面的底部有一个分页器，这样我们可以保持每页都只显示指定数目的条目。

图14-1　主页线框图

而对于每个植物的详细信息，需要另外一个页面，如图14-2所示。

在详细页面中，页面的顶部有一个比较大的图片，以及几段用来描述该植物的文字。页面的中部可以有一些该植物的其他图片。页面的底部是一些类似植物推荐。或者也可以是基于其他用户的收藏频度给出的推荐列表等。

有了线框图，我们就可以告诉其他人这个网站的初步想法。应该注意的是，线框图更多地是表达思路以及大致的布局，我们总是可以对它作进一步的修改，调整元素的位置，添加更加有意义的组件等等。

图14-2　详情页线框图

14.2　搜索结果页面

有了线框图之后，我们就可以进一步使用一个简单的页面和CSS框架来快速地搭建出一个原型了。这个页面中的所有数据都是静态的，而且功能也并没有完成，不过页面已经看起来像模像样了。

我们使用Twitter Bootstrap来完成此处的mockup页面。首先，创建好工程，安装bootstrap包：

$ mkdir -p qipa
$ cd qipa
$ touch .bowerrc

在该文件中定义需要下载bootstrap到哪个目录：

{
"directory": "src/vendor/"
}

然后使用bower来安装bootstrap：

$ bower install bootstrap

安装之后，bootstrap目前还是源码的形式，我们需要将其编译为可用的CSS格式，首先计入bootstrap的源码目录，然后执行npm install来安装依赖：

$ cd src/vendor/bootstrap
$ npm install
$ grunt dist

14.2.1　模板页面

编译完成之后，本地会生成dist目录，其中即为我们需要的最终CSS文件。我们现在可以在根目录（qipa）中创建一个index.html文件，并在其中开始我们的HTML代码编写：

<!DOCTYPE html>
<htmllang="en">
<head>
<metacharset="utf-8">
<metahttp-equiv="X-UA-Compatible" content="IE=edge">
<metaname="viewport" content="width=device-width, initial-scale
=1">
<title>Qi Pa</title>
<linkrel="stylesheet" href="src/vendor/bootstrap/dist/css/
bootstrap.css" />
</head>
<body>
<!-- content should be here -->
<scriptsrc="src/vendor/jquery/dist/jquery.js" type="text/javascript"> </script>
<scriptsrc="src/vendor/bootstrap/dist/js/bootstrap.js" type="text/ javascript"></script>
</body>
</html>

如果现在在浏览器中打开这个模板，你会得到一个空白页。别着急，这已经是一个HTML5的文档，并且可以根据屏幕的宽度来自动调整缩放，嵌入了jQuery库和bootstrap的页面了。

14.2.2　导航栏

我们接下来的开发就变成了在内容区添加内容了。首先我们来创建一个导航栏，其中包含了应用的名称，以及几个菜单项，最后在最右边有一个注册的按钮：

<div class="navbar navbar-default navbar-fixed-top" role="navigation">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand" href="#">奇葩</a>
</div>
<div class="navbar-collapse collapse">
<ul class="nav navbar-nav">
<li class="active"><ahref="#">Home</a></li>
<li><ahref="#about">About</a></li>
<li><ahref="#contact">Contact</a></li>
</ul>
<ul class="nav navbar-nav navbar-right">
<li class="active"><ahref="./">Sign up</a></li>
</ul>
</div>
</div>
</div>

我们可以在页面上看到如图14-3所示的效果。

由于我们使用了navbar-fixed-top类，这个导航栏会一直出现在页面的顶部，即使出现了滚动条之后，导航栏不会随着内容区域而滚动。由于我们现在还在Mockup页面阶段，因此这里的所有链接都指向了本页。

图14-3　导航栏效果

14.2.3　走马灯

在导航栏之后，我们需要在页面上创建一个大的走马灯。走马灯分为三个部分，分别为指示器、图片集合以及控制按钮：

<div id="carousel" class="carousel slide" data-ride="carousel">
<!-- Indicators -->
<ol class="carousel-indicators">
<li data-target="#carousel" data-slide-to="0" class="active"></li>
<li data-target="#carousel" data-slide-to="1"></li>
<li data-target="#carousel" data-slide-to="2"></li>
</ol>

<div class="carousel-inner">
<div class="item active">
<img src="carnivorous-plant-image.jpg" alt="Carnivorous Plant">
</div>
<div class="item">
<img src="cauliflower.jpg" alt="Cauliflower">
</div>
<div class="item">
<img src="mushroom-22200-1920x1080.jpg" alt="Mushroom">
</div>
</div>
<a class="left carousel-control" href="#carousel" role="button" data-slide="prev"><spanclass="glyphicon glyphicon-chevron-left"></span>
</a>
<aclass="right carousel-control" href="#carousel" role="button" data-slide="next"><spanclass="glyphicon glyphicon-chevron-right"></span>
</a>
</div>

类carousel-indicators定义了指示器，其中包含了三个li元素，它们在展示上为三个小圆点，实心的圆点表示了当前的位置。类carousel-inner中定义了图片集，这里可以有多张图片，图片个数与指示器中的li元素相对应。而最后两个carousel-control则实现了先前/向后导航的按钮。

加上了走马灯之后的页面显得更加的生动，如图14-4所示。

图14-4　添加走马灯效果

走马灯中的图片可以自动切换，当然用户还可以通过点击左右的两个carousel-control来控制图片切换。

14.2.4　搜索框

紧接着我们需要实现一个搜索框。搜索框可以采取最简单的文本框加一个搜索按钮来完成：

<div class="row">
<div class="col-lg-8">
<input type="text" class="form-control" value="" placeholder="食人花" />
</div>
<div class="col-lg-4">
<button class="btn btn-success">Search</button>
</div>
</div>

结果如图14-5所示。

图14-5　搜索框

我们通过拉伸文本框使得用户更容易输入。

14.2.5　目录侧栏

紧接着我们需要定义一个植物的目录侧栏。侧栏采用bootstrap提供的列表来实现：

<div class="list-group">
<a href="#" class="list-group-item">花朵植物</a>
<a href="#" class="list-group-item active">草本植物</a>
<a href="#" class="list-group-item">蕨类植物</a>
<a href="#" class="list-group-item">木本植物</a>
<a href="#" class="list-group-item">孢子植物</a>
</div>

渲染结果如图14-6所示，其中第二个由于加上了active的CSS类，因此显示为蓝色（激活状态）来告诉用户目前是哪个目录。

图14-6　侧边栏

14.2.6　植物列表

植物列表是我们的应用中最重要的内容，也是用户最关心的部分。列表中每一个条目都包含这样一些信息：植物的图片，植物的名字，植物的简单描述，一个收藏按钮（用户可以将这个植物加入收藏）。

列表中的一个条目看起来如图14-7所示。

图14-7　植物条目

我们还需要多个条目可以在不同尺寸的屏幕上都自动排列，比如在小一些的屏幕上，每行显示1张图片，而较大的屏幕则一行显示2张，中等屏幕每行显示3张，最后如果是桌面系统则显示4张。这在bootstrap中很容易实现：

<div class="col-lg-3 col-sm-6 col-md-4 col-xs-12">
<div class="item thumbnail">
<a href="">
<imgsrc="http://placehold.it/270x300">
</a>
<h4><ahref="#">猪笼草</a></h4>
<p>猪笼草，是猪笼草科的唯一属，也是多种能够捕食昆虫的多年生草本植物，主产地是热带亚洲地区</p>
<button class="btn btn-success">Collect</button>
</div>
</div>

这样，当我们缩放到不同尺寸时，可以看到不同的排列，如图14-8所示。

图14-8　多条植物详情

14.2.7　分页器

最后是分页器，我们可以很容易地使用bootstrap中内置的分页器来实现，如图14-9所示。

<div class="row">
<div class="col-12 col-lg-12 text-center">
<ul class="pagination text-center">
<li class="disabled"><ahref="#">Previous</a></li>
<li class="active" id="page_nav"><ahref="#">1</a></li>
<li id="page_nav"><ahref="#">2</a></li>
<li id="page_nav"><ahref="#">3</a></li>
<li id="page_nav"><ahref="#">4</a></li>
<li id="page_nav"><ahref="#">5</a></li>
<li id="page_nav"><ahref="#">Next</a></li>
</ul>
</div>
</div>

图14-9　分页器

这样，搜索结果页面就算完成了，我们来看一下所有部分合在一起的结果，如图14-10所示。

图14-10　最终的主页效果

确实已经有模有样了，虽然这些按钮点击了没有反应，也不能搜索，不能翻页。但是仅仅从界面上来看，我们已经有了一个比较专业的页面了。

14.3　详细信息页面

详细信息页面比搜索结果页面要简单得多，我们几乎用不到bootstrap提供的常用组件，只需要使用它的布局器就可以完成任务了。

比如在页面的顶部，需要一个醒目的标题：

<div class="row">
<div class="col-lg-2 col-md-2">
<img class="img-responsive" src="http://placehold.it/80x80">
</div>
<div class="col-lg-10 col-md-10">
<h1>This is a really weired plant</h1>
</div>
</div>

我们只需要将一行分为1:5的两部分即可，第一部分放置一张图片，第二部分放置植物的一个标题，如图14-11所示。

图14-11　详情页的标题

页面的其余部分比较容易，此处就不再列出具体的代码了，读者可以在本书对应的代码库中下载完整的文件，下面是最终的Mockup页面的效果，如图14-12所示。

好了，我们目前所需要的暂时就这么多。后续的需求，比如用户如何添加一个新的植物，如何编辑一个既有的植物，用户如何注册，如何分享等等，我们这里都不做考虑。

核心的概念是，我们需要快速地将应用发布出去，然后找到实际的用户来试用，并在试用的过程中提供反馈。我们会根据这些反馈来进行下一步的决策，如果某些功能是用户迫切需要而我们自己没有考虑到的，那么应该为其分配较高的优先级；反之如果某些我们“感觉”很好用的功能则未必是用户需要的。

图14-12　详情页的最终效果

14.4　加入JavaScript

既然我们已经有了两个非常漂亮的Mockup页面，那么是时候加入一些JavaScript代码，让这些页面渲染“真实”的数据了。

到目前为止，我们一直的策略都是：让擅长于做某件事情的框架/库/工具来负责它擅长的部分，然后将这些不同的框架/库/工具组合起来。在这个应用的开发中，我们仍然会采取这种策略。

对于前端开发，我们将采取这样的技术栈：

（1）AngularJS作为JavaScript框架。

（2）Bootstrap作为CSS框架。

（3）Grunt作为前端的构建脚本。

（4）Moko作为Mock服务器（用作前期的测试）。

（5）Bower作为前端的依赖管理工具。

AngularJS可以帮助我们节省很多代码量，与后台REST风格的API集成更是非常容易，Moko帮助我们在没有后台的情况下独立地测试AngularJS应用程序。构建脚本则用来帮助我们合并JavaScript文件，压缩CSS代码等。Bower可以使得安装各个JavaScript库变得轻松。

首先我们来定义一个清晰的目录结构，所有的前端应用，包括CSS、JavaScript、第三方的库、图片、模板文件等都放置在src目录下对应的位置，如图14-13所示。

图14-13　加入AngularJS后目录结构

在根目录中的index.html保持简单，其中引入了所有的JavaScript库以及我们应用程序的JavaScript代码：

<script src="src/vendor/jquery/dist/jquery.js"
type="text/javascript"> </script>
<script src="src/vendor/bootstrap/dist/js/bootstrap.js"
type="text/ javascript"> </script>
<script src="src/vendor/angularjs/angular.js"
type="text/javascript"> </script>
<script src="src/vendor/angular-resource/angular-resource.js"
type= "text/javascript"> </script>
<script src="src/vendor/angular-route/angular-route.js"
type="text/ javascript"> </script>
<script src="src/application/plantapp.js"
type="text/javascript"> </script>

在index.html的头部，我们加入了AngularJS的ng-app指令：

<htmlng-app="PlantApplication">

PlantApplication是我们应用程序的名字，在plantapp.js中我们将创建这个应用，并注册一些列的控制器、服务等。

由于搜索结果页面和详细信息页面都会有导航栏，我们可以共用导航栏，这部分代码留在index.html中，而搜索页面和详细信息页面的内容则分别抽取到不同的模板中：

<divclass="navbar navbar-default navbar-fixed-top" role="navigation">
...
</div>
<divng-view></div>

此处的ng-view会被AngularJs在运行时动态替换成对应的模板。只需要定义一个简单的路由规则即可：

var app = angular.module('PlantApplication', ['ngRoute', 'ngResource']);
app.config(['$routeProvider', function($routeProvider) {
$routeProvider.when('/', {
templateUrl: 'src/templates/main-content.html',
controller: 'PlantController'
}).when('/details/:id', {
templateUrl: 'src/templates/detail-page.html',
controller: 'DetailController'
});
}]);

这个路由定义了，当请求应用程序的根（/）时，我们使用main-content.html这个模板来插入ng-view，如果请求诸如/details/3这样的带有一个数字形式的参数的URL时，我们会使用detail-page.html来插入ng-view。

$routeProvider是AngularJS本身提供的一个服务，它可以根据用户请求的URL来分发不同的控制器，启用不同的模板等。

14.4.1　moko

我们的前台应用不能脱离后台服务而独立运行，但是我们又希望在后台没有就绪，或者后台有bug的时候，前台可以不受阻塞。这时候就需要使用一些“假“的服务器。我们将使用moko，moko是一个Gem，是对另外一个服务器moco的包装（因为这个包装非常的简单，所以我采用了一个读起来非常相似的名字）。

使用moko可以非常快速地搭建一个REST风格的后台服务器。首先安装moko：

$ gem install moko

安装之后，我们在当前目录创建一个moko的配置文件moko.up，内容如下：

resource :plant do |p|
p.string :name
p.string :description
end

然后执行下面的命令来生成moco（moko的底层依赖）的配置文件以及一些样例文件：

$ mokoup generate

你可以查看当前目录下的conf/moko.conf.json来查看这个配置文件，另外，这条命令还在resources目录下生成了一个样例文件：plants.json。这个文件是根据moko.up配置中的数据类型生成的，你可以根据需要来修改它。

有了这些配置之后，我们可以使用：

$ mokoup server

来启动服务，你可以通过curl或者浏览器来测试moco服务器是否已经正常启动，默认的mokoup会在12306端口启动，如图14-14所示。

图14-14　使用curl测试服务器

我们可以为moco服务器再添加一条规则，使得其将当前目录作为Web应用的根目录，这样我们就可以通过moco来访问index.html了。

为conf目录下的moko.conf.json添加这样一条规则（可以看到，该文件中已经有很多规则了）：

{
"mount": {
"dir": "./",
"uri": "/"
}
}

这样，我们在浏览器中访问http://localhost:12306/index.html 就可以看到如图14-15所示的结果了。

图14-15　使用moco服务器

14.4.2　AngularJS应用

有了真实的后台，我们就可以开始前端的开发了。首先需要安装基本的依赖：

$ bower install angularjs --save
$ bower install angular-route --save
$ bower install angular-resource --save

使用--save选项可以将这些依赖关系保存到bower.json文件中，如果你本地没有这个文件，可以通过bower init来创建一个：

{
"name": "qipa-frontend",
"version": "0.1.0",
"authors": [
"Qiu Juntao <juntao.qiu@gmail.com>"
],
"description": "qipa is a plants collection application",
"license": "MIT",
"private": true,
"ignore": [
"**/.*",
"node_modules",
"bower_components",
"src/vendor/",
"test",
"tests"
],
"dependencies": {
"angular-resource": "~1.2.25",
"angular-route": "~1.2.25",
"angularjs": "~1.2.25",
"bootstrap": "~3.2.0"
}
}

这个文件我们也会提交到版本库中，这样其他开发人员就可以直接在本地通过bower install来安装所有的开发依赖了。

首先从搜索结果页面开始，可以看到，搜索结果会是一个集合，集合中的每个元素都是一样的，我们只需要遍历这个集合，然后动态创建每一个条目即可。

因此这部分代码很自然地就会使用ng-repeat指令:

<div class="col-lg-9" ng-controller="PlantController">
<div class="row">
<div class="col-lg-3 col-sm-6 col-md-4 col-xs-12" ng-repeat="plant in plants">
<div class="item thumbnail">
<ahref="#">
<img ng-src="http://placehold.it/270x300">
</a>
<h4><a href="#">{{plant.name}}</a></h4>
<p>{{plant.description}}</p>
<button class="btn btn-success" ng-click="collect($index)">
Collect </button>
</div>
</div>
</div>
</div>

我们让这个片段使用PlantController，然后使用ng-repeat指令来重复plants集合，对于其中的每一项，我们会展现名称和描述信息，最后还为每个条目注册了ng-click时间，当点击时，会触发collect函数，并且会将当前的索引传入。

var app = angular.module('PlantApplication', ['ngRoute', 'ngResource']);
app.controller('PlantController',
['$scope', '$location', 'PlantService',
function ($scope, $location, PlantService) {
$scope.plants = PlantService.query();
$scope.collect = function (index) {
$location.path("/details/" + index);
}
}]);

PlantController依赖于PlantService，PlantService负责实际的请求。collect函数在执行时会将传入的索引拼接上/details/，然后传入$location.path，这会触发一次路由：控制会交给DetailController，页面会发生跳转。

PlantService的实现使用了AngularJS提供的$resource服务：

app.factory('PlantService', ['$resource', function ($resource) {
return $resource('/plants', {}, {
query: { method: 'GET', isArray: true }
});
}]);

$resource会发送REST风格的请求，此处的query函数会发送GET请求，并与其后台返回一个数组。

这样，当初始化之后，AngularJS的路由服务发现请求的是根路径，则初始化PlantController，然后触发PlantService的query动作，等所有内容就绪之后，页面上的ng-repeat会执行，然后绘制出所有的内容。

如果此时的resources/plants.json文件中的内容是这样的话：

[
{
"description": "红盖鳞毛蕨（学名：Dryopteris erythrosora），为鳞毛蕨科鳞毛蕨属下的一个植物种。产江苏、安徽、浙江、江西、福建、湖南、湖北、广东、广西、四川、贵州、云南。在日本、朝鲜及菲律宾等地有分布。",
"name": "红盖鳞毛蕨"
},
{
"name": "量湿地星",
"description": "孢子浓茶褐色，球形，表面有微细的疣状突起。子实体初发生时呈球状，外皮褐色，质厚而强韧，富吸湿性；3层，外层薄，松软，中层纤维质，内层脆骨质，成熟后，裂成6～10余片，湿润时舒展而向下反卷，直立地上，状如星芒，干燥时向内卷缩，甚刚硬，内侧具深裂痕；内皮球形，质薄，灰色至褐色，顶端有1孔，孢子由此散出。季节与生长处夏、秋间生于山野路旁。"
}
]

对应到页面上，会看到如图14-16所示的结果。

图14-16　加入较为实际的数据后的效果

也就是说，如果后台的真实的服务器也提供同样的接口的话，我们的前端代码无需做任何的修改就可以和它集成在一起了。而且重要的是，我们现在的开发过程完全不依赖于后台（比如目前我们还没有编写任何一行后台代码）。

如果需要更多的植物，只需要修改plants.json这个静态文件，然后就可以得到如图14-17所示的结果。

图14-17　添加更多的植物

14.4.3　细节页面

细节页面不但要显示植物的细节，还需要提供一个推荐列表。推荐的算法可能是基于用户收藏次数最多的列表，也可能是根据收藏了当前植物的用户的其他收藏。具体的算法我们可以放到下一步，但是无疑我们需要一个提供推荐的服务。

我们在moko.up文件中添加一个新的资源：

resource :recommendation do |r|
r.string :name
r.string :description
end

然后生成该资源并重启mokoup服务。另外，moko默认生成的moko.conf.json中，对于单独的资源访问如http://localhost:12306/plants/1 也会返回plants.json，这可能会造成一些混淆，我们可以修改该规则：

{
"request": {
"method": "get",
"uri": {
"match": "/plants/\\d+"
}
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"file": "resources/plants-single.json"
}
}

当请求单个资源时，我们返回另一个文件plants-single.json。有了这些准备之后，我们就可以修改细节页面的模板了：

<div class="row">
<div class="col-lg-4 col-md-4">
<img class="img-responsive" src="http://placehold.it/340x320">
</div>
<divclass="col-lg-8 col-md-8">
<p>{{plant.description}}</p>
</div>
</div>
<div class="row">
<hr>
</div>
<divclass="row">
<divclass="col-lg-3 col-md-3" ng-repeat="recommendation in recommendations">
<imgclass="img-responsive" src="http://placehold.it/250x300" alt="170x180">
<h4>{{recommendation.name}}</h4>
<p>{{recommendation.description}}</p>
</div>
</div>

在DetailController中，我们需要定义description属性，这个属性由PlantService返回：发送一次带id的请求，取到结果之后赋值给description属性。另外，recommendations是一个集合，这个集合由RecommendationService返回。

app.controller('DetailController',
['$scope', '$routeParams',
'PlantService', 'RecommendationService',
function ($scope, $routeParams, PlantService, RecommendationService) {
$scope.plant= PlantService.get({id: $routeParams.id});
$scope.recommendations = RecommendationService.query({id: $routeParams.id});
}]);

RecommendationService的实现与PlantService一致，唯一不同的地方是这里添加了一个get方法，该方法也发送GET方式的请求，但是返回的结果为一条：

app.factory('RecommendationService', ['$resource', function($resource){
return $resource('/recommendations/:id', {}, {
get: {method: 'GET'},
query: { method: 'GET', isArray: true }
});
}]);

这样，当点击查询结果页面上的一个collect按钮之后，我们会看到如图14-18所示的页面结果。

图14-18　详情页面

好了，这样我们就有了一个基本的AngularJS应用程序，这个应用程序包括了JavaScript代码，使用了BootStrap的Mockup页面，可以与后台进行通信的一组Service，以及一个结构清晰的目录结构。

第15章 一个实例（后台部分）

上一章我们已经根据一个原始的想法，设计出了简单的线框图，并且根据线框图，又一步步地做出了模型页面。这一章，我们将开发出对应的后台API，并且将这些API部署在Heroku上。这样我们的应用就可以被外部世界感知，迅速得到真实用户的反馈。

基本思路是这样的：前后端分离开发，前端框架消费由后端提供的REST风格的API，两者使用JSON作为数据交换格式。在最开始的一个迭代中，我们会完成添加和查询操作的API。然后在后续迭代中逐步完善，直到最后达到一个完整的应用程序。

基本技术栈如下：

Sinatra作为Web框架。

ActiveRecord作为数据库访问层。

Git作为版本管理工具。

AngularJS作为前端JavaScript框架。

Bootstrap作为前端CSS框架。

Rake作为后台开发的构建脚本。

Grunt作为前台开发的构建脚本。

PostgreSQL作为数据库。

Heroku作为发布平台。

之所以选择这样的技术栈，是因为这些组件都是该领域中最为轻量级，且功能强大的工具，将这些工具组合起来，会发挥极为强大的威力。另一方面，这些工具可以帮助提升开发效率，节省前期的准备时间。唯一一个可能的例外是PostgreSQL数据库，这是因为该数据库是Heroku平台下默认的数据库。

15.1　第一个迭代

我们将采用逐步迭代的方式来完成奇葩应用的后台。在第一个迭代中，我们需要完成这样的功能：

（1）添加一种植物。

（2）查询所有的植物。

（3）按照ID查找一个特定的植物。

开始实际功能的开发之前，我们需要先搭建基础设施，比如创建版本库，创建数据库等。

15.1.1　配置环境

首先，需要创建一个文件夹，并使用git init将这个目录初始化成一个本地的代码仓库：

$ mkdir -p qipa
$ cd qipa
$ git init
Initialized empty Git repository in
/Users/jtqiu/develop/ ruby/qipa/.git/

有了代码仓库，我们可以创建一个Gemfile来定义需要哪些Ruby的Gem：

source "http://ruby.taobao.org"
gem 'sinatra'
gem 'activerecord'
group :development, :test do
gem 'sqlite3'
end
group :production do
gem 'pg'
end

注意此处，我们为测试环境安装sqlite的数据库驱动，而为产品环境安装PostgreSQL的驱动。

执行bundle install之后，我们所需要的基本开发环境就已经具备了。接下来我们可以使用Sinatra定义一个简单的应用来测试环境是否正常：

require 'sinatra'
get '/' do
"Qi Pa"
end

然后通过ruby app.rb来启动应用，可以看到Web服务器已经启动，如图15-1所示。

图15-1　启动基本的应用

通过浏览器也可以看到运行结果。下面我们来定义数据迁移部分。

15.1.2　定义数据

我们将使用ActiveRecord作为数据访问层，并且使用它来完成数据迁移。首先需要定义好数据迁移脚本。根据ActiveRecord的约定，我们需要将迁移脚本定义在以一个时间戳开头的文件中，下面的命令可以生成一个时间戳：

$ date +%Y%m%d%H%M%S
20140725225909

可以将数据迁移脚本定义为：20140725225909_create_exotic_plants.rb。文件内容如下：

class CreateExoticPlants <ActiveRecord::Migration
def change
create_table :plants do |t|
t.string :name
t.text :description
t.timestamps
end
end
end

简单起见，这个叫做plants的表只有5个字段：ID（由迁移脚本产生），植物名称，植物描述，和两个时间戳。有了数据迁移脚本，我们还需要定义一个迁移任务，这样其他开发人员就可以直接使用我们的脚本了。

在当前目录创建一个Rakefile，然后在其中定义一个migrate任务：

require 'active_record'
require 'erb'
require 'yaml'
require 'logger'
task :default =>:migrate
desc "Migrate the database through scripts in db/."
task :migrate =>:environment do
ActiveRecord::Migrator.migrate('db/', ENV["VERSION"] ?
ENV["VERSION"]. to_i : nil )
end
task :environment do
dbconfig = YAML.load(ERB.new(File.read(File.join("config",
"database. yml"))).result)
env = ENV["ENV"] ? ENV["ENV"] : 'production'
ActiveRecord::Base.establish_connection(dbconfig[env])
ActiveRecord::Base.logger = Logger.new(File.open('database.log', 'a'))
end

注意在此处的environment任务中，我们判断了环境变量中的ENV变量，如果用户设置了该变量，则使用该变量的值决定目前采用哪种环境，否则使用production环境。environment任务会读取config目录下的database.yml文件来获取不同环境的不同配置，该文件的内容如下：

development:
adapter: sqlite3
database: db/development.sqlite3
pool: 5
timeout: 5000
production:
adapter: postgresql
encoding: utf8
database: plants
username: jtqiu
password:

如果环境是development，使用sqlite作为数据库，而如果使用production环境，则使用PostgreSQL。

这样，我们就可以在本地测试不同的环境。定义好迁移文件之后，就可以执行数据迁移任务，如图15-2所示。

$ ENV="development" rake migrate

图15-2　执行数据迁移

这时候可以使用数据库查看工具来查看数据库中的表结构，如图15-3所示。

图15-3　数据库中的表结构

有了数据表之后，需要添加一个数据模型，在目录model中创建一个plants.rb文件：

require 'active_record'
class Plant <ActiveRecord::Base
end

这个模型继承了ActiveRecord::Base类，我们可以通过它来完成对数据库的增删改查操作。有了数据表，有了数据模型，我们就可以开始编写应用程序的逻辑。

在app.rb中添加代码来访问数据库：

require 'sinatra'
require 'active_record'
require 'json'
require 'pg'
require 'sqlite3'
require './model/plants'
env = ENV["ENV"] ? ENV["ENV"] : 'production'
dbconfig = YAML.load(ERB.new(File.read(File.join("config","database. yml"))).result)
ActiveRecord::Base.establish_connection(dbconfig[env])
get '/plants' do
plants = Plant.all || []
plants.to_json
end

此时启动应用：

$ ENV="development" ruby app.rb

即可通过浏览器或者命令行的curl来访问http://localhost:8100/plants这个URL来获取所有的植物列表（当然，目前还是空的，因为我们还没有插入任何数据）。

15.1.3　第一次提交

好了，现在可以做一次独立的提交。但是在此之前，我们并不想将诸如development.sqlite3这样的文件提交到代码库中，这个可以通过在当前目录添加一个.gitignore的文件来解决，在.gitignore文件中，列出不想被提交的文件：

*.log
db/*.sqlite3

然后来完成一次提交：

$ git add .
$ git commit -m "First commit"

频繁提交是一个很好的实践。当完成一个可以独立工作的模块之后，我们应该做一次本地提交，这样可以避免后续的修改影响到既有代码。

15.1.4　添加数据

接下来需要为奇葩应用加上添加数据的功能，根据REST风格的描述，添加数据需要客户端发送HTTP的POST请求，然后将需要保存的数据和请求一起发送到服务器上。

要从请求中读取数据，我们需要另外一个Gem：rack-contrib。和安装其他的Gem一样，在Gemfile中添加一条：

gem 'rack-contrib'

执行bundle安装rack-contrib之后，就可以在app.rb中使用它：

require 'rack/contrib'
use Rack::PostBodyContentTypeParser
post '/plants' do
plant = Plant.create(:name => params[:name],
:description => params[:description],
:created_at =>Time.now,
:updated_at =>Time.now)
if plant.save
[201, "/plants/#{plant['id']}"]
end
end

此处的Rack::PostBodyContentTypeParser 是一个Rack的中间件（详见第2章）。使用该中间件之后，客户端POST请求中的数据会被抽取到params变量中，这样我们就可以根据其中的内容来创建数据记录了。

再次启动服务，我们通过curl来进行测试：

$ curl -X POST -H "Content-Type: application/json" -d @de.json \
http://localhost:8100/plants -i

其中de.json中定义了一个植物的描述：

{
"description": "红盖鳞毛蕨（学名：Dryopteris erythrosora），为鳞毛蕨科鳞毛蕨属下的一个植物种。产江苏、安徽、浙江、江西、福建、湖南、湖北、广东、广西、四川、贵州、云南。在日本、朝鲜及菲律宾等地有分布。"
"name": "红盖鳞毛蕨"
}

curl命令的-i选项表示打印出详细的HTTP头信息，如图15-4所示。

图15-4　使用curl进行测试

当然你也可以使用图形界面工具来完成这个动作，比如Chrome浏览器的插件POSTMan。需要注意的是此处的HTTP头信息的Content-Type值为application/json，这样服务器端就可以正确解析请求数据了，如图15-5所示。

图15-5　使用Chrome的插件POSTMan进行测试

从浏览器中请求http://localhost:8100/plants会看到一个JSON格式的文档，如图15-6所示。

图15-6　浏览器中访问应用

我们再为应用添加一个根据ID获取详情的处理函数：

get '/plants/:id' do
plant = Plant.find(params[:id])
plant.to_json
end

当请求诸如localhost:8100/plants/1 这样的URL时，这个函数会从数据库中搜索记录，并以JSON格式返回。

好了，完成这一步之后，我们可以做第二次提交：

$ git add .
$ git commit -m "A very simple RESTFul API for exotic plants"

15.2　发布到Heroku

现在，奇葩的后台API已经具备以下功能：

（1）添加一种新的植物。

（2）查找所有的植物列表。

（3）根据ID查找一条植物记录。

现在是时候将它发布出去让更多人访问了。这一小节中将使用Heroku提供的服务来完成应用的发布。

15.2.1　环境准备

首先需要在当前目录下创建一个Procfile文件，内容如下：

web: bundle exec ruby app.rb -p $PORT

可以使用foreman进行本地测试，如图15-7所示。

$ foreman start

图15-7　使用foreman本地测试

完成测试之后，就可以开始部署应用到Heroku提供的远程服务器中。首先使用命令来创建一个应用：

$ heroku create

创建应用之后，当前的git会自动添加一个remote：

$ git remote -v
heroku git@heroku.com:mysterious-spire-5626.git (fetch)
heroku git@heroku.com:mysterious-spire-5626.git (push)

借助Heroku的工具，发布应用非常容易，使用下面命令即可完成：

$ git push heroku master

15.2.2　添加数据库插件

我们的应用程序需要使用数据库，所以需要安装一个PostgreSQL的插件，默认情况下，这个插件并没有安装：

$ heroku addons
mysterious-spire-5626 has no add-ons.

不过安装过程非常容易，只需要执行Heroku自带的命令添加即可：

$ heroku addons:add heroku-postgresql:dev
Adding heroku-postgresql:dev on mysterious-spire-5626... done, v4 (free)
Attached as HEROKU_POSTGRESQL_WHITE_URL
Database has been created and is available
! This database is empty. If upgrading, you can transfer
! data from another database with pgbackups:restore.
Use 'heroku addons:docs heroku-postgresql' to view documentation.

以后我们还可以使用下列命令来查看目前应用中安装了哪些插件：

$ heroku addons
=== mysterious-spire-5626 Configured Add-ons
heroku-postgresql:dev　HEROKU_POSTGRESQL_WHITE

这条命令会列出当前应用中已经安装的插件。比如上面的输出表示已经安装了heroku-postgresql:dev的这个数据库支持插件。

15.2.3　测试远程应用

一旦发布成功，就可以通过浏览器来访问这个应用。当然，我们需要先添加几条数据。注意POSTMan中URL的值是我们的应用程序对应的域名：http://mysterious-spire-5626. herokuapp.com/plants。如图15-8所示。

图15-8　使用POSTMan访问远程API

创建之后，可以通过发送GET请求来获取植物列表，如图15-9所示。

图15-9　Heroku中的植物列表

我们的应用程序已经成功地发布到远程服务器上了，世界上任何地方的人都可以访问到这个应用，并且可以为其添加数据（当然，目前几乎没有人会这么做）。

15.2.4　访问远程数据

Heroku提供了很多命令来帮助我们访问远程数据库，我们可以查看数据库的信息，可以从远程拉数据到本地，还可以将本地的数据发送到远端。

下面的命令可以获取数据库信息，如图15-10所示。

$ heroku pg:info

图15-10　数据库信息

我们还可以直接连接到远程数据库来查看情况：

$ heroku pg:psql --app mysterious-spire-5626 HEROKU_POSTGRESQL_ WHITE_URL

其中，mysterious-sprie-5626是应用程序的名字，HEROKUPOSTGRESQL WHITE_URL是数据库名称，这个名称正是pg:info命令输出的数据库连接名称。

连接之后，可以执行任意的查询SQL，如图15-11所示。

图15-11　查询远程数据库

15.2.5　导出数据

更进一步，我们可以将远程数据导出到本地：

$ heroku pg:pull HEROKU_POSTGRESQL_WHITE qipa --app mysterious-spire- 5626

字符串qipa表示本地PostgreSQL的数据库名称。这条命令会将远程的数据库中的数据、索引、函数等全部导入到本地。

在本地执行psql（PostgreSQL的命令行工具）访问数据库qipa，会看到所有的数据已经导入成功，如图15-12所示。

图15-12　导出远程数据到本地

15.3　更进一步

15.3.1　模块化的Sinatra应用

前两个小节完成的应用虽然可以正确工作，但是存在一些问题：所有的路由/处理函数都定义在一个全局作用域中，如果加入更多的路由规则，会使得维护工作变得越来越困难。

模块化的Sinatra是一种推荐的组织应用代码的方式，我们可以趁着所有东西都还很简单的时候开始做一些小的重构，使得代码更加容易扩展。

简而言之，我们需要定义一个扩展自Sinatra::Base的类，其余代码没有太多变化：

class PlantApplication <Sinatra::Base
dbconfig = YAML.load(ERB.new(File.read(File.join("config",
"database. yml"))).result)
configure :development do
ActiveRecord::Base.establish_connection(dbconfig['development'])
end
configure :production do
ActiveRecord::Base.establish_connection(dbconfig['production'])
end
use Rack::PostBodyContentTypeParser
get '/plants' do
end
get '/plants/:id' do
end
post '/plants' do
end
end

注意此处的configuration语句，Sinatra使用它来为不同的环境准备不同的配置，这样我们就无需自己传递ENV变量，而是使用一个更加通用的RACK_ENV来进行配置。

有了这个新的PlantApplication应用，我们还需要创建一个config.ru文件来启动它：

require File.dirname(__FILE__) + '/app'
run PlantApplication

然后使用这条命令来启动应用程序：

$ RACK_ENV="production" rackup
Thin web server (v1.6.2 codename Doc Brown)
Maximum connections set to 1024
Listening on 0.0.0.0:9292, CTRL+C to stop

或者在开发模式下使用下面命令启动：

$ RACK_ENV="development" rackup

15.3.2　测试

有了基础设施和基本原型之后，我们需要为后台应用加入一些测试，以确保后续修改不会破坏当前行为。

首先需要安装一些测试用的Gem，在Gemfile中添加rspec和rack-test两个Gem：

group :development, :test do
gem 'sqlite3'
gem 'rack-test'
gem 'rspec'
end

然后通过bundle install安装。随后为测试创建如下目录结构：

spec
├── features
│　 └── plants_spec.rb
└── spec_helper.rb

一旦有了测试，我们会频繁地执行它们，因此需要将这些命令固化在构建脚本中。在Rakefile中加入下列代码：

require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new :specs do |task|
task.pattern = Dir['spec/**/*_spec.rb']
end
task :default => ['specs']

此处的pattern定义了spec目录下的所有以_spec.rb结尾的文件都会被认为是测试。这样，可以通过下面命令运行测试：

$ rake specs
Finished in 0.00036 seconds (files took 1.61 seconds to load)
0 examples, 0 failures

我们现在还没有编写任何测试，因此此处的运行结果为0个测试，0次失败。下面我们来编写具体的测试。

首先需要在spec_helper.rb文件中实例化我们的应用程序：

ENV['RACK_ENV'] = 'test'
require 'rspec'
require 'rack/test'
require_relative File.join('..', 'app')
RSpec.configure do |config|
include Rack::Test::Methods
config.color = true
config.tty = true
def app
PlantApplication
end
end

这里我们还设置了启用RSpec的颜色。这样当失败时，RSpec会打印红色输出，成功时会打印绿色输出。

1．第一个测试

有这个助手文件之后就可以编写实际的测试用例了。这里我们测试当发送请求到根路径（/）时，服务器会返回状态码200。当然，这个测试必然会失败，因为我们的奇葩应用并没有定义对根路径（/）的处理函数。

require_relative '../spec_helper'
describe 'Root Path' do
describe 'GET /' do
before { get '/' }
it 'is successful' do
expect(last_response.status).to eq 200
end
end
end

测试文件首先引入了上级目录中的spec_helper文件，然后定义了一个测试用例。这里的before方法会在it之前执行，它会发送一次GET请求到根路径，然后我们在it中预期最后的响应状态为200。

运行测试会失败，如图15-13所示。

图15-13　执行RSpec测试

失败原因正如RSpec所提示的，预期状态码为200，但是得到了404。我们可以修改测试，发送请求到/plants：

require_relative '../spec_helper'
describe 'Plants Application' do
describe 'GET /plants' do
before { get '/plants' }
it 'is successful' do
expect(last_response.status).to eq 200
end
end
end

这样测试就可以通过了，如图15-14所示。

图15-14　测试通过

2．第二个测试

现在，让我们添加一个新的测试，来测试创建一条记录：

describe 'Create a plant' do
let(:body) { {:name =>"plant", :description =>"really weird"} }
it 'crates a plant'　do
post '/plants', body, {'Content-Type' =>'application/json'}
expect(last_response.status).to eq 201
end
end

此处的let语句为body赋值一个植物的信息，然后我们调用post方法来发送请求。post接受三个参数：请求的URL、body和头信息，其中头信息可以是多条。最后我们期望创建之后服务器会返回状态码201。

运行测试，一切正常，如图15-15所示。

图15-15　再次执行测试

如果此时查看数据库，会发现数据真实地被保存进去。这就产生了一个问题：测试会存在潜在的危害，比如某个测试在测记录的个数，而另一个测试在测插入记录，这样每运行一次，记录的个数都会加一，那么对于记录个数的测试总会失败。这不是我们想要的，事实上我们每次都想要一个干净的数据库来进行测试，即每运行完一次测试，都清空数据库，使得每个测试的运行都相对稳定。

3．数据库清理器

要完成清空数据库的工作，首先需要安装数据库清理器 database_cleaner。修改Gemfile中的依赖：

group :development, :test do
gem 'sqlite3'
gem 'rack-test'
gem 'rspec'
gem 'database_cleaner'
end

然后在spec_helper.rb中使用清理器。我们想要在每次运行测试之前清理数据库，因此需要在RSpec.configure中添加如下配置：

config.before(:suite) do
DatabaseCleaner.strategy = :transaction
DatabaseCleaner.clean_with(:truncation)
end
config.around(:each) do |example|
DatabaseCleaner.cleaning do
example.run
end
end

清理策略采用transaction，这种方式通过数据库的回滚事务来清理数据库，可以获得比较好的性能，这也是默认的清理策略。同时可选的还有truncation和deletion两种，这两种都是采用删除的方式，速度较慢。

接下来编写一个更高级的测试：

describe 'Create a plant' do
let(:body) { {:name =>"plant", :description =>"really weird"} }
it 'create a plant'　do
post '/plants', body, {'Content-Type' =>'application/json'}
expect(last_response.status).to eq 201
get '/plants'
created = JSON.parse(last_response.body)[0]
expect(created['name']).to eq "plant"
expect(created['description']).to eq "really weird"
end
end

这个测试中，首先做一次POST操作，然后再发送一次GET请求获取创建成功的植物信息，得到服务器的返回之后，我们还可以判断创建的植物正是我们存入的数据。

另外，我们来看一下加入了数据库清理器的好处。下面这个测试会查看数据库中植物列表的长度。默认情况下，数据库应该是空的：

describe 'List all plants' do
before { get '/plants' }

it 'is empty at the very begining' do
list = JSON.parse(last_response.body)
expect(list.length).to eq 0
end
end

在使用数据库清理器之前，如果数据库中有数据，或者该测试之前的测试向数据库中插入了数据，它都会运行失败。而使用数据库清理器会保证每次的测试结果都是固定的，不依赖于数据库的实际状态，如图15-16所示。

图15-16　数据清理之后

最后，我们来提交所有的代码，然后部署到heroku上：

$ git add .
$ git commit -m "add intergration tests"
$ git push heroku master

可以通过浏览器查看，如图15-17所示。

图15-17　远程数据

到目前为止，我们已经做到了：

（1）基本的数据库结构。

（2）一组支持添加/获取植物列表的REST风格的API。

（3）使用RSpec的集成测试。

（4）应用程序运行在互联网上。

下一章开始，我们将学习如何将前后端集成在一起，使它更加真实，更加专业。

第16章 一个实例（集成）

在原型阶段，我们的前后端开发是分离的，前台依赖于一个“假”的服务器。后台虽然有集成测试来保证功能的完整，但是我们仍然需要将前后台集成在一起，形成一个独立的工程。

一种策略是通过反向代理服务器来整合整个应用：前后台的代码并不在同一个工程中，甚至部署在不同的机器上。通过反向代理服务器nginx或者httpd来分发请求，如果访问静态内容，则直接从文件系统返回，如果是动态内容，则通过代理的方式转发该请求。对于发起请求的浏览器，这部分工作是透明的。如图16-1所示。

图16-1　应用结构

这种策略有很多好处：

（1）独立开发（开发时没有强烈的依赖）。

（2）独立部署（修复前台的bug无需测试后台，反之亦然）。

（3）动态静态分离，可以获得更好的性能。

（4）平滑扩容更加容易。

当然，它也有一些弊端：

（1）需要两套/多套部署环境。

（2）更多的维护成本。

当应用规模较小的时候，我们可以通过简单的合并前后台代码做到集成。当应用程序的规模较大的时候，则需要考虑前后端完全分离的策略。

目前这个阶段，我们的应用可以通过简单的合并来集成，这样做可以很容易将我们的应用部署到Heroku上。

首先创建一个新的Sinatra应用：

class FrontendApplication <Sinatra::Base
get '/' do
File.open('index.html').read
end
end

这个应用仅仅有一个路由，即请求根路径的时候返回index.html文件。另外，需要在本地创建一个public目录，将所有的图片、CSS、JavaScript代码都放在这个目录中。目录结构如图16-2所示。

图16-2　集成之后的目录结构

在Sinatra中，public目录中的文件可以通过HTTP直接访问。该目录被当做Web的根目录，因此使用时不需要在路径中加上“public”。比如在index.html中引用的JavaScript文件可以直接写成：

<scriptsrc="vendor/angularjs/angular.js" type="text/javascript"></script>
<scriptsrc="application/plantapp.js" type="text/javascript"></script>

然后需要修改JavaScript的引用路径。例如plantapp.js中模板文件的路径需要修改为：

app.config(['$routeProvider', function($routeProvider) {
$routeProvider.when('/', {
templateUrl: 'templates/main-content.html',
controller: 'PlantController'
}).when('/details/:id', {
templateUrl: 'templates/detail-page.html',
controller: 'DetailController'
});
}]);

创建新的应用FrontendApplication之后，还需要在config.ru中将它和PlantApplication级联起来以完成集成：

require File.dirname(__FILE__) + '/app'
run Rack::Cascade.new [PlantApplication, FrontendApplication]

然后在本地启动集成之后的应用程序：

$ rackup

从浏览器中访问根路径 http://localhost:9292/，会看到命令行中的请求记录如图16-3所示。

图16-3　启动服务器

发往根路径的请求会被FrontendApplication执行，而访问public目录下资源的是普通HTTP请求，最后，当AngularJS应用启动之后，发送到/plants的请求被PlantApplication处理。这样我们的应用程序就正式集成起来了，如图16-4所示。

图16-4　在浏览器中访问应用

16.1　发布

完成本地集成之后，我们将这些修改提交，并部署到Heroku上测试：

$ git add .
$ git commit -m "integrate with front-end angularjs application"
$ git push heroku master
# 打开应用
$ heroko open

Heroku上看到的结果与本地类似，如图16-5所示。

图16-5　部署至Heroku中

16.1.1　添加植物页面

已经有了添加一条植物的后台API，现在我们需要开始编写前台的页面。从最简单的情况开始，一条新植物记录只需要名称和描述两项。使用Bootstrap可以快速创建一个页面：

<div class="container show-me-up" id="add-plant-form">
<div class="row">
<div class="col-lg-12" ng-controller="AddPlantController">
<form role="form">
<div class="form-group">
<label for="name">Plant Name</label>
<input type="text" class="form-control"
id="name" placeholder="食人花" ng-model="plant.name">
</div>
<div class="form-group">
<label for="description">Plant Description
</label>
<textarea class="form-control"
id="description" placeholder="传说中的食人花" ng-model="plant.
description">
</textarea>
</div>
<buttontype="submit" class="btn btn-primary" ng-click="save()">
Save </button>
</form>
</div>
</div>
</div>

植物名称都比较短，采用一个文本框即可，描述信息比较长，我们使用一个文本域（textarea）。展现在页面上的效果如图16-6所示。

图16-6　添加植物页面

我们为创建植物定义一个新的路由：

when('/add', {
templateUrl: 'templates/add-plant.html',
controller: 'AddPlantController'
})

并且定义一个新的控制器AddPlantController。这个控制器依赖于之前创建的PlantService，当用户点击Save按钮的时候，控制器会调用PlantService的save方法：

app.controller('AddPlantController', ['$scope', '$location', 'PlantService',
function($scope, $location, PlantService) {
$scope.plant = {
name: "",
description: ""
};
$scope.save = function() {
PlantService.save($scope.plant, function() {
$location.path("/");
});
}
}]);

当用户点击save的时候，我们会将界面上输入的plant组织起来，然后请求PlantService的save方法，如果保存成功，就会执行回调函数，跳转到根路径。

16.1.2　一个奇怪的bug

在完成上一节的代码之后，页面正确地跳转到了首页，但奇怪的是，新添加的条目中并没有用户填写的内容！如图16-7所示。

图16-7　新创建的条目中没有用户填写的内容

通过跟踪浏览器的网络请求发现，AngularJS确实发送了POST请求，而且服务器的返回值为201，如图16-8所示。

图16-8　请求正常

在发送的请求中，数据是完整的，如图16-9所示。

图16-9　发送的数据正常

那么可以肯定问题是出在后台应用程序中。通过打印PlantApplication中对应的处理函数：

post '/plants' do
p params
end

我们发现此处的值为空。也就是说，处理函数并没有接收到POST来的数据。你可能还记得，我们在代码中使用了一个中间件Rack::PostBodyContentTypeParser来抽取POST中的数据。

这个Bug正是该中间件产生的，问题出在HTTP请求头中。PostBodyContentTypeParser会查看头中的Content-Type的值，如果该值为application/json，则抽取请求中的内容。但是AngularJS在发送请求时，会发送application/json; charset=UTF-8。

这个Bug在rack-contrib之后的版本已经修复，如果你遇到了这个问题，可以修改这个Gem中的文件post_body_content_type_parser.rb：

def call(env)
case env[CONTENT_TYPE]
when APPLICATION_JSON
env.update(FORM_HASH =>JSON.parse(env[POST_BODY].read), FORM_INPUT => env[POST_BODY])
end
@app.call(env)
end

将上边的代码修改为：

def call(env)
if Rack::Request.new(env).media_type == APPLICATION_JSON && (body = env[POST_BODY].read).length != 0
env.update(FORM_HASH =>JSON.parse(body), FORM_INPUT => env[POST_BODY])
end
@app.call(env)
end

修改后的代码使用Rack::Request中的media_type来确定请求是否是JSON格式的数据，这种方式可以识别application/json; charset=UTF-8。

我们的应用中还有一个潜在的bug：搜索结果页面中每个条目的ID都是由集合的索引产生的，实际中肯定会存在这个ID和数据库中的ID不匹配的情况。因此需要将其修改为：

<divclass="item thumbnail">
<ahref="#">
<imgng-src="http://placehold.it/270x300">
</a>
<h4><ahref="#">{{plant.name}}</a></h4>
<p>{{plant.description}}</p>
<buttonclass="btn btn-success" ng-click="collect(plant.id)">
Collect </button>
</div>

到目前为止，我们的应用的基本功能都已经实现：

（1）用户可以添加一条植物的信息。

（2）用户可以查看所有的植物信息。

（3）用户可以查看某一种植物的信息。

这些功能从前端到后台应用，再到数据库，使用了一系列小巧的工具和库。这些工具和库相互协作，最终完成了整个功能。而且最有趣的是，这些工具都可以找到对应的替代者，例如，一旦我们发现Sinatra存在任何问题，就可以很容易地切换到Grape或者其他的Web框架。同样，如果发现ActiveRecord有一些不适合的场景，也可以快速切换到DataMapper上等等。

基本功能完成之后，我们来关注一个更为实际的功能：为植物添加图片。这个功能是在已有的应用上添加功能，是一个需求的变更。我们可以从这个例子出发，看看轻量级的开发工具和流程如何更好地迎合变化。

16.2　添加图片

目前的应用使用placehold.it 提供的服务。在创建新植物时没有上传图片，这对于一个真实的应用来说是不可接受的，我们在这一小节来为应用添加实际的图片。

首先需要在API级别提供上传图片的功能。这个改动涉及到一系列修改：

（1）数据库的表结构。

（2）表对应的数据模型。

（3）创建新植物的API。

（4）创建植物的页面。

（5）搜索结果页面的图片。

如果从数据库开始，手工添加一个字段，然后逐步向上修改，那么整个过程无疑容易出错并且比较无趣。我们可以使用ActiveRecord的迁移功能，这里需要创建一个新的迁移脚本来为植物表添加一个新的图片列。

和创建植物表一样，添加新列的脚本同样需要以时间戳开头，我们将其命名为 add_image_to_plants，加上时间戳之后即为20141003215957_add_image_to_plants.rb。该文件的内容很简单：

class AddImageToPlants <ActiveRecord::Migration
def change
add_column :plants, :image, :string
end
end

该类的change方法表明，为plants表添加一个类型为字符串的列image。

我们在Rakefile中已经定义好了数据迁移任务，这里可以直接使用，如图16-10所示。

$ RACK_ENV=development rake migrate

图16-10　数据迁移

执行之后，数据库中的plants表就会多一个新的列。

16.2.1　后台API

表结构修改之后，需要修改对应的模型。我们还有这样一些工作需要完成：

（1）接收上传文件。

（2）保存到服务器本地。

（3）与ActiveRecord集成。

（4）生成图片的URL。

这些工作都可以通过carrierwave这个Gem来简化。另外我们还需要生成缩略图的功能，mini_magick可以帮助我们完成。

在Gemfile中添加：

gem 'carrierwave'
gem "mini_magick"

然后执行bundle install即可完成安装。接下来需要修改数据模型plants.rb，这里定义了一个新的carrierwave上传器：

require 'carrierwave'
require 'carrierwave/orm/activerecord'
class PlantImageUploader <CarrierWave::Uploader::Base
def store_dir
'uploaded'
end
end

它继承了carrierwave默认的上传器，并且定义存储上传文件的目录为uploaded。有了上传器，我们需要将其和模型绑定起来：

class Plant <ActiveRecord::Base
mount_uploader :image, PlantImageUploader
end

image属性是Plant对应的表plants中的列。绑定之后，当我们通过Plant来获取图片时，就会得到完整的路径（因为上传器会计算出图片的实际位置）。

这样就完成了对模型的修改，我们可以在irb中进行简单的测试：

plant = Plant.new
plant.name = "食人花"
plant.description = "食人花"
plant.image = File.open("public/uploaded/2014-10-04_10.01.29.jpg")
plant.save

接下来是对接受请求的API部分的修改。由于carrierwave会帮我们处理大部分事情，因此对API的修改非常容易，只需要为模型上的image属性赋值即可：

post '/plants' do
plant = Plant.create(:name => params[:name],
:description => params[:description],
:created_at =>Time.now,
:updated_at =>Time.now)
plant.image = params[:image]
if plant.save
[201, "/plants/#{plant['id']}"]
end
end

后台API部分的修改到这里就完成了。

16.2.2　客户端上传文件

上传文件是一个比较有意思的功能。HTML提供了file类型的输入框来调用系统的文件选择框，但是AngularJS中强调的数据双向绑定功能在这种输入框上就比较尴尬，AngularJS官方文档中也没有特别介绍，我们这里通过另外一种方式来绕过这个问题。

首先需要自定义一个AngularJS指令：

app.directive('filesModel', function (){
return {
controller: function($parse, $element, $attrs, $scope){
var exp = $parse($attrs.filesModel);
$element.on('change', function(){
exp.assign($scope, this.files);
$scope.$apply();
});
}
};
});

这个指令会监听change事件，当用户点击文件选择器来选择文件时，会触发该事件，如图16-11所示。

图16-11　上传图片

此时的files属性为一个FileList对象，这个对象包含了用户的选择，如图16-12所示。

图16-12　FileList对象

由于用户可以选择多张图片，此处的FileList事实上是一个列表。我们在指令中指定，当change事件发生时，会根据当前的$scope进行一次赋值。事实上此刻的$scope是我们在AddPlantController中创建的，因此此次的赋值结果会保证AddPlantController中的$scope.plant.image会被赋值为FileList。

这样AddPlantController就会变成：

app.controller('AddPlantController',
['$scope', '$location', 'PlantService',
function ($scope, $location, PlantService) {
$scope.plant = {
name: "",
description: "",
image: null
};
$scope.save = function () {
PlantService.save($scope.plant, function () {
$location.path("/");
});
}
}]);

在add-plant.html模板中，我们使用了新创建的指令：

<divclass="form-group">
<label for="image">Plant Image</label>
<input type="file" files-model="plant.image" required >
</div>

添加文件上传按钮之后，页面如图16-13所示。

图16-13　上传文件

点击保存之后，图片会被保存到服务器端的public/uploaded目录下。

缩略图

为了在页面上显示一个缩略图，我们还需要对后台代码做一点点改动：

require 'mini_magick'
class PlantImageUploader <CarrierWave::Uploader::Base
include CarrierWave::MiniMagick
version :thumb do
process :resize_to_fill => [200,200]
end
def store_dir
'uploaded'
end
end

通过使用mini_magick，存储图片的时候会同时生成一个200×200的缩略图。我们在搜索结果页面使用缩略图，而在详情页面则显示完整的大图。

现在，我们的搜索结果页面已经非常真实，如图16-14所示。

图16-14　更加专业的页面

当然，要显示缩略图需要修改对占位符图片的引用：

<div class="item thumbnail">
<a href="#">
<img ng-src="{{plant.image.thumb.url}}">
</a>
<h4><a href="#">{{plant.name}}</a></h4>
<p>{{plant.description}}</p>
<button class="btn btn-success" ng-click="collect(plant.id)">
Collect </button>
</div>

注意此处的plant.image.thumb.url表达式，我们为Plant添加了image属性，carrierwave会自动生成url和thumb属性：

[
{
id: 6,
name: "某种木耳",
description: "拍摄于2014年秋天，连绵数日的阴雨之后，在绿地世纪城发现",
created_at: "2014-10-04T02:25:43.968Z",
updated_at: "2014-10-04T02:25:44.608Z",
image: {
url: "/uploaded/2014-10-03_16.56.34.jpg",
thumb: {
url: "/uploaded/thumb_2014-10-03_16.56.34.jpg"
}
}
}
]

同样，还需要替换详情页面中的大图：

<div class="row">
<div class="col-lg-4 col-md-4">
<img class="img-responsive" ng-src="{{plant.image.url}}">
</div>
<div class="col-lg-8 col-md-8">
<p>{{plant.description}}</p>
</div>
</div>

此时看到的详情页面如图16-15所示。

图16-15　详情页面

现在我们的页面更加真实，如图16-16所示。

图16-16　最终的页面

16.3　新的问题

再次将应用程序部署到Heroku之后，需要执行数据库迁移。但是执行的时候会发生这样的错误，如图16-17所示。

$ heroku run rake

图16-17　Rake执行失败

错误信息显示rspec/core/rack_task找不到，这是因为Heroku在安装依赖包时会自动过滤归类到development和test组的包，比如：

group :development, :test do
gem 'sqlite3'
gem 'rack-test'
gem 'rspec'
gem 'database_cleaner'
end

RSpec是测试用的库，在生产环境中不需要，因此我们需要将Rakefile修改为：

if ENV["RACK_ENV"] == 'development'
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new :specs do |task|
task.pattern = Dir['spec/**/*_spec.rb']
end
task :default => ['specs']
end

这样在开发环境才加载RSpec任务，不会影响数据迁移脚本的运行，如图16-18所示。

图16-18　Rake正常工作

经过简单的测试，我们又发现了一个问题：页面刷新几次之后，我们的数据都不见了！在Chrome的开发者工具中可以看到如图16-19所示的结果。

图16-19　数据消失

通过Heroku提供的命令行工具，可以看到日志中记录的错误信息：

$ heroku logs
...
2014-10-04T08:13:54.776943+00:00 app[web.1]: ActiveRecord::Connection TimeoutError -
could not obtain a database connection within 5.000 seconds (waited 5.000 seconds):
...

由于Heroku提供的免费PostgreSQL有一些连接数上的限制，如果应用程序不及时关闭连接，就可能导致后续的连接无法建立，从而造成超时错误。一个简单的解决方式是在每一次数据库操作之后都关闭数据库连接，这在Sinatra中非常容易实现：

在PlantApplication中加上这个过滤器即可：

after do
ActiveRecord::Base.connection.close
end

16.4　文件存储

在Heroku上免费创建的应用中，上传的文件不会被持续存储在服务器端。每次重启之后（重新发布）文件都会被清除，因此我们需要一种更合理的方式来管理这些数据。

这里会使用亚马逊的S3（云存储平台）服务托管所有图片。S3服务为新注册用户提供了5G的免费空间，并且一年内，每个月可以有20000次读请求，2000次写请求，以及15G的流量。这对于普通应用已经足够，而且亚马逊采取按需收费的方式，实际上也可以满足大部分应用的需求。

与S3集成需要完成这样一些步骤：

（1）创建一个亚马逊账户（如果还没有的话）。

（2）创建一个IAM组，并赋予对S3服务的读写权限。

（3）创建一个IAM用户，归为新创建的组。

（4）导出该用户的凭证。

（5）创建一个S3的bucket。

（6）修改这个bucket的授权信息。

16.4.1　创建分组及用户

登录到亚马逊的控制台，如图16-20所示。

图16-20　创建分组

创建一个新组，然后为该组分配对S3的全权策略，如图16-21所示。

图16-21　分配S3访问权限

所谓的策略，在实现上是一个JSON文件，比如此处策略文件的内容为：

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": "*"
}
]
}

这时候我们可以去仿真页面测试该用户的权限，选择S3服务的上传操作（PutObject），并确认仿真，如图16-22所示。

可以看到该分组已经拥有了写权限。接下来，我们再创建一个新用户，并将该用户分配到这个组中，这样该用户就自然拥有了对S3的写权限。

创建用户之后，需要导出用户的凭证信息，如图16-23所示。

图16-22　测试权限

图16-23　下载凭证

导出的凭证是一个csv格式的文件，其中包含三列：用户名，访问码ID（Access Key ID），安全访问码（Security Access Key）。这个文件是访问API的凭证，请注意保密。

16.4.2　创建S3中的bucket

在S3中创建一个新的bucket，并将其命名为qipa-images，如图16-24所示。

图16-24　创建bucket

然后为该bucket添加一个新的权限记录，使得所有授权用户都可以访问该目录，如图16-25所示。

图16-25　允许授权用户上传下载

到此，所有涉及亚马逊控制台的工作就完成了。

16.4.3　存储到云端

完成了基础设施的设置工作，我们开始修改代码。首先需要安装用来访问云端的Gem：fog。

修改Gemfile：

gem 'fog'

运行bundle install安装fog。

由于carrierwava提供了对S3的集成功能，因此我们的修改非常容易，只需要定义S3的凭证信息：

CarrierWave.configure do |config|
config.fog_credentials = {
:provider　　　　　　　 =>'AWS',
:aws_access_key_id　　　=>'your-access-key-id',
:aws_secret_access_key　=>'your-secret-access-key',
:region　　　　　　　　 =>'us-west-2',
:endpoint　　　　　　　 =>'https://s3.amazonaws.com/'
}
config.fog_directory　= 'qipa-images'
config.fog_attributes = {'Cache-Control'=>'max-age=315576000'}
end

注意此处的region需要与上边创建的bucket对应的地区一致。然后将文件上传器中的存储方案修改为fog：

class PlantImageUploader <CarrierWave::Uploader::Base
include CarrierWave::MiniMagick

version :thumb do
process :resize_to_fill => [200,200]
end
storage :fog
end

这样，当用户上传文件时CarrierWave会使用fog将文件保存到S3中。在S3的控制台中可以看到已经上传的文件列表，如图16-26所示。

图16-26　上传的文件列表

我们先在本地测试，文件上传之后，用Chrome浏览器的开发者工具查看图片的URL，如图16-27所示。

图16-27　图片文件的URL

查看结果表明，我们已经将图片保存到S3上。

16.4.4　部署到Heroku

现在还有一个问题：我们将安全凭证写在了代码中，存在很大的安全隐患。一个简单的做法是将安全凭证写入系统的环境变量，然后在代码中访问这些环境变量：

CarrierWave.configure do |config|
config.fog_credentials = {
:provider　　　　　　　　　=>'AWS',
:aws_access_key_id　　　 =>ENV['AWS_ACCESS_KEY_ID'],
:aws_secret_access_key　=>ENV['AWS_SECRET_ACCESS_KEY'],
:region　　　　　　　　　　=>ENV['AWS_REGION'],
:endpoint　　　　　　　　　=>ENV['AWS_ENDPOINT']
}
config.fog_directory　= ENV['AWS_S3_BUCKET']
config.fog_attributes = {'Cache-Control'=>'max-age=315576000'}
end

可以通过Heroku的命令行工具来查看或者设置环境变量。要查看当前的环境变量，只需要执行：

$ heroku config

如图16-28所示。

图16-28　查看应用配置

可以在命令行添加如下环境变量：

$ heroku config:add \
AWS_ACCESS_KEY_ID="your-access-key-id"\
AWS_SECRET_ACCESS_KEY="your-secret-access-key"\
AWS_REGION="us-west-2"\
AWS_ENDPOINT="https://s3.amazonaws.com/"\
AWS_S3_BUCKET="qipa-images"

最后发布我们的应用程序到Heroku：

$ git add .
$ git commit -m "migrate to AWS S3 storage"
$ git push heroku master

发布之后，页面和之前并没有变化，但是数据已经存储在亚马逊的云端服务器上，这样就不会存在数据丢失的问题，如图16-29所示。

图16-29　最终页面

附录A Web如何工作

A.1　CGI的相关背景

在设计之初，Web只是可以提供静态内容，用于诸如文档分享、论文引用等方面。但是很快人们就不满足于静态的内容。根据UNIX系统的哲学，人们倾向于让不同的应用程序通过已有的机制（进程间通信如管道，UNIX域socket，以及TCP/IPsocket）连接起来。

自然地，人们在Web服务器（诸如Apache httpd）中加入了与外部应用程序的通信接口。CGI（通用网关接口，Common Gateway Interface）即是在这种背景下被发明的。

基本来说，CGI可以是任何的可执行程序，例如Shell脚本，二进制应用，或者其他的脚本（Python脚本，Ruby脚本等）。CGI的基本流程是这样：

（1）Apache接收到客户端的请求。

（2）通过传统的fork-exec机制启动外部应用程序（cgi程序）。

（3）将客户端的请求数据通过环境变量和重定向发送给外部应用（cgi程序）。

（4）将cgi程序产生的输出写回给客户端（浏览器）。

（5）停止cgi程序（kill）。

如图A-1所示。

图A-1　CGI基本流程

A.2　配置Apache支持CGI

这里所有示例都是在Mac OSX环境下编写和实验。

先创建一个cgi的运行目录/Users/jtqiu/Sites/cgi-bin/，然后创建一个空文件echo.cgi：

$ mkdir -p /Users/jtqiu/Sites/cgi-bin/
$ cd /Users/jtqiu/Sites/cgi-bin/
$ touch echo.cgi

在这个文件中，添加一小段python代码：

#!/usr/bin/env python
print("Content-Type: text/html\n\n")
print("<b>Hello, World</b>\n")

修改文件的执行权限：

$ chmod +x echo.cgi

这段python代码并无特别，如果在shell运行这个脚本，可以得到：

Content-Type: text/html
<b>Hello, World</b>

这个可执行文件将作为我们的第一个CGI脚本。完成了这一步，我们需要配置Apache来支持CGI。首先，在目录/etc/apache2/users/中创建一个文件，文件名就是你的用户名，如jtqiu.conf。

在该文件中添加以下配置：

<Directory "/Users/jtqiu/Sites/cgi-bin/">
AddHandler cgi-script .cgi
Options +ExecCGI
</Directory>

其中，/Users/jtqiu/Sites/cgi-bin/目录是所有cgi脚本所在的目录，AddHandler cgi-script .cgi表示为所有后缀为cgi的文件添加cgi-script的Handler。然后重启apache:

$ sudo apachectl restart

我们就可以通过curl进行测试：

$ curl -v http://localhost/~jtqiu/cgi-bin/echo.cgi

A.3　更进一步

传统CGI脚本的生命周期很短，Web服务器在接收到一次请求之后，会fork出一个进程来执行CGI脚本，一旦请求完成，这个进程就会被终止。

我们可以在echo.cgi文件中设置一个超时：

#!/usr/bin/env python
import time
time.sleep(5)
print("Content-Type: text/html\n\n")
print("<b>Hello, World</b>\n")

然后，再次通过curl脚本来查看：

$ curl -v http://localhost/~jtqiu/cgi-bin/echo.cgi

或者另起一个窗口，通过ps -Af | grep cgi来查看，如图A-2所示。

图A-2　Web服务器启动后的进程运行状态

A.4　一个稍微有用的脚本

Web服务器通过环境变量和CGI脚本进行部分的数据传递，比如下面这个例子会打印所有来自Web服务器的环境变量：

#!/usr/bin/env python
import os
print "Content-type: text/html\n\n";
print "<b>Environment</b>\n";
print "<ul>"
for param in os.environ.keys():
print "<li>%20s: %s</li>" % (param, os.environ[param])
print "</ul>"

通过curl来测试：

$ curl http://localhost/\~jtqiu/cgi-bin/echo.cgi

会得到一个完整的环境变量列表：

<b>Environment</b>
<ul>
<li>VERSIONER_PYTHON_PREFER_32_BIT: no</li>
<li>　　 SERVER_SOFTWARE: Apache/2.2.26 (Unix) DAV/2 PHP/5.4.24 mod_ssl/2.2.26 OpenSSL/0.9.8y</li>
<li>　　　　 SCRIPT_NAME: /~jtqiu/cgi-bin/echo.cgi</li>
<li>　　SERVER_SIGNATURE: </li>
<li>　　　REQUEST_METHOD: GET</li>
<li>　　 SERVER_PROTOCOL: HTTP/1.1</li>
<li>　　　　QUERY_STRING: </li>
<li>　　　　　　　　PATH: /usr/bin:/bin:/usr/sbin:/sbin</li>
<li>　　 HTTP_USER_AGENT: curl/7.30.0</li>
<li>　　　　 SERVER_NAME: localhost</li>
<li>　　　　 REMOTE_ADDR: ::1</li>
<li>　　　　 SERVER_PORT: 80</li>
<li>　　　　 SERVER_ADDR: ::1</li>
<li>　　　 DOCUMENT_ROOT: /Library/WebServer/Documents</ li >
<li>　　 SCRIPT_FILENAME: /Users/jtqiu/Sites/cgi-bin/echo.cgi</ li >
<li>　　　　SERVER_ADMIN: you@example.com</li>
<li>　　　　　 HTTP_HOST: localhost</li>
<li>　　　　 REQUEST_URI: /~jtqiu/cgi-bin/echo.cgi</li>
<li>　　　　 HTTP_ACCEPT: */*</li>
<li>　 GATEWAY_INTERFACE: CGI/1.1</li>
<li>　　　　 REMOTE_PORT: 64361</li>
<li>__CF_USER_TEXT_ENCODING: 0x46:0:0</li>
<li>VERSIONER_PYTHON_VERSION: 2.7</li>
</ul>

A.5　更进一步FastCGI

传统的CGI脚本，生命周期很短，只会serve一次请求就终止了。如果有高并发的场景服务器性能就会受到极大的冲击，因此人们设计了FastCGI。FastCGI的生命周期很长，甚至可以被实现成一个TCP/IP的服务器，这样就会永远运行下去。

目前，Apache、Nginx等Web服务器都支持FastCGI。

附录B Angular.js的测试

B.1　测试Controller

B.1.1　AngularJS的一个典型Controller

在AngularJS中，Controller主要用于持有一些与视图有关的状态，以及数据模型，比如界面上某些元素是否展示，以及展示哪些内容等。通常来说，Controller会依赖于一个Service来提供数据：

app.controller('EventController', ['$scope', 'EventService',
function($scope, EventService) {
EventService.getEvents().then(function(events) {
$scope.events = events;
});
}]);

而Service则需要通过向后台服务发送请求来获取数据：

app.factory('EventService', ['$http', '$q',
function($http, $q) {
return {
getEvents: function() {
var deferred = $q.defer();
$http.get('/events.json').success(function(result) {
deferred.resolve(result);
}).error(function(result) {
deferred.reject(result);
});
return deferred.promise;
}
};
}]);

通常的做法是Service会返回一个promise对象，然后当数据准备完整之后，controller的then会被执行。

在AngularJS中，这是一个非常典型的场景，那么对于这种情况，我们如何进行单元测试呢？

B.1.2　测试依赖于Service的Controller

通常在单元级别的测试中，我们肯定不希望Service发送真正的请求，因为这样就变成集成测试，而且使得前端开发完全依赖于后台的开发进度/稳定程度等。

所以我们需要做一个假的Service，它仅仅在测试中存在。基本的测试代码如下：

var app = angular.module('MyApp');
describe("EventController", function() {
var scope, q;
var controllerFactory;
var mockSerivce = {};
var events = ["Event1", "Event2", "Event3"];
beforeEach(function() {
module("MyApp");
inject(function($rootScope, $controller, $q) {
controllerFactory = $controller;
scope = $rootScope.$new();
q = $q;
});
});
beforeEach(function() {
var deferred = q.defer();
deferred.resolve(events);
mockSerivce.getEvents = jasmine.createSpy('getEvents');
mockSerivce.getEvents.andReturn(deferred.promise);
});
function initController() {
return controllerFactory('EventController', {
$scope: scope,
EventService: mockSerivce
});
}
it("should have a events list", function() {
initController();
scope.$digest();
expect(scope.events.length).toEqual(3);
expect(scope.events).toEqual(events);
});
});

B.1.3　在何处实例化Controller

不要在beforeEach中初始化Controller。很多示例都会在注入了$controller之后紧接着实例化Controller，如果Controller有多个外部的依赖的话，那么beforeEach中的代码将越来越多，而且严重影响代码的可读性。

一个好的做法是将依赖注入到describe的临时变量中，然后将初始化的动作延后到一个函数中：

function initController() {
return controllerFactory('EventController', {
$scope: scope,
EventService: mockSerivce
});
}

B.1.4　如何mock一个service

在AngularJS中，Service一般会返回一个promise对象。在测试时存在一些技巧来模拟该对象：

var events = ["Event1", "Event2", "Event3"];
beforeEach(function() {
var deferred = q.defer();
deferred.resolve(events);
mockSerivce.getEvents = jasmine.createSpy('getEvents');
mockSerivce.getEvents.andReturn(deferred.promise);
});

这样，使用注入EventService.getEvents().then(callback)的地方就可以访问到此处的promise对象。

如果添加了新的用例，

app.controller('EventController', ['$scope', 'EventService',
function($scope, EventService) {
EventService.getEvents().then(function(events) {
$scope.events = events;
$scope.recentEvent = $scope.events[0];
});
}]);

则在用例的开始完成创建Controller的动作：

it("should have a recent event", function() {
initController();
scope.$digest();
expect(scope.recentEvent).toEqual("Event1");
});

B.2　测试Service

B.2.1　Service的典型示例

在AngularJS中，Service都是单例的实体，通常会将Service作为与后台交互的数据提供者，所有需要数据的组件只需要依赖于这个Service即可。

var app = angular.module('MyApp', []);
app.factory('SearchSettingService',
['$http', '$q', function($http, $q) {
return {
setting: function() {
var deferred = $q.defer();
$http.get('/settings.json').success(function(result) {
deferred.resolve(result);
}).error(function(result) {
deferred.reject("network error");
});
return deferred.promise;
}
};
}]);

B.2.2　$httpBackend服务

测试的时候，我们不需要真实地发送HTTP请求来获取数据。如果只需要测试Service的逻辑，当发送请求时，我们将这个请求拦截下来，然后返回一个预定义的数据：

it('should have settings from http request', function() {
var result;
var expected = {
"period": "day",
"date": "Sat Dec 21 12:56:53 EST 2013",
};
httpBackend.expectGET('/settings.json').respond(expected);
var promise = settingService.setting();
promise.then(function(data) {
result = data;
});
httpBackend.flush();
expect(result).toEqual(expected);
});

$httpBackend是AngularJS提供的一个用于测试的服务，可以在spec中注入进来：

beforeEach(function() {
module('services');
inject(function(SearchSettingService, $httpBackend) {
settingService = SearchSettingService;
httpBackend = $httpBackend;
});
});

httpBackend服务有一些方便测试的方法：

httpBackend.expectGET(url).respond(data);
httpBackend.expectPOST(url, param).respond(data);

设置之后，当调用httpBackend.flush时，AngularJS会调用这个setup，拦截发送的请求并返回预定义的文件。这样service中的数据就被填充好了。

B.2.3　Service的测试模板

当测试一个Service时，我们应该测哪些方面呢？

（1）正常流程，一个完整的处理过程。

（2）异常处理，如果服务器出错了，程序需要如何反馈。

（3）其他异常情况。

正常流程的测试很容易，调用Service提供的方法之后，在获得的promise对象上调用then方法来填充一个数据，然后调用httpBackend.flush()来发送请求，最后验证数据的格式/内容是否正确。

下面测试的主要目的是，验证当调用Service的方法时，它真实地发送了一个HTTP请求：

it('should have settings from http request', function() {
var result;
var expected = {
"period": "day",
"date": "Sat Dec 21 12:56:53 EST 2013",
};
httpBackend.expectGET('/settings.json').respond(expected);
var promise = settingService.setting();
promise.then(function(data) {
result = data;
});
httpBackend.flush();
expect(result).toEqual(expected);
});

对于异常情况，比如服务器返回了错误（如500，404等），那么最低程度，程序应该可以处理这个异常：

it("should throw error when network expection", function() {
var result, error;
httpBackend.expectGET('/settings.json').respond(500);
var promise = settingService.setting();
promise.then(function(data) {
result = data;
}, function(data) {
error = data;
});
httpBackend.flush();
expect(result).toBeUndefined();
expect(error).toEqual("network error");
});

B.2.4　服务器Moco

Moco是我在ThoughtWorks的同事郑晔开发的一个测试框架/工具。基本上，Moco是一个用来集成测试的HTTP服务器。

可以通过API方式或者启动Moco服务器的方式来使用它，我比较喜欢将Moco作为一个独立的服务器。Moco的发行包是一个标准的JAR文件，可以使用下面的命令来启动它：

$ java -jar moco-runner-0.9-standalone.jar start -p 12306 -c moco.conf.json

这条命令会在12306端口启动一个Moco，使用的配置文件为moco.conf.json，运行结果如图B-1所示。

图B-1　moco启动界面

Moco的配置文件是一个JSON文件，根元素是一个数组。数组中的每个元素都是一条规则：对于符合某种特征的请求，进行某种形式的响应。请求中一般包含：

（1）请求类型（HTTP的GET，POST，PUT，DELETE等）。

（2）请求路径。

（3）请求字符串（如/locations=Melbourne）。

（4）HTTP头信息（比如Content-Type）。

（5）请求文档中的XPath/JSONPath。

它支持多种形式的响应：

（1）字符串。

（2）文本文件。

（3）一个远程服务器上的内容。

另一个常用的功能是mount，即将一个本地目录映射为HTTP服务器的根目录，这个功能在调试时非常有用。使用Moco，使得你在测试时无需另外启动真实的服务器。而且在绝大多数情况下，Moco的服务器会比正在开发的那个真实服务器更加稳定。

比如moco.conf.json中定义了以下规则：

[
{
"request": {
"method": "post",
"uri": "/resource"
},
"response": {
"status": 201,
"text": "resource has been created"
}
},
{
"request": {
"uri": "/resource"
},
"response": {
"status": 200,
"file": "resources.json"
}
}
]

启动Moco的服务器之后，所有发往/resource的post请求都会得到如下响应：

201
resource has been created

这个功能在前端开发越来越独立的情况下非常好用。

看完了

如果您对本书内容有疑问，可发邮件至contact@epubit.com.cn，会有编辑或作译者协助答疑。也可访问图灵社区，参与本书讨论。

如果是有关电子书的建议或问题，请联系专用客服邮箱：ebook@epubit.com.cn。

在这里可以找到我们：

- 微博 @异步社区 : 电子书和好文章的消息

- 微信 异步社区 : 讲述异步精彩人生