使用 Bigtable、Blobstore 和 Google Storage 实现 GAE 存储

学习实现 GAE 的三种数据存储方法

Google App Engine 抛开了关系数据库,并采用了几个非关系数据存储方法:Bigtable、Blobstore 和最新的 Google Storage for Developers。作者 John Wheeler 介绍了 GAE 的三种大数据存储方法的优缺点,同时详细地介绍了一个应用场景,从而有助于您熟悉每一种方法的创建和使用。

John Wheeler, 应用程序经理, Xerox

John Wheeler 在专业编程方面有十多年的经验。他是 Spring in Practice 的作者之一,目前在 Xerox 担任应用程序经理职务。请访问 John 的 网站,了解更多关于他在软件开发方面的著作。



2011 年 4 月 11 日

因为数据总是围绕着字节处理,所以我们很容易获得它们的磁盘驱动器和文件系统。当您写入一个文件时,您只需要考虑它的位置、权限和空间需求。您只需要创建一个 java.io.File,然后就可以操作文件了;java.io.File 的用法与桌面电脑、Web 服务器或移动设备环境的用法是一样的。但是在 Google App Engine (GAE) 上,这种透明性,或者因此缺少的透明性,马上会变得很明显。在 GAE 上,您是无法将文件写入到磁盘的,因为 GAE 根本没有可用的文件系统。事实上,只要声明一个 java.io.FileInputStream 就会抛出一个编译错误,因为这个类在 GAE SDK 已经不能使用了。

幸好,我们还有其他的方法,GAE 提供了一些非常强大的存储方法。因为它的设计本身就考虑了可扩展性,所以 GAE 支持两种主要的存储方法:Datastore(即 Bigtable)负责保存您一般保存到数据库的常规数据,而 Blobstore 则负责保存大型的二进制文件。这两种方法均有固定的访问时间,而且它们与您过去可能操作过的文件系统是完全不同的。

除了这两种方法,还有一种结合这两种方法的新方法:Google Storage for Developers。它的工作方法类似于 Amazon S3,它也是与传统的文件系统非常不同的。在本文中,我们将创建一个示例应用程序,依次实现每一种 GAE 存储方法。您将获得使用 Bigtable、Blobstore 和 Google Storage for Developers 的第一手经验,而且您将了解到每一种方法的优缺点。

前置条件

您需要一个 GAE 帐号 和几个免费的开源工具才能够完成本文所介绍的一些例子。在您的开发环境中,您需要安装 JDK 5 或 JDK 6,以及 Eclipse IDE for Java™ developers。此外,您还需要:

Google Storage for Developers 目前仅向少数的美国开发人员开放使用。如果您无法马上获得 Google Storage,您仍然能够练习 Bigtable 和 Blobstore 的例子,而且您能够很好地理解 Google Storage 的工作方式。

初步设置:示例应用程序

在开始介绍 GAE 存储系统之前,我们需要创建示例应用程序所需要的三个类:

  • 一个表示照片的 BeanPhoto 包含了诸如 title 和 caption 等字段,以及一些用于存储二进制图像数据的字段。
  • Photo 存储到 GAE 数据存储, Bigtable 的 DAO。这个 DAO 有一个插入 Photo 的方法,以及另一个根据 ID 查询照片的方法。它使用一个名为 Objectify-Appengine 开源库实现持久化操作。
  • 一个采用 Template Method 模式的 Servlet 封装一个包含三个步骤的工作流。我们将使用这个工作流来介绍每一种 GAE 存储方法。

应用程序工作流程

我们将采用相同的流程来学习每一种 GAE 数据存储方法:这样您就能够关注于技术,同时可以比较每一种存储方法的优缺点。这个应用程序工作流程每一次都是一样的:

  1. 显示一个上传表单。
  2. 上传一个图像到存储,并在数据存储上保存一条记录。
  3. 显示这个图像。

图 1 是表示这个应用程序工作流程的图片:

图 1. 用于演示每一种存储方法的三步工作流程
一个表示 GAE 数据存储示例应用程序图片。

作为一个附加的好处,这个示例应用程序也使您能够练习对于所有 GAE 项目来说均非常重要的一些任务,如写入和读取二进制文件。现在,让我们开始创建这些类!


一个 GAE 示例应用程序

如果您还没有安装 Eclipse,您需要先 下载 Eclipse,然后安装 Google Plug-in for Eclipse,并创建一个新的不使用 GWT 的 Google Web Application 项目。请参考 示例代码,其中包含了创建本文的项目文件的指引。当您创建好 Google Web 应用程序之后,就可以添加这个应用程序的第一个类 Photo,如清单 1 所示。(注意,我删除了其中的 getters 和 setters。)

清单 1. Photo
import javax.persistence.Id;

public class Photo {

    @Id
    private Long id;
    private String title;
    private String caption;
    private String contentType;
    private byte[] photoData;
    private String photoPath;

    public Photo() {
    }

    public Photo(String title, String caption) {
        this.title = title;
        this.caption = caption;
    }

    // getters and setters omitted
}

@Id 注释指定该域为一个主键,这对于操作 Objectify 是非常重要的。保存在数据存储中的每一条记录,即实体,都需要有一个主键。当一个图像上传后,我们可以选择将它直接存储到 photoData,这是一个字节数组。然后它会作为 Photo 的一个 Blob 属性与其他域一起存储到数据存储中。换句话说,图像是与 Bean 保存在一起的。相反,如果这个图像是上传到 Blobstore 或 Google Storage,那么二进制字节是存储在系统外部,而 photoPath 则指向它们的位置。这两种情况中只使用了 photoDataphotoPath。图 2 说明了每一个方法的工作方式:

