续集:加强服务器安全性保护WordPress

自上次写过WordPress被黑之后,虽然安装了Wordfence,但发现依然不够,Wordfence能反复扫描出各种被黑的PHP木马。

没想到WordPress的安全性如此成问题,看来安全性还是必须从系统入手,不能光依赖WordPress本身。

一、禁用PHP的系统相关函数

首先经过检查,发现虽然篡改了PHP文件,甚至在tmp下面跑了一个进程,但似乎并没有渗透到系统级别,没有获得root权限,所以我立即在php.ini里面禁用了一些系统相关的函数,以防止被获得系统的权限


disable_functions = pcntl_alarm,pcntl_fork,pcntl_waitpid,pcntl_wait,pcntl_wifexi
ted,pcntl_wifstopped,pcntl_wifsignaled,pcntl_wexitstatus,pcntl_wtermsig,pcntl_ws
topsig,pcntl_signal,pcntl_signal_dispatch,pcntl_get_last_error,pcntl_strerror,pc
ntl_sigprocmask,pcntl_sigwaitinfo,pcntl_sigtimedwait,pcntl_exec,pcntl_getpriorit
y,pcntl_setpriority,exec,passthru,shell_exec,system,proc_open,popen,curl_exec,cu
rl_multi_exec,parse_ini_file,show_source

二、将WordPress目录下的文件权限改为root

为了防止PHP文件被插入恶意脚本,我将PHP文件都设置为只读,虽然这样会导致WordPress无法自动更新或者是自动安装插件,但要好过被篡改。然而uploads目录是必须要可以写入的,没有这个功能无法上传图片。


# chown -R root .
# chown -R www-data wp-content/uploads

三、禁止 uploads 目录中执行任何 PHP 脚本

在做了前两步之后,我发现依然有黑客可以上传脚本到 uploads 目录下,并通过远程请求调用执行,所以必须要禁止这个目录下的PHP脚本的运行。我在nginx中增加了一下配置


server {
listen 80;
server_name shiningray.cn *.shiningray.cn ;
root /var/www/shiningray;
index index.html index.htm index.php;
include /etc/nginx/php_params;
location ~* /(?:uploads|files)/.*.php$ {
deny all;
}

}

四、配置防火墙

当我还没配置第三步的时候,我通过 netstat 和 lsof 等指令调试系统的时候,发现PHP进程会连接到很多邮件服务器的25(smtp)端口,我想起来之前Linode客服所说的经常能发现我的服务器在投递垃圾邮件,原来是拿我当肉鸡发垃圾邮件。所以我觉得直接堵死smtp端口的连接,就可以断了他们的念想。

我使用Ubuntu的ufw来配置了端口的访问:

表示拒绝任何从本地发起到外部25端口的连接,使用 deny 替换掉 reject 也可以,但是 deny 是丢弃掉链接上发出去的包,而 reject 是直接断开链接。由于木马往往会反复不停的尝试,如果只是deny的话,可能会导致进程被堵死,所以最后选择了 reject

至此之后,再也没有感染过PHP的恶意脚本了。

Rails SQL Session Store优化版

问题根源

原始的ActiveRecord会话仓库很慢。对于低流量的网站而言没有什么问题,但是对于大一点的而言就慢了。首先,它的慢是因为ActiveRecord本身比较慢。虽然这是一个强大的ORM框架,但对于像会话管理这种简单的任务而言就是杀鸡用牛刀了。

还有其他的解决方案如cookie会话仓库(会话长度有限,不能在会话中存放敏感数据),memcached(无法持久化+难以实现高可用性方案)。

这就是为何要创建SqlSession仓库的原因。它直接操作mysql的数据库API,要比原始的AR会话仓库快很多。不过有时候它还是比较慢,因为:

  • 每次访问都会创建、更新会话 – 任何机器人或者偶然的访客都会在数据库中创建一条会话记录,最后导致会话表里面会有成千上万的无用记录,但其中99%的访问其实是不需要任何会话更新的。
  • 它使用32位字符串作为会话记录的键 – 所有RDBMS在处理字符串索引都要比整数慢很多,所以使用整数更好,然而我们的会话ID非常长,同时这些会话仓库都直接使用他们作为表索引。
  • 使用了auto_increment 主键,对于MySQL 5.1.21之前的版本都会导致InnoDB使用表级锁。在不必要的插入上使用表级锁会给大型网站造成奇怪的问题。

解决方案

FastSessions Rails插件便是作为对于以上几个问题的解决方案而诞生的。

首先,我们从会话表中去掉了id字段,所以我们无须用到auto-increment锁。接下来为了让查找更快,我们使用了以下这些技术:不使用(session_id)作为查找索引,使用(CRC32(session_id), session_id)——双列键,可以帮助MySQL更快地找到会话记录因为键的基数更高(这样,mysql可以更快地找到记录而不用检查很多索引行)。我们测试过这种方法,在大会话表中显示了10-15%的性能提升

最后,也是最强的优化是,对于空会话不创建数据库记录,同时如果数据没有在请求处理过程中没有更新过,则会话数据不会存回数据库。这个更改基本上可以减少50-90%的插入数量(根据应用程序的情况)。

那么,你肯定在想,用了这个插件之后会话究竟能快多少?这很难说。结果要看你是怎么操作会话的:如果你很少更改会话数据(如登录时储存用户id),那么可能会有90%的性能提升,但如果每次访问,你都要给用户会话写入些信息(比如最后一次访问时间last_visit_time),那么根据服务器的负载和会话表的大小,可能有5-15%的性能提升。

