Хранилища Bigtable, Blobstore и Google Storage для GAE

Выберите подходящий вариант из трех хранилищ данных для GAE

Google App Engine избегает реляционных баз данных, предпочитая несколько нереляционных хранилищ данных: Bigtable, Blobstore и новейшую систему Google Storage for Developers. Автор статьи Джон Уилер исследует преимущества и недостатки трех вариантов хранилищ данных большого объема для GAE, рассматривая сценарий приложения, который поможет вам освоить настройку и использование каждого варианта.

Джон Уилер, менеджер приложений, Xerox

Джон Вилер (John Wheeler) – фотографияДжон Уилер (John Wheeler) работает профессиональным программистом более десяти лет. Он соавтор книги "Spring на практике". Работает в компании Xerox в качестве менеджера приложений. Посетите Web-сайт Джона и познакомьтесь с его статьями о разработке программного обеспечения.



29.02.2012

Развить навыки по этой теме

Этот материал — часть knowledge path для развития ваших навыков. Смотри Использование NoSQL для анализа данных большого объема

Предназначенные для хранения информации дисковые накопители и файловые системы воспринимаются как нечто само собой разумеющееся. При записи файла не нужно задумываться ни о чем, кроме его месторасположения, прав доступа и требований к свободному месту. Вы просто создаете java.io.File и все; java.io.File работает одинаково на настольном компьютере,Web-сервере и мобильном устройстве. Но когда вы начинаете работать с Google App Engine (GAE), эта прозрачность, точнее, ее отсутствие, быстро становится очевидной. В GAE нельзя записать файлы на диск из-за отсутствия пригодной к использованию файловой системы. Фактически объявление java.io.FileInputStream вызовет ошибку компиляции, поскольку этот класс занесен в черный список GAE SDK.

К счастью, есть и другие подходы, и GAE предлагает несколько эффективных вариантов хранилищ данных. Поскольку GAE был разработан с нуля с учетом требований масштабируемости, он предлагает две системы хранения данных ключ-значение: Datastore (также известна под названием Bigtable) хранит обычные данные, которые в обычной ситуации записываются в базу данных, а Blobstore хранит большие двоичные blob-данные. Обе имеют постоянное время доступа и обе радикально отличаются от файловых систем, с которыми вы, возможно, работали ранее.

Кроме этих двух систем, имеется новичок – 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, мы создадим три класса, необходимые для нашего примера приложения:

  • Bean-компонентPhoto, представляющий фотографию, содержит поля title (заголовок), caption (надпись) и несколько других для хранения двоичных данных изображения.
  • DAO, сохраняющий компоненты Photo в хранилище GAE, оно же Bigtable. DAO содержит один метод для вставки Photo и еще один для их извлечения по идентификатору (ID). Для сохранения используется библиотека с открытыми исходными кодами Objectify-Appengine.
  • Сервлет, инкапсулирующий трехступенчатую последовательность операций с помощью шаблона Template Method. Мы будем использовать эту последовательность операций для исследования каждого варианта хранилища GAE.

Последовательность операций приложения

При изучении каждого варианта мы будем следовать одной и той же процедуре; это даст нам возможность сконцентрироваться на технологии, а также сравнить достоинства и недостатки каждого способа хранения. Последовательность операций приложения будет все время одна и та же:

  1. Отобразить форму загрузки.
  2. Загрузить изображение в систему хранения и сохранить запись в хранилище данных.
  3. Извлечь изображение.

На рисунке 1 представлена схема последовательности операций приложения:

Рисунок 1. Трехступенчатая последжовательность операций, используемая для демонстрации каждого варианта хранилища
Рисунок 1. Трехступенчатая последжовательность операций, используемая для демонстрации каждого варианта хранилища

В качестве бонуса пример приложения позволяет попрактиковаться в решении задач, являющихся ключевыми для любого GAE-проекта, сохраняющего и извлекающего двоичные файлы. Начнем с создания классов.


Простое приложение для GAE

Загрузите на свой компьютер Eclipse, если его у вас нет, а затем установите Google Plug-in for Eclipse и создайте новый проект Google Web Application, не использующий GWT. Обратитесь к примеру кода, включенному в данную статью, чтобы понять, как структурируются файлы проекта. После настройки Google Web Application добавьте первый класс приложения, 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 и setters опущены
}