图 2. photoData 和 photoPath
一个显示 photoData 和 photoPath 之间差别的图片。

接下来,我们将处理这个 Bean 的存储。

基于对象的存储

正如之前所介绍的,我们将使用 Objectify 创建一个处理 Photo Bean 的 DAO。虽然 JDO 和 JPA 是更加流行和普及的持久化 API,但是它们的学习难度更大一些。另一个方法是使用 GAE 底层数据存储 API,但是这需要编写包含业务逻辑的保存和读取数据存储实体的 Bean。Objectify 则能够使用 Java 反射帮我们处理这些业务。(请查看 参考资料 以了解更多关于 GAE 持久化方法的信息,包括 Objectify-Appengine。)

首先创建一个名为 PhotoDao 的类,然后编写如清单 2 所示的代码:

清单 2. PhotoDao
import com.googlecode.objectify.*;
import com.googlecode.objectify.helper.DAOBase;

public class PhotoDao extends DAOBase {

    static {
        ObjectifyService.register(Photo.class);
    }

    public Photo save(Photo photo) {
        ofy().put(photo);
        return photo;
    }
    
    public Photo findById(Long id) {
        Key<Photo> key = new Key<Photo>(Photo.class, id);
        return ofy().get(key);
    }
}

PhotoDao 扩展了 DAOBase,这是一个延缓加载 Objectify 实例的便利类。Objectify 是这个 API 的主要接口,它是通过 ofy 方法获取的。然而,在我们使用 ofy 之前,我们需要在一个静态的初始化方法中注册持久化类,如 清单 2Photo

这个 DAO 包含了两个方法,分别用来插入和查找 Photo。在每一个方法中,Objectify 的操作与哈希表的操作一样简单。您可能注意到 Photo 是在 findById 中通过一个 Key 获取的,但是请不要担心它是怎么获得的:在本文中,您只需要将 Key 看作是封装的 id 域。

我们现在有了一个 Photo Bean 和一个管理持久化的 PhotoDao。接下来,我们将详细介绍应用程序工作流程。

采用 Template Method 模式的应用程序工作流程

如果您曾经玩过 Mad Libs,那么您肯定知道什么是 Template Method 模式。一个 Mad Lib 表示一个故事,其中包含一系列由读者填写的空白点。读者的输入 — 空白点的完成方法 — 会显著地改变这个故事。类似地,采用 Template Method 模式的类也包含一系列的步骤,而其余的则留空。

我们将创建一个 Servlet,它采用 Template Method 模式来完成我们示例应用程序的工作流程。首先,我们创建一个抽象 Servlet,并将它命名为 AbstractUploadServlet。您可以参考清单 3 中的代码。

清单 3. AbstractUploadServlet
import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.*;

@SuppressWarnings("serial")
public abstract class AbstractUploadServlet extends HttpServlet {

}

接下来,添加清单 4 所示的三个抽象方法。每一个方法表示工作流程中的一个步骤。

清单 4. 三个抽象方法
protected abstract void showForm(HttpServletRequest req, 
    HttpServletResponse resp) throws ServletException, IOException;

protected abstract void handleSubmit(HttpServletRequest req, 
    HttpServletResponse resp) throws ServletException, IOException;

protected abstract void showRecord(long id, HttpServletRequest req, 
    HttpServletResponse resp) throws ServletException, IOException;

现在,假设我们正在使用 Template Method 模式,那么我们可以将 清单 4 中的方法看作是空白,而清单 5 中的代码是组成故事的情节:

清单 5. 一个工作流程合并
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
    throws ServletException, IOException {
    String action = req.getParameter("action");
    if ("display".equals(action)) {
        // don't know why GAE appends underscores to the query string
        long id = Long.parseLong(req.getParameter("id").replace("_", ""));
        showRecord(id, req, resp);
    } else {
        showForm(req, resp);
    }
}

@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) 
    throws ServletException, IOException {
    handleSubmit(req, resp);
}

关于 Servlet 的一个提醒

在您长期使用的老式 Servlet 中,doGetdoPost 是处理 HTTP GETPOST 的标准方法。我们一般是使用 GET 获取 Web 资源,而使用 POST 发送数据。按照这种方法,我们的 doGet 实现或者用来显示一个上传表单,或者显示来自存储的一个照片,而 doPost 则处理上传表单的提交操作。而每一个行为则由扩展 AbstractUploadServlet 的子类负责定义。图 3 的图片显示了事件发生的顺序。您可能需要花一些时间才能够清晰理解图片所表达的信息。

图 3. 以顺序图表示的工作流程
一个表示示例应用程序工作流程的顺序图。

有了这三个类之后,我们的示例应用程序大体完成了。我们现在可以关注于 GAE 存储方法是如何与应用程序工作流程进行交互的,我们首先从 Bigtable 开始。


GAE 存储方法 #1:Bigtable

Google 的 GAE 文档将 Bigtable 描述为一个分散的排序阵列,但是我发现将它描述为一个巨大的分布在无数服务器的哈希表更好理解。类似于关系数据库,Bigtable 也具有数据类型。事实上,Bigtable 和关系数据库都使用 blob 数据类型来存储二进制文件。

不要将 Blobstore — 我们接下来讨论的 GAE 的 另一个 基于键-值存储 — 与 blob 数据类型混淆。