你只需要安装一个简单的插件,便可以自动实现上面这些更改。

我们解决了AUTO_INC锁的问题并去掉auto-increment键之后,又引入了一个新的问题,我在这里想说一下。这个问题是这样的。InnoDB会根据主键将所有数据分组。这意味着当我们使用自增长主键并向表插入记录时,会话记录会被组到一起,顺序地存储在磁盘上。然而如果使用比较随机的值(如一个随机会话id的crc32值)作为主键,那么每一个会话记录会被插入到属于它自己的不同的地方,那么会产生一些随机I/O,这对于I/O有限制的服务器不是很理想。所以,我们决定让用户自己选择在部署的时候使用何种主键,当你想在MySQL 5.1.22+上使用这个模块的话,可以设置

CGI::Session::ActiveRecordStore::FastSessions.use_auto_increment = true

这样在InnoDB中会连贯地插入数据。另一个情况当你的MySQL服务器的I/O有限制,不想因为随机主键而增加随机I/O,也可以这样设置。

如果你不想丢失使用AR会话插件创建的旧会话数据,你可以设置

CGI::Session::ActiveRecordStore::FastSessions.fallback_to_old_table = true

这样当某些session_id在新的会话表中找不到的时候,就会回去访问旧的会话表。旧会话表的名字可以使用CGI::Session::ActiveRecordStore::FastSessions.old_table_name 变量进行设置.

这个选项会使会话变慢所以我建议只要在升级到新会话表的时候才使用。在这种情况下,新的会话数据就会存入新表中,当到了会话超时期限的时候,就可以删除旧表了(我们用了两个月的期限,过了两个月之后,我们就可以删除旧表,并将此选项关闭。)。

安装

安装FastSessions插件十分简单,只需以下几个步骤:

  1. 将该插件代码从我们的SVN库中安装到vendor/plugins目录中(可以使用./script/plugin install安装,或者piston import命令进行安装——看你喜欢)例如:

    $ piston import http://rails-fast-sessions.googlecode.com/svn/trunk/ vendor/plugins/fast_sessions
  2. 在config/environment.rb文件中启用ActiveRecord会话仓库:

    Rails::Initializer.run do |config|
    ......
    config.action_controller.session_store = :active_record_store
    ......
    end
  3. 为新的会话表创建数据库迁移:

    $ ./script/generate fast_session_migration AddFastSessions
  4. 如果需要,可以打开新创建的迁移脚本并更改表名table_name和插件use_auto_increment参数。
  5. 运行数据库迁移:

    $ rake db:migrate
  6. 启动应用程序并尝试进行一定会保存数据到会话的操作。然后检查fast_sessions会话表(如果你没有改名字的话)有没有这条记录。

下载

该插件的最新版本可以在它的项目网站或在 SVN仓库中找到。该插件由Alexey KovyrinPercona的MySQL性能专家)制作。开发由Scribd.com赞助。

横向扩展(Facebook)

原文:Scaling Out

作者:Jason Sobel (notes)

翻译:ShiningRay

我于2007年四月加入了Facebook,在结束了几周的课程之后,我的经理Robert Johnson来找我。我们谈了很久,不过内容可以归结为:

Bobby: “那么,Jason,我们要在2008年之前在弗吉尼亚开一个新的数据中心。你能去帮点忙吗?”
Me: “呃…. 可以?”
Bobby: “很好!”

我在Facebook的第一个项目上投入的要比我预期的多一点点,但是我认为这是为何我们拥有如此一个非常强大的工程组织的原因;我们还有很多难题有待解决,这里每个人都迫不及待要立刻去解决他们。我开始了解为何我们需要建造一个新的数据中心以及我们需要解决什么问题才能让他正常工作。

有何必要?

在东海岸建造一个新的数据中心的主要原因就是“延迟”。在一个高速连接上发送一个包横穿大陆需要大概70微秒的时间,而对于普通的互联网用户而言,可能会需要的时间就长得多。通过将服务器放在弗吉尼亚,我们可以大大减少给东海岸和欧洲的用户传送网页的时间。

第二个关注点是空间、能源和灾难恢复。在我们位于加利福尼亚的主数据中心中已经没有多少物理空间了,而弗吉尼亚的点可以给我们充分的空间添加东西。我们还有一个类似问题就是要给予充足的电能驱动所有的服务器。最后,如果把我们限制在某个单独的地方,意味着如果出现灾难事件(断电、地震、怪兽),可能会导致Facebook长时间无法访问。

开始构建!

在我们能处理应用级的问题之前,我们的小组在弗吉尼亚投入了大量的心血构建服务器和物理空间。他们还完成了数据中心之间的网络和低延迟光线通道连接。这些工作是非常巨大的工程,然而我们顶尖的团队使之看上去像是小菜一碟。

网络和硬件都到位后,我们搭建了标准的3层架构:Web服务器,memcache服务器和MySQL数据库。在弗吉尼亚的MySQL数据库作为西海岸的数据库的从数据库(Slave)运行,所以我们花了几周的时间复制所有的数据,然后建立同步复制流(replication stream)。

现在硬件、网络和基础的设备都已经建立好,那现在就要面对两个主要的应用级的挑战:缓存一致性(Cache Consistency)和流量路径选择(traffic routing)。

缓存一致性

先说一下我们的缓存模型:当一个用户修改了数据对象后,我们的底层设施会向数据库写入新的值,并且从memcache中删除旧的值(如果存在)。下一次用户请求该用户对象的时候,我们从数据库中取出新的结果并写入memcache。后续的请求就会直接从从memcache中取出数据直到缓存过期或者被另外一次更新删除。

