JSON & Mashups

Dan Morrill,Google 开发者关系团队

更新于 2009 年 1 月

简介

如果你被困在自己的服务器上,那么 Web 应用程序有什么乐趣呢?与新代码会面要有趣得多,这就是 Web Mashups 的用武之地。如果你有合适的工具,Mashups 让你可以惊人地快速构建强大的应用程序。最近我一直在使用 GWT 进行一个 Mashup 项目。我的项目目标之一是让使用 GWT 编写的应用程序与以 JavaScript 对象表示法 (JSON) 格式公开数据的其他 Web 应用程序集成。

听起来很简单,对吧?好吧,确实是 - 几乎 - 请继续阅读,了解其中的原因!本文是关于如何在 GWT 应用程序中合并 Mashup 式 JSON 数据的案例研究。如果你是一个 GWT 用户,这对你来说是相关的,但即使是一般的 Ajax 开发人员也可能会觉得它很有趣。

关于安全性的说明

本文讨论了 JSON。JSON 很酷,但对于不小心的人来说也可能很危险。由于 JSON 是 JavaScript 的子集,因此它是可执行代码;这使得它容易受到各种攻击。如果你使用 JSON,务必了解这些安全风险并应用对策。

这个话题太大,我们无法在这里讨论。幸运的是,我们已经在其他地方讨论过:查看这篇关于 GWT 应用程序的安全 的文章。我敦促你阅读该文档并熟悉安全风险,然后再将本文中讨论的任何内容付诸实践。

JSON 基础知识

JSON 到底是什么?好吧,巧合的是,JavaScript 用于定义数据对象的语法与其他语言有很高的兼容性。这使得它成为指定数据的最低公分母语法。其他人比我做得更好,所以我直接把你送到源代码:json.org.

JSON 的主要优势之一是,由于它本质上是 JavaScript 语法,因此浏览器可以通过简单地调用 JavaScript eval 函数来“解析”JSON 数据。这既简单又快,因为它利用了浏览器中的本机代码进行解析。(这也是 JSON 会造成安全问题的原因;如果 JSON 字符串实际上包含非 JSON 代码,那么对它调用 eval 就非常危险。)

一旦你拥有可以生成 JSON 数据的服务,通常有三种不同的使用方式。这些方法可以根据你获取数据的方式进行分类。

  • 服务器可以输出包含原始 JSON 数据的字符串,浏览器使用 XMLHTTPRequest 获取该字符串,并手动将其传递给 eval 函数。

          Example server-generated string: `{'data': ['foo', 'bar', 'baz']}`
    
  • 服务器可以输出包含将 JSON 对象分配给变量的 JavaScript 代码的字符串;浏览器会使用 <script> 标签获取此代码,然后通过按名称引用变量来提取已解析的对象。

          Example server-generated string: `var result = {'data': ['foo', 'bar', 'baz']};`
    
  • 服务器可以输出包含将 JSON 对象传递给请求 URL 中指定的函数的 JavaScript 代码的字符串;浏览器会使用 <script> 标签获取此代码,该标签会在 JavaScript 解析后自动调用该函数,就好像它是事件回调一样。

          Example server-generated string: `handle_result({'data': ['foo', 'bar', 'baz']});`
    

术语 JSON 实际上只指数据表示语法(这就是其名称中的“对象表示法”部分的由来),因此 JSON 是 JavaScript 的严格子集。因此,最后两种方法从技术上讲不是 JSON - 它们是处理 JSON 格式数据的 JavaScript 代码。不过,它们仍然是 JSON 的近亲,并且“JSON”经常被用作所有此类情况的统称。特别是第三种方法通常被称为“带填充的 JSON”(JSONP);我所知道的这种技术的最初描述在这里:远程 JSON - JSONP.

这些技术之间的主要区别在于它们是如何获取的。由于第一种情况 - 也就是纯粹的 JSON - 不包含任何可执行组件,因此它通常只对 XMLHTTPRequests 有用。由于该函数受同源策略限制,这意味着纯粹的 JSON 只能用作浏览器应用程序与其 HTTP 服务器之间的数据传输技术。

然而,后两种技术使用动态 <script> 标签插入来获取字符串。由于这种技术不受同源策略限制,因此它可以在跨域使用。结合以 JavaScript 语法公开数据的服务,这允许浏览器从多个不同的服务器发出数据请求。这就是使 Mashups 成为可能的技术。(更准确地说 - 完全在浏览器内部运行的 Mashups。如果你不想使用 JSONP 并且不介意维护自己的服务器,也可以使用服务器端代理创建 Mashups。)

