轻量级Web应用开发

978-7-115-39152-0
作者: 邱俊涛
译者:
编辑: 陈冀康胡俊英

图书目录:

详情

如何快速的将一个可以工作的应用程序发布,并由真实的用户进行试用,反馈,然后逐步改进。其中包括了很多的轻量级的工具,程序库,框架,以及轻量级的开发流程如强调测试,持续集成,自动化部署等。 另外,书中的最后一部分包含了一个完整的实例,从头到尾展现了如何开发一个现代的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日于西安

 

列出当前目录下所有*.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

 

著名的编辑器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

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

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打印匹配之后的两行。

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,可以将那些重复的,容易忘记或者容易犯错的任务都交给计算机来完成。

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"
   }
  }
  ...
 ]

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

 

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

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 搜索需要安装的插件

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

 

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

图1-25 Launchy启动器

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 快速启动谷歌搜索

 

搜索框组件可以由一个输入框和一个按钮组成,当点击按钮时,该组件会发起一个请求。如果再进一步,点击按钮时,会触发一个事件,而事件的响应则交给具体的监听器来完成。

那么搜索框的实现就非常简单了:

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的全局事件。至于谁来捕获这个事件,则可以交由下一个组件来处理。

我们完全可以将发送请求的动作独立出来,作为一个组件来进行测试。

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;
  });
};

完成了搜索之后,我们来看看结果集。

结果集作为一个独立的组件,可以被设置值。既然是结果集,它自然接受一个数组作为参数,并将数组包装成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可以被实现成任意的形式。

如果我们将所有的组件放在一起,会得到这样一段代码:

$(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);
});

即可完成新的需求。

 

搜索服务应该是最简单,而且最独立的模块,只需要指定一个搜索的端点(数据的提供者),然后暴露一个search的接口即可:

function SearchService(url) {
    this.search = function(location, successcb, errorcb) {
     $.ajax({
      url: url + location,
      dataType: 'json',
      success: successcb,
      error: errorcb
    });
  };
}

由于搜索服务是一个异步调用,我们将搜索成功以及搜索失败的函数作为参数传入,这样既便于测试,也可以获得更大的灵活性。

结果视图作为一个视图,仅仅需要正确的渲染即可。另外,当发生意外错误时,结果视图需要显示一个错误信息。

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上。

搜索框视图的实现非常简单,它需要一个获取框中内容的接口,这样调用者可以在任何时刻调用这个接口来获得搜索条件。另外,它需要公开一个接口,方便别人注册在其上,当点击搜索按钮时,调用这个注册过的回调函数。

function SearchLocationView() {
  this.getLocation = function() {
    return $("#location").val();
   };
  this.addSearchHandler = function(callback) {
     $("#search").on('click', callback);
   };
}

有了这些简单逻辑之后,该应用的核心代码就会变成:

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。

这时候,应用程序的入口将会变为:

$(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);
  };
}

代码越小巧,犯错的可能也越小,而且一旦出现错误,也可以很容易定位并修复。

由于视图的分离,应用程序的核心逻辑被包装到了搜索逻辑部分,如果我们可以保证这部分代码的质量,视图部分事实上是无需测试的(视图已经被简化为简单的值-对象,类似于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的测试。

 

相关图书

TypeScript全栈开发
TypeScript全栈开发
Java EE企业级应用开发实战(Spring Boot+Vue+Element)
Java EE企业级应用开发实战(Spring Boot+Vue+Element)
Vue.js全平台前端实战
Vue.js全平台前端实战
Flutter内核源码剖析
Flutter内核源码剖析
智能前端技术与实践
智能前端技术与实践
从0到1:ES6快速上手
从0到1:ES6快速上手

相关文章

相关课程