Ajax 应用程序设计
在过去的几个月中,我一直在本专栏中探讨与 Ajax 相关的多种技术和技巧,Ajax 是一种异步 JavaScript 和 XML 范例,是现代 Web 开发中最热门的技术。每个人都在争先恐后地在其站点上包含 Ajax,这有充分的理由。对于用户而言,Ajax 应用程序看起来更具响应性和类似桌面。对于开发人员而言,Ajax 很有吸引力,因为它打破了自 Web 诞生以来一直存在的每单击一页规则,从而使新型应用程序成为可能。
在 Ajax 应用程序中,单击操作可能强制执行完整的页面重新加载,就像在传统的 Web 应用程序中一样。但是,它也可能在后台触发 HTTP 请求。此 HTTP 请求的响应由 JavaScript 函数(也在后台)处理,该函数可以使用内容来修改页面的部分或全部。
如果您已经开发 Web 应用程序一段时间了,您可能会想知道 Ajax 有什么大不了的。毕竟,通过 DOM,JavaScript 函数修改当前页面既不新鲜也不困难,不是吗?也许不是,但有时最强大的想法并非源于花哨的技术,而是源于简单技术的巧妙结合。HTML、HTTP 和 URL 都是相当简单的发明,它们本身可能走不了多远。但是,通过以恰当的方式将它们组合在一起,蒂姆·伯纳斯-李发起了一场至今仍在继续的革命。
正如 Web 改变了我们看待出版和通信的方式一样,Ajax 也改变了我们对基于 Web 的应用程序工作方式的期望。幸运的是,使用 Ajax 只需要比 Web 开发人员到目前为止需要掌握的技能多一些,特别是 JavaScript、DOM 和 CSS。
上个月,我们构建了一个小型应用程序,演示了 Ajax 为表格带来的改进的可用性。当访问者在 HTML 表单中填写请求的用户名时,JavaScript 函数(通过 HTTP)从服务器请求当前用户名的列表。HTTP 响应包含当前用户的列表。通过检查新请求的用户名是否在该列表中,可以提前告知用户选择其他名称。
这种方法存在许多问题,但最大的两个问题是可伸缩性和安全性。如果我们的网站变得特别受欢迎,我们将有很多注册用户,因此发送完整的用户名列表将消耗越来越多的 CPU 和带宽。
此外,将站点上的所有用户名发送给任何请求者都是一个很大的安全风险。很有可能这些用户中至少有一个选择了弱密码,这将使其很容易冒充该人的身份。这种安全漏洞的影响取决于您的用户、您的应用程序和您的国家/地区。一些国家/地区的法律体系甚至可能将其视为可起诉的违反数据库隐私法的行为。
因此,出于技术和安全原因,我们需要找到更好的解决方案。一个明显的候选方案,也是我们本月要考察的方案,涉及通过 Ajax 请求将建议的用户名发送到服务器。因此,服务器的响应将是一个简短的“是”或“否”,指示浏览器是应该允许还是阻止注册。
Ajax 应用程序由几个部分组成
一个 JavaScript 函数,在 Web 页面中定义,在特定事件发生时调用。即使没有 Ajax,这些事件处理程序函数在 JavaScript 世界中也很常见。例如,在 CSS 出现之前,通常使用 JavaScript 来更改 img 标签的 src 属性,每当鼠标悬停在其上方(onmouseover 事件)或移开它(onmouseout 事件)时。在 Ajax 的情况下,事件处理程序函数不会操作 DOM,而是使用 XMLHttpRequest 对象发送异步 HTTP 请求。
在我们的示例应用程序中,JavaScript 函数将创建一个 XMLHttpRequest 对象,并使用它来调用驻留在服务器上的程序。作为请求的参数,我们将发送用户名文本字段的内容。
一个服务器端程序,它期望接收 HTTP 请求以及一个或多个参数,并生成适当的 HTTP 响应。理论上,响应可以是任何合法的 MIME 格式,尽管 XML、纯文本和 JSON(JavaScript 对象表示法)似乎是最流行的选择。服务器端程序几乎肯定不是用 JavaScript 编写的。您可以选择编写此程序的语言,以及调用它的方法。关键是它有权访问您需要的资源,例如数据库,并且它可以以您想要的格式生成输出。在本月的示例应用程序中,服务器端程序获取用户名参数并在数据库中查找它是否已被使用。它返回的 XML 将指示其发现。
第二个 JavaScript 函数,也在用户的 Web 浏览器中定义,在收到 HTTP 响应时调用。这个回调函数(有时也称为回调例程)接收 HTTP 响应,然后对其进行操作。因此,我们的回调例程将需要解析 Ajax HTTP 响应,然后使用 DOM 来根据需要修改当前页面。
鉴于以上列表,我们如何从上个月编写的简单程序转移到满足我们的可伸缩性和安全性要求的程序呢?
当我们在上个月的专栏中创建简单的 Ajax 用户名检查程序时,我们使用了这三个元素中的两个。我们创建了一个 HTML 表单(如清单 1 所示),该表单允许人们通过输入用户名、密码和电子邮件地址在我们的网站上注册。然后,我们指示每当用户名文本字段更改时,都应调用 checkUsername JavaScript 函数
<input type="text" name="username" onchange="checkUsername()" />
然后 checkUsername 向我们的服务器(与当前 HTML 页面来自同一服务器)请求文本文件的内容
function checkUsername() { // Send the HTTP request xhr.open("GET", "usernames.txt", true); xhr.onreadystatechange = parseUsernames; xhr.send(null); }
这是我们需要进行更改的第一个地方。我们将发送带有单个参数 (username) 的 POST 请求,而不是发送不带任何参数的 GET 请求来请求静态文档,这将导致服务器端程序的执行。
最后,我们的回调例程 (parseUsernames) 迭代了服务器发送的用户名列表,使用 DOM 警告用户是否找到匹配项。这是我们需要进行更改的另一个地方。但在这种情况下,更改将是简化。我们不再需要解析服务器发送的用户名。相反,我们将只需要确定响应是肯定的还是否定的。
清单 1. ajax-register.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head><title>Register</title> <script type="text/javascript"> function getXMLHttpRequest () { try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch(e) {}; try { return new ActiveXObject("Microsoft.XMLHTTP"); } catch(e) {} try { return new XMLHttpRequest(); } catch(e) {}; return null; } function removeText(node) { if (node != null) { if (node.childNodes) { for (var i=0 ; i < node.childNodes.length ; i++) { var oldTextNode = node.childNodes[i]; if (oldTextNode.nodeValue != null) { node.removeChild(oldTextNode); } } } } } function appendText(node, text) { var newTextNode = document.createTextNode(text); node.appendChild(newTextNode); } function setText(node, text) { removeText(node); appendText(node, text); } var xhr = getXMLHttpRequest(); function parseUsernames() { // Set up empty array of usernames var usernames = [ ]; // Wait for the HTTP response if (xhr.readyState == 4) { if (xhr.status == 200) { usernames = xhr.responseText.split("\n"); } else { alert("problem: xhr.status = " + xhr.status); } } // Get the username that the person wants var new_username = document.forms[0].username.value; var found = false; var warning = document.getElementById("warning"); var submit_button = document.getElementById("submit-button"); // Is this new username already taken? Iterate over // the list of usernames to be sure. for (i=0 ; i<usernames.length; i++) { if (usernames[i] == new_username) { found = true; } } // If we find the username, issue a warning and stop // the user from submitting the form. if (found) { setText(warning, "Warning: username '" + new_username +"' was taken!"); submit_button.disabled = true; } else { removeText(warning); submit_button.disabled = false; } } function checkUsername() { // Send the HTTP request xhr.open("GET", "usernames.txt", true); xhr.onreadystatechange = parseUsernames; xhr.send(null); } </script> </head> <body> <h2>Register</h2> <p id="warning"></p> <form action="/cgi-bin/register.pl" method="post"> <p>Username: <input type="text" name="username" onchange="checkUsername()" /></p> <p>Password: <input type="password" name="password" /></p> <p>E-mail address: <input type="text" name="email_address" /></p> <p><input type="submit" value="Register" id="submit-button" /></p> </form> </body> </html>
上个月的程序版本发送了一个 GET 请求。可以使用 GET 请求发送一个或多个参数,甚至很常见。这些参数随后会附加到 URL 上,如下所示:http://www.example.com/foo.pl?param1=value1¶m2=value2。
另一种称为 POST 的请求类型将参数放在请求正文中。这有几个优点,包括更简洁的 URL 以及对参数名称和值的长度没有限制。(许多浏览器限制 URL 的总大小,其中包括 GET 请求的参数。)
虽然对于此示例程序来说,严格来说没有必要使用 POST 请求,但很高兴看到我们如何在请求中传递参数。而且,实际上,这样做非常容易。将以下代码(取自清单 2)与上面(取自清单 1)的类似摘录进行比较
function checkUsername() { // Send the HTTP request xhr.open("POST", "/cgi-bin/check-name-exists.pl", true); xhr.onreadystatechange = parseResponse; var username = document.forms[0].username.value; xhr.send("username=" + escape(username)); }
如您所见,我们已将 xhr.open 的前两个参数更改为 POST(而不是 GET),并指向将生成动态输出的程序。第三个参数告诉 XMLHttpRequest 对象它应该在后台(即异步)进行查询,仍然设置为 true。我还将回调例程的名称从 parseUsername 更改为 parseResponse。
另一个变化是我们现在正在向服务器发送参数。变量 queryString 只是一个字符串,由名称-值对组成,采用传统的 Web 格式
param1=value1¶m2=value2
因此,我们构建了这样一个查询字符串,并将其发送到服务器。
清单 2. post-ajax-register.html
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head><title>Register</title> <script type="text/javascript"> function getXMLHttpRequest () { try { return new ActiveXObject("Msxml2.XMLHTTP"); } catch(e) {}; try { return new ActiveXObject("Microsoft.XMLHTTP"); } catch(e) {} try { return new XMLHttpRequest(); } catch(e) {}; return null; } function removeText(node) { if (node != null) { if (node.childNodes) { for (var i=0 ; i < node.childNodes.length ; i++) { var oldTextNode = node.childNodes[i]; if (oldTextNode.nodeValue != null) { node.removeChild(oldTextNode); } } } } } function appendText(node, text) { var newTextNode = document.createTextNode(text); node.appendChild(newTextNode); } function setText(node, text) { removeText(node); appendText(node, text); } var xhr = getXMLHttpRequest(); function parseResponse() { // Get variables ready var response = ""; var new_username = document.forms[0].username.value; var warning = document.getElementById("warning"); var submit_button = document.getElementById("submit-button"); // Wait for the HTTP response if (xhr.readyState == 4) { if (xhr.status == 200) { response = xhr.responseText; switch (response) { case "yes": setText(warning, "Warning: username '" + new_username +"' was taken!"); submit_button.disabled = true; break; case "no": removeText(warning); submit_button.disabled = false; break; case "": break; default: alert("Unexpected response '" + response + "'"); } } else { alert("problem: xhr.status = " + xhr.status); } } } function checkUsername() { // Send the HTTP request xhr.open("POST", "/cgi-bin/check-name-exists.pl", true); xhr.onreadystatechange = parseResponse; var username = document.forms[0].username.value; xhr.send("username=" + escape(username)); } </script> </head> <body> <h2>Register</h2> <p id="warning"></p> <form action="/cgi-bin/register.pl" method="post" enctype="application/x-www-form-urlencoded"> <p>Username: <input type="text" name="username" onchange="checkUsername()" /></p> <p>Password: <input type="password" name="password" /></p> <p>E-mail address: <input type="text" name="email_address" /></p> <p><input type="submit" value="Register" id="submit-button" /></p> </form> </body> </html>
Ajax 几乎完全是客户端范例。而且,实际上,越来越清楚的是,我们可以普遍使用 JavaScript,特别是 Ajax,来创建新的有趣的应用程序和界面。也就是说,服务器端程序仍然在 Web 应用程序(包括 Ajax 应用程序)中发挥着重要作用。
首先,只有服务器端程序才能访问站点的关系数据库。(是的,理论上可以使用 JavaScript 直接访问数据库,但这将是一场安全和性能噩梦。)这意味着您通常会存储在数据库中,但希望在浏览器中显示的所有内容都需要通过服务器端程序进行过滤。因此,几乎任何重要的应用程序都将受益于成为更大的 Web 框架的一部分,例如 Zope、Ruby on Rails 甚至是您自己的系统,该系统将行为封装在一组相关的方法或函数中。换句话说,Ajax 应用程序中的服务器端程序变得非常专业化的数据库查询和报告工具。
为了节省时间和空间,我们本月不访问数据库。但是,HTTP 客户端无法知道 HTTP 服务器是在检查数据库还是返回随机结果,我们将利用这种保密性来伪造缺少数据库的情况。如果我们决定在某个时候修改我们的服务器端程序以从数据库而不是在哈希中硬编码列表来检索用户名列表,那也没问题。
我们的服务器端程序 check-name-exists.pl(清单 3)是一个用 Perl 编写的简单 CGI 程序。我们获取从 Ajax 请求接收到的 POSTDATA 参数,并在其中查找我们是否收到了用户名的设置。如果是,我们然后在 %usernames 哈希的键中查找匹配项。如果我们找到匹配项,它会向调用者返回 yes。如果没有匹配项,则返回 no。
清单 3. check-name-exists.pl
#!/usr/local/bin/perl use strict; use diagnostics; use warnings; use CGI; use CGI::Carp; # Define the usernames that are taken # (Use a hash for lookup efficiency) my %usernames = ('abc' => 1, 'def' => 1, 'ghi' => 1, 'jkl' => 1); # ------------------------------------------------------------ my $query = new CGI; print $query->header("text/plain"); # Get the POST data my $postdata = $query->param("POSTDATA"); # Get the username my ($name, $value) = split /=/, $postdata; my $username = ''; if ($name eq 'username') { $username = $value; } # If this username is defined, say "yes"! if (exists $usernames{$username}) { print "yes"; } # Otherwise, say "no"! else { print "no"; }
请注意我们如何使用哈希而不是数组来存储用户名。这是一种为了提高效率而采用的技巧;查找数组元素(并查看是否有匹配项)所花费的时间与数组中元素的数量成正比。相比之下,哈希键查找花费的时间是恒定的,无论有多少元素。在生产环境中,我们显然希望在数据库或服务器端磁盘文件中查找用户名,而不是在哈希或数组中查找。
此示例还演示了一种在开发仍在进行中时模拟 Ajax 应用程序的方法——创建一个服务器端程序,该程序为数据的非常小的子集生成结果,模拟您通常可能想要使用的全范围数据库查询。通过这种方式,项目 JavaScript 端的开发不必等待服务器端部分完成,从而实现更并行化的开发。
当响应从服务器到达时,我们的回调例程 parseResponse 将被调用。与往常一样,我们等待 XMLHttpRequest 的 readyState 为 4 且 HTTP 状态代码为 200。此时,我们可以预期来自服务器的四种不同响应之一
yes 响应表明用户名已被占用。我们禁用表单的提交按钮并显示警告。如果用户更改用户名文本字段内的文本,警告将被删除,提交按钮将被重新启用。
no 响应表明用户名可用。我们删除可能已放置的任何警告,并启用提交按钮。
空响应可能在 yes 或 no 之前出现,在这种情况下,我们忽略它。
最后,我们的程序可能无法完全按照我们的预期运行。如果发生这种情况,我们会显示我们收到的意外响应以进行调试。这可能是您可能想要从生产代码中删除的内容。
请注意我们如何使用 switch 语句来查看不同的可能性。另请注意,我们如何通过与服务器共享工作来降低 JavaScript 代码的复杂性。这是良好 Ajax 应用程序的关键。客户端或服务器都没有独自完成所有工作,而是它们共同承担负担,做它们可以最快、最干净地完成的事情。
最后,您可能会注意到,尽管我们谈论了很多关于 XML 的内容——毕竟,它是 Ajax 中的 x——但在此应用程序中却明显缺少 XML。没错,我们使用了 XMLHttpRequest 向服务器发送 HTTP 请求,但是 XML 发生了什么变化?
事实是,Ajax 是一个很棒的名称,但它并不能完全描述编程范例提供的选项范围。正如我在上面指出的,HTTP 响应可以采用任何 MIME 类型,尽管 XML 和纯文本是最常见的。如果此应用程序返回更复杂的数据集,例如商店库存或图表点,则 XML 可能更合适。另一种越来越流行的格式是 JSON,它类似于 Perl 的“Data::Dumper”在 JavaScript 对象的表示形式中。Ajax 只是一种在客户端和服务器之间分配工作的技术;如果 XML 不适合手头的任务,您不应感到必须使用 XML 进行数据传输。
Reuven M. Lerner 是一位长期的 Web/数据库顾问,是伊利诺伊州埃文斯顿西北大学学习科学专业的博士候选人。他目前与妻子和三个孩子住在伊利诺伊州斯科基。您可以在他的 Weblog 上阅读他的文章:altneuland.lerner.co.il。