高级设计

现在我们已经掌握了 JSON 基础知识,我的项目怎么样?我正在进行的任务涉及将来自另一个服务的 - 特别是 Google Base - 的数据合并在一起。这意味着我需要使用 Google Data API 来获取信息。Google 的 GData 服务器提供 XML、JSON 和 JSONP 样式的接口,以允许开发人员在构建应用程序时获得最大的灵活性。在我的项目中,我想构建一个浏览器内 Mashup,这意味着我需要使用 Google Data API 的 JSONP 样式接口.

由于 GWT 应用程序是用 Java 编写的,因此有一个编译阶段,它将 Java 源代码编译为 JavaScript。编译器还优化生成的代码,它执行的优化之一是代码混淆,这使得输出更小,因此加载速度更快。但是,这样做的缺点是,输出 JavaScript 代码中的函数名称是不可预测的。这使得难以将回调函数名称指定给 JSONP 服务。

对于此类情况,有一种有效的技术,我们有时将其称为“函数桥”。我们在 文档中 阐述了它,但简而言之,这种技术涉及创建混淆函数的句柄,并将该句柄复制到 JavaScript 命名空间中的一个众所周知的变量名。当外部 JavaScript 代码(例如 Google Data 服务器响应)在其众所周知的名称下调用该函数时,它实际上会通过复制的句柄调用实际函数。(查看本文段中较早的链接以查看基本示例。)

但是,我的项目进行了很多此类 Google Data 请求。这可能导致相当多的函数句柄堆积,因此需要管理一些簿记。

在考虑所有这些因素之后,我选择了以下粗略设计

  • 每个 Google Data JSON 数据请求都被分配一个唯一的令牌。
  • 使用 GWT 的 JavaScript 本机接口 (JSNI) 按需创建每个请求的新回调函数;请求的令牌包含在函数名称中,以确保其唯一性。
  • 回调实际上是令牌的 JavaScript 函数闭包;当回调被调用时,令牌将被传递给内部函数。
  • 每个回调都使用相同的内部函数;此函数实际上是我 GWT 类上的一个方法,它使用令牌将响应数据分派到相应的 GWT 代码。

这种策略具有以下“活动部分”

  • 我 GWT 类上用于处理来自 Google Data 服务器的传入响应的单个分派方法
  • 我 GWT 类上的第二个方法,它使用 JSNI 来构造函数闭包并启动 Google Data 请求

最终,还应该有一个清理阶段,该阶段将删除回调句柄,以避免弄乱 JavaScript 命名空间,但这很容易在以后添加。为了开始,我继续进行上述其他设计元素。

希望你已经跟上,但如果没有 - 别担心,我在下面包含了源代码!

第一次实现

我做的第一件事是编写了一些代码来演示基本概念。我确实对最终 API 的外观有所了解,但此时的目标是验证概念,而不是设计最终 API。因此,我首先实现了一个包含我期望最终 API 拥有的所有关键部分的单一类。首先,我认为,我会证明它有效,然后我会将其重构为一个真正的 API。

具体来说,以下是我所拥有的关键要求

  • 必须将动态 `<script>` 插入抽象到方法调用之后
  • 必须处理 `<script>` 标签的簿记(以便能够稍后清理它们并防止内存泄漏)
  • 必须提供一种方法来生成和保留回调函数名称

此时,我需要一个 Google 数据提要进行测试。我决定获取 Google Base 的“片段”URL,它是一个 JSON 模式下的 GData 提要。此提要的基准 URL 为 `http://www.google.com/base/feeds/snippets`。要请求 JSON 数据作为输出,您需要向 URL 添加一些 GET 参数:`?alt=json-in-script&callback=foo`。最后一个值 - foo - 是回调函数的名称(即 JSONP 钩子)。Google 数据提要的输出将使用对该函数的调用来包装 JavaScript 对象。

如果您想查看 Google 数据输出的完整示例,请查看此 URL:`http://www.google.com/base/feeds/snippets`。您将很快发现,即使对于单个结果,也有大量数据。为了帮助您可视化提要的总体结构,这里是一个更小的自定义构建样本结果,它只包含与这个故事相关的数据

{
  'feed': {
    'entry': [
      {'title': {'type': 'text', '$t': 'Some Text'}},
      {'title': {'type': 'text', '$t': 'Some More Text'}}
    ]
  }
}

正如您所看到的,核心结构相当简单;真实 Google 数据提要的大部分长度来自各种数据字段。

