前端工程精粹

http://www.infoq.com/cn/articles/front-end-engineering-and-performance-optimization-part1
(一):静态资源版本更新与缓存

每个参与过开发企业级web应用的前端工程师或许都曾思考过前端性能优化方面的问题。我们有雅虎14条性能优化原则,还有两本很经典的性能优化指导 书:《高性能网站建设指南》、《高性能网站建设进阶指南》。经验丰富的工程师对于前端性能优化方法耳濡目染,基本都能一一列举出来。这些性能优化原则大概 是在7年前提出的,对于web性能优化至今都有非常重要的指导意义。

然而,对于构建大型web应用的团队来说,要坚持贯彻这些优化原则并不是一件十分容易的事。因为优化原则中很多要求是与工程管理相违背的,比如“把 css放在头部”和“把js放在尾部”这两条原则,我们不能让团队的工程师在写样式和脚本引用的时候都去修改一个相同的页面文件。这样做会严重影响团队成 员间并行开发的效率,尤其是在团队有版本管理的情况下,每天要花大量的时间进行代码修改合并,这项成本是难以接受的。因此在前端工程界,总会看到周期性的 性能优化工作,辛勤的前端工程师们每到月圆之夜就会倾巢出动根据优化原则做一次性能优化。

本文从一个全新的视角来思考web性能优化与前端工程之间的关系,通过解读百度前端集成解决方案小组(F.I.S)在打造高性能前端架构并统一百度40多条前端产品线的过程中所经历的技术尝试,揭示前端性能优化在前端架构及开发工具设计层面的实现思路。

性能优化原则及分类

笔者先假设本文的读者是有前端开发经验的工程师,并对企业级web应用开发及性能优化有一定的思考,因此我不会重复介绍雅虎14条性能优化原则。如果您没有这些前续知识,请移步这里来学习。

首先,我们把雅虎14条优化原则,《高性能网站建设指南》以及《高性能网站建设进阶指南》中提到的优化点做一次梳理,按照优化方向分类,可以得到这样一张表格:

优化方向

优化手段
请求数量 合并脚本和样式表,CSS Sprites,拆分初始化负载,划分主域
请求带宽 开启GZip,精简JavaScript,移除重复脚本,图像优化
缓存利用 使用CDN,使用外部JavaScript和CSS,添加Expires头,减少DNS查找,配置ETag,使AjaX可缓存
页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出
代码校验 避免CSS表达式,避免重定向

 

表格1 性能优化原则分类

目前大多数前端团队可以利用yui compressor或者google closure compiler等 压缩工具很容易做到“精简Javascript”这条原则;同样的,也可以使用图片压缩工具对图像进行压缩,实现“图像优化”原则。这两条原则是对单个资 源的处理,因此不会引起任何工程方面的问题。很多团队也通过引入代码校验流程来确保实现“避免css表达式”和“避免重定向”原则。目前绝大多数互联网公 司也已经开启了服务端的Gzip压缩,并使用CDN实现静态资源的缓存和快速访问;一些技术实力雄厚的前端团队甚至研发出了自动CSS Sprites工具,解决了CSS Sprites在工程维护方面的难题。使用“查找-替换”思路,我们似乎也可以很好的实现“划分主域”原则。

我们把以上这些已经成熟应用到实际生产中的优化手段去除掉,留下那些还没有很好实现的优化原则。再来回顾一下之前的性能优化分类:

优化方向 优化手段
请求数量 合并脚本和样式表,拆分初始化负载
请求带宽 移除重复脚本
缓存利用 添加Expires头,配置ETag,使Ajax可缓存
页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出

 

表格2 较难实现的优化原则

现在有很多顶尖的前端团队可以将上述还剩下的优化原则也都一一解决,但业界大多数团队都还没能很好的解决这些问题。因此,本文将就这些原则的解决方 案做进一步的分析与讲解,从而为那些还没有进入前端工业化开发的团队提供一些基础技术建设意见,也借此机会与业界顶尖的前端团队在工业化工程化方向上交流 一下彼此的心得。

静态资源版本更新与缓存

如表格2所示,“缓存利用”分类中保留了“添加Expires头”和“配置ETag”两项。或许有些人会质疑,明明这两项只要配置了服务器的相关选 项就可以实现,为什么说它们难以解决呢?确实,开启这两项很容易,但开启了缓存后,我们的项目就开始面临另一个挑战:如何更新这些缓存。

相信大多数团队也找到了类似的答案,它和《高性能网站建设指南》关于“添加Expires头”所说的原则一样——修订文件名。即:

最有效的解决方案是修改其所有链接,这样,全新的请求将从原始服务器下载最新的内容。

思路没错,但要怎么改变链接呢?变成什么样的链接才能有效更新缓存,又能最大限度避免那些没有修改过的文件缓存不失效呢?

先来看看现在一般前端团队的做法:

或者

大家会采用添加query的形式修改链接。这样做是比较直观的解决方案,但在访问量较大的网站,这么做可能将面临一些新的问题。