Аннотация @Id указывает поле, служащее первичным ключом, что будет важно при работе с Objectify. Для каждой записи, сохраняемой в хранилище данных и называемой также логическим объектом (entity), необходимо указать первичный ключ. После загрузки изображение можно сохранить непосредственно в photoData, представляющем собой массив байтов. Оно записывается в хранилище данных как свойство Blob вместе с остальными полями Photo. Другими словами, изображение сохраняется и извлекается непосредственно вместе с bean-компонентом. Если же изображение загружается в Blobstore или Google Storage, данные сохраняются на соответствующей внешней системе, а на их месторасположение указывает photoPath. В обоих случаях используются либо photoData, либо photoPath. На рисунке 2 продемонстрировано назначение каждого из них:

Рисунок 2. Как работают photoData и photoPath
Рисунок 2. Как работают photoData и photoPath

Далее мы займемся персистентностью bean-компонента.

Объектная персистентность

Как упоминалось ранее, для создания DAO для bean-компонента Photo мы будем использовать Objectify. Хотя наиболее популярными и распространенными API персистентности являются JDO и JPA, они сложны для освоения. Другим вариантом могло бы быть использование низкоуровневого GAE Datastore API, но это потребовало бы трудоемкой работы по маршаллизации bean-компонентов в логические объекты хранилища данных и обратно. Objectify берет на себя эту работу посредством Java-отражения (reflection) (ссылки на дополнительную информацию об альтернативных вариантах реализации персистентности в 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 необходимо зарегистрировать персистентные классы в статическом инициализаторе, как Photo в листинге 2.

DAO содержит два метода вставки и поиска Photo. В каждом из них работать с Objectify также просто, как и с хеш-таблицей (hashtable). Возможно, вы заметили, что объекты Photo извлекаются при помощи ключа Key в findById, но не беспокойтесь об этом: в данной статье считайте Key оберткой поля id.

Теперь у нас есть bean-компонент Photo и PhotoDao для управления персистентностью. Далее мы конкретизируем последовательность операций приложения.

Последовательность операций приложения в виде шаблона Template Method

Если вы когда-либо играли в Mad Libs, то сразу поймете, как работает шаблон Template Method. Каждая игра Mad Lib – это написание рассказа на основе заполнения читателями набора "белых пятен". Процесс заполнения читателем "белых пятен" кардинально меняет рассказ. Аналогично, классы, использующие шаблон Template Method, состоят из ряда шагов, некоторые из которых оставлены пустыми.

Мы создадим сервлет, использующий шаблон Template Method для поддержки последовательности операций нашего примера приложения. Начнем с создания абстрактного сервлета под названием 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)) {
        // не знаю, зачем GAE добавляет знаки подчеркивания в строку запроса
        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);
}

Напоминание о сервлетах

Когда мы работаем с привычными сервлетами, стандартными методами обработки HTTP-запросов GET и POST являются doGet и doPost. Общепринятой практикой является использование GET для извлечения Web-ресурсов и POST для отправки данных. Аналогично, наша реализация doGet отображает либо форму загрузки, либо фотографию из хранилища, а doPost занимается отправкой заполненной формы загрузки. Определять каждую часть поведения должны классы, расширяющие AbstractUploadServlet. На диаграмме, представленной на рисунке 3, показана последовательность происходящих событий. Возможно, придется потратить пару минут, чтобы разобраться, что же на самом деле происходит.

Рисунок 3. Схема последовательности операций
Рисунок 3. Схема последовательности операций

После создания трех классов наше приложение готово к работе. Теперь можно сосредоточиться на том, как каждый из вариантов системы хранения GAE, начиная с Bigtable, взаимодействует с последовательностью операций приложения.


Вариант №1 системы хранения GAE: Bigtable

В документации по Google GAE система хранения Bigtable описывается как шардированный отсортированный массив, но проще, по-моему, представлять ее как гигантскую хеш-таблицу, распределенную по несметному числу серверов. Как и реляционная база данных, Bigtable имеет типы данных. На практике и Bigtable, и реляционные базы данных используют для хранения двоичных данных тип blob.

Не путайте тип blob и Blobstore – это другое хранилище ключ-значение GAE, которое мы рассмотрим далее.