为了使我的开发简单,我使用那个简化的示例进行测试,这样我就不会被完整的 Google 数据提要淹没。当然,这意味着我需要一个 Web 服务器来提供我自定义版本的 JSON 数据。通常,我会从 GWT 的托管模式中包含的内置 Tomcat 实例提供它。但是,这意味着我的 JSON 数据和我的 GWT 应用程序将从同一个站点提供服务。由于我的最终目标是从另一个站点加载真实的 JSON 数据,我需要一个第二个独立的本地服务器来获取我的 JSON 数据 - 否则,它将不是一个准确的模拟。由于设置一个完整的 Web 服务器实例将需要大量工作,我使用这个 Python 程序创建了一个小型自定义服务器

import BaseHTTPServer, SimpleHTTPServer, cgi
class MyHandler(SimpleHTTPServer.SimpleHTTPRequestHandler):
  def do_GET(self):
    form = self.path.find('?') > -1 and dict([x.split('=') for x in self.path.split('?')[1].split('&')]) or {'callback': 'foo'}
    fun_name = form.get('callback', 'foo')
    body = '%s(%s);' % (fun_name, file('json.js').read())
    self.send_response(200)
    self.send_header('Content-Type', 'text/plain')
    self.send_header('Content-Length', len(body))
    self.end_headers()
    self.wfile.write(body)
bhs = BaseHTTPServer.HTTPServer(('', 8000), MyHandler)
bhs.serve_forever()

此程序为每个请求返回 json.js 文件的内容,并将其包装在 `callback` 查询参数中指定的函数名称中。它很愚蠢,但它不需要很聪明。

在服务器受控的情况下,以下是我的浏览器应用程序的 GWT 代码

public class Hax0r implements EntryPoint {
  protected HashMap scriptTags = new HashMap();
  protected HashMap callbacks = new HashMap();
  protected int curIndex = 0;

  public native static void setup(Hax0r h, String callback) /*-{
    $wnd[callback] = function(someData) {
      [email protected]::handle(Lcom/google/gwt/core/client/JavaScriptObject;)(someData);
    }
  }-*/;

  public String reserveCallback() {
    while (true) {
      if (!callbacks.containsKey(new Integer(curIndex))) {
        callbacks.put(new Integer(curIndex), null);
        return "__gwt_callback" + curIndex++;
      }
    }
  }

  public void addScript(String uniqueId, String url) {
    Element e = DOM.createElement("script");
    DOM.setAttribute(e, "language", "JavaScript");
    DOM.setAttribute(e, "src", url);
    scriptTags.put(uniqueId, e);
    DOM.appendChild(RootPanel.get().getElement(), e);
  }

  public void onModuleLoad() {
    String gdata = "http://www.google.com/base/feeds/snippets?alt=json-in-script&callback=";
    String callbackName = reserveCallback();
    setup(this, callbackName);
    addScript(callbackName, gdata + callbackName);
  }

  public void handle(JavaScriptObject jso) {
    JSONObject json = new JSONObject(jso);
    JSONArray ary = json.get("feed").isObject().get("entry").isArray();
    for (int i = 0; i < ary.size(); ++i) {
      RootPanel.get().add(new Label(ary.get(i).isObject().get("title").isObject().get("$t").toString()));
    }
  }
}

这段代码可能需要一些解释。以下是一些突出显示代码如何实现我之前概述的高级设计的注释

  • setup() 方法是一个使用 GWT 的 JavaScript 本机接口 的本机方法。JSNI 允许需要“低级”访问 JavaScript 的开发人员获取它。该方法只是将 `handle()` 方法分配给浏览器窗口上的一个众所周知的名称。
  • setup() 将回调创建为一个函数闭包,因为 JavaScript 会垃圾回收普通的旧函数指针。也就是说,如果我们只是将 `handle()` 方法直接分配给 `$wnd[callback]`,它将立即被垃圾回收。为了防止这种情况,我们创建了一个新的内联匿名函数。
  • 由于 GWT 在子 iframe 中加载应用程序的实际代码,因此全局变量 `$wnd` 设置为指向应用程序实际所在的 `window` 句柄。也就是说,它被设置为父框架的 `window` 句柄,而不是子 iframe。
  • addScript() 方法处理动态将 `<script>` 标签插入页面所需的所有 DOM 篡改。它还通过唯一的 ID 跟踪生成的 DOM 元素句柄,以便以后可以清理它们(尽管这个概念验证代码实际上并没有执行任何清理)。
  • handle() 方法是服务器的 JSON 响应调用的实际函数。它包含一个循环,该循环只打印出 JSON 请求获取的所有结果的标题。请注意,此方法使用现有的 GWT JSON 解析和操作库。由于没有错误检查,因此特定调用序列非常脆弱,但目标只是获取一些数据来证明该技术有效。
  • 最后,onModuleLoad() 方法 - 它是 GWT 应用程序的主要入口点 - 只需调用各种其他方法来练习移动部件。