这种设置在只有一套数据库的时候运行得很好,因为我们只有当数据库完成了新值的写操作之后才删除memcache中的值。这种方式保证了我们能够从数据库中获得新值并且放入memcache中。然而,当在东海岸有一个从数据库后,情况就有些棘手了。

当我们在西海岸的主数据库中更新了一些数据之后,在东海岸的从数据库能正确反映这些新数值之前,中间有一个同步复制的延迟。通常这个延迟小于一秒钟,但是在高峰时期,它可能会延长到20秒。

现在我们假设在更新了加利福尼亚的主数据库的同时,我们从弗吉尼亚的memcache层中删除了旧值。然后有一个对弗吉尼亚的从数据库的读操作可能由于复制延迟还是看到的旧数值。然后弗吉尼亚的memcache可能会更新为旧的(不正确)的数值,然后它可能被“困住”直到被删除。如你所见,最差的情况是弗吉尼亚的memcache层可能总是同一个版本而非正确的数据。

考虑下面的例子:

  1. 我将我的名字从“Jason”改成了“Monkey”
  2. 我们把“Monkey”写入了加利福尼亚的主数据库并且从加利福尼亚和弗吉尼亚的memcache中删除了原来的名字
  3. 有个人在弗吉尼亚访问我的信息
  4. 在memcache中没有找到我的姓名信息,所哟我们从弗吉尼亚的从数据库中读取,由于复制的延迟获得了“Jason”
  5. 我们将姓名“Jason”存入弗吉尼亚的memcache
  6. 同步复制上来了,我们将名字信息在从数据库中更新为“Monkey”
  7. 另一个人在弗吉尼亚访问我的信息
  8. 我们在memcache中找到了名字并返回“Jason”。

在我再更新我的名字或者数据过期需要再访问数据库之前,我的名字在弗吉尼亚会一直显示为“Jason”,在加利福尼亚显示为“Monkey”。混乱吧?确实。欢迎来到分布式系统的世界,在这里一致性确实是一个难题。

幸好,解决方案要比问题容易解释。我们对MySQL做了一个小小的改动,让MySQL能在同步复制流中附加一个额外的信息。我们利用这个功能将要变更的所有数据对象追加到给定查询上,然后当从数据库“看到”这些对象后,要负责在进行了数据库更新后将这些值从缓存中删除。

我们是怎么做到的呢?MySQL是用了一个词法解析器和yacc语法来定义查询的结构然后对其进行解析。为了解释方便,我对其进行了简化,这个语法最顶层差不多如下:

很直观吧?一个query(查询)是一个能分解成某种我们熟知的MySQL表达式的statement(语句)。我们将这个语法修改为允许在任意查询后追加memcache键,如下:

查询现在可以有一个额外的组件;在语句statement之后有mc_dirty可以为空或者为一个关键词MEMCACHE_DIRTY后面跟着一个mc_key_list。一个mc_key_list只是一个逗号隔开的字符串列表,该规则会告诉解析器将所有字符串一个接一个存入某个叫做mc_key_list向量中,这个向量将被存入每查询解析器对象中。

看个例子,某个老式的查询看上去像:
REPLACE INTO profile (first_name) VALUES ('Monkey') WHERE user_id='jsobel'
在新语法下会变成:a
REPLACE INTO profile (first_name) VALUES ('Monkey') WHERE user_id='jsobel' MEMCACHE_DIRTY 'jsobel:first_name'

新的查询会告诉MySQL,除了要将我的名字更改为Monkey外,它还需要将一个对应的memcache键设脏。这很容易实现。由于每对象解析器对象现在储存了所有的memcache键,我们在mysql_execute_command最后添加了一小段代码——如果查询成功了,就设脏这些键。看看,我们成功地按照我们的目的——缓存一致性——劫持了MySQL同步复制流。

新的工作流变成了(更改的内容为粗体):

  1. 我将我的名字从“Jason”改为“Monkey”。
  2. 我将“Monkey”写入加利福尼亚的主数据库并从加利福尼亚的memcache中删除我的名字,但不包括弗吉尼亚的memcache
  3. 某个人在弗吉尼亚访问了我信息。
  4. 在memcache中找到了我的名字,并返回“Jason”。
  5. 同步复制到了之后,将从数据库中我的名字更新为“Monkey”。还需要从弗吉尼亚的memcache中删除我的名字因为缓存对象出现在同步复制流中了。
  6. 另一个人在弗吉尼亚访问了我的信息
  7. 没有在memcache中找到我的名字,所以从从数据库读出名字,得到了“Monkey”。

页面路径选择

我们还需要解决的另一个主要的问题是只有在加利福尼亚州的主数据库才可能接受写操作。这个情况就是说我们需要避免在弗吉尼亚服务那些需要进行数据库写操作的页面,因为他们都需要穿越整个大陆访问我们在加利福尼亚的主数据库。幸好,我们最频繁访问的页面(首页、档案、照片页面)在正常情况下都不会进行写操作。这样这个问题就归结于,当一个用户请求某个页面时,我们怎么判断它是否可以被“安全”地送到弗吉尼亚,或者它必须被引导到加利福尼亚?