Работать с blob-данными в Bigtable очень удобно, поскольку они загружаются вместе с другими полями, что немедленно делает их доступными. Одно важное предостережение – blob не может иметь размер более 1 МБ, хотя это ограничение может быть ослаблено в будущем. Сегодня непросто найти цифровую фотокамеру, которая делает снимки, вписывающиеся в это ограничение, поэтому использование Bigtable может быть препятствием для любого варианта работы с изображениями (как наше приложение). Если правило 1 МБ выполняется или вы сохраняете что-то, имеющее меньший размер, Bigtable может быть отличным выбором – из трех альтернативных систем хранения GAE с ней работать легче всего.

Чтобы загружать данные в Bigtable, необходимо создать форму загрузки. Затем мы поработаем над реализацией сервлета, который состоит из трех абстрактных методов, настроенных для Bigtable. Наконец, мы реализуем обработку ошибок, поскольку ограничение 1 МБ легко нарушить.

Создание формы загрузки

На рисунке 4 показана форма загрузки для Bigtable:

Рисунок 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 мы оказываемся в методе doPost сервлета AbstractUploadServlet, который, в свою очередь, вызывает handleSubmit.

Форма готова, поэтому перейдем к сервлету, ее обслуживающему.

Загрузка в Bigtable и из Bigtable

Здесь мы последовательно реализуем три метода. Один отображает только что созданную форму, второй обрабатывает загружаемые ею данные. Последний метод отправляет загруженные данные обратно, чтобы можно было увидеть как все работает.

Сервлет использует библиотеку 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 не скомпилируется, пока мы не реализуем абстрактные методы. Давайте рассмотрим каждый из них, начиная с showForm (см. листинг 8):

Листинг 8. showForm
@Override
protected void showForm(HttpServletRequest req, HttpServletResponse resp) 
    throws ServletException, IOException {
    req.getRequestDispatcher("datastore.jsp").forward(req, resp);        
}

Как можно заметить, showForm просто переадресовывает нашу форму загрузки. Метод handleSubmit, приведенный в листинге 9, более сложен:

Листинг 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.

Вернемся к исходному коду. Когда мы подходим к полю file, ByteArrayOutputStream помогает передать загруженные байты в photoData. Наконец, мы сохраняем Photo с 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 и устанавливает заголовок content-type перед записью массива байтов photoData непосредственно в HTTP-ответ. flushBuffer выгружает все оставшееся содержимое из браузера.

Последнее, что необходимо сделать, – добавить код обработки ошибок для загрузок, превышающих 1 МБ.

Отображение сообщения об ошибке

Как уже говорилось, Bigtable накладывает ограничение 1 МБ, которое трудно не нарушить в большинстве ситуаций, предусматривающих работу с изображениями. Лучшее, что можно сделать, – порекомендовать пользователям изменить размер изображения и повторить попытку. Для демонстрационных целей исходный код, приведенный в листинге 11, просто отображает сообщение при возникновении исключительной ситуации GAE. (Отметим, что это стандартная обработка ошибок в сервлетах, а не специфическая для 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>");
    }
}

Не забудьте зарегистрировать ErrorServlet в web.xml вместе с другими сервлетами, которые мы создадим в этой статье. Код, приведенный в листинге 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 Datastore. Возможно, Bigtable – это самый интуитивно понятный вариант систем хранения GAE, но из-за ограничения на размер файлов (1 МБ на файл) вы вряд ли будете его использовать для чего-то большего, чем эскизы изображений. Следующий вариант системы хранения ключ-значение – система Blobstore, способная работать с файлами размерами до 2 ГБ.


Вариант №2 системы хранения GAE: Blobstore

У Blobstore есть преимущество перед Bigtable, касающееся размера файлов, но она имеет и свои проблемы – вынуждает использовать одноразовый URL загрузки, с которым сложно создавать Web-сервисы. Вот пример того, как это выглядит:

/_ah/upload/aglub19hcHBfaWRyGwsSFV9fQmxvYlVwbG9hZFNlc3Npb25fXxh9DA