还有一件事

顺便说一句,不要尝试运行上面的代码;它不起作用。它可能看起来对你来说是正确的 - 至少对我来说,起初看起来是正确的 - 但它里面有一个错误。问题在于 GWT 的 JSON 库对数据进行了各种检查,包括一些“instanceof”测试,以确定数据的某些部分是对象还是数组。事实证明,所有这些“instanceof”测试都不适用于上面的代码,导致上面的应用程序失败。

我花了好长时间调试这个问题,直到最后我问了 GWT 工程师 Scott Blum。Scott 只问了一个问题:“这个数组是在你测试它的同一个窗口中创建的吗?”

Scott 知道而我不知道的是,与基本类型相对应的 JavaScript 类(如 Array)是随着窗口对象一起构建的。因为 JavaScript 是一种原型导向的语言,“类”实际上只是具有特殊名称的对象实例。这两个问题结合起来揭示了一个微妙但重要的问题:来自两个不同窗口的 Array 对象不是同一个对象!JavaScript 中的表达式“x instanceof y”归结为伪代码中的类似内容:“如果“x”的“prototype”属性与“y”相同,则返回 true,否则返回 false。”

此时,您可能想知道多个窗口是如何进入讨论的。关键在于 GWT 应用程序代码加载在一个隐藏的 iframe 中,因此对像 `window` 这样的对象的引用是指向该 iframe 窗口而不是它的父窗口。要引用浏览器父窗口,GWT 定义了 `$wnd` 变量。GWT 中的 DOM 对象也指向父窗口的文档对象;毕竟,您的应用程序代码对操作浏览器窗口感兴趣,而不是 GWT 的隐藏 iframe。因此,在上面的代码中,`<script>` 标签被添加到父窗口,而使用它的代码驻留在不同的 iframe 中。这意味着该对象是在与进行“instanceof”检查的窗口不同的窗口中创建的,从而导致了上述问题。

有几种方法可以修复代码:最终,我只需要确保 `<script>` 标签和 JSONP 回调被添加到 GWT 应用程序代码所在的同一个 iframe 中。以下是我修复它的方法

public native static void setup(Hax0r h, String callback) /*-{
    window[callback] = function(someData) {
      [email protected]::handle(Lcom/google/gwt/core/client/JavaScriptObject;)(someData);
    }
  }-*/;

public native void addScript(String uniqueId, String url) /*-{
  var elem = document.createElement("script");
  elem.setAttribute("language", "JavaScript");
  elem.setAttribute("src", url);
  document.getElementsByTagName("body")[0].appendChild(elem);
}-*/;

新版本是以前版本的 JSNI 重写,编码为使用当前的 `document` 对象而不是父窗口的 `document`。我还必须将 `setup()` 方法中对 `$wnd` 的引用更改为 `window`。这确保了所有相关部分都存在于同一个上下文中,特别是子 iframe。通过这些调整,新代码可以正常工作。

结论

通过我刚刚描述的更改,我的概念验证代码可以完美运行。您可以随意使用这段代码并尝试一下 - 它确实有效!

在这个项目中,我学到了两件事,我希望我已经传递给了你。首先,当然,是如何使用 GWT 构建 mashup 的基本技术。很容易看出,如何利用我在上面实现的技术,并使用它来构建一个从两个(或更多!)不同站点获取 JSONP 数据的应用程序。一旦你能够做到这一点,你就可以做一些非常有趣的事情。Mashup 现在很受欢迎,我希望我已经给了你尝试构建自己的 know-how 和兴奋。

我从这个项目中学到的第二件事是,JavaScript 非常挑剔。我并不是想说它不是一个很棒的语言;我只是想指出,对于开发者来说有很多陷阱,而我直接走进了其中一个陷阱。不幸的是,很多时候,这些陷阱会阻碍你的工作。虽然你可能会争辩说 GWT 本身为我遇到的具体问题奠定了基础,但对于纯 JavaScript 程序员在处理多个框架时遇到类似情况,这并不难想象。*Caveat hax0r!*

我希望您发现这篇文章有用;但更重要的是,我希望您 喜欢使用 GWT!快乐编码!