Bigtalbe 的 blob 数据处理是很方便的,因为它们是与其他域一起加载的,这使它们能够马上被使用。其中一个很大的问题是 blob 不能够超过 1MB,虽然这个限制在将来可能进一步放松。您现在很难找到一个拍摄照片小于这个大小的数码相机,所以使用 Bigtable 可能会在大部分使用图像的用例中出现问题(像我们的示例程序就会有问题)。如果您现在能够接受 1MB 的限制,或者您存储的文件比图像还小,那么 Bigtalbe 可能是一个很好的选择:在三种 GAE 存储方法中,它的使用是最简单的。

在我们上传数据到 Bigtable 之前,我们需要创建一个上传表单。然后我们将完成 Servlet 实现,它是由三个专门为 Bigtable 定制的抽象方法组成的。最后,我们将实现错误处理,因为 1MB 限制是很容易超过的。

创建上传表单

图 4 显示的是针对 Bigtable 实现的上传表单:

图 4. 针对 Bigtable 实现的一个上传表单
一个数码照片上传表单的截图。

要创建这个表单,我们首先需要创建一个 datastore.jsp 文件,然后编写清单 6 所示的代码块:

清单 6. 编写的上传表单
<html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    </head>
    <body>		
        <form method="POST" enctype="multipart/form-data">
            <table>
                <tr>	
                    <td>Title</td>
                    <td><input type="text" name="title" /></td>
                </tr>
                <tr>	
                    <td>Caption</td>
                    <td><input type="text" name="caption" /></td>
                </tr>
                <tr>	
                    <td>Upload</td>
                    <td><input type="file" name="file" /></td>
                </tr>
                <tr>
                    <td colspan="2"><input type="submit" /></td>
                </tr>				
            </table>
        </form>
    </body>	
</html>

这个表单必须将它的 method 属性设置为 POST,并将附件类型设置为 multipart/form-data。因为不指定 action 属性,这个表单会提交给自己。通过 POST,我们将到达 AbstractUploadServletdoPost 方法,然后它会调用 handleSubmit

这样我们就得到了这个表单,接下来让我们继续完成处理表单的 Servlet。

Bigtalbe 的上传和结果

这样我们依次实现了三个方法。一个显示我们刚刚创建的表单,另一个处理表单的上传。最后一个方法将结果返回,这样您就能够看到结果。

这个 Servlet 使用了 Apache Commons FileUpload 包。下载它及其依赖的其他包,并将它们包含在项目中。完成之后,再完成清单 7 所示的代码:

清单 7. DatastoreUploadServlet
import info.johnwheeler.gaestorage.core.*;
import java.io.*;
import javax.servlet.ServletException;
import javax.servlet.http.*;
import org.apache.commons.fileupload.*;
import org.apache.commons.fileupload.servlet.ServletFileUpload;
import org.apache.commons.fileupload.util.Streams;

@SuppressWarnings("serial")
public class DatastoreUploadServlet extends AbstractUploadServlet {
    private PhotoDao dao = new PhotoDao();
}

这里的代码很简单。我们导入所需要的类,然后创建一个 PhotoDao 对象以便将来使用。在我们需要实现抽象方法之后,我们才能够成功编译 DatastoreUploadServlet。让我们从清单 8 的 showForm 开始完成这里的每一步。

清单 8. showForm
@Override
protected void showForm(HttpServletRequest req, HttpServletResponse resp) 
    throws ServletException, IOException {
    req.getRequestDispatcher("datastore.jsp").forward(req, resp);        
}

您可以看到,showForm 只是转向我们的上传表单。清单 9 所示的 handleSubmit 完成了更多的操作:

清单 9. handleSubmit
@Override
protected void handleSubmit(HttpServletRequest req,
    HttpServletResponse resp) throws ServletException, IOException {
    ServletFileUpload upload = new ServletFileUpload();

    try {
        FileItemIterator it = upload.getItemIterator(req);

        Photo photo = new Photo();

        while (it.hasNext()) {
            FileItemStream item = it.next();
            String fieldName = item.getFieldName();
            InputStream fieldValue = item.openStream();

            if ("title".equals(fieldName)) {
                photo.setTitle(Streams.asString(fieldValue));
                continue;
            }

            if ("caption".equals(fieldName)) {
                photo.setCaption(Streams.asString(fieldValue));
                continue;
            }

            if ("file".equals(fieldName)) {
                photo.setContentType(item.getContentType());
                ByteArrayOutputStream out = new ByteArrayOutputStream();
                Streams.copy(fieldValue, out, true);
                photo.setPhotoData(out.toByteArray());
                continue;
            }
        }

        dao.save(photo);
        resp.sendRedirect("datastore?action=display&id=" + photo.getId());            
    } catch (FileUploadException e) {
        throw new ServletException(e);
    }        
}

这里有很多代码,但是它实现的功能很简单。handleSubmit 打开了表单的请求内容流,将每一个表单值提取到 FileItemStream。同时,每一次都会创建一个 Photo 对象。我们没有必要一一讨论每一个域及其内容,但是流数据和流处理 API 会一一处理这些域。

回到代码,在我们遇到文件域时,我们会用一个 ByteArrayOutputStream 来将上传的字节传递给 PhotoDao,然后重定向到我们的抽象类的 showRecord,如清单 10 所示:

清单 10. showRecord
@Override
protected void showRecord(long id, HttpServletRequest req, 
    HttpServletResponse resp) throws ServletException, IOException {
    Photo photo = dao.findById(id);
        
    resp.setContentType(photo.getContentType());        
    resp.getOutputStream().write(photo.getPhotoData());
    resp.flushBuffer();                    
}