Клиенты Web-сервиса перед выполнением к нему запроса POST должны запросить URL, что приводит к дополнительному вызову по сети. Возможно, это небольшая проблема для многих приложений, но выглядит это не слишком элегантно. Это также может оказаться препятствием, когда клиент работает с GAE и процессорное время стоит денег. Если вы думаете, что сможете обойти проблему путем создания сервлета, направляющего загрузки по одноразовому URL посредством URLFetch, подумайте еще раз. URLFetch имеет ограничение 1 МБ, поэтому прекрасно можно было бы использовать Bigtable. Для справки на рисунке 5 показано различие между однократным и двойным вызовами Web-сервисов:

Рисунок 5. Различие между однократным и двойным вызовами Web-сервисов
Рисунок 5. Различие между однократным и двойным вызовами Web-сервисов

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 загрузки генерируется в сервлете, код которого мы напишем далее. Здесь же этот URL извлекается из запроса и помещается в атрибут action формы. Мы не контролируем сервлет Blobstore, в который выполняем загрузку, поэтому как нам получить другие значения формы? Ответ состоит в том, что Blobstore API использует механизм обратных вызовов (callback). Мы передаем URL обратного вызова при генерировании одноразового URL. После загрузки Blobstore извлекает URL обратного вызова, передавая оригинальный запрос вместе со всеми загруженными blob-данными. Вы увидите, как все это работает, после реализации AbstractUploadServlet.

Загрузка в 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. Она генерирует одноразовый URL в showForm (см. листинг 15):

Листинг 15. showForm для blobstore
@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 загрузки. URL обратного вызова устанавливается в контекст этого сервлета, как это было определено в файле web.xml. Таким образом, после возврата Blobstore POST мы оказываемся в handleSubmit (см. листинг 16):

Листинг 16. handleSubmit для Blobstore
@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. showRecord для blobstore
@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 компонент Photo, сохраненный нами в handleSubmit, перезагружается из Blobstore. Реальные загруженные байты не сохраняются в bean-компоненте, как это было в Bigtable. Вместо этого BlobKey модифицируется с учетом photoPath и используется для извлечения изображения в браузер.

В Blobstore очень легко работать с загрузками, основанными на Web-формах, но загрузки, основанные на Web-сервисах – это совершенно другая история. Ниже мы рассмотрим систему Google Storage for Developers, которая представит нам в точности противоположную дилемму: загрузки через Web-формы требуют некоторых усилий, тогда как загрузки через Web-сервисы выполняются легко.


Вариант №3 системы хранения GAE: 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 выходит за рамки данной статьи, поэтому всему, что мы будем загружать, будет назначаться открытый доступ только для чтения. Информация по работе ACL приведена в интерактивной документации Google Storage (см. раздел Ресурсы).

О Google Storage

Google Storage for Developers, выпущенный в виде предварительной версии в мае 2010 года, в настоящее время доступен только ограниченному числу разработчиков в США. Google Storage находится на ранней стадии развития и сталкивается с некоторыми проблемами реализации, которые я рассматриваю в данном разделе. Отсутствие четкой интеграции между Google Storage и GAE означает необходимость написания дополнительного кода, но в некоторых случаях (например, когда требуется управление доступом) затраченные усилия оправданны. Я надеюсь, что мы увидим библиотеки интеграции в ближайшем будущем.

В отличие от Blobstore, Google Storage по умолчанию совместим с Web-сервисами и браузерами. Данные отправляются посредством RESTful-запросов PUT или POST. Первый вариант предназначен для клиентов Web-сервисов, которые могут управлять структурированием запросов и написанием заголовков. Второй вариант, который мы рассмотрим ниже, предназначен для загрузок через браузеры. Нам понадобится модифицировать JavaScript для обработки формы загрузки, что, как вы увидите, представляет некоторые трудности.

Модификация формы загрузки Google Storage

В отличие от Blobstore, Google Storage не выполняет перенаправление по URL обратного вызова после запроса POST. Вместо этого он генерирует переадресацию по указанному нами URL. Это является проблемой, поскольку значения формы не передаются при переадресации. Чтобы обойти эту проблему, можно создать две формы на одной и той же странице: одна будет содержать текстовые поля заголовка и названия, а другая – поле загрузки файла и необходимые параметры Google Storage. Затем для отправки первой формы можно использовать Ajax. После активизации обратного вызова Ajax мы отправим вторую форму загрузки.