这个问题最后有一个比较直观的答案。某个用户请求Facebook时,命中了第一批服务器其中的一个,这个服务器称之为负载均衡器;该机器的主要职责是选择一个Web服务器来处理该请求,不过它也进行一些其他目的的服务:防御拒绝服务攻击,多路复用用户连接等。这个负载均衡器拥有可以在第7层模式运行的能力,这样他可以检查用户请求的URI并根据这个信息进行路由选择决定。这个特性意味着,我们可以很容易地告诉负载均衡器哪些是“安全”页面,然后可以根据页面的名字和用户的位置决定是否要将请求发送到弗吉尼亚或者是加利福尼亚。

不过,这里还有一点问题。假设你访问editprofile.php来更改家乡信息。该页面没有被标记为安全所以他被引导到了加利福尼亚,并且进行了更改。然后你访问你的档案页面,同时由于这个页面是安全页面,所以被引导到了弗吉尼亚。然而因为前面提到的同步复制延迟,你可能不能立刻看到你刚刚做过的改动!这种体验会令用户感到非常混乱,同时会导致双重提交。我们通过在浏览器中设置一个包含(有过写入数据库操作的)当前时间cookie来绕开这个问题。负载均衡器会查看该cookie,如果它注意到20秒内你写入了些东西,将无条件地传送到加利福尼亚。过了20秒之后,我们确保数据已经同步到弗吉尼亚,这时便允许你回来访问安全页面。

回顾

从我们第一个用户在弗吉尼亚数据中心访问页面后的九个月中我们一直在运行同样架构获得了很好的效果。当然,一路上还有挫折;在头一两个月中,缓存一致性的框架非常地不稳定,逼我们在诊断和修复错误的时候每隔一段时间就要把流量从弗吉尼亚转移出去。当然,过了一段时间,我们消灭了这个问题,现在这个数据中心在Facebook的流量中占了很大的比重。

这个架构中主要的伸缩方面的挑战很明显:所有的写操作必须在同一个地方发生。更进一步我们对开发新的可以让我们在任何位置进行写操作的技术非常感兴趣。我们也在思考如何将新的数据中心做成一个灾难恢复点,以防怪兽要进攻加利福尼亚!想来帮帮我们吗?www.facebook.com/jobs!

ORDER BY RAND()

原文地址:http://jan.kneschke.de/projects/mysql/order-by-rand

翻译:ShiningRay

译者序
之前有位朋友提到从MySQL随机取1条记录其实只要SELECT * FROM table ORDER BY RAND() LIMIT 1即可。其实这个语句有很大的性能问题,对于大表的效率是非常低下的。我们可以看一下MySQL对其的解释:

EXPLAIN SELECT *
FROM money_logs
ORDER BY RAND( )
LIMIT 1
id select_type table type possible_keys key key_len ref rows Extra
1 SIMPLE table ALL NULL NULL NULL NULL 173784 Using temporary; Using filesort

这个SQL语句无法使用任何索引,还必须使用临时表和文件排序,在一个15万条记录的MyISAM表需要花大约0.3秒。已经是相当慢的了。如何优化,请往下看:

继续阅读“ORDER BY RAND()”

浅析Ruby on Rails部署方案

2006初,我接到了公司分配的一个遗留项目,让我负责一个基于C/S的系统的服务器端。其实是系统是基于HTTP协议的,因为负责客户端的同事对于服务器端编程不甚了解,虽然使用PHP对熟悉C++的他来说是驾轻就熟,但是在进一步实现更多的功能和更高的性能上就捉襟见肘了。项目是在非常突然的情况下交给我的,因为该同事在客户端上有更多的事情要做。我在分析了他的数据库结构和PHP源代码之后,决定按照与客户端的通讯协议重写他的服务器端。为了能应付老板苛刻的时间限制,我打算使用正在学习的Ruby on Rails。后来,项目在功能上非常顺利地交付了。

两年过去了,随着客户端数量的不断增加、客户端功能的增加、与服务器端交互数据的增加、老板对功能的要求不断增加,我在这个项目上走了不少弯路,尤其是在部署——或者说是架构——方面。

我遇到的最大的问题就在于并发链接数上。服务器与客户端的每次交互的数据量并不大,但内容无法缓存。起初用的是Nginx/Apache+Mongrel 的部署方式,但当遇到大量并发请求时,常常会遇到Mongrel进程死掉的情况。而客户端的用户在无法登录客户端的时候,经常会反复尝试,加重了服务器的负担、导致最后所有的Mongrel进程都挂掉。

最后,经过不懈努力,在现有的3台低端服务器上,可以满足每天500万次的请求。在这里,我将我的一些心得和研究成果总结出来,与大家分享。


文档地址:http://docs.google.com/Doc?id=ddcvzh74_28f9xppqfh

文章发布在Google Docs上,欢迎大家在其上做批注,有意见和建议也可以在此留言。文章按照知识共享“署名 3.0 中国大陆”许可协议发布,欢迎转载。

WordPress标签分隔符插件

WordPress 2.3加入了标签功能,我们可以在撰写文章的时候,在文章编辑框的下方输入一些以“,”分割的标签。这是一个半角的逗号,对于国内的用户来说,经常需要输入中文的标签,这时候中文输入法打出来的是全角的逗号“,”,就必须切换到半角模式或者关闭输入法,再输入一个半角的逗号,输入完了之后还要把输入法切换回来,十分麻烦。目前没有找到任何一种插件可以解决这个问题。当我研究了WordPress的代码之后,发现光写一个插件是不能达到我要的功能的,必须对现有的代码进行一些修改。这是由于WordPress代码本身有一个小小的不完善的地方。下面我给出我的解决方案,希望对大家有用:

找到WordPress安装目录下wp-includes/post.php,打开编辑,将第432行(WordPress 2.3.1版)由原有的:

改为

大家可以看到在这个数组的最后添加了一个’tags_input’, 这样修改的原因是:包含这一行的函数
sanitize_post 要对输入的帖子的数据数组进行过滤和转义,这个数组就是告诉 sanitize_post
帖子数据中有哪些键对应的值需要过滤和转义,正好漏掉了标签对应的键名“tags_input”。另外很有意思的是,我们还能在这一行的上面看到一个 TODO 注释:

// TODO: Use array keys instead of hard coded list

也就是说,WordPress的开发团队注意到了这个问题,将来会直接遍历整个数组(或对象)来进行过滤和转义而非使用这样一个列表。

接下来,将我写的以下代码保存为一个php文件,放到wp-content/plugins目录下:

或者直接下载这个文件:multiple_tag_separator.zip
解压缩后放入/wp-content/plugins目录下。

然后进入你的WordPress的后台,进入“Plugins”管理界面,找到“Multiple Tag Separator”并启用,便可使用这个功能了。

绕开万网防火墙

从今年7月开始,在被无奈的服务器网络问题郁闷了很久之后,终于让我找到真正的原因,确实在于防火墙的问题。我发现问题原因的过程如下:

公司有2台服务器在万网机房,而且在同一子网中,两台服务器上有同样的两套服务器端程序。我通过ab,对指定的URL发出10个并发总计1000个请求,两台服务器都在200个请求之后,就不能连接了。其中有一台是Windows服务器,我当时还开着远程桌面,ab刚打印出“Completed 200 requests”,远程桌面立刻变灰,随即右上角显示出断开图标,闪烁之后便告诉我链接被终止。另一台是Linux服务器,我开着Putty链接在上面,通过ab测试的时候,到200个请求之后,Putty便报警说链接断开。

至于万网要这样进行限制的原因还不得知,至少即便我打电话,他们不会去改正这个问题更不会承认他们有问题。那么如果我们有放在万网或者类似机房的服务器(比如叫A),又需要在另外的地方放一个反向代理服务器到这个服务器(比如叫B),那么怎么办才能解决这个问题?在这里我找到两种解决方案:

  1. 把A的服务器端移动到服务器B,让A做专门的数据库服务器,B通过长连接(或称持久链接,persistant connection)连接到A。当然如果服务器端应用使用了短链接也是没用的。现在的很多框架如Rails能很好地利用长连接。另外,Memcached的客户端链接一般也是长链接,
  2. 架设一个VPN,然后A,B都接入这个VPN中,并只使用VPN分配的地址进行交互,这个可以完全解决前面出现的问题。

两个方案都可以尝试,复杂程度相当。

就这个问题上,我得出的结论是,压力测试是必须的,如果以后公司再托管服务器,我会先利用工具从外部测试一下其是否满足要求。

2008-03-04更新:

经过长久的深思熟虑之后,我觉得如果服务器分布在不同机房,有必要使用数据加密来防止数据包被嗅探,VPN方案可以采用加密的方式,长链接的方式也可以配置使用ssl。MySQL的SSL配置请参考:http://dev.mysql.com/doc/refman/5.0/en/secure-connections.html

在Java2平台企业版中应用异步JavaScript技术和XML(AJAX)

作者Greg Murray, 2005年6月9日  翻译:ShiningRay@Nirvana Studio2005年9月9日
任何试过过Flickr、GMail、Google Suggest或者是Google Maps的人都会意识到一种新型的动态Web应用正在逐渐浮出水面。这些应用外观和表现都和传统的桌面应用程序很像,而他们不需要依赖于插件或者是特定于浏览器的功能。过去Web应用只是一系列HTML页面,他们任意一部份内容的更改都必须重新载入页面。像JavaScript编程语言和层叠样式表 (CSS)之类的技术已经成熟,可以有效地应用他们来创建高动态的Web应用,而且可以运行在所有的主流浏览器中。本文将会详细介绍你马上就可以使用的一些技术,让他们使你的Web应用像桌面应用更加丰富和更有交互性。

介绍异步JavaScript技术和XML(AJAX)


使用JavaScript技术,一个HTML页面可以异步地对服务器(一般是载入页面的服务器)发送请求并获取XML文档。然后JavaScript可以使用XML文档来更新或改动HTML页面的文档对象模型(DOM)。最近形成了一个术语AJAX(Asynchronous JavaScript Technology and XML)来描述这种交互模型。

AJAX其实不是很新的东西。这些技术对于Windows平台上专注于Internet Explorer的开发人员来说,已经存在好几年了。直到最近,这个技术才被作为Web远程技术或者远程脚本技术被大家了解。Web开发人员也有一段时间曾经使用过插件、Java applet和隐藏框架来模拟这种交互模型。最近发生的变化是,对XMLHttpRequest对象的支持已经成为所有平台上的主流浏览器都包括的特性了。JavaScript技术的XMLHttpRequest对象是。尽管在正式的JavaScript技术标准中并没有提到这种对象,然而今天主流的浏览器都对他提供了支持。而当代的浏览器如Firefox、Internet Explorer以及Safari在JavaScript技术和CSS的支持上有些细微的差别,但是这种差别是可以处理的。如果你要考虑支持较老的浏览器,AJAX也许就不能成为你的解决方法。