通常一个大型的web应用几乎每天都会有迭代和更新,发布新版本也就是发布新的静态资源和页面的过程。以上述代码为例,假设现在线上运行着index.html文件,并且使用了线上的a.js资源。index.html的内容为:

这次我们更新了页面中的一些内容,得到一个index.html文件,并开发了新的与之匹配的a.js资源来完成页面交互,新的index.html文件的内容因此而变成了:

好了,现在要开始将两份新的文件发布到线上去。可以看到,index.html和a.js的资源实际上是要覆盖线上的同名文件的。不管怎样,在发布 的过程中,index.html和a.js总有一个先后的顺序,从而中间出现一段或大或小的时间间隔。对于一个大型互联网应用来说即使在一个很小的时间间 隔内,都有可能出现新用户访问。在这个时间间隔中,访问了网站的用户会发生什么情况呢?

  1. 如果先覆盖index.html,后覆盖a.js,用户在这个时间间隙访问,会得到新的index.html配合旧的a.js的情况,从而出现错误的页面。
  2. 如果先覆盖a.js,后覆盖index.html,用户在这个间隙访问,会得到旧的index.html配合新的a.js的情况,从而也出现了错误的页面。

这就是为什么大型web应用在版本上线的过程中经常会较集中的出现前端报错日志的原因,也是一些互联网公司选择加班到半夜等待访问低峰期再上线的原 因之一。此外,由于静态资源文件版本更新是“覆盖式”的,而页面需要通过修改query来更新,对于使用CDN缓存的web产品来说,还可能面临CDN缓 存攻击的问题。我们再来观察一下前面说的版本更新手段:

我们不难预测,a.js的下一个版本是“1.0.1”,那么就可以刻意构造一串这样的请求“a.js?v=1.0.1”、 “a.js?v=1.0.2”、……让CDN将当前的资源缓存为“未来的版本”。这样当这个页面所用的资源有更新时,即使更改了链接地址,也会因为CDN 的原因返回给用户旧版本的静态资源,从而造成页面错误。即便不是刻意制造的攻击,在上线间隙出现访问也可能导致区域性的CDN缓存错误。

此外,当版本有更新时,修改所有引用链接也是一件与工程管理相悖的事,至少我们需要一个可以“查找-替换”的工具来自动化的解决版本号修改的问题。

对付这个问题,目前来说最优方案就是基于文件内容的hash版本冗余机制了。也就是说,我们希望工程师源码是这么写的:

但是线上代码是这样的:

其中”_82244e91”这串字符是根据a.js的文件内容进行hash运算得到的,只有文件内容发生变化了才会有更改。由于版本序列是与文件名写在一起的,而不是同名文件覆盖,因此不会出现上述说的那些问题。同时,这么做还有其他的好处:

  1. 线上的a.js不是同名文件覆盖,而是文件名+hash的冗余,所以可以先上线静态资源,再上线html页面,不存在间隙问题;
  2. 遇到问题回滚版本的时候,无需回滚a.js,只须回滚页面即可;
  3. 由于静态资源版本号是文件内容的hash,因此所有静态资源可以开启永久强缓存,只有更新了内容的文件才会缓存失效,缓存利用率大增;
  4. 修改静态资源后会在线上产生新的文件,一个文件对应一个版本,因此不会受到构造CDN缓存形式的攻击

虽然这种方案是相比之下最完美的解决方案,但它无法通过手工的形式来维护,因为要依靠手工的形式来计算和替换hash值,并生成相应的文件。这将是一项非常繁琐且容易出错的工作,因此我们需要借助工具。我们下面来了解一下fis是如何完成这项工作的。

首先,之所以有这种工具需求,完全是由web应用运行的根本机制决定的:web应用所需的资源是以字面的形式通知浏览器下载而聚合在一起运行的。这 种资源加载策略使得web应用从本质上区别于传统桌面应用的版本更新方式。为了实现资源定位的字面量替换操作,前端构建工具理论上需要识别所有资源定位的 标记,其中包括:

  • css中的@import url(path)、background:url(path)、backgournd-image:url(path)、filter中的src
  • js中的自定义资源定位函数,在fis中我们将其规定为__uri(path)。
  • html中的<script src=”path”>、<link href=”path”>、<imgsrc=”path”>、已经embed、audio、video、object等具有资源加载功能的标签。

为了工程上的维护方便,我们希望工程师在源码中写的是相对路径,而工具可以将其替换为线上的绝对路径,从而避免相对路径定位错误的问题(比如js中需要定位图片路径时不能使用相对路径的情况)。

fis的资源定位设计思想

fis有一个非常棒的资源定位系统,它是根据用户自己的配置来指定资源发布后的地址,然后由fis的资源定位系统识别文件中的定位标记,计算内容hash,并根据配置替换为上线后的绝对url路径。

要想实现具备hash版本生成功能的构建工具不是“查找-替换”这么简单的。我们考虑这样一种情况:

资源引用关系