Поскольку эта форма сложнее, чем две предыдущие, мы будем создавать ее шаг за шагом. Прежде всего извлечем несколько значений, установленных перенаправляющим сервлетом, который пока не создан (см. листинг 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 содержит конечную точку REST Google Storage. API предоставляет две точки, показанные ниже. Можно использовать любую из них, но нужно заменить компоненты, записанные курсивом, своими собственными значениями:

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

Остальные переменные являются обязательными параметрами Google Storage:

  • key: имя данных, загружаемых в Google Storage.
  • success_action_redirect: куда выполнять переадресацию после завершения загрузки.
  • GoogleAccessId: ключ API, назначенный Google.
  • policy: закодированная в Base64 JSON-строка, указывающая, как загружать данные.
  • signature: политика, подписанная с использованием хеш-алгоритма и закодированная в Base64. Используется для аутентификации.
  • 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), помещаются в скрытые поля. Поле file input находится внизу. Кнопка submit – это старая добрая кнопка, которая не делает ничего, пока мы не активируем ее при помощи 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>

JavaScript в листинге 21 написан с использованием библиотеки JQuery. Даже если вы раньше не имели с ней дела, код не должен вызвать затруднений. Сначала импортируется JQuery. Затем на кнопке устанавливается перехватчик нажатий, чтобы после нажатия кнопки первая форма отправлялась посредством Ajax. Отсюда мы переносимся в метод handleSubmit сервлета (который мы вскоре создадим), где Photo формируется и сохраняется в хранилище данных. Наконец, новый Photo ID возвращается в функцию обратного вызова и добавляется к URL в success_action_redirect перед отправкой формы загрузки. Таким образом, вернувшись после переадресации, мы можем найти Photo и показать его изображение. На рисунке 6 показана полная последовательность событий:

Рисунок 6. Схема последовательности вызовов JavaScript
Рисунок 6. Схема последовательности вызовов JavaScript

Данной форме необходим вспомогательный класс, предназначенный для создания и подписания документов политики. Затем мы можем перейти к созданию подкласса AbstractUploadServlet.

Создание и подписание документа политики

Документы политики ограничивают загрузки. Например, можно указать допустимые размеры загрузок или допустимые типы файлов; можно даже указать ограничения на имена файлов. Открытые хранилища не требуют документов политики, в отличие от закрытых, таких как Google Storage. Чтобы все заработало, создадим вспомогательный класс GSUtils на основе исходного кода, приведенного в листинге 22:

Листинг 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, но этот формат слишком прост, чтобы не прибегать к каким-либо сложным библиотекам. Вместо этого все можно сделать руками при помощи простого StringBuilder. Сначала необходимо создать дату в формате ISO8601 и ограничить ею срок действия документа политики. По истечении этой даты загрузки не будут выполняться. Затем нужно наложить ограничения (о которых мы уже говорили), называемые в документе политики условиями. Наконец, документ кодируется в Base-64 и возвращается запрашивающей стороне.

Добавьте в GSUtils методы из листинга 23:

Листинг 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 в документ политики, чтобы не вписывать его жестко в код. Можно передать любую переменную как аргумент метода. Наконец, документ кодируется с использованием 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
Рисунок 7. Аутентификация загрузок в Google Storage

Для генерирования подписи используются пакеты javax.crypto и java.security, которые мы импортировали при создании GSUtils. В листинге 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-коде – это утомительная процедура, которую мне в данной статье хотелось бы опустить. Важно, что в листинге 24 показано, как это сделать правильно, и что этот хеш перед возвращением должен быть закодирован с использованием Base-64.

После выполнения предварительных требований мы возвращаемся к уже знакомой нам реализации трех абстрактных методов для загрузки и извлечения файлов из Google Storage.

Загрузка в Google Storage

Начнем с создания класса GStorageUploadServlet, основываясь на коде, приведенном в листинге 25:

Листинг 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();
}

Метод showForm, показанный в листинге 26, настраивает параметры, которые необходимо передать в Google Storage через форму загрузки:

Листинг 26. showForm для Google Storage
@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, т.е. все загруженное будет доступно для просмотра всем пользователям. Следующие три переменные, secret, accessKey и endpoint, используются для доступа и аутентификации в Google Storage. Они извлекаются из init-params, объявленного в web.xml; подробности приведены в примере исходного кода. Вспомните, что в отличие от Blobstore, которое выполняет перенаправление по URL, переносящему нас в showRecord, Google Storage выполняет переадресацию. URL переадресации сохраняется в successActionRedirect. При создании URL переадресации successActionRedirect использует вспомогательный helper-метод, приведенный в листинге 27.