showRecord 会查询一个 Photo,并在将 photoData 直接写入 HTTP 响应之前设置 content-type 头信息。flushBuffer 会将所有剩余的内容发送到浏览器。

最后,我们需要对大于 1MB 的上传文件进行错误处理。

显示一个错误消息

正如之前所介绍的,Bigtable 的 1MB 限制是大多数用例可能超过的,包括图像。至少,我们要告诉用户要调整图像大小后再重新上传。出于演示的目的,清单 11 的代码只是在出现 GAE 异常时简单地显示一个异常消息。(注意,这是一个标准 Servlet 特有的错误处理,而不是 GAE 特有的。)

清单 11. 发生的错误
import java.io.*;
import javax.servlet.ServletException;
import javax.servlet.http.*;

@SuppressWarnings("serial")
public class ErrorServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse res) 
        throws ServletException, IOException {
        String message = (String)   
            req.getAttribute("javax.servlet.error.message");
        
        PrintWriter out = res.getWriter();
        out.write("<html>");
        out.write("<body>");
        out.write("<h1>An error has occurred</h1>");                
        out.write("<br />" + message);        
        out.write("</body>");
        out.write("</html>");
    }
}

不要忘记在 web.xml 中注册 ErrorServlet,以及我们在本文所创建的其他 Servlet。清单 12 的代码注册了一个错误页面,它指回到 ErrorServlet

清单 12. 注册错误
<servlet>
    <servlet-name>errorServlet</servlet-name>	  
    <servlet-class>
        info.johnwheeler.gaestorage.servlet.ErrorServlet
    </servlet-class>
</servlet>

<servlet-mapping>
    <servlet-name>errorServlet</servlet-name>
    <url-pattern>/error</url-pattern>
</servlet-mapping>

<error-page>
    <error-code>500</error-code>
    <location>/error</location>
</error-page>

前面我们简明地介绍了 Bigtable,即 GAE 数据存储。Bigtable 是最直观的 GAE 存储方法,但是它的缺点是文件大小限制:每个文件不超过 1MB,您可能不希望在除缩略图之外的应用程序中使用它作为存储方法。下一个要介绍的是 Blobstore,这是另一个基于键-值的存储方法,它能够保存和显示最大为 2GB 的文件。


GAE 存储方法 #2:Blobstore

Blobstore 相对于 Bigtable 有文件大小方面的优势,但是它也并非没有问题:那就是,它必须使用 Web 服务中很难实现的一次上传 URL。下面是这种 URL 的一个例子:

/_ah/upload/aglub19hcHBfaWRyGwsSFV9fQmxvYlVwbG9hZFNlc3Npb25fXxh9DA

Web 服务客户端在 POST 数据之前获得 URL,这需要一个额外的调用。这对于许多应用程序来说可能并不是一个大问题,但是这种方法并不十分友好。在运行于 GAE 的客户端上,由于 CPU 时间是会产生费用的,所以它也可能是不适合使用的。如果您认为您可以创建一个 Servlet 将上传请求通过 URLFetch 转发到一次性 URL 而解决这个问题,那么您要再认真考虑一下。URLFetch 有 1MB 的传输限制,所以您采用这种方法时可能会遇到与 Bigtable 一样的问题。作为参考,图 5 所示的图片显示了一次和两次的 Web 服务调用之间的区别:

图 5. 一次和两次 Web 服务调用之间的区别
一个显示 Web 服务客户端与 Blobstore(两次)及 Bigtable(一次)之间传输图像文件的图片。

Blobstore 有其自身的优缺点,您可以在后续的章节中看到其中更多的优缺点。我们将再一次创建一个上传表单和实现 AbstractUploadServlet 中的三个抽象方法 — 但是这个时候我们将代码修改为使用 Blobstore。

一个针对 Blobstore 创建的上传表单

我们不需要重复地为 Blobstore 创建上传表单:我们只需要将 datastore.jsp 复制为一个名为 Blobstore.jsp 文件,然后增加清单 13 所示的粗体代码:

清单 13. blobstore.jsp
<% String uploadUrl = (String) request.getAttribute("uploadUrl"); %><html>
    <head>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    </head>
    <body>		
        <form method="POST" action="<%= uploadUrl %>"
            enctype="multipart/form-data">
		<!-- labels and fields omitted -->
        </form>
    </body>	
</html>

这个一次上传 URL 是在一个 Servlet 生成的,我们将在后面编写它的代码。这里,这个 URL 是从请求解析而来,然后保存到表单的 action 属性中。我们 没有 对 Blobstore Servlet 进行任何的控制,那么我们将如何获取其他的表单值呢?答案就是 Blobstore API 的一个回调机制。我们在一次 URL 生成之后将一个回调 URL 传递给 API。在上传之后,Blobstore 会调用这个回调 URL,从而传递原始的请求及其他上传的大文件。您将在我们在后面实现的 AbstractUploadServlet 的 Action 中看到这个过程。

上传到 Blobstore

我们首先使用清单 14 的代码为 BlobstoreUploadServlet 的参考实现,它扩展了 AbstractUploadServlet

清单 14. BlobstoreUploadServlet
import info.johnwheeler.gaestorage.core.*;
import java.io.IOException;
import java.util.Map;
import javax.servlet.ServletException;
import javax.servlet.http.*;
import com.google.appengine.api.blobstore.*;

@SuppressWarnings("serial")
public class BlobstoreUploadServlet extends AbstractUploadServlet {
    private BlobstoreService blobService = 
        BlobstoreServiceFactory.getBlobstoreService();
    private PhotoDao dao = new PhotoDao();
}

