代理模式

在代理模式中,一个对象充当了另一个对象的接口的角色。它和外观模式不一样,外观模式带来的方便仅限于将几个方法调用联合起来。而代理对象位于某个对象和它的使用者之间,可以保护对对象的访问。

这个模式看起来开销有点大,但在出于性能考虑时非常有用。代理对象可以作为目标对象的保护者,让目标对象做尽量少的工作。

一种示例用法是“懒初始化”(延迟初始化)。假设负责初始化的对象是开销很大的,并且正好使用者将它初始化后并不真正使用它。在这种情况下,代理对象可以作为目标对象的接口起到帮助作用。代理对象接收到初始化请求,但在目标对象真正被使用之前都不会将请求传递过去。

图7-2展示了这个场景,当使用目标对象的代码发出初始化请求时,代理对象回复一切就绪,但并没有将请求传递过去,只有在真正需要目标对象做些工作的时候才将两个请求一起传递过去。

图7-2 通过代理对象时目标对象与使用者的关系

图7-2 通过代理对象时目标对象与使用者的关系

一个例子

在目标对象做某件工作开销很大时,代理模式很有用处。在web应用中,开销最大的操作之一就是网络请求,此时尽可能地合并HTTP请求是有意义的。我们来看一个这种场景下应用代理模式的实例。

一个视频列表(expando)

我们假设有一个用来播放选中视频的应用。你可以在这里看到真实的例子http://www.jspatterns.com/book/7/proxy.html

页面上有一个视频标题的列表,当用户点击视频标题的时候,标题下方的区域会展开并显示视频的更多信息,同时也使得视频可被播放。视频的详细信息和用来播放的URL并不是页面的一部分,它们需要通过网络请求来获取。服务端可以接受多个视频ID,这样我们就可以在合适的时候通过一次请求多个视频信息来减少HTTP请求以加快应用的速度。

我们的应用允许一次展开好几个(或全部)视频,所以这是一个合并网络请求的绝好机会。

图7-3 真实的视频列表

图7-3 真实的视频列表

没有代理对象的情况

这个应用中最主要的角色是两个对象:

  • videos

    负责对信息区域展开/收起(videos.getInfo()方法)和播放视频的响应(videos.getPlayer()方法)

  • http

    负责通过http.makeRequest()方法与服务端通讯

当没有代理对象的时候,videos.getInfo()会为每个视频调用一次http.makeRequest()方法。当我们添加代理对象proxy后,它将位于vidoeshttp中间,接手对makeRequest()的调用,并在可能的时候合并请求。

我们首先看一下没有代理对象的代码,然后添加代理对象来提升应用的响应速度。

HTML

HTML代码仅仅是一个链接列表:

  1. <p><span id="toggle-all">Toggle Checked</span></p>
  2. <ol id="vids">
  3. <li><input type="checkbox" checked><a
  4. href="http://new.music.yahoo.com/videos/--2158073">Gravedigger</a></li>
  5. <li><input type="checkbox" checked><a
  6. href="http://new.music.yahoo.com/videos/--4472739">Save Me</a></li>
  7. <li><input type="checkbox" checked><a
  8. href="http://new.music.yahoo.com/videos/--45286339">Crush</a></li>
  9. <li><input type="checkbox" checked><a
  10. href="http://new.music.yahoo.com/videos/--2144530">Don't Drink The Water</a></li>
  11. <li><input type="checkbox" checked><a
  12. href="http://new.music.yahoo.com/videos/--217241800">Funny the Way It Is</a></li>
  13. <li><input type="checkbox" checked><a
  14. href="http://new.music.yahoo.com/videos/--2144532">What Would You Say</a></li>
  15. </ol>

事件处理

现在我们来看一下事件处理的逻辑。首先我们定义一个方便的快捷函数$

  1. var $ = function (id) {
  2. return document.getElementById(id);
  3. };

使用事件代理(第八章有更多关于这个模式的内容),我们将所有id="vids"的条目上的点击事件统一放到一个函数中处理:

  1. $('vids').onclick = function (e) {
  2. var src, id;
  3. e = e || window.event;
  4. src = e.target || e.srcElement;
  5. if (src.nodeName !== "A") {
  6. return;
  7. }
  8. if (typeof e.preventDefault === "function") {
  9. e.preventDefault();
  10. }
  11. e.returnValue = false;
  12. id = src.href.split('--')[1];
  13. if (src.className === "play") {
  14. src.parentNode.innerHTML = videos.getPlayer(id);
  15. return;
  16. }
  17. src.parentNode.id = "v" + id;
  18. videos.getInfo(id);
  19. };

videos对象

videos对象有三个方法:

  • getPlayer()

    返回播放视频需要的HTML代码(跟我们讨论的无关)

  • updateList()

    网络请求的回调函数,接受从服务器返回的数据,然后生成用于视频详细信息的HTML代码。这一部分也没有什么需要关注的事情。

  • getInfo()

    这个方法切换视频信息的可视状态,同时也调用http对象的方法,并传递updaetList()作为回调函数。