Листинг 27. getBaseUrl()
private static String getBaseUrl(HttpServletRequest req) {
    String base = req.getScheme() + "://" + req.getServerName() + ":" + 
        req.getServerPort() + "/";
    return base;
}

Helper-метод просматривает входящий запрос и формирует базовый URL перед передачей управления обратно в showForm. После возвращения создается ключ с уникальным идентификатором или UUID, который имеет тип String с гарантированно уникальным содержимым. Затем при помощи созданного нами вспомогательного класса генерируется политика и подпись. Наконец, мы устанавливаем атрибуты запроса JSP до перенаправления на нее.

В листинге 28 показан handleSubmit:

Листинг 28. handleSubmit для Google Storage
@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();
}

Вспомните, что при отправке первой формы мы помещаем handleSubmit с помощью Ajax POST. Сама загрузка отрабатывается не здесь, а отдельно в функции обратного вызова Ajax. handleSubmit просто извлекает первую форму, формирует Photo и сохраняет его в хранилище данных. Затем идентификатор (ID) этого Photo возвращается функции обратного вызова Ajax путем записи его в тело запроса.

В функции обратного вызова форма загрузки отправляется в конечную точку Google Storage. Обработав загрузку, Google Storage выполняет переадресацию обратно в showRecord (см. листинг 29):

Листинг 29. showRecord для Google Storage
@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 легко работать, но она налагает ограничение на размер файлов – не более 1 МБ. Blob-данные в Blobstore могут иметь размер до 2 ГБ, но одноразовый URL порождает проблему, которую приходится решать в Web-сервисах. Наконец, Google Storage for Developers является самым надежным вариантом. Мы платим только за используемое место в хранилище, а ограничений на объем данных в одном файле практически не существует. Однако Google Storage является наиболее сложным для реализации решением, поскольку его библиотеки в настоящее время не поддерживают GAE. Поддержка загрузок при помощи браузеров тоже не является образцом совершенства.

Поскольку Google App Engine становится все более популярной платформой разработки для Java-разработчиков, важно знать различные варианты его хранилищ данных является очень. В данной статье были рассмотрены простые примеры реализации для Bigtable, Blobstore и Google Storage for Developers. Независимо от того, остановились вы на одном варианте или будете использовать разные варианты в разных ситуациях, теперь у вас есть все необходимые средства для хранения огромного количества данных в GAE.


Загрузка

ОписаниеИмяРазмер
Пример кода для данной статьиj-gaestorage.zip12 КБ

Ресурсы

Научиться

  • Оригинал статьи: GAE storage with Bigtable, Blobstore, and Google Storage (EN).
  • Google App Engine for Java (Рик Хайтауер (Rick Hightower), developerWorks, август 2009 года): написанная чуть более года назад серия трех статей переносит читателей в ранний период развития GAE. Чтение этих статей все еще актуально в плане получения разнообразной информации и понимания того, насколько далеко продвинулся GAE за такое короткое время (EN).
  • Применение GAE в Java development 2.0 (Эндрю Гловер (Andrew Glover), developerWorks): концентрируясь на важных развивающихся технологиях, серия статей Java development 2.0 следует за быстро развивающейся инфраструктурой инструментальных Java-программ Google (EN). Недавно были добавлены связанные с GAE темы по Gaelyk (с Bigtable) и Objectify-Appengine (также с Bigtable).
  • Домашняя страница Google Storage for Developers: познакомьтесь с интерактивной документацией и подпишитесь на предварительную редакцию сервиса Google Storage for Developers, доступную в настоящее время только ограниченному числу разработчиков в США.
  • Google Storage for Developers на Google I/O 2010: инженеры Майк Шварц (Mike Schwartz) и Дэвид Эрб (David Erb) представляют часовое общее введение в Google Storage for Developers.
  • В книжном магазине представлены книги по технологии Java и другим техническим темам.

Получить продукты и технологии

Обсудить

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java
ArticleID=799544
ArticleTitle=Хранилища Bigtable, Blobstore и Google Storage для GAE
publish-date=02292012