基于AJAX的客户端之所以独特的原因是客户端包含了用JavaScript嵌入的特定于页面的控制逻辑。应用JavaScript技术的页面基于事件进行交互,如文档载入、鼠标点击、焦点改变甚至是定时器。AJAX交互使得表现层逻辑更加清晰地与数据分离。一个HTML页面也可以根据需要每次读入适当的数据,而不是每次需要显示一个更改时都重新载入整个页面。AJAX要求一种不同的服务器架构来支持它这种交互模型。以前,服务器端Web应用关注于对每个导致服务器调用的客户端事件都生成HTML文档。然后客户端对每个回应都要重新读入并重新渲染完整的HTML页面。富Web应用(Rich Web Application)关注于,让一个客户端获取一个HTML文档让它表现为一个模板或者是一个容器,可以基于事件并使用从服务器端组件中获取的XML 数据来对文档注入内容。

一些AJAX交互的应用如:

  • 实时表单数据检验:像用户ID、序列号、邮政编码或者是特殊的票据代码这类需要服务器端验证的数据也可以在用户提交表单之前进行验证。
  • 自动补全:像电子邮件地址、姓名或城市名之类的表单数据都可以根据用户情况自动补全。
  • 处理细节操作:根据一个客户端事件,一个HTML页面可以根据现存的一些数据再去获取更多详细的信息,如现在有一个产品列表,客户端可以控制查看单独的产品信息而无需刷新页面。
  • 复杂的用户界面控件:像树型控件、菜单和进度条之类不要求页面刷新的控件也能实现。
  • 页面内刷新数据:HTML页面可以从服务器上查询最新的数据如分数、股指、天气还有其它的特定于应用的数据。
  • 服务器端通知:一个HTML页面可以通过对服务器进行定时查询来模拟一个服务器的事件通知推送,实现像通知客户端一个消息、刷新页面数据或将客户端重定向到另一个页面。

这个列表并未把所有的应用都列出来,但它已经显示了AJAX交互可以让Web应用比从前能做更多的事情。但尽管这些好处是值得关注的,这种方式也有一些缺点:

  • 复杂度:服务器端开发人员必需理解,HTML客户端页面中的表现层逻辑以及生成HTML客户端页面所需的XML内容的服务器端逻辑。HTML页面开发人员必须了解JavaScript技术。如果开发新的框架和发展已有的框架来支持这种交互模型,那么AJAX应用的创建就会越来越简单。
  • XMLHttpRequest对象的标准化:XMLHttpRequest对象还不是JavaScript技术标准的一部分,这就意味着根据客户端的不同,应用的行为也有所会不同。
  • JavaScript技术的实现:AJAX交互极大地依赖于JavaScript技术,而由于客户端的原因JavaScript还有一些细微的差别。见QuirksMode.org来了解更多关于浏览器之间区别的内容。
  • 调试:AJAX应用也难于调试,因为流程逻辑是同时嵌在客户端中和服务器上的。
  • 代码可见:客户端的JavaScript可以很容易通过“查看源代码”被人看见。一个没有良好设计的AJAX应用很可能被黑客攻击或被他人剽窃。

当开发人员在使用AJAX交互模型上获得更多的经验后,AJAX技术的框架和模式就会慢慢浮现出来。现在就关注于完全通用的AJAX交互框架,还为时过早。本文和相关的解决方案将关注于在现有的Java 2平台企业版(J2EE)上如何对AJAX进行支持,像servlet,JavaServer Page(JSP)软件、JavaServer Face应用和Java标准标签库(JSTL)。

AJAX交互剖析


现在我们已经讨论了AJAX是什么以及一些高层次的问题。那现在让我们把所有的零件放在一起来展示一个具有AJAX的J2EE应用。

首先考虑一个例子。一个Web应用包括了一个静态HTML页面,或者是一个由JSP生成的HTML页面,这个JSP中还包括了一个HTML表单,它需要服务器端逻辑来对表单中的数据进行检验,而不用刷新页面。一个名为ValidateServlet服务器端组件(servlet)用来提供这种验证逻辑。图一描述了这种具有验证逻辑的AJAX交互的细节。

AJAX Interaction
图1: 一个提供验证逻辑的AJAX交互

以下条目代表了图1中出来AJAX交互的过程:

  1. 发生一个客户端事件
  2. 创建和配置一个XMLHttpRequest对象。
  3. XMLHttpRequest对象进行一个调用。
  4. ValidateServlet对请求进行处理。
  5. ValidateServlet返回一个包含了结果的XML文档。
  6. XMLHttpRequest对象调用callback()函数并处理结果。
  7. 更新 HTML DOM。

现在让我们逐个研究这个AJAX模型的每一步。

1.发生一个客户端事件。

在一个事件发生时可以调用相应的JavaScript函数。在这里,validate()函数可以被映射到一个链接或者是表单组件的onkeyup事件上去。

<input type="text"
size="20"
id="userid"
name="id"
onkeyup="validate();">

每次用户在表单域中按下一个键时,表单元素将都调用validate()函数。 

2. 建立和配置一个XMLHttpRequest对象

创建和配置一个XMLHttpRequest对象

var req;

function validate() {
var idField = document.getElementById("idField");
var url = "validate?id=" + escape(idField.value);
if (window.XMLHttpRequest) {
req = new XMLHttpRequest();
} else if (window.ActiveXObject) {
req = new ActiveXObject("Microsoft.XMLHTTP");
}
req.open("GET", url, true);
req.onreadystatechange = callback;
req.send(null);
}

validate()函数建立了一个XMLHttpRequest对象并对象中的open函数。open函数需要两个参数:HTTP方法,可以是GETPOST; 和对象进行交互的服务器端组件的URL;一个布尔变量,表示是否要进行异步调用。API是XMLHttpRequest.open(String method, String URL, boolean asynchronous)。如果一个交互被设置为异步, (true) 那就必须指明一个回调函数。可以使用req.onreadystatechange = callback;来设置这个交互的回调函数。详细内容见第六节。