由于我们的资源版本号是通过对文件内容进行hash运算得到,如上图所示,index.html中引用的a.css文件的内容其实也包含了 a.png的hash运算结果,因此我们在修改index.html中a.css的引用时,不能直接计算a.css的内容hash,而是要先计算出 a.png的内容hash,替换a.css中的引用,得到了a.css的最终内容,再做hash运算,最后替换index.html中的引用。

这意味着构建工具需要具备“递归编译”的能力,这也是为什么fis团队不得不放弃gruntjs等task-based系统的根本原因。针对前端项目的构建工具必须是具备递归处理能力的。此外,由于文件之间的交叉引用等原因,fis构建工具还实现了构建缓存等机制,以提升构建速度。

在解决了基于内容hash的版本更新问题之后,我们可以将所有前端静态资源开启永久强缓存,每次版本发布都可以首先让静态资源全量上线,再进一步上线模板或者页面文件,再也不用担心各种缓存和时间间隙的问题了!

在本系列的下一部分,我们将介绍静态资源管理与模板框架的思路和用法。

(二):静态资源管理与模板框架

本系列文章从一个全新的视角来思考web性能优化与前端工程之间的关系,通过解读百度前端集成解决方案小组(F.I.S)在打造高性能前端架构并统一百度40多条前端产品线的过程中所经历的技术尝试,揭示前端性能优化在前端架构及开发工具设计层面的实现思路。

在上一部分,我们介绍了静态资源版本更新与缓存。今天的部分将会介绍静态资源管理与模板框架的用法。

静态资源管理与模板框架

让我们再来看看前面的优化原则表还剩些什么:

 

优化方向 优化手段

请求数量 合并脚本和样式表,拆分初始化负载
请求带宽 移除重复脚本
缓存利用 使Ajax可缓存
页面结构 将样式表放在顶部,将脚本放在底部,尽早刷新文档的输出

 

很不幸,剩下的优化原则都不是使用工具就能很好实现的。或许有人会辩驳:“我用某某工具可以实现脚本和样式表合并”。嗯,必须承认,使用工具进行资源合并并替换引用或许是一个不错的办法,但在大型web应用,这种方式有一些非常严重的缺陷,来看一个很熟悉的例子:

某个web产品页面有A、B、C三个资源

工程师根据“减少HTTP请求”的优化原则合并了资源

产品经理要求C模块按需出现,此时C资源已出现多余的可能

C模块不再需要了,注释掉吧!但C资源通常不敢轻易剔除

不知不觉中,性能优化变成了性能恶化……

事实上,使用工具在线下进行静态资源合并是无法解决资源按需加载的问题的。如果解决不了按需加载,则势必会导致资源的冗余;此外,线下通过工具实现 的资源合并通常会使得资源加载和使用的分离,比如在页面头部或配置文件中写资源引用及合并信息,而用到这些资源的html组件写在了页面其他地方,这种书 写方式在工程上非常容易引起维护不同步的问题,导致使用资源的代码删除了,引用资源的代码却还在的情况。因此,在工业上要实现资源合并至少要满足如下需 求:

  1. 确实能减少HTTP请求,这是基本要求(合并)
  2. 在使用资源的地方引用资源(就近依赖),不使用不加载(按需)
  3. 虽然资源引用不是集中书写的,但资源引用的代码最终还能出现在页面头部(css)或尾部(js)
  4. 能够避免重复加载资源(去重)

将以上要求综合考虑,不难发现,单纯依靠前端技术或者工具处理是很难达到这些理想要求的。现代大型web应用所展示的页面绝大多数都是使用服务端动 态语言拼接生成的。有的产品使用模板引擎,比如smarty、velocity,有的则干脆直接使用动态语言,比如php、python。无论使用哪种方 式实现,前端工程师开发的html绝大多数最终都不是以静态的html在线上运行的。

接下来我会讲述一种新的模板架构设计,用以实现前面说到那些性能优化原则,同时满足工程开发和维护的需要,这种架构设计的核心思想就是:

基于依赖关系表的静态资源管理系统与模板框架设计

考虑一段这样的页面代码:

根据资源合并需求中的第二项,我们希望资源引用与使用能尽量靠近,这样将来维护起来会更容易一些,因此,理想的源码是:

当然,把这样的页面直接送达给浏览器用户是会有严重的页面闪烁问题的,所以我们实际上仍然希望最终页面输出的结果还是如最开始的截图一样,将css放在头部输出。这就意味着,页面结构需要有一些调整,并且有能力收集资源加载需求,那么我们考虑一下这样的源码:

在页面的头部插入一个html注释“<!–[CSS LINKS PLACEHOLDER]–>”作为占位,而将原来字面书写的资源引用改成模板接口(require)调用,该接口负责收集页面所需资源。 require接口实现非常简单,就是准备一个数组,收集资源引用,并且可以去重。最后在页面输出的前一刻,我们将require在运行时收集到的 “A.css”、“B.css”、“C.css”三个资源拼接成html标签,替换掉注释占位“<!–[CSS LINKS PLACEHOLDER]–>”,从而得到我们需要的页面结构。