初始的类定义与我们之前定义的 DatastoreUploadServlet 很相似,但是这个类增加了一个 BlobstoreService 变量。这就是在 showForm 中生成一次 URL 的代码,如清单 15 所示:

清单 15. 针对 Blobstore 实现的 showForm
@Override
protected void showForm(HttpServletRequest req, HttpServletResponse resp) 
    throws ServletException, IOException {
    String uploadUrl = blobService.createUploadUrl("/blobstore");
    req.setAttribute("uploadUrl", uploadUrl);
    req.getRequestDispatcher("blobstore.jsp").forward(req, resp);
}

清单 15 所示的代码创建了一个上传 URL,并将它保存在请求中。然后代码会转发到 清单 13 所创建的表单中,而上传表单就是在这个表单中使用的。这个回调 URL 会被保存在这个 Servlet 的上下文中,就像它在 web.xml 定义一样。通过这种方法,当 Blobstore POST 回来时,我们就到达 handleSubmit,如清单 16 所示:

清单 16. 针对 Blobstore 实现的 handleSubmit
@Override
protected void handleSubmit(HttpServletRequest req, 
    HttpServletResponse resp) throws ServletException, IOException {
    Map<String, BlobKey> blobs = blobService.getUploadedBlobs(req);
    BlobKey blobKey = blobs.get(blobs.keySet().iterator().next());

    String photoPath = blobKey.getKeyString();
    String title = req.getParameter("title");
    String caption = req.getParameter("caption");
    
    Photo photo = new Photo(title, caption);
    photo.setPhotoPath(photoPath);
    dao.save(photo);

    resp.sendRedirect("blobstore?action=display&id=" + photo.getId());
}

getUploadedBlobs 会返回一个 MapBlobKeys。因为我们的上传表单是一次上传,我们只会得到惟一一个 BlobKey,而表示它的一个字符串会保存到 photoPath 变量中。在这之后,其余的域会解析到一些变量中,并重新创建一个新的 Photo 实例。然后这个实例会在重定向到 showRecord 之前被保存到一个数据存储中,如清单 17 所示:

清单 17. 针对 Blobstore 实现的 showRecord
@Override
protected void showRecord(long id, HttpServletRequest req, 
    HttpServletResponse resp) throws ServletException, IOException {
    Photo photo = dao.findById(id);
    String photoPath = photo.getPhotoPath();

    blobService.serve(new BlobKey(photoPath), resp);
}

showRecord 中,我们刚刚在 handleSubmit 保存的 Photo 会从 Blobstore 重新加载。所上传的实际字节并没有像 Bigtable 一样存储在这个 Bean 中。相反,它会基于 photoPath 创建一个 BlobKey,并使用它来向浏览器发送一个图像。

Blobstore 可以很好地支持基于表单的上传,但是对于基于 Web 服务的上传而言则是另一回事。接下来,我们将尝试 Google Storage for Developers,这种方法会给我们完全相反的难题:基于表单的上传变得非常复杂,而基于服务的上传则非常简单。


GAE 存储方法 #3:Google Storage

Google Storage for Developers 是三种 GAE 存储方法中最强大的一种,而且您只需要理解很少的使用方法就能够轻松上手。Google Storage 与 Amazon S3 在很多方面具有共同点;事实上,它们都使用相同的协议,并且使用相同的 RESTful 接口,所以 S3 所使用的程序库,如 JetS3t,也可以在 Google Storage 中使用。可惜,在本文撰写时,这些程序库还无法在 Google App Engine 上可靠地运行,因为它们执行了一些不允许的操作,如多线程。所以,现在我们只使用 RESTful 接口,同时进行这些 API 原本会做的一些重要修改。

Google Storage 是值得花大精力的,这主要是因为它支持通过访问控制列表(ACL)实现的强大访问控制。通过 ACL,我们可以给对象授予只读和读写访问权限,这样您可以很容易地将照片设置为公开或私密,就像 Facebook 和 Flickr 一样。ACL 不属于本文介绍的范围,所以我们所上传的所有文件都会授予公开的只读访问权限。请阅读 Google Storage 的在线文档(见 参考资料)了解更多关于 ACL 的信息。

关于 Google Storage

2010 年 5 月,Google Storage for Developers 发布了一个预览版,目前它仍然只对美国一小部分开发人员开放使用,并且现在预览版还有一个等待列表。虽然只是在开发初期,但是 Google Storage 也遇到了一些实现问题,这就是我在本节要专门处理的。Google Storage 和 GAE 之间没有明确的整合方法使得我们需要额外的编码,但是对于一些用例 — 比如要求进行访问控制的 — 而言这是值得的。我希望我们将在不远的将来看到整合程序包的出现。

与 Blobstore 不同,Google Storage 默认是支持 Web 服务和浏览器客户端使用的。数据可以通过 RESTful PUTPOST 发送。首先,我们会介绍 Web 服务客户端的应用,它能够控制请求的创建和请求头的编写。然后,我们在这里将会介绍基于浏览器的上传。我们需要采用 JavaScript 来处理上传表单,您会发现这会有一些复杂。

编写 Google Storage 上传表单

与 Blobstore 不同,Google Storage 在 POST 之后不会转发到一个回调的 URL。相反,它会重定向到我们指定的一个 URL。这里会有一个问题,因为表单值是无法在重定向过程中传递的。解决这个问题的一个方法是在同一个页面上创建两个表单 — 一个包含 title 和 caption 文本域,另一个包含文件上传域和所需要的 Google Storage 参数。然后,我们将使用 Ajax 提交第一个表单。当 Ajax 回调调用时,我们将提交第二个上传表单。