3.XMLHttpRequest对象进行调用

当收到了语句req.send(null);,就会进行一次调用。HTTPGET的情况下,内容可以是null或者留空。当调用XMLHttpRequest的这个函数时,也会对已经配置了的URL进行调用。在下面这个例子中,要发送的数据(id)将作为一个URL参数。

使用HTTPGET,两个重复的请求将返回同样的结果。当使用HTTPGET方法时,要注意URL的长度,包括已经转义的URL参数,可能会受到某些浏览器和服务器端的Web容器的限制。当发送的数据会影响到服务器端的应用程序的状态时,就应该使用HTTPPOST方法。使用HTTPPOST必须要对XMLHttpRequest对象设置一个Content-Type头,使用以下语句:

req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
req.send("id=" + escape(idTextField.value));

当从JavaScript中发送表单值得时候,你应该考虑对字段值进行编码。JavaScript中有一个函数escape(),应该用他来确保区域化的内容被正确编码,同时特殊字符也被正确转义。

4. ValidateServlet对请求进行处理.

一个映射到URI “validate” 的servlet将检验user ID是不是已经在数据库中存在了。

一个servlet处理一个XMLHttpRequest ,就像对待其它的HTTP请求一样。下面的例子显示了服务器从请求中抽取出id参数并检验是否被占用了。

public class ValidateServlet extends HttpServlet {

private ServletContext context;
private HashMap users = new HashMap();

public void init(ServletConfig config) throws ServletException {
this.context = config.getServletContext();
users.put("greg","account data");
users.put("duke","account data");
}

public void doGet(HttpServletRequest request, HttpServletResponse response)
throws IOException, ServletException {

String targetId = request.getParameter("id");

if ((targetId != null) && !users.containsKey(targetId.trim())) {
response.setContentType("text/xml");
response.setHeader("Cache-Control", "no-cache");
response.getWriter().write("<message>valid</message>");
} else {
response.setContentType("text/xml");
response.setHeader("Cache-Control", "no-cache");
response.getWriter().write("<message>invalid</message>");
}
}
}

在这个例子中,一个简单的HashMap用来存放存在的用户名。在这个例子中,我们假设用户的ID是duke

5.ValidateServlet返回一个包含结果的XML文档

用户ID “duke” 在users HashMap的用户ID列表中出现了。将在应答中写一个包含值为invalidmessage元素的XML文档。更复杂的用例将要求DOM、XSLT或其他API来生成这个应答。

response.setContentType("text/xml");
response.setHeader("Cache-Control", "no-cache");
response.getWriter().write("<message>invalid</message>");

开发人员必须注意两个事情。第一,Content-Type必须设为text/xml。第二,Cache-Control必须设为no-cacheXMLHttpRequest对象只会处理Content-Typetext/xml的应答,同时把将Cache-Control设为no-cache将确保浏览器不会从缓存相同的URL(包括参数)返回的应答。

6.XMLHttpRequest对象调用callback()函数并处理结果。

XMLHttpRequest对象已经配置为当有readyState改变的时候就调用callback()函数。让我们假设已经ValidateServlet调用了而且ValidateServlet4,表示XMLHttpRequest的调用已经完成。HTTP状态代码200表示一个成功的HTTP交互。

function callback() {
if (req.readyState == 4) {
if (req.status == 200) {
// update the HTML DOM based on whether or not message is valid
}
}
}

浏览器维护了一个所显示的文档的对象形式(也就是所谓的Docuemt Object Model或DOM)。HTML页面中的JavaScript可以访问DOM,同时在页面载入完之后,可以使用API来修改DOM。

根据成功的请求,JavaScript代码可以修改HTML页面的DOM。从ValidateServlet获得的对象形式的XML文档可以通过req.responseXML在JavaScript中获得,req是一个XMLHttpRequest对象。DOM API给JavaScript提供了获取这个文档中的内容以及修改HTML页面的DOM的方法。所返回的字符串形式的XML文档可以通过req.responseText获得。现在我们看看如何在JavaScript中使用DOM API,先看以下从ValidateServlet返回的XML文档。

<message>
valid
</message>

这个例子是一个简单的只包含了一个message元素的XML片断,里面只有一个简单的字符串validinvalid。一个更高级的例子可以包含多于一个的消息和可以给用户看的有效的名字:

function parseMessage() {
var message = req.responseXML.getElementsByTagName("message")[0];
setMessage(message.childNodes[0].nodeValue);
}

parseMessages()函数将处理一个从ValidateServlet获取的XML文档。这个函数会调用setMessage()with the,并给出message作为参数来更新HTML DOM。

7.更新HTML DOM

JavaScript技术可以使用很多API从HTML DOM中获得任何元素对象的引用。推荐的获得元素引用的方法是调用document.getElementById("userIdMessage")"userIdMessage"是HTML文档中出现的一个元素的ID属性。有了这个元素的引用,就可以使用JavaScript来修改元素的属性、修改元素的样式、添加、删除或修改子元素。

一个常见的改变元素主体内容的方法是设置元素的innerHTML属性,如下所示:

<script type="text/javascript">
function setMessage(message) {
var userMessageElement = document.getElementById("userIdMessage");
userMessageElement.innerHTML = "<font color=\"red\">" + message + " </font>";
}
</script>
<body>
<div id="userIdMessage"></div>
</body>