下面是这个对象的代码片段:

  1. var videos = {
  2. getPlayer: function (id) {...},
  3. updateList: function (data) {...},
  4. getInfo: function (id) {
  5. var info = $('info' + id);
  6. if (!info) {
  7. http.makeRequest([id], "videos.updateList");
  8. return;
  9. }
  10. if (info.style.display === "none") {
  11. info.style.display = '';
  12. } else {
  13. info.style.display = 'none';
  14. }
  15. }
  16. };

http对象

http对象只有一个方法,它向Yahoo!的YQL服务发起一个JSONP请求:

  1. var http = {
  2. makeRequest: function (ids, callback) {
  3. var url = 'http://query.yahooapis.com/v1/public/yql?q=',
  4. sql = 'select * from music.video.id where ids IN ("%ID%")',
  5. format = "format=json",
  6. handler = "callback=" + callback,
  7. script = document.createElement('script');
  8. sql = sql.replace('%ID%', ids.join('","'));
  9. sql = encodeURIComponent(sql);
  10. url += sql + '&' + format + '&' + handler;
  11. script.src = url;
  12. document.body.appendChild(script);
  13. }
  14. };

YQL(Yahoo! Query Language)是一种web service,它提供了使用类似SQL的语法来调用很多其它web service的能力,使得使用者不需要学习每个service的API。

当所有的六个视频都被选中后,将会向服务端发起六个独立的像这样的YQL请求:

  1. select * from music.video.id where ids IN ("2158073")

代理对象

前面的代码工作得很好,但我们可以让它工作得更好。proxy对象就在这样的场景中出现,并接管了httpvideos对象之间的通讯。它将使用一个简单的逻辑来尝试合并请求:50ms的延迟。videos对象并不直接调用后台接口,而是调用proxy对象的方法。proxy对象在转发这个请求前将会等待一段时间,如果在等待的50ms内有另一个来自videos的调用,则它们将被合并为同一个请求。50ms的延迟对用户来说几乎是无感知的,但是却可以用来合并请求以提升点击“toggle”时的体验,一次展开多个视频。它也可以显著降低服务器的负载,因为web服务器只需要处理更少量的请求。

合并后查询两个视频信息的YQL大概是这样:

  1. select * from music.video.id where ids IN ("2158073", "123456")

在修改后的代码中,唯一的变化是videos.getInfo()现在调用的是proxy.makeRequest()而不是http.makeRequest(),像这样:

  1. proxy.makeRequest(id, videos.updateList, videos);

proxy对象创建了一个队列来收集50ms之内接受到的视频ID,然后将这个队列传递给http对象,并提供回调函数,因为videos.updateList()只能处理一个接收到的视频信息。

下面是proxy对象的代码:

  1. var proxy = {
  2. ids: [],
  3. delay: 50,
  4. timeout: null,
  5. callback: null,
  6. context: null,
  7. makeRequest: function (id, callback, context) {
  8. // 添加到队列
  9. this.ids.push(id);
  10. this.callback = callback;
  11. this.context = context;
  12. // 设置延时
  13. if (!this.timeout) {
  14. this.timeout = setTimeout(function () {
  15. proxy.flush();
  16. }, this.delay);
  17. }
  18. },
  19. flush: function () {
  20. http.makeRequest(this.ids, "proxy.handler");
  21. // 清除延时和队列
  22. this.timeout = null;
  23. this.ids = [];
  24. },
  25. handler: function (data) {
  26. var i, max;
  27. // 单个视频
  28. if (parseInt(data.query.count, 10) === 1) {
  29. proxy.callback.call(proxy.context, data.query.results.Video);
  30. return;
  31. }
  32. // 多个视频
  33. for (i = 0, max = data.query.results.Video.length; i < max; i += 1) {
  34. proxy.callback.call(proxy.context, data.query.results.Video[i]);
  35. }
  36. }
  37. };

使用代理模式可以在只改动一处原来代码的情况下,将多个web service请求合并为一个。

图7-4和7-5展示了使用代理模式将与服务器三次数据交互(不用代理模式时)变为一次交互的过程。

图7-4 与服务器三次数据交互

图7-4 与服务器三次数据交互

图7-5 通过一个代理对象合并请求,减少与服务器数据交互

图7-5 通过一个代理对象合并请求,减少与服务器数据交互

使用代理对象做缓存

在这个例子中,目标对象的使用者(videos)已经可以做到不对同一个对象重复发出请求,但现实情况中并不总是这样。其实这个代理对象还可以通过缓存之前的请求结果到cache属性中来进一步保护http对象(图7-6)。然后当videos对象需要对同一个ID的视频请求第二次时,proxy对象可以直接从缓存中取出,从而避免一次网络交互。

图7-6 代理缓存

图7-6 代理缓存