因为这个表单比之前两个更复杂一些,所以我们将一步步地创建这个表单。首先,我们将在一个转发 Servlet 中提取一些值,这是一个新的 Servlet,如清单 18 所示:

清单 18. 提取值
<% 
String uploadUrl = (String) request.getAttribute("uploadUrl");
String key = (String) request.getAttribute("key");
String successActionRedirect = (String) 
    request.getAttribute("successActionRedirect");
String accessId = (String) request.getAttribute("GoogleAccessId");
String policy = (String) request.getAttribute("policy");
String signature = (String) request.getAttribute("signature");
String acl = (String) request.getAttribute("acl");
%>

uploadUrl 指的是 Google Storage 的 REST 终端。这个 API 提供了以下所示两个对象。这两个对象都是可以接受的,但是我们需要将斜体组件替换成我们自己的值:

  • bucket.commondatastorage.googleapis.com/object
  • commondatastorage.googleapis.com/bucket/object

Google Storage 参数还需要以下的变量:

  • key:Google Storage 中上传的数据名称。
  • success_action_redirect:当上传完成后的上传地址。
  • GoogleAccessId:由 Google 分配的 API 键。
  • policy:采用 base 64 编码并包含数据上传方式的一个 JSON 字符串。
  • signature:采用哈希算法签名并采用 base 64 编码的策略。它是在认证时使用的。
  • acl:一个访问控制列表规范。

两个表单和一个提交按钮

清单 19 中的第一个表单只包含 title 和 caption 域。其中节省了其外围的 <html><body> 标签。

清单 19. 第一个上传表单
<form id="fieldsForm" method="POST">
    <table>
        <tr>	
            <td>Title</td>
            <td><input type="text" name="title" /></td>
        </tr>
        <tr>	
            <td>Caption</td>
            <td>
                <input type="hidden" name="key" value="<%= key %>" />	
                <input type="text" name="caption" />
            </td>
        </tr>			
    </table>		
</form>

这个表单非常简单,它只能够 POST 自己。让我们转到清单 20 的表单,这个表单更大一些,因为它包含了许多的隐藏输入域:

清单 20. 带有隐藏域的第二个表单
<form id="uploadForm" method="POST" action="<%= uploadUrl %>" 
    enctype="multipart/form-data">
    <table>
        <tr>
            <td>Upload</td>
            <td>
                <input type="hidden" name="key" value="<%= key %>" />
                <input type="hidden" name="GoogleAccessId" 
                    value="<%= accessId %>" />
                <input type="hidden" name="policy" 
                    value="<%= policy %>" />
                <input type="hidden" name="acl" value="<%= acl %>" />
                <input type="hidden" id="success_action_redirect" 
                    name="success_action_redirect" 
                    value="<%= successActionRedirect %>" />
                <input type="hidden" name="signature"
                    value="<%= signature %>" />
                <input type="file" name="file" />
            </td>
        </tr>
        <tr>
            <td colspan="2">
                <input type="button" value="Submit" id="button"/>
            </td>
        </tr>
    </table>
</form>

JSP 标签所提取的值(见 清单 18)都被设置在隐藏域中。而文件输入域是位于最后。提交按钮是一个简单的按钮,它本身没有任何操作,它是通过我们编写的 JavaScript 来操作的,如清单 21 所示:

清单 21. 提交表单
<script type="text/javascript" 
src="https://Ajax.googleapis.com/Ajax/libs/jquery/1.4.3/jquery.min.js">
</script>
<script type="text/javascript">
    $(document).ready(function() {			
        $('#button').click(function() {
            var formData = $('#fieldsForm').serialize();
            var callback = function(photoId) {
                var redir = $('#success_action_redirect').val() +
                    photoId;
                $('#success_action_redirect').val(redir)
                $('#uploadForm').submit();
             };
			
             $.post("gstorage", formData, callback);
         });
     });
</script>

清单 21 的 JavaScript 是使用 JQuery 实现的。即使您还没有使用过这个程序库,这段代码也不难理解。代码首先导入 JQuery。然后,它在按钮上注册一个单击事件,所以当按钮被单击时,第一个表单会通过 Ajax 方式提交。这时,我们会到达 Servlet(我们将马上创建它)的 handleSubmit 方法,其中我们会创建一个 Photo 对象,并将它保存到数据存储中。最后,新的 Photo ID 会返回给回调函数,并在上传表单提交之前被附加到 success_action_redirect 的 URL 上。这样,当我们从重定向返回时,我们就能够查找到这个 Photo 并显示它的图像。图 6 显示了整个序列的事件:

图 6. 一个显示 JavaScript 调用路径的顺序图
一个显示 JavaScript 调用路径的顺序图。

为了保护这个表单,我们需要使用一个工具类来创建策略文档并签名。然后我们才能够创建一个 AbstractUploadServlet 的子类。

创建一个策略文档并签名

策略文档是用来约束上传的。例如,我们可能会规定上传的大小限制或者可接受的文件类型,甚至我们可能会对文件名进行限制。公开上传并不需要使用策略文档,但是私密上传则需要,如 Google Storage。要实现这一点,我们需要基于清单 22 的代码创建一个名为 GSUtils 的工具类:

清单 22. GSUtils
import java.io.UnsupportedEncodingException;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.text.DateFormat;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.TimeZone;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import com.google.appengine.repackaged.com.google.common.util.Base64;

private class GSUtils {
}

由于这种工具类通常是由一些静态方法构成的,所以我们最好将它们的默认构造函数设置为私有的,以防止实例化。在创建的这个类之后,我们就能够将注意力转移到创建策略文档上了。