受到影响的那部分HTML页面会立刻根据innerHTML的设置重新渲染。如果innerHTML属性包含类似<image>或者是<iframe>之类的元素,那么由那些元素所指定的内容同样会被获取并渲染。

这种途径的主要缺点是HTML元素是作为字符串硬编码在JavaScript中的。JavaScript中硬编码的HTML标记不是一种好的实践,因为它使代码难于阅读、维护和修改。我们应该考虑在JavaScript中使用DOM API来创建和修改HTML元素。把显示和JavaScript代码的字符串混在一起只会让页面更难于阅读和编辑。

另一种修改HTML DOM的方法是动态地产生新的元素并把他们作为子元素追加到目标元素,如下面的例子所示:

<script type="text/javascript">
function setMessage(message) {
var userMessageElement = document.getElementById("userIdMessage");
var userIdMessageFont = document.getElementById("userIdMessageFont");
var messageElement = document.createTextNode(message);

if (userMessageElement.childNodes[0]) {
// 更新元素
userIdMessageFont.replaceChild(messageElement, userIdMessageFont.childNodes[0]);
} else {
// 建立一个新的元素
var fontElement = document.createTextNode("font");
fontElement.setAtribute("id", "userIdMessageFont");
fontElement.setAtribute("color", "red");
userMessageElement.appendChild(fontElement);
fontElement.appendChild(messageElement);
}
}
</script>
<body>
<div id="userIdMessage"></div>
</body>

这个范例展示了JavaScript技术的DOM API可以用来更有目的地建立或改变一个元素。当然JavaScript的DOM AP在不同的浏览器上也可能有差别,所以你必须在开发应用程序时小心。

Java BluePrint的解决方案目录

TheJava Blueprints Solutions Catalog是用来收集J2EE技术上AJAX的最佳实践的。每个解决方案包含一个问题和方法的描述、一个设计文档和可运行的源码。这些解决方案是为了让你根据需要在自己的应用程序中复用。以下是已经提供的AJAX交互:

自动补全

自动补全提供了当用户在一个HTML表单中输入一个请求时对数据浏览的简化方式。当用户面对一大片数据时,可以在输入数据时把可能的完整形式显示给用户。然后选择其中一个完整形式可以保证用户输入的数据已经存在在服务器上。

考虑一个大公司的一个名字查找的Web应用。如图2所示,只要输入姓或名的开头几个字母就可以得到人的列表。用户可以然后就只要点击一下就可以浏览用户的详细信息。

Autocompletion of a Name
图2:名字自动补全

进度条

在Web应用中,一个服务器端任务也可能要花一段时间去完成。这段时间很可能会超过HTTP交互的时间上限(超时)。当用户不知道这个任务什么时候才能完成时,用户很可能会重新提交一次表单或直接退出会话状态。一般来说,Web应用使用页面刷新来跟踪服务器端操作的状态,这种方式可能会让人厌烦而且也不准确。AJAX可以用来仅在一个HTML页面中跟踪服务器端操作的状态而无需刷新页面。用户可以以图形方式看到服务器端操作的进度,如图3。

Progress Bar
图3:进度条 

刷新数据

向一个HTML页面提供最新的数据或服务器消息提醒在现在的Web世界中也是十分重要的,因为现在的Web世界中数据一直不停变化。尽管它不是一个实实在在的推送技术,但它可以通过使用AJAX交互不断进行查询来模拟。当数据需要更新或者要进行提醒,HTML页面将会动态地改变。图4显示了HTML页面中的一个服务器端计数器。这个计数器会在页面后台自动更新。

Server-side Counter Shows Refreshing Data
图4:服务器端计数器在刷新数据

实时检验

不是所有的表单域都可以单独用JavaScript技术在客户端完成。某些表单数据要求服务器端的验证逻辑。传统和Web应用曾使用页面刷新来完成这种验证,但这可能有些让人烦。

考虑一个需要一个唯一用户ID的Web应用。使用AJAX交互,用户可以在输入的时候就知道ID是否有效(图5)。

Invalidating the ID as User Types
图5:指出用户ID无效

当一个用户输入了一个无效的用户ID,应用程序禁止了提交按钮并且向用户显示了一个信息(图6)。

Validating the ID as User Types
图6:用户ID通过验证

用户马上就能知道用户ID是可用的也是有效的。

最后的思考

我们已经看到AJAX交互可以解决很多问题。配合HTTP处理、数据库、Web服务、XML处理和业务对象等API,J2EE技术已经提供了一个开发和部属基于AJAX应用的一个良好的基础。有了对于这个交互模型的更好的理解,今天的应用程序可以变得更加有交互性,给最终用户更好的体验。

使用AJAX要求你使用支持XMLHttpRequest对象的最新浏览器版本。使用AJAX还要求大量对JavaScript技术和CSS的应用。作为一个应用程序架构师或是一个开发人员,你要会针对浏览器支持、架构复杂度和对开发人员的培训等方面来衡量开发一个富应用的需要。当AJAX编程模型不断地发展,现有的技术和框架会让这种转变更加容易。

很明显的是,突出的Web应用都越来越有交互性了。那么你的呢?

更多信息

关于作者


Greg Murray 是is a Sun Microsystems 的一名工程师,是servlet标准的领导人,BluePrint小组的前成员,在这个小组时他已经开始关注Web层次问题。他也是《Enterprise Applications With the Java 2 Platform,Enterprise EditionDesigning Web Services With the J2EE 1.4 Platform(Addison-Wesley)》一书的协助编撰者。