HTTP 请求的拦截技术可以广泛地应用在反向代理、拦截 Ajax 通信、网页的在线翻译、网站改版重构等方面。而拦截根据位置可以分为服务器端和客户端两大类,客户端拦截借助 JavaScript 脚本技术可以方便地和浏览器的解释器及用户的操作进行交互,能够实现一些服务器端拦截不容易实现的功能。本文将重点介绍通过 JavaScript 脚本在客户端对页面内容进行拦截修改的一些原理和技术。
HTTP 请求的拦截功能的应用
随着互联网应用及 B/S 架构的软件系统的风行,越来越多的基于浏览器的应用得以开发和部署。对已经上线的应用系统进行拦截和跟踪可以方便、快捷地实现很多功能。例如,
- IBM 的 Tivoli Access Manager 单点登录(SSO,关于更多 TAM 的信息,请参见本文后附的资源列表)就是基于逆向代理服务器的原理实现的,逆向代理服务器的实现必须要对既有页面中的一些 URL 进行拦截和改写;
- 现今一些有趣的匿名代理服务器也是根据逆向代理服务器的原理实现的(如 http://www.youhide.com/),这类网站也有 URL 改写的需求;
- 另外,IBM 提供的在线翻译网页的服务中,为了能够让被翻译页面的链接所指向的页面在用户交互时继续得到翻译,我们同样需要使用 URL 的拦截和改写功能。
不仅仅是 URL 改写,通过拦截和跟踪技术可以在极小修改或者使用反向代理不修改原网页的前提下为基于 BS 的 Web 应用提供更复杂的页面改写,脚本改写等功能。
基本原理
如图 1 所示,传统的 Web 访问模型通过浏览器向 HTTP 服务器获取 Web 数据,浏览器将获取的数据进行解释渲染,得到用户的客户端界面。
图 1. 传统 Web 访问模型
而在带有服务器端和浏览器端跟踪和拦截的访问模型中,传统的访问方式发生了变化。服务器端和浏览器端的跟踪和拦截将页面中的拦截对象,在适当的拦截时机进行了拦截变化,导致用户面前的浏览器的解释渲染结果和 HTTP 服务器上的内容存在了逻辑上的差异,而这种差异恰巧是 HTTP Server 所需要的结果,而不方便将差异部署在 HTTP Server 上。 Server trace 可以作为反向代理服务器的一项逻辑功能存在,Browser trace 是通过脚本完成在客户端拦截和跟踪行为。
服务器端实现和客户端浏览器实现的比较
拦截根据位置可以分为服务器端和客户端两大类,客户端拦截借助 JavaScript 脚本技术可以方便地和浏览器的解释器和用户的操作进行交互,能够实现一些服务器端拦截不容易实现的功能。如果将服务器端和客户端拦截融合在一起,可以很好地处理拦截和跟踪问题。
表 1. 服务器端拦截和客户端拦截的功能比较
功能比较 | 服务器端拦截 | 客户端拦截 |
---|---|---|
向页面头部插入代码 | 强。简洁,无需逐个加入代码 | 麻烦。需要逐个为页面加代码 |
访问资源的权限 | 强。可以访问跨域资源。 | 受限。浏览器间有差异 |
会话的控制和访问 | 强。可以使用相应 API 。 | 受限。需要使用 cookie |
页面 HTML 的拦截和跟踪 | 一般。通过正则跟踪。 | 强。可以使用 DOM 操作 |
页面脚本的拦截和跟踪 | 一般。通过正则跟踪 | 强。可以利用解释器环境 |
跟踪用户的交互行为 | 一般。通过正则跟踪 | 强。可以利用浏览器事件 |
从表 1 可以看出服务器端拦截和客户端拦截具有很强的互补性,服务器端拦截擅长服务器 - 浏览器的通信控制、资源的访问以及批量页面的操作,浏览器端拦截可以依靠 Script 解释器的环境,擅长个体页面的逻辑拦截和跟踪。本文因篇幅限制,主要针对使用 JavaScript 的 浏览器端的拦截和跟踪进行介绍。不过在进入具体的实现之前,先介绍几个术语。
拦截对象
拦截对象指的是需要被处理的元素,可能包括 [HTML 标签 ],[HTML 标签的属性 ],[ 脚本变量 ],[ 对象 ],[ 函数 ] 等。拦截对象 和 拦截时机 就构成了一个个非常实际的功能或用途。
功能:拦截页面中的所有链接,将其 href 属性的值 xxx 改为 http://proxyhost/agent?url=xxx
拦截对象 : <A> 标签
拦截时机:根据需要适当选择
功能:拦截页面中的 document.write 的功能,在调用此函数前修改函数参数
拦截对象 : 对象方法
拦截时机:根据需要适当选择
功能 : 拦截页面中用户自定义的函数 functionA 的调用
拦截对象: 用户自定义函数
拦截时机 : 根据需要适当选择
插入点和拦截时机
插入点: 插入点指的是拦截脚本 (JavaScript) 植入页面文件的位置。
拦截时机: 拦截时机从页面的载入过程可以分为 [ 页面加载前 ],[ 页面加载中 ],[ 页面加载后 ] 。
通常来说,在页面加载后进行处理代码相对简单,但是功能也相对受限。页面加载前处理代码实现相对复杂,但是功能相对丰富。用户可以根据需要混合使用。
从用户的角度出发,自然希望插入点简单,拦截时机和拦截点丰富。 因此本文的讨论的拦截和跟踪的技术的出发点也就明了了,即 : 在尽可能少的对原有系统的改变的前提下,通过 JavaScript 代码跟踪并改变系统的行为与内容。
拦截时机的实现
拦截时机中讲到的 [ 页面加载前 ] 是指在用户的 page 内容被浏览器解释前进行拦截的时机。这种拦截可以在用户没有看到渲染结果的时候完成拦截修改,这种技术将在第五种方法中详细介绍。
[ 页面加载中 ] 拦截通常是将代码放在页面中合适的位置,实现代码简单,但是代码的插入点 ( 也就是页面的合适位置 ) 可能阻碍了这种时机的使用频度,也违背了本文的初衷。因此 [ 页面加载中 ] 的拦截时机通常使用 [ 页面加载前 ] 的拦截时机进行替代。
例如 : 某 Web 页面的第 100 行有如下内容:
<script>
function userFunction() {
//do some actions
}
</script>
在页面的第 200 行调用了上面定义的函数,如:
<script>userFunction(); <script>
第 200 行的脚本会在页面的渲染过程中发生作用,如果希望拦截的话,就需要在第 100 行后,200 行前对 userFunction 进行覆盖,这样就会在页面的加载过程中完成拦截。这种做法严重依赖代码的插入点,因此使用效果不好。
[ 页面加载后 ] 拦截,首先在这里介绍,实现简单,能实现很多功能。
方法 : 通常在页面的尾部 </body> 标签前,加入:
<script type= "text/javascript" src="lib.js" ></script>
在 lib.js 文件中,通过改变 window.onload 的定义来构建我们的代码入口。为了保持功能,建议首先保存原来的函数的指针。典型的实现代码可以参考如下 :
var oldOnload = window.onload;
window.onload = function() {
//code to insert
if(oldOnload != null) {
oldOnload();
}
}
在文件末尾 </body> 前植入代码,是为了尽量防止其他加载代码的覆盖,如果不考虑加载过程中的代码覆盖的话,也可以考虑将代码放在页面的相对靠前的位置。
只要不是在加载过程中执行的代码和效果都可以考虑通过 [ 页面加载后 ] 这种拦截时机实现。这种代码的结构简单,可以很好的执行拦截和跟踪任务。特别是针对函数覆盖这种需求,通常情况下,onload 事件之前系统已经加载好所需要的 script 定义的函数和类,因此 onload 事件中的代码覆盖可以起到很好的作用。下面介绍的几种方法都可以首先考虑这种拦截时机。
常见用法 : 在代码的插入位置,可以植入一些 DOM 的操作,来实现页面的变化。
例如 : 遍历 DOM Tree, 修改特定 Node 的特定属性,或者在特定位置插入新的 Node 等等。
拦截和跟踪的主要方法及其实现
主要方法
在浏览器端的拦截和跟踪主要是利用 JavaScript 的脚本环境完成,根据笔者的经验,主要寻找并总结了如下的方法。这些方法的使用效果和支持平台可以互相弥补。
表 2
名称 | 特点 | 优点 | 缺点 |
---|---|---|---|
利用浏览器的 Event | 通过对 [ 鼠标事件 ],[ 键盘事件 ],[HTML 事件 ],[Mutation 事件 ] 的监听,可以对用户的交互,页面的变化,特别是标签节点的变化做出响应 | 浏览器自身支持,代码量小,几乎可以用来控制所有的 HTML 内容 | 此方法中的 Mutation Event,Firefox2.0 平台已支持,IE6.0 尚未支持 |
通过 AOP 技术拦截 | 可以拦截大部分对象的方法调用。 | 很多 JS 代码库和框架已经支持 AOP 技术,代码简单 | ActiveX 对象无法有效拦截。无法拦截普通的函数。另外单独使用此项技术会造成插入点复杂。 |
覆盖函数进行拦截 | 通过编写同名方法覆盖系统定义,用户自定义的函数 ( 构造函数 ),达到拦截目的,对普通函数的拦截是对 AOP 的补充。 | 不依赖其他的代码库和 JS 框架,对系统函数的覆盖有很好的效果,可以拦截构造函数用来控制对象的生成。 | 拦截自定义函数会造成插入点复杂 |
通过动态代理进行拦截 | 主要用来解决 ActiveX 对象的拦截问题,通过构造 ActiveX 对象的代理对象,实现拦截和跟踪。 | 典型的例子如 IE 平台下 AJAX 通信的拦截 | 代码复杂,属性更新的同步机制可能导致某些应用异常。 |
通过自代理和 HTML 解析进行拦截 | 此方法主要解决的是拦截时机的问题,配合上面的方法,就可以实现很多功能,而不必要等待页面的 onload 事件。 | 实现浏览器端页面加载前拦截的好方法 | 代码复杂 |
主要方法的实现
利用浏览器的 Event
浏览器的事件也可以很好地用来拦截和跟踪页面。 鼠标和键盘的交互事件这里暂不介绍。比较常用的是 onload,onunload 事件和 Mutation Events 事件。 onload 和 onunload 事件可以作用在 window,frame,img 和 object 等对象上,利用 onload 可以在对象载入前执行一些操作 ,onunload 事件可以跟踪浏览器关闭前执行操作。
在浏览器的 Event 中,Mutation Eventsii 是更加重要的跟踪工具之一。 Mutation Events 是 DOM2.0 标准的一部分,目前 Firefox2.x 已经开始支持 Mutation Events, IE6.0 目前尚不支持。 在 IE6.0 中可以是通过使用 onpropertychange 事件及覆盖节点的方法弥补部分的不足。这里重点介绍一下 Mutation Events 。
Mutation Event 主要包括了七种事件,如下所示。
- DOMAttrModified:跟踪 DOM 节点属性的变化;
- DOMCharacterDataModified:DOM 节点字符数据的变化;
- DOMNodeInserted:DOM 新节点被插入到给定的父节点;
- DOMNodeInsertedIntoDocument:DOM 节点被直接或随着祖先节点而插入;
- DOMNodeRemoved:DOM 节点被从父节点删除;
- DOMNodeRemovedFromDocument:DOM 节点被直接或跟随祖先节点被删除;
- DOMSubtreeModified:DOM 元素或文档变化。
可以说利用 Onload 事件的拦截,我们基本上解决了静态 HTML 内容的拦截,而对于脚本操作的 HTML 变化,我们就可以通过 Mutation Event 来进行解决。
下面类似的实现框架可以用来跟踪 src、action、href 等属性的变化。
document.addEventListener("DOMAttrModified", AttributeNodeModified, false);
function AttributeNodeModified(evt)
{
if(evt.attrName == "href") { }
if(evt.attrName == "src") { }
if(evt.attrName == "action") { }
}
通过 DOMAttrModified、DOMNodeInserted 等事件可以很好地跟踪和拦截 HTML 的变化。只可惜在 IE6.0 平台上还不能支持 Mutation Event,导致在 IE6.0 上进行拦截和跟踪的效果大打折扣。针对此平台,通过覆盖 document.write/DomNode.appendChild/DomNode.insertBefore 等方法和利用 onpropertychange 事件可以有限度地支持拦截和跟踪 HTML。
通过 AOP 技术拦截
针对对象方法调用的拦截,比较成熟的方案是使用 JavaScript 平台下的 AOP 技术。
目前,JavaScript 平台上的 AOP 方案主要有 Ajaxpectiii、jQuery AOPiv、Dojo AOPv 等。这些代码库主要功能是给指定对象的指定方法添加 Before, After,Around 等通知,从而达到拦截对象方法调用的目的 , 并且支持正则搜索方法名称。
Ajaxpect 的示例代码如下 :
var thing = {
makeGreeting: function(text) {
return 'Hello ' + text + '!';
}
} function aopizeAdvice(args) {
args[0] = 'AOP ' + args[0];return args;
} function shoutAdvice(result) {
return result.toUpperCase();
} Ajaxpect.addBefore(thing, 'makeGreeting', aopizeAdvice);
Ajaxpect.addAfter(thing, /make*/, shoutAdvice);
当然,在不方便使用上述代码库并且需求简单的时候,我们同样可以通过对象的方法覆盖的方式达到同样的效果。但是无论 AOP 还是方法覆盖, 都存在一个问题, 就是拦截代码的插入点不能做到很简捷,因为拦截代码的存在位置直接影响了代码的执行效果,因此在使用上还有一定的不方便。另外,针对 IE 平台的 ActiveX 对象,代码库不能很好的发挥功效,这是一些不足的地方。
覆盖系统类 / 方法进行拦截
覆盖已定义的函数是一种比 AOP 更直接的拦截和跟踪脚本调用的方式。
其原理是在原函数定义后,调用前通过定义同名函数,达到拦截和跟踪的目的。其一般形式多如下面 :
var oriFunction = someFunction;
someFunction = function () {
return oriFunction(); //or oriFunction.call(x,);
}
第一步是(第一行代码)为了将指向原来函数的指针保存,以便后续使用。
第二步便是定义同名函数,在同名函数里面的适当位置调用原来的功能。这种方法不但可以跟踪原来函数,还可以修改和过滤函数的参数,甚至可以修改返回值。当需要操纵参数的时候,只需在新定义的函数中访问 arguments 对象即可。
例如:针对系统函数 window.open(URL,name,specs,replace) 我们可以通过下面的代码进行拦截:
var oriWindowOpen = window.open;
window.open = function(url,names,specs,replace) {
url = "http://www.ibm.com"; //or arguments[0]="http://www.ibm.com";
return oriWindowOpen(url,names,specs,replace);
}
上面的拦截会导致所有的 window.open 调用全部打开 http://www.ibm.com 窗口 。
函数覆盖的适用范围较广,不但可以模拟 AOP 的实现,还可以对非对象函数进行操作。函数覆盖可以根据使用的差异分成若干情况 :
- 覆盖系统定义的函数、对象的方法:覆盖系统定义的函数或方法可以不用顾及代码插入点的问题,大可以将函数覆盖的代码放置在页面的最前边,并参照上面的形式进行操作。但是特别注意在 IE 平台下对 ActiveX 的对象的方法无效。
- 覆盖用户自定义的函数、对象的方法:覆盖用户自定义的函数,对象的方法需要考虑代码插入点的问题。正确的代码插入点的位置应该是在原函数定义之后,调用之前。
- 覆盖构造函数:覆盖构造函数是满足上面两种情况的一种特殊使用形式,跟踪对象创建之除,可以有效地针对对象的需要作出各种特殊的设置。
覆盖构造函数的一般形式 :
var oriFunction = someFunction;
someFunction = function () {
temp = oriFunction(); //oriFunction.call(x,);
return temp;
}
下面结合动态代理的方法给出 IE/Firefox 平台的 Ajax 通信拦截的一种简单实现。
Ajax 通信的核心是通过 XMLHttpRequest 对象和 HTTP Server 进行通信 ( 同步 / 异步 ),Firefox 和 IE 平台对 XMLHttpRequest 对象的实现不一样,因此两种浏览器的拦截方案也大相径庭。我们通过上面的技术将对 XMLHttpRequest 对象的方法进行跟踪。
拦截方法调用,我们可以使用 AOP,当然也可以直接覆盖函数。
在 Firefox 平台,我们可以通过下面的代码实现拦截 Ajax 对象的通信:
var oriXOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method,url,asncFlag,user,password) {
//code to trace or intercept
oriXOpen.call(this,method,url,asncFlag,user,password);
};
但是在 IE 6.0 平台,上面的代码将不会有作用,因为在 IE 6.0 平台,Ajax 通信对象是通过 ActiveX 对象完成的,JS 中的函数覆盖不能起到作用。
通过动态代理进行拦截
当在 IE6.0 平台遭遇 ActiveX 对象的时候,面对直接的函数覆盖不能奏效的时候,我们可以考虑通过另外一种办法,即动态代理 ActiveX 对象的方式实现拦截和跟踪。
首先我们通过覆盖构造函数的方法,将创建 XMLHttpRequest 对象的过程进行改造。
var oriActiveXObject = ActiveXObject;
ActiveXObject = function(param) {
var obj = new oriActiveXObject(param);
if(param == "Microsoft.XMLHTTP" ||
param=="Msxml2.XMLHTTP" ||
param == "Msxml2.XMLHTTP.4.0") {
return createActiveXAgent(obj);
}
return obj;
};
我们将构造过程拦截下来后,进行自己的改造,主要操作是创建对象,对象中设置与 ActiveX 对象相同的属性和方法,并且还需要同步属性方法。
function createActiveXAgent(ao) {
var agent = new Object;
agent.activeXObject = ao; //被包裹的内核,是真正的通信对象
agent.syncAttribute = function() { //syncAttribute是用来同步属性的
try{
this.readyState = this.activeXObject.readystate;
this.responseText = this.activeXObject.responseText;
this.responseXML = this.activeXObject.responseXML;
this.status = this.activeXObject.status;
this.statusText = this.activeXObject.statusText;
}catch(e) { }
};
agent.trigStateChange = function() { //模拟onreadystatechange
agent.syncAttribute();
if(agent.onreadystatechange != null) {
agent.onreadystatechange();
}
};
agent.activeXObject.onreadystatechange = agent.trigStateChange;
agent.abort = function() { //模拟abort
this.activeXObject.abort();
this.syncAttribute();
};
agent.getAllResponseHeaders =function() { //模拟内核对应的方法
var result = this.activeXObject.getAllResponseHeaders();
this.syncAttribute();
return result;
};
agent.getResponseHeader = function(headerLabel) { //模拟内核对应的方法
var result = this.activeXObject.getResponseHeader(headerLabel);
this.syncAttribute();
return result;
};
agent.open = function(method,url,asyncFlag,userName,password) {
//code to trace and intercept;
this.activeXObject.open(method,url,asyncFlag,userName,password);
this.syncAttribute();
};
agent.send = function(content) { //模拟内核对应的方法
this.activeXObject.send(content);
this.syncAttribute();
};
agent.setRequestHeader = function (label,value) { //模拟内核对应的方法
this.activeXObject.setRequestHeader(label,value);
this.syncAttribute();
};
return agent;
};
从上面的代码可以看出来,代理对象通过自身的方法模拟了原来 ActiveX 对象的方法。而更关键的属性问题,是通过在函数调用前后的属性同步函数实现的。即:在调用代理内核方法之前,将属性从代理对象同步给内核对象;在内核方法调用之后,将属性从内核对象同步给代理对象。
因为 AJAX 对象的属性几乎不被用户写入,故上面的实现只需要单向属性同步,即将内核属性同步给代理属性。对于复杂的应用,可以通过双向属性同步函数来解决属性的代理问题。
这种动态代理的方法将 ActiveX 对象像果核一样包裹起来,通过代理对象自身的同名属性和方法提供给外界进行访问,从而达到跟踪和拦截的目的。
通过自代理和 HTML 解析进行拦截
当代码拦截点需要简单可靠的时候,上面的方法无法很好的满足需求。于是我们需要新的思路来解决代码的拦截点问题。
自代理和 HTML 解析是通过拦截原有的 HTTP 通信,通过重新通信获得内容后,在浏览器解析前通过我们自己的代码进行简单解析过滤的方式进行代码处理的方案。
首先是拦截原有的解析,重新加载新的内容:
var s = document.location.href;
var comm = new ActiveXObject("Microsoft.XMLHTTP");
comm.open('get',s,false);
comm.onreadystatechange = function() {
if(comm.readyState == 4) {
document.execCommand("stop");
var retText = removeMe(comm.responseText);
retText = processContent(retText);
}
}
comm.send(null);
如果将上面的代码写在一个 js 文件里,然后通过 <script> 标签插入到页面的最开始位置(<HTML> 后面)。
在 IE6.0 的浏览器下面,上面的代码因为使用了 XMLHTTP 的同步通信机制,因此代码会阻塞在 comm.send(null) 处,当通信结束得到完整的页面之后,会触发 stop 导致浏览器停止解析,转而执行我们的 processContent. removeMe 的意义在于重新获得的片面中去除这段代码自身,防止无穷迭代。
在 Firefox 下,我们需要使用 window.stop 代替上面的 execCommand.
当我们抢在浏览器之前拿到 HTML 内容后,我们下面的任务就是分析 HTML. 目前尚没有成熟的 JS 分析 HTML 的框架。因此我们可以选择将 HTML 转换成 XML, 然后借助 DOM 进行分析,也可以实现我们自己的 HTML 分析方案 .
我们可以将 HTML 的页面分析成节点如下的一颗树:
节点 {
父节点 ;
属性个数 ;
属性集合 ;
子节点个数 ;
子节点集合
}
图 2. HTML 文本分析状态图
图 2 是个简单的 HTML 文本分析状态图,不支持 HTML 的 & 字符串拼接功能。可以反复调用这个模块用来从 HTML 文档中提取字符块生成相应的节点,然后可以利用 JavaScript 的正则表达时提取节点的属性。
通过 HTML 文本分析状态图可以得到 HTML 解析的代码,然后得到一个根为 root 的节点。后面对树进行进一步的处理,就可以实现很多拦截功能。比如 function 覆盖。前面讲到的用户自定义函数覆盖会受到代码插入点复杂的影响。如果在这种方法的拦截下,就可以实现分析出 <script> 节点的内容中含有特定的 function 定义,进而替换或者在其后插入新的函数定义,不会造成插入点复杂的结果。
结论
通过上面的介绍,可以看出发挥 JavaScript 的优势,我们能够很好的控制页面的加载过程,在不需要二进制浏览器插件的情况下,仅仅通过脚本对 Web 内面的逻辑拦截是可行的。这种方法在反向代理、网站的改版重构、以及不方便对原有内容进行直接改动的场合是十分有益的。