这个策略文档是基于 JSON 格式的,但是这个 JSON 是非常简单的,所以我们不需要使用任何的程序库。相反,我们可以使用简单的 StringBuilder 来手工创建 JSON。首先,我们需要创建一个 ISO8601 日期,然后将它设置为策略文档的过期时间。一旦策略文档过期之后,上传就无法继续进行。然后,我们在文档中制定我们之前所讨论的限制,这在策略文档中称为 条件。最后,这个文档会经过 base-64 编码,然后再返回给调用者。

将清单 23 中的方法添加到 GSUtils

清单 23. 创建一个策略文档
public static String createPolicyDocument(String acl) {
    GregorianCalendar gc = new GregorianCalendar();
    gc.add(Calendar.MINUTE, 20);

    DateFormat df = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
    df.setTimeZone(TimeZone.getTimeZone("GMT"));
    String expiration = df.format(gc.getTime());

    StringBuilder buf = new StringBuilder();
    buf.append("{\"expiration\": \"");
    buf.append(expiration);
    buf.append("\"");
    buf.append(",\"conditions\": [");
    buf.append(",{\"acl\": \"");
    buf.append(acl);
    buf.append("\"}");        
    buf.append("[\"starts-with\", \"$key\", \"\"]");
    buf.append(",[\"starts-with\", \"$success_action_redirect\", \"\"]");
    buf.append("]}");

    return Base64.encode(buf.toString().replaceAll("\n", "").getBytes());
}

我们使用一个 GregorianCalendar 对象来设置后推 20 分钟作为过期时间。这段代码非常简单,如果将它打印到控制台、复制并通过诸如 JSONLint(见 参考资料)的工具上运行,我们就可以直观地看到它的结果。接下来,我们将在策略文档中设置 acl,以避免通过硬编码实现。所有变化的东西都应该通过方法的参数传递,如 acl。最后,这个文档在返回给调用者之前会经过 base-64 编码。请参考 Google Storage 文档了解更多关于策略文档所支持内容的介绍。

Google 的 Secure Data Connector

我们在本文将不会讨论 Google 的 Secure Data Connector,但是如果您计划使用 Google Storage,那么您应该了解一下。SDC 能够简化您对自已系统的数据访问,即使这些系统位于防火墙之后。

Google Storage 的认证

策略文档有两个作用。除了实施策略之后,它们也是我们用于认证上传的基本签名。当我们注册 Google Storage 时,我们会获得一个只有 Google 和我们自己知道的密钥。我们会在文件中写入这个密钥,而 Google 也会使用相同的密钥。如果两个签名相匹配,那么上传就是允许的。图 7 很好地说明了这个过程:

图 7. 上传在 Google Storage 是如何认证的
GAE 认证过程示意图。

为了生成一个签名,我们需要使用 GSUtils 类中导入的 javax.cryptojava.security 程序包。清单 24 显示了这些方法:

清单 24. 对一个策略文档添加签名
public static String signPolicyDocument(String policyDocument,
    String secret) {
    try {
        Mac mac = Mac.getInstance("HmacSHA1");
        byte[] secretBytes = secret.getBytes("UTF8");
        SecretKeySpec signingKey = 
            new SecretKeySpec(secretBytes, "HmacSHA1");
        mac.init(signingKey);
        byte[] signedSecretBytes = 
            mac.doFinal(policyDocument.getBytes("UTF8"));
        String signature = Base64.encode(signedSecretBytes);
        return signature;
    } catch (InvalidKeyException e) {
        throw new RuntimeException(e);
    } catch (NoSuchAlgorithmException e) {
        throw new RuntimeException(e);
    } catch (UnsupportedEncodingException e) {
        throw new RuntimeException(e);
    }
}

Java 代码的安全哈希法需要使用一些 rigmarole,但是在本文中我将不对它进行介绍。但是重要的是 清单 24 显示了它是如何正确实现的,并且这个哈希在返回之前必须经过 base-64 的编码。

在考虑这些前提条件之后,我们就可以回到我们熟悉的问题上来:实现三个抽象方法,上传文件到 Google Storage,以及从 Google Storage 获取文件。

上传文件到 Google Storage

首先,我们要基于清单 25 的代码创建一个名为 GStorageUploadServlet 的类:

清单 25. GStorageUploadServlet
import info.johnwheeler.gaestorage.core.GSUtils;
import info.johnwheeler.gaestorage.core.Photo;
import info.johnwheeler.gaestorage.core.PhotoDao;

import java.io.IOException;
import java.io.PrintWriter;
import java.util.UUID;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

@SuppressWarnings("serial")
public class GStorageUploadServlet extends AbstractUploadServlet {
    private PhotoDao dao = new PhotoDao();
}

清单 26 所示的 showForm 方法创建了我们需要通过上传表单传递给 Google Storage 的参数:

清单 26. 针对 Google Storage 实现的 showForm
@Override
protected void showForm(HttpServletRequest req, HttpServletResponse resp) 
    throws ServletException, IOException {
    String acl = "public-read";
    String secret = getServletConfig().getInitParameter("secret");
    String accessKey = getServletConfig().getInitParameter("accessKey");
    String endpoint = getServletConfig().getInitParameter("endpoint");
    String successActionRedirect = getBaseUrl(req) + 
        "gstorage?action=display&id=";
    String key = UUID.randomUUID().toString();
    String policy = GSUtils.createPolicyDocument(acl);
    String signature = GSUtils.signPolicyDocument(policy, secret);

    req.setAttribute("uploadUrl", endpoint);
    req.setAttribute("acl", acl);
    req.setAttribute("GoogleAccessId", accessKey);
    req.setAttribute("key", key);
    req.setAttribute("policy", policy);
    req.setAttribute("signature", signature);
    req.setAttribute("successActionRedirect", successActionRedirect);

    req.getRequestDispatcher("gstorage.jsp").forward(req, resp);
}