经过fis团队的总结,我们发现模板层面只要实现三个开发接口,既可以比较完美的实现目前遗留的大部分性能优化原则,这三个接口分别是:

  1. require(String id):收集资源加载需求的接口,参数是资源id。
  2. widget(String template_id):加载拆分成小组件模板的接口。你可以叫它为load、component或者pagelet之类的。总之,我们需要一个接口把 一个大的页面模板拆分成一个个的小部分来维护,最后在原来的大页面以组件为单位来加载这些小部件。
  3. script(String code):收集写在模板中的js脚本,使之出现的页面底部,从而实现性能优化原则中的“将js放在页面底部”原则。

实现了这些接口之后,一个重构后的模板页面的源代码可能看起来就是这样的了:

而最终在模板解析的过程中,资源收集与去重、页面script收集、占位符替换操作,最终从服务端发送出来的html代码为:

不难看出,我们目前已经实现了“按需加载”,“将脚本放在底部”,“将样式表放在头部”三项优化原则。

前面讲到静态资源在上线后需要添加hash戳作为版本标识,那么这种使用模板语言来收集的静态资源该如何实现这项功能呢?答案是:静态资源依赖关系表

假设前面讲到的模板源代码所对应的目录结构为下图所示:

那么我们可以使用工具扫描整个project目录,然后创建一张资源表,同时记录每个资源的部署路径,可以得到这样的一张表:

基于这张表,我们就很容易实现 {require name=”id”} 这个模板接口了。只须查表即可。比如执行{require name=”jquery.js”},查表得到它的url是“/jquery_9151577.js”,声明一个数组收集起来就好了。这样,整个页面执行 完毕之后,收集资源加载需求,并替换页面的占位符,即可实现资源的hash定位,得到:

接下来,我们讨论如何在基于表的设计思想上是如何实现静态资源合并的。或许有些团队使用过combo服务,也就是我们在最终拼接生成页面资源引用的 时候,并不是生成多个独立的link标签,而是将资源地址拼接成一个url路径,请求一种线上的动态资源合并服务,从而实现减少HTTP请求的需求,比 如:

这个“/combo?files=file1,file2,file3,…”的url请求响应就是动态combo服务提供的,它的原理很简单,就是根据get请求的files参数找到对应的多个文件,合并成一个文件来响应请求,并将其缓存,以加快访问速度。

这种方法很巧妙,有些服务器甚至直接集成了这类模块来方便的开启此项服务,这种做法也是大多数大型web应用的资源合并做法。但它也存在一些缺陷:

  1. 浏览器有url长度限制,因此不能无限制的合并资源。
  2. 如果用户在网站内有公共资源的两个页面间跳转访问,由于两个页面的combo的url不一样导致用户不能利用浏览器缓存来加快对公共资源的访问速度。

对于上述第二条缺陷,可以举个例子来看说明:

  • 假设网站有两个页面A和B
  • A页面使用了a,b,c,d四个资源
  • B页面使用了a,b,e,f四个资源
  • 如果使用combo服务,我们会得:
    • A页面的资源引用为:/combo?files=a,b,c,d
    • B页面的资源引用为:/combo?files=a,b,e,f
  • 两个页面引用的资源是不同的url,因此浏览器会请求两个合并后的资源文件,跨页面访问没能很好的利用a、b这两个资源的缓存。

很明显,如果combo服务能聪明的知道A页面使用的资源引用为“/combo?files=a,b”和“/combo?files=c,d”,而 B页面使用的资源引用为“/combo?files=a,b”,“/combo?files=e,f”就好了。这样当用户在访问A页面之后再访问B页面 时,只需要下载B页面的第二个combo文件即可,第一个文件已经在访问A页面时缓存好了的。

基于这样的思考,fis在资源表上新增了一个字段,取名为“pkg”,就是资源合并生成的新资源,表的结构会变成:

相比之前的表,可以看到新表中多了一个pkg字段,并且记录了打包后的文件所包含的独立资源。这样,我们重新设计一下{require name=”id”}这个模板接口:在查表的时候,如果一个静态资源有pkg字段,那么就去加载pkg字段所指向的打包文件,否则加载资源本身。 比如执行{require name=”bootstrap.css”},查表得知bootstrap.css被打包在了“p0”中,因此取出p0包的url“/pkg /utils_b967346.css”,并且记录页面已加载了“bootstrap.css”和“A/A.css”两个资源。这样一来,之前的模板代码 执行之后得到的html就变成了:

css资源请求数由原来的4个减少为2个。

这样的打包结果是怎么来的呢?答案是配置得到的。

我们来看一下带有打包结果的资源表的fis配置:

我们将“bootstrap.css”、“A/A.css”打包在一起,其他css另外打包,从而生成两个打包文件,当页面需要打包文件中的资源时,模块框架就会收集并计算出最优的资源加载结果,从而解决静态资源合并的问题。

这样做的原因是为了弥补combo在前面讲到的两点技术上的不足而设计的。但也不难发现这种打包策略是需要配置的,这就意味着维护成本的增加。但好在它有两个优势可以一定程度上弥补这个问题:

  1. 打包的资源只是原来独立资源的备份。打包与否不会导致资源的丢失,最多是没有合并的很好而已。
  2. 配置可以由工程师根据经验人工维护,也可以由统计日志生成,这为性能优化自适应网站设计提供了非常好的基础。

关于第二点,fis有这样辅助系统来支持自适应打包算法:

至此,我们通过基于表的静态资源管理系统和三个模板接口实现了几个重要的性能优化原则,现在我们再来回顾一下前面的性能优化原则分类表,剔除掉已经做到了的,看看还剩下哪些没做到的:

优化方向 优化手段
请求数量 拆分初始化负载
缓存利用 使Ajax可缓存
页面结构 尽早刷新文档的输出

 

“拆分初始化负载”的目标是将页面一开始加载时不需要执行的资源从所有资源中分离出来,等到需要的时候再加载。工程师通常没有耐心去区分资源的分类情况,但我们可以利用组件化框架接口来帮助工程师管理资源的使用。还是从例子开始思考:

模板源代码

在fis给百度内部团队开发的架构中,如果这样书写代码,页面最终的执行结果会变成:

模板运行后输出的html代码

fis系统会分析页面中require(id)函数的调用,并将依赖关系记录到资源表对应资源的deps字段中,从而在页面渲染查表时可以加载依赖的资源。但此时dialog.js是以script标签的形式同步加载的,这样会在页面初始化时出现资源的浪费。因此,fis团队提供了require.async的接口,用于异步加载一些资源,源码修改为:

这样书写之后,fis系统会在表里以async字段来标准资源依赖关系是异步的。fis提供的静态资源管理系统会将页面输出的结果修改为:

dialog.js不会在页面以script src的形式输出,而是变成了资源注册,这样,当页面点击按钮触发require.async执行的时候,async函数才会查表找到资源的url并加载它,加载完毕后触发回调函数。

到目前为止,我们又以架构的形式实现了一项优化原则(拆分初始化负载),回顾我们的优化分类表,现在仅有两项没能做到了:

优化方向 优化手段
缓存利用 使Ajax可缓存
页面结构 尽早刷新文档的输出

 

剩下的两项优化原则要做到并不容易,真正可缓存的Ajax在现实开发中比较少见,而尽早刷新文档的输出的情况facebook在2010年的 velocity上提到过,就是BigPipe技术。当时facebook团队还讲到了Quickling和PageCache两项技术,其中的 PageCache算是比较彻底的实现Ajax可缓存的优化原则了。fis团队也曾与某产品线合作基于静态资源表、模板组件化等技术实现了页面的 PipeLine输出、以及Quickling和PageCache功能,但最终效果没有达到理想的性能优化预期,因此这两个方向尚在探索中,相信在不久 的将来会有新的突破。

总结

其实在前端开发工程管理领域还有很多细节值得探索和挖掘,提升前端团队生产力水平并不是一句空话,它需要我们能对前端开发及代码运行有更深刻的认识,对性 能优化原则有更细致的分析与研究。fis团队一直致力于从架构而非经验的角度实现性能优化原则,解决前端工程师开发、调试、部署中遇到的工程问题,提供组 件化框架,提高代码复用率,提供开发工具集,提升工程师的开发效率。在前端工业化开发的所有环节均有可节省的人力成本,这些成本非常可观,相信现在很多大 型互联网公司也都有了这样的共识。

本文只是将这个领域中很小的一部分知识的展开讨论,抛砖引玉,希望能为业界相关领域的工作者提供一些不一样的思路。欢迎关注fis项目,对本文有任何意见或建议都可以在fis开源项目中进行反馈和讨论。

(三):本地调试与数据模拟

自Web项目诞生以来,程序员们渐渐把整个项目的开发拆分为了前端与后端两个部分。随着项目复杂度的不断增加,同时为了快速迭代出更多Web项 目,IT界就把两个部分的工作分成了两个工种来完成–前端开发工程师以及后端XX语言开发工程师(我们在此简称为FE与RD)。所以,在一个Web项目 开发过程中,就出现了前后端定义数据接口、参数等等工作,同时产生了一个巨大的耦合问题–前端工程师完全需要依赖后端工程师的数据接口以及后端联调环 境。当一个FE快速完成了页面的搭建,需要后端数据来完成页面交互等工作时,他唯一能做的就是等待RD完成他的工作,有时甚至还需要RD来搭建一个联调环 境等等。

也许更多时候情况比我上述所说的更复杂,需要项目管理者协调好项目进度、前后端协同开发等等问题。当然更多时候FE希望自己能解决这些问题,靠别人不如靠自己,对吧?这时候为了不依赖后端工程师,可以自己搞定后端环境及数据,都需要准备什么呢?下面我来举例:

  • 完整的server支持,可以真实运行模板、后端程序等,同时最好满足可以跨平台运行、支持常用后端语言(java、php)等
  • 模拟url请求,能够有控制url请求的能力,不是单纯的直接访问返回内容的server,通过控制url来解决ajax请求模拟返回的功能
  • 数据模拟,有本地数据mock的能力,分离模板与对后端数据的依赖,使得fe能独立于rd进行项目开发

如果可以达到以上要求,你便能拥有一套完整的本地开发环境,拥有独立开发前端项目的环境以及解决与后端开发的耦合问题。使用过FIS2.0的用户也 许会发现,FIS2.0可以完整解决以上的问题,在任何地方、任何环境都可以独立开发,接下来我为大家介绍下FIS本地开发环境是如何做到这些要求的。

一个轻巧独立的服务器

我们需要一个什么样的Server?

  • 可以监听请求,负责对页面请求进行响应
  • 后端语言的解析能力,比如可运行java、php等,不是简单的静态Web服务器
  • 易搭建,没有繁琐的安装过程,尽可能的不依赖其他复杂环境
  • 性能与可伸缩性,可快速的响应请求,同时稳定响应一定级别的并发数
  • 平台需求,可跨平台运行,解决使用不同平台开发的用户需求
  • 可靠性,可稳定长期运行,有服务器异常处理机制等

以上这些就是我们总结出一个合理可靠轻巧的服务器需要达到的要求,它是本地开发环境的基石。面对需求,我们需要的就是解决这些需求,当然从最开始用 很挫的方式一步一步尝试,到最后发现需要怎么实现这个server,经历的痛苦也是不言而喻的。最后我们发现需要实现以下内容以及找到了一些匹配的“轮 子”来做这些事:

  • 为了可跨平台部署及易搭建,我们选择了用JAVA开发。
  • 为了满足性能需求,我们需要多个CGI进程来处理并发请求,同时需要一个队列来保证接受请求不会丢失
  • 为了可以运行PHP程序,我们选择使用PHP-CGI进行解析以及通过Fastcgi协议与其通信
  • 为了可扩展不同的Web服务,我们需要一个可灵活扩展的Servlet容器,可处理不同的Web应用
  • 为了保证服务器的稳定性,我们需要一个服务器守护进程,保证服务器是正常运行的

我们需要一个强大的HTTP SERVER来监听请求、分析HTTP协议、创建socket通讯,同时还得需要一个Servlet容器来扩展不同的Servlet服务,进行不同Web 应用的解析,因此我们选择了JETTY来作为SERVER的Web服务器。JETTY可做作为Web服务器嵌入到JAVA程序中,而且是轻量级、性能极高 的,同时提供灵活可扩展的Servlet容器,满足开发针对各类Web应用的业务Servlet。

选择了JETTY,就意味着sever天生就可以运行JSP/Servlet,剩下我们需要解决的就是如何运行PHP了。为了跨平台且满足高效的性 能,我们选择了使用FASTCGI开发扩展与PHP-CGI进行通信,server将CGI环境变量和标准输入发送到FastCGI子进程php- cgi,子进程完成处理后将标准输出和错误信息从同一连接返回Server。我们为了可以解决并发请求必须启动多个PHP-CGI进程,保持可以多线程处 理请求。同时需要一个进程管理器对PHP-CGI进行管理,比如当PHP-CGI处理了几千个请求有内存溢出现象时,需要KILL掉重新启动新的PHP- CGI进程,以及当PHP-CGI进程出现异常情况挂掉后,会及时发现且启动新的进程保持可用的进程数。

当然你会发现做这些事是十分困难的,需要将HTTP SERVER与PHP-CGI建立链接、通过FASTCGI协议封装请求与PHP-CGI进行通信等等。我们最开始也尝试过且成功的运行了服务器,只是发 现并不是那么完美,特别是在进程管理方面。最后我们发现了一个牛逼且轻巧的东西——php-java-bridge,利用其对PHP-CGI封装的 FastCGIServlet,与JETTY进行完美对接,可在多平台且高性能的运行PHP。其实php-java-bridge的作用不止于此,它最厉 害的地方是可以在PHP中运行JAVA程序,这就是另外一个扩展点了。

有了以上这些开源的东西,我们要做的就是把这个服务器如何搭建起来,建立对应的Web应用。JETTY作为内嵌Web服务器,需要一个基础的Web.xml初始化参数,同时建立WebAppContext来负责处理Web应用请求。在Jetty中,有几个比较重要的模块:

  • Connector负责解析服务器请求并产生应答,不同的Connector用于处理不同协议的请求。
  • Handler用于处理经过Connector解析的请求并产生应答内容,同样可以通过配置不同的Handler来负责处理不同的请求。
  • TheadPool:管理和调度多个线程,用于服务于多个连接请求。
  • Server代表一个Jetty服务器对象,主要作用是协同Connector、Handler和TheadPool的工作。

JETTY启动需要做的事,启动ThreadPool线程池,启动设置到 Server 的 Handler,通常这个 Handler 会有很多子 Handler,这些 Handler 将组成一个 Handler 链。最后会启动 Connector,打开端口,接受客户端请求。在启动handler时,会启动Handler链上的子Handler,比如我们针对运行一个Web应用 程序创建一个WebAppContext,同时在WebAppContext初始化时设置处理请求时对应的Servlet,这样配置的请求都会传送到这个 WebAppContext进行处理。

下面一段伪代码说明如何启动Jetty及配置可处理PHP程序的Web应用程序:

//context
HandlerCollection hc = new HandlerCollection();
WebAppContext context = new WebAppContext(root, "/");

//set default descriptor
String descriptor = Thread.currentThread().getClass().getResource("/jetty/Web.xml").toString();
context.setDefaultsDescriptor(descriptor);

//Servlet
Iterator<Entry<String, String>> iter = map.entrySet().iterator();
while(iter.hasNext()){
	Entry<String, String> entry = iter.next();
	String key = entry.getKey().toLowerCase();
	String value = entry.getValue();
	System.setProperty("php.java.bridge." + key, value);
}
context.addServlet(FastCGIServlet.class, "*.php");
context.addEventListener(new ContextLoaderListener());

hc.addHandler(context);
Server server = new Server(port);
server.setHandler(hc);
try {
	server.start();
} catch(Exception e){
	System.out.print("fail");
}

启动Server时,添加一个WebAppContext处理请求php的文件,同时使用php-java-bridge中的FastCGIServlet来解析php程序,这样一个PHP服务器就打造出来了。同理,可以利用这样的原理处理其他支持CGI的后端语言程序。

从启动Server的代码中,大家可以发现我们会设置一个路由页面路径或者一个工程目录,这相当于Tomcat、Apache设置Web工程目录, 是服务器访问文件的根目录。还有一个需要注意的问题是从Server访问的文件都是可以正常上线的代码,而非还需要被“处理”的源码。

源码!=上线代码,这个关系开发者应该都是了解。随着很多自动化工具的诞生,我们开发的代码都是会经过工具处理后才会发布到线上机器,这个过程就是 所谓的编译过程。在本地开发时,我们依然需要将源码编译发布到一个本地临时预览环境中,通过Server去访问Web工程。这样的目的就是让Sever是 个完全独立的东西,只是接受HTTP请求、分析URL、解析代码就够了,至于代码编译发布的工作就不需要交给Server去做了。而且,也没必要同时再 Server中实现一遍编译工作,Server和编译上线是没有关系的,它的责任就是负责本地服务器的作用。

一个可模拟的环境

有了本地运行服务器作为基石,接下来我们就可以打造属于FE自己的开发环境。也许你会发现通过Server你可以正常访问到你工程中的页面,但是与 后端对接的数据接口怎么办?异步请求怎么处理?突然发现还有很多请求问题没有得到完美的解决。在联调环境中,也许RD会为项目配置好服务转发规则,不论是 使用的是Aapache、Tomcat,还是lighttpd。当然作为本地Server,你依然可以解决这个问题。

  1. 制定一套转发配置的规则
  2. 打造一个Rewrite模块,可以与Server配合使用

当然你会想到既然我们使用了Jetty,就可以使用其rewrite配置,但这并不能达到我们的要求而且过于复杂。比如我们要运行PHP项目时,我 们可能需要在FastCGIServlet处理URL转发的问题,相当麻烦,而且使Server不够独立、过于复杂。所以我们是这样做的:

  1. 一个conf文件配置URL转发规则
  2. 一个PHP文件充当一个Route模块

所以我们的Server就需要有两种状态,一种是不转发状态,一种是全部URL转发到Router文件中。因此我们的Server需要这样改造:

//context
HandlerCollection hc = new HandlerCollection();
WebAppContext context;
if(rewrite){
	context = new FISWebAppContext(root, "/" + script);
} else {
	context = new WebAppContext(root, "/");
}

//set default descriptor
String descriptor = Thread.currentThread().getClass().getResource
("/jetty/Web.xml").toString();
context.setDefaultsDescriptor(descriptor);

//Servlet
if(hasCGI){
	Iterator<Entry<String, String>> iter = map.entrySet().iterator();
	while(iter.hasNext()){
		Entry<String, String> entry = iter.next();
		String key = entry.getKey().toLowerCase();
		String value = entry.getValue();
		System.setProperty("php.java.bridge." + key, value);
	}
	context.addServlet(FastCGIServlet.class, "*.php");
	context.addEventListener(new ContextLoaderListener());
}
hc.addHandler(context);
Server server = new Server(port);
server.setHandler(hc);
try {
	server.start();
} catch(Exception e){
	System.out.print("fail");
}

同时我们需要构造FISWebAppContext继承WebAppContext类重写doScope方法

class FISWebAppContext extends WebAppContext {
		
	private String filename = "/index.php";
	
	public FISWebAppContext(String root, String input) {
		super(root, "/");
		filename = input;
	}

	@Override
	public void doScope(String target, Request baseRequest,
			HttpServletRequest request, HttpServletResponse response)
			throws IOException, ServletException {
		super.doScope(filename, baseRequest, request, response);
	}
}

当我们启动Server时,如果添加了rewrite参数,便将所有的请求转发到路由页面中,默认是index.php,路由页面将根据转发配置规 则进行URL处理。我们可将各类URL都由路由页面转发不同的文件中进行处理,比如一个异步请求需要返回JSON数据,一个模拟线上URL显示模板页面等 等。

在rewrite模式下,我们会将所有的请求都转发到index.php中,比如下面的情况:

当我们把路由页面写成一个固定内容时,任何请求都是返回index.php里的内容,所以我们需要根据不同的URI来做不同的处理,比如:

这时我们对URI为”/a.js”的请求做了特殊处理,返回一个JS内容。其实路由页面的作用就是要针对URI做不同的处理,就像lighttpd一样需要一个配置文件,将线上URI转发对应上服务器的各个文件,因此我们也需要类似一个Rewrite库以及配置文件:

我们根据server.conf的配置来控制URI找到对应的文件,当然这是最基本的要求。同时我们也可以模拟一些Ajax请求,来满足异步数据的获取:

你会发现现在解决了很多问题,静态资源可以访问了、异步数据也可以获取了,当然根据不同类型的项目,我们还需要模板。为了达到需求,我们为Rewrite模块添加一类转发类型

Rewrite模块提供灵活的添加转发规则的机制,可让我们在不重启Server的情况下动态对转发配置进行修改。根据项目情况可以具体打造路由页面、Rewrite模块以及Server.conf,来合理的模拟开发环境。

大家可能会发现一个问题,我们的路由页面和转发配置放在哪?其实我们的源码会经过编译后发布到本地预览环境中,在没有rewrite状态 时,Server会根据URL请求直接读取到预览环境中的文件。当启动rewrite模式时,Server会将请求都转发到路由页面,同时路由页面还需要 找到转发配置文件进行分析,找到被转发的文件进行渲染。当你发现配置文件、路由页面和你的项目文件都有路径关系时,那就代表其实这三样都应该发布在预览环 境中的,同时转发配置文件应该跟随源码进行维护。

我们需要的数据

通过以上努力,我们已经可以正常在本地运行页面了,当然我们还缺数据。通常页面所需要的数据基本分为两种,一种是页面渲染时由后端传入的数据,一种 是通过Ajax请求获取的数据。通过请求获取的数据,我们可以通过控制URL的方式来获取,那由后端传入的数据我们该怎么做呢?

通过以上的路由页面的处理流程可以发现,处理模板有个独立的过程:

在开发的过程中,我们可以建立模板与测试数据的对应关系,将测试数据与源码工程一起维护。当源码工程进行编译发布到预览环境中,浏览预览环境中的模板时,可以获取测试数据进行渲染,同时也可对测试数据进行在线编辑。

对于测试数据的管理,一方面我们可以根据与后端的数据接口创建测试数据,一方面我们可以通过某种方式获取线上或沙盒环境的真实数据进行调试。

FIS的本地开发环境

从上文的介绍中,我们了解到建立一个本地开发环境都需要哪些必须的条件。下面我简单的为大家介绍下FIS的本地开发环境是怎么样的。

  • 独立、跨平台、高性能的Server
  • 路由页面
  • Rewrite模块
  • 测试数据模块

以上四样法宝构成了一个本地开发环境,开发者将开发代码经过FIS编译后发布到本地的临时预览目录,通过Server可预览页面,具体的大家可以动手尝试下[Fis官网](http://fis.baidu.com)提供的Demo。

纵观以上流程,你会发现fis-plus提供的整个本地调试预览都是依赖本文最开始提出的三个要求来进行打造的,你可以根据项目以及环境的实际情况 打造属于你们团队自己的调试平台。一个好的易用的本地调试辅助开发工具,可以有效的提高开发联调效率同时减少一些繁琐重复甚至是烦人的环节。程序员们当然 希望拥有更多的时间去面对写码,而不是浪费太多时间以及精力在搭环境、催单子、找接口人等环节。FIS本地辅助开发工具就是秉着此原则和要求不断挖掘以及 通用化FE的本地开发需求,希望可以给大家带来更多的帮助和想法。

作者简介

袁方,百度Web前端研发部前端集成解决方案(FIS)小组核心成员,负责FIS本地开发环境的设计与开发、参与实现PC端解决方案等项目,全面负责FIS与百度各产品线前端团队合作落地。

This entry was posted in ASP.NET. Bookmark the permalink.

发表评论

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / 更改 )

Twitter picture

You are commenting using your Twitter account. Log Out / 更改 )

Facebook photo

You are commenting using your Facebook account. Log Out / 更改 )

Google+ photo

You are commenting using your Google+ account. Log Out / 更改 )

Connecting to %s