注意 acl 被设置为 public-read,所以所有上传的内容可以被所有人看到。接下来是三个变量,secretaccessKeyendpoint,它们是用于连接 Google Storage 和通过认证。它们是从 web.xml 声明的 init-params 获得的;请参考 示例代码 了解其细节。重定向 URL 存储在 successActionRedirect 中。successActionRedirect 使用清单 27 的帮助方法来生成重定向 URL。

清单 27. getBaseUrl()
private static String getBaseUrl(HttpServletRequest req) {
    String base = req.getScheme() + "://" + req.getServerName() + ":" + 
        req.getServerPort() + "/";
    return base;
}

这个帮助方法使用到达的请求来生成基本的 URL,然后再将控制返回给 showForm。在返回时,会有一个全局惟一的标识符创建,即 UUID,这是一个保证惟一的 String。接下来,我们所创建的工具类会创建策略和签名。最后,我们将在转发到 JSP 页面之前设置供 JSP 使用的请求属性。

清单 28 显示的是 handleSubmit

清单 28. 针对 Google Storage 实现的 handleSubmit
@Override
protected void handleSubmit(HttpServletRequest req, HttpServletResponse 
    resp) throws ServletException, IOException {
    String endpoint = getServletConfig().getInitParameter("endpoint");
    String title = req.getParameter("title");
    String caption = req.getParameter("caption");
    String key = req.getParameter("key");

    Photo photo = new Photo(title, caption);
    photo.setPhotoPath(endpoint + key);
    dao.save(photo);

    PrintWriter out = resp.getWriter();
    out.println(Long.toString(photo.getId()));
    out.close();
}

记住,当第一个表单提交后,我们会通过一个 Ajax POST 到达 handleSubmit。这里并不会处理上传,但是它会在 Ajax 回调函数中单独处理。handleSubmit 只是解析第一个表单,创建一个 Photo,并将它保存到数据存储中。然后,Photo 的 ID 会被写到响应体中返回给 Ajax 回调函数。

在回调函数中,这个上传表单会提交到 Google Storage 终端。当 Google Storage 处理这个上传时,它会重定向到 showRecord,如清单 29 所示:

清单 29. 针对 Google Storage 实现的 showRecord
@Override
protected void showRecord(long id, HttpServletRequest req, 
    HttpServletResponse resp) throws ServletException, IOException {
    Photo photo = dao.findById(id);
    String photoPath = photo.getPhotoPath();
    resp.sendRedirect(photoPath);
}

showRecord 会查找 Photo,然后重定向到它的 photoPath。而 photoPath 指向 Google 服务器所挂载的图像。


结束语

我们介绍了三种与 Google 技术相关的存储方法,并讨论了它们的优缺点。Bigtable 使用很简单,但是它有 1MB 文件大小限制。Blobstore 的大文件最高可以支持 2GB,但是一次 URL 在 Web 服务中很难实现。最后,Google Storage for Developers 是最健壮的方法。我们仅仅为我们使用的存储空间支付费用,而问题在于一个文件所能够存储的文件是多大。然而,Google Storage 也是其中最复杂的方法,因为它的程序库目前并不支持 GAE。而且它对于基于浏览器的上传的支持并不是最简单的。

随着 Google App Engine 越来越受到 Java 开发人员的欢迎,理解它的各种存储方法是非常重要的。在本文中,您了解了 Bigtable、Blobstore 和 Google Storage for Developers 的一些实现例子。不管您是选择其中一种存储方法并坚持只使用它,或者在不同用例中使用不同的方法,您现在应该都掌握了在 GAE 存储大数据的工具了。


下载

描述名字大小
本文的示例代码j-gaestorage.zip12KB

参考资料

学习

获得产品和技术

讨论

  • 加入 developerWorks 中文社区。查看开发人员推动的博客、论坛、组和维基,并与其他 developerWorks 用户交流。

条评论

developerWorks: 登录

标有星(*)号的字段是必填字段。


需要一个 IBM ID?
忘记 IBM ID?


忘记密码?
更改您的密码

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件

 


在您首次登录 developerWorks 时,会为您创建一份个人概要。您的个人概要中的信息(您的姓名、国家/地区,以及公司名称)是公开显示的,而且会随着您发布的任何内容一起显示,除非您选择隐藏您的公司名称。您可以随时更新您的 IBM 帐户。

所有提交的信息确保安全。

选择您的昵称



当您初次登录到 developerWorks 时,将会为您创建一份概要信息,您需要指定一个昵称。您的昵称将和您在 developerWorks 发布的内容显示在一起。

昵称长度在 3 至 31 个字符之间。 您的昵称在 developerWorks 社区中必须是唯一的,并且出于隐私保护的原因,不能是您的电子邮件地址。

标有星(*)号的字段是必填字段。

(昵称长度在 3 至 31 个字符之间)

单击提交则表示您同意developerWorks 的条款和条件。 查看条款和条件.

 


所有提交的信息确保安全。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=10
Zone=Java technology, Cloud computing
ArticleID=646159
ArticleTitle=使用 Bigtable、Blobstore 和 Google Storage 实现 GAE 存储
publish-date=04112011