Bulkloader — это интерфейс в Google App Engine для загрузки данных из/в хранилище на серверах Google. Bulkloader удобно использовать для бэкапа/восстановления/миграции данных приложения, однако документации и примеров использования катастрофически мало, а на сложном приложении придётся натыкаться на разнообразные проблемы и баги. Сам я довольно долго раскапывал разные источники информации, копался в исходном коде SDK, читал баги, писал свои воркараунды; и теперь готов представить некоторые плоды в виде подробной статьи.
Статья очень большая, имейте в виду.
Я не буду особенно вдаваться в детали создания App Engine приложений, эта тема неоднократно поднималась и примеров можно найти море, в том числе и на русском языке. Однако синтетические примеры обычно плохо воспринимаются, поэтому будем рассматривать «реальное» приложение — движок персонального блога, тема всем известная и понятная. А в качестве формата файлов бэкапа выберем обычный XML.
Пересказа документации здесь также не будет.
Русскоязычной устоявшейся терминологии нет, поэтому я себе позволил некоторую вольность, обзывая «Kind» то «классом», то «типом». «Entity» осталась «сущностью».
Импорт=десериализация=восстановление. Экспорт=сериализация=бэкап.
Здесь и далее используем GAE SDK версии 1.4.1 или выше на локальной unix/linux-машине, на windows всё почти то же самое. При работе с приложением на серверах google есть определённые нюансы, но о них можно прочитать в официальной документации, здесь же мы пока работает только с локальным сервером.
Главные программы GAE SDK (appcfg.py, dev_appserver.py) должны быть доступны для исполнения в консоли (пути к SDK прописаны в соотвествующих переменных окружения, например).
Bulkloader представляет собой фреймворк на Python и для его использования придётся не только писать файлы конфигурации, но ещё и код. Зато, освоив фреймворк, вы получите очень мощный механизм сохранения и восстановления данных с вашего сервера внутри App Engine.
Вы выбираете, в каком формате хранить данные на локальной машине, а bulkloader преобразует их по определённым правилам (импорт и экспорт). В конце статьи есть ссылке, по которым вы можете узнать про bulkloader больше.
Чтобы bulkloader заработал, в приложении сначала нужно включить точку доступа до API. Итак, включаем remote_api, для этого в конфиг приложения (app.yaml) добавляем секцию (если её там нет)
Эта секция включает точку доступа до API по адресу http:// servername/_ah/remote_api, для локального сервера с настройками по умолчанию это будет адрес http:// localhost:8080/_ah/remote_api.
Начнём со схемы данных приложения. Для блога тут всё понятно: Статьи (Article), Комментарии (ArticleComment), Отрендеренные Статьи (RenderedArticle). Комментарии представлены в виде дерева. Отрендеренная в html статья хранится в отдельной сущности хранилища.
Классы сущностей ссылаются друг на друга следующим образом:
Article → RenderedArticle (ссылка на отрендеренную статью в статье)
ArticleComment → Article (ссылка на статью из комментария)
ArticleComment → ArticleComment (ссылка на родительский комментарий)
По моделям видно, что используется много разнообразных типов данных: ссылки двух видов, даты, строки, булевские значения, списки. Забегая вперёд отмечу, что со списками и ссылками возникли самые большие проблемы.
Заполняем базу данными и проверяем работу bulkloader через API:
В параметре -A указываем название приложения, в параметре --email любую строку (для локального сервера она неважна), в параметре --kind — класс сущности, который мы хотим скачать (смотрим на аргумент download_data). После выполнения команды (на запросе пароля просто нажмите Enter) в текущем каталоге появится файл с бэкапом указанном класса сущностей (out.dat) и куча разных логов (файлы вида bulkloader-*). По умолчанию для бэкапа используется формат SQLITE3, результирующий файл (out.dat) вы можете открыть в любом просмотрщике SQLITE3 и поизучать. Его структура малопригодна для практического использования (например, миграции), поэтому мы будем дальше писать конфиг (и другие сопутствующие файлы) для bulkloader, чтобы экспорт данных производился в более удобном для нас формате.
В текущей версии SDK поддерживаются два формата экспорта/импорта данных: CSV и XML, мы будем использовать второй. Конфигурационный файл является уже привычным вам YAML-файлом, в нём описывается, как именно выполняются трансформация данных при экспорте/импорте данных из хранилища. В официальной документации написано, как сгенерировать базисный конфиг из приложения, но мы будем его писать с нуля. Файл этот назовём config.yaml, обычно я создаю отдельный каталог backup в дереве приложения и в нём размещаю всё необходимое, с основным приложением он практически никак не пересекается.
В начале — в секции python_preamble — определяются те python-модули, которые понадобятся в процессе экспорта/импорта. Вот «джентльменский набор» модулей, base64 и re — это стандартные python-модули, google.* — это модули из SDK, а вот helpers — это наш собственный модуль, файл helpers.py, находящийся в текущем каталоге. В helpers.py у нас будут лежать различные воркэраунды и прочие полезные функции для импорта/экспорта данных, но в начале просто создайте пустой файл с таким именем, код добавим позже.
Следующая секция конфига — это transformers, в ней описываются «конвертеры» сущностей в локальный формат бэкапа и наоборот. Здесь вы должны описать все поля класса сущностей, которые вам нужны. Каждый класс сущностей описывается в отдельной секции с именем kind, вот простейший пример такой секции, в ней мы описываем конвертер для класса Article:
Небольшое замечание, поддержка XPath весьма слабая, реально можно использовать только выражения вида «/AAA/BBB/CCC».
Теперь ска��аем данные с сервера, используя только что созданный конфиг (опция --config):
И получим такой итоговый XML, содержащий данные о двух объектах:
Обратите внимание, в XML вошли только те поля, которые мы описали в конфигурационном файле в секции transformers; в нашем случае — это только ключ записи. В параметре export_transform у нас указан конкретный конвертер для этого поля — transform.key_id_or_name_as_string. Это функция из модуля google.appengine.ext.bulkload.transform. Для полей другого типа используются другие функции-конвертеры, причём в качестве такого конвертера может выступать обычное lambda-выражение на питоне.
А теперь целиком кусок конфига, описывающий класс сущности Article:
Давайте разберём его детально. Для каждого поля объекта задаётся параметр property, в котором описываются правила преобразования данных для этого поля.
В параметре external_name задаётся имя соответствующего элемента в XML-файле.
В параметре import_transform — функция для импорта данных, она преобразует данные из бэкапа в нужный тип данных поля. Можно считать, что это десериализация.
В параметре export_transform — функция преобразования поля в текст, который будет записан в бэкап, сериализация данных.
Для простых типов (String, например) явного описания функций импорта и экспорта не нужно, используется стандартная, которой вполне достаточно. Об остальных типах поговорим отдельно.
Начнём с поля rendered_html, оно является, во-первых, ссылкой (reference) на объект другого класса (в нашем случае — RenderedArticle), а во-вторых, этот объект класса RenderedArticle является дочерним по отношению к соответствующему объекту класса Article. Поэтому при десериализации необходимо «сконструировать» валидную ссылку на объект, делается это из значений двух полей при помощи стандартного метода transform.create_deep_key:
Обратите внимание, в параметрах import/export_transform должны стоять выражения, приводящие в итоге к функции, принимающей один аргумент и возвращающей одно значение. А в примере выше мы видим вызов функции с конкретными аргументами, эта функция является своего рода декоратором и возвращает уже подготовленную функцию для преобразования данных. transform.create_deep_key принимает на вход несколько двухэлементых кортежей, каждый из которых отражает один уровень в цепочке отношений объектов, а сам кортеж содержит название класса сущности и название элемента (из XML-файла); из указанных полей происходит формирование ключевого значения.
В нашем случае цепочка состоит из двух объектов, причём мы используем значение transform.CURRENT_PROPERTY, чтобы избавиться от указания имени поля текущего объекта из цепочки отношений. В принципе, вместо transform.CURRENT_PROPERTY вполне можно написать rendered_html.
Поля с датами также требуют особого подхода, но здесь всё просто — используем генераторы функций из SDK, в аргументе указываем шаблон форматирования даты/времени:
Поля со списком строк, здесь для сериализации используется стандартный метод, поэтому ничего писать не надо, а вот для импорта нужен особый подход:
При экспорте (сериализации) список строк преобразуется в элемент такого вида:
Однако пустой список строк преобразуется в пустую строку:
И при импорте с использованием стандартного конвертера пустое поле будет преобразовано в значение None, которое, очевидно, не является валидным списком и вызовет проблемы при попытке прочитать это поле в приложении. Поэтому используем лямбда-выражение, которое выполняет правильное (относительно) преобразование. Впрочем, из-за бага в SDK вам это всё равно мало поможет, поскольку ошибка в валидаторе типа поля.
При работе с булевскими полями также используем простой конвертер при десериализации:
При стандартном экспорте булевские значения преобразуются в строки «True» и «False», а мы при импорте используем даже более общий метод — в значение True преобразуется только строка «True», а все остальные — в False.
Итоговый XML-файл с импортированными объектами класса Article выглядит примерно так:
Отношения или зависимости между объектами выстраиваются при помощи аргумента parent при создании объекта некоторого класса сущностей. Новый объект при этом попадает в ту же группу сущностей, что и указанный в parent. Такой подход позволяет пользоваться, например, транзакциями для сохранения целостности данных. Цепочки отношений при импорте и экспорте необходимо обрабатывать специальным образом. И здесь есть несколько нюансов, которые мы рассмотрим ниже.
Итак, у нас есть класс сущностей Article, объекты этого типа представляют собой статьи, в нём содержится исходный код статьи в языке разметки, небольшое превью и прочая служебная информация. А отрендеренный в html код текст статьи хранится в отдельном объекте класса RenderedArticle. Отделение отрендеренного текста в отдельный класс сущностей было сделано для того, чтобы обойти ограничение на общий размер объекта, принятый в App Engine, и по сути объекты Article и RenderedArticle выступают между собой в отношении «один к одному». Объект RenderedArticle создаётся в той же группе сущностей, что и объект Article.
Вот как выглядит часть конфига config.yaml для класса сущностей RenderedArticle
Обратите внимание на то, как в примере выше описан экспорт данных. Во-первых, одно ключевое поле объекта преобразуется в два элемента в бэкапе. Во-вторых, при импорте ключевое поле «собирается» из значений двух элементов — ParentArticle и key. Код transform.key_id_or_name_as_string_n(0) возвращает функцию, которая в результате исполнения на ключевом поле выдаёт указанный компонент составного ключа.
Сгенерированный XML на основе этого конфига выглядит примерно так:
Теперь рассмотрим экспорт-импорт объекта класса ArticleComment, напомню, что комментарии представляют собой дерево, то есть у комментария может быть «родительский» комментарий, кроме того, у каждого комментария есть ссылка на родительский пост.
На первый взгляд всё выглядит просто, однако на одном моменте «умолчальное» поведение конвертеров ломается. Обратите внимание, что поле parent_comment может быть равно None, что обозначает комментарий верхнего уровня. Если мы в процессе импорта воспользуемся методом transform.create_deep_key, то на значении None получим ошибку:
Про эту ошибку я тоже завёл баг, но пока никакой реакции от разработчиков на него ещё не получил. Для обхода этого бага воспользуется файлом helpers.py, куда поместим замену метода transform.create_deep_key. Воркэраунд очень простой, мы выполняем генерацию ключа, только если значение не равно None:
В комментариях я могу рассказать поподробнее о происходящем в этой функции, если кого заинтересует.
Таким образом, при опциональная ссылка на объект будет восстановлена корректно.
Теперь работаем с полем article, в котором содержится ссылка на статью, которой принадлежат комментарии. Для восстановления ссылки на объект используем метод transform.create_foreign_key, он работает аналогично методу transform.create_deep_key, только без учёта цепочек отношений. Тут хочу обратить внимание на потенциальный баг, если ссылка на объект будет пустая, при восстановлении вы столкнётесь с ровно той же ошибкой, что пару абзацев выше.
Работать с bulkloader уже вполне можно, но очень осторожно. Нужно постоянно следить за анонсами и читать внимательно документацию после каждого релиза SDK, так как не все изменения попадают в ченжлог. Также за бортом остался обзор работы с бинарными данными, но тут всё просто:
В следующий раз поговорим об особенностях локализации в GAE-python-django приложениях.
Статья очень большая, имейте в виду.
Я не буду особенно вдаваться в детали создания App Engine приложений, эта тема неоднократно поднималась и примеров можно найти море, в том числе и на русском языке. Однако синтетические примеры обычно плохо воспринимаются, поэтому будем рассматривать «реальное» приложение — движок персонального блога, тема всем известная и понятная. А в качестве формата файлов бэкапа выберем обычный XML.
Пересказа документации здесь также не будет.
Терминология
Русскоязычной устоявшейся терминологии нет, поэтому я себе позволил некоторую вольность, обзывая «Kind» то «классом», то «типом». «Entity» осталась «сущностью».
Импорт=десериализация=восстановление. Экспорт=сериализация=бэкап.
Подготовка
Здесь и далее используем GAE SDK версии 1.4.1 или выше на локальной unix/linux-машине, на windows всё почти то же самое. При работе с приложением на серверах google есть определённые нюансы, но о них можно прочитать в официальной документации, здесь же мы пока работает только с локальным сервером.
Главные программы GAE SDK (appcfg.py, dev_appserver.py) должны быть доступны для исполнения в консоли (пути к SDK прописаны в соотвествующих переменных окружения, например).
Что такое bulkloader
Bulkloader представляет собой фреймворк на Python и для его использования придётся не только писать файлы конфигурации, но ещё и код. Зато, освоив фреймворк, вы получите очень мощный механизм сохранения и восстановления данных с вашего сервера внутри App Engine.
Вы выбираете, в каком формате хранить данные на локальной машине, а bulkloader преобразует их по определённым правилам (импорт и экспорт). В конце статьи есть ссылке, по которым вы можете узнать про bulkloader больше.
Чтобы bulkloader заработал, в приложении сначала нужно включить точку доступа до API. Итак, включаем remote_api, для этого в конфиг приложения (app.yaml) добавляем секцию (если её там нет)
builtins: - remote_api: on
Эта секция включает точку доступа до API по адресу http:// servername/_ah/remote_api, для локального сервера с настройками по умолчанию это будет адрес http:// localhost:8080/_ah/remote_api.
Схема данных
Начнём со схемы данных приложения. Для блога тут всё понятно: Статьи (Article), Комментарии (ArticleComment), Отрендеренные Статьи (RenderedArticle). Комментарии представлены в виде дерева. Отрендеренная в html статья хранится в отдельной сущности хранилища.
Классы сущностей ссылаются друг на друга следующим образом:
Article → RenderedArticle (ссылка на отрендеренную статью в статье)
ArticleComment → Article (ссылка на статью из комментария)
ArticleComment → ArticleComment (ссылка на родительский комментарий)
class RenderedArticle(db.Model): html_body = db.TextProperty(required=True) class Article(db.Model): shortcut = db.StringProperty(required=True) title = db.StringProperty(required=True) body = db.TextProperty(required=True) html_preview = db.TextProperty() rendered_html = db.ReferenceProperty(RenderedArticle) published = db.DateTimeProperty(auto_now_add=True) updated = db.DateTimeProperty(auto_now_add=True) tags = db.StringListProperty() is_commentable = db.BooleanProperty() is_draft = db.BooleanProperty() class ArticleComment(db.Model): parent_comment = db.SelfReferenceProperty() name = db.StringProperty() email = db.StringProperty() homepage = db.StringProperty() body = db.TextProperty(required=True) html_body = db.TextProperty(required=True) published = db.DateTimeProperty(auto_now_add=True) article = db.ReferenceProperty(Article) ip_address = db.StringProperty() is_approved = db.BooleanProperty(default=False) is_subscribed = db.BooleanProperty(default=False)
По моделям видно, что используется много разнообразных типов данных: ссылки двух видов, даты, строки, булевские значения, списки. Забегая вперёд отмечу, что со списками и ссылками возникли самые большие проблемы.
Проверка работы bulkloader
Заполняем базу данными и проверяем работу bulkloader через API:
appcfg.py download_data --email=doesntmatter -A wereword --url=http://localhost:8080/_ah/remote_api --kind=Article --filename=out.dat
В параметре -A указываем название приложения, в параметре --email любую строку (для локального сервера она неважна), в параметре --kind — класс сущности, который мы хотим скачать (смотрим на аргумент download_data). После выполнения команды (на запросе пароля просто нажмите Enter) в текущем каталоге появится файл с бэкапом указанном класса сущностей (out.dat) и куча разных логов (файлы вида bulkloader-*). По умолчанию для бэкапа используется формат SQLITE3, результирующий файл (out.dat) вы можете открыть в любом просмотрщике SQLITE3 и поизучать. Его структура малопригодна для практического использования (например, миграции), поэтому мы будем дальше писать конфиг (и другие сопутствующие файлы) для bulkloader, чтобы экспорт данных производился в более удобном для нас формате.
Написание конфигурационного файла для bulkloader
В текущей версии SDK поддерживаются два формата экспорта/импорта данных: CSV и XML, мы будем использовать второй. Конфигурационный файл является уже привычным вам YAML-файлом, в нём описывается, как именно выполняются трансформация данных при экспорте/импорте данных из хранилища. В официальной документации написано, как сгенерировать базисный конфиг из приложения, но мы будем его писать с нуля. Файл этот назовём config.yaml, обычно я создаю отдельный каталог backup в дереве приложения и в нём размещаю всё необходимое, с основным приложением он практически никак не пересекается.
В начале — в секции python_preamble — определяются те python-модули, которые понадобятся в процессе экспорта/импорта. Вот «джентльменский набор» модулей, base64 и re — это стандартные python-модули, google.* — это модули из SDK, а вот helpers — это наш собственный модуль, файл helpers.py, находящийся в текущем каталоге. В helpers.py у нас будут лежать различные воркэраунды и прочие полезные функции для импорта/экспорта данных, но в начале просто создайте пустой файл с таким именем, код добавим позже.
python_preamble: - import: base64 - import: re - import: google.appengine.ext.bulkload.transform - import: google.appengine.ext.bulkload.bulkloader_wizard - import: google.appengine.ext.db - import: google.appengine.api.datastore - import: google.appengine.api.users - import: helpers
Следующая секция конфига — это transformers, в ней описываются «конвертеры» сущностей в локальный формат бэкапа и наоборот. Здесь вы должны описать все поля класса сущностей, которые вам нужны. Каждый класс сущностей описывается в отдельной секции с именем kind, вот простейший пример такой секции, в ней мы описываем конвертер для класса Article:
transformers: - kind: Article connector: simplexml # тип коннектора connector_options: # параметры коннектора xpath_to_nodes: "/blog/Articles/Article" # XPath, определяющий путь к элементу с данными для одного объекта style: element_centric # стиль работы с XML, в данном случае — элементо-ориентированный property_map: - property: __key__ external_name: key export_transform: transform.key_id_or_name_as_string
Небольшое замечание, поддержка XPath весьма слабая, реально можно использовать только выражения вида «/AAA/BBB/CCC».
Теперь ска��аем данные с сервера, используя только что созданный конфиг (опция --config):
appcfg.py download_data --email=doesntmatter -A wereword --url=http://localhost:8080/_ah/remote_api --kind=Article --config=test.yaml --filename=Article.xml
И получим такой итоговый XML, содержащий данные о двух объектах:
<?xml version="1.0"?> <blog> <Articles> <Article> <key>6</key> </Article> <Article> <key>8</key> </Article> </Articles> </blog>
Обратите внимание, в XML вошли только те поля, которые мы описали в конфигурационном файле в секции transformers; в нашем случае — это только ключ записи. В параметре export_transform у нас указан конкретный конвертер для этого поля — transform.key_id_or_name_as_string. Это функция из модуля google.appengine.ext.bulkload.transform. Для полей другого типа используются другие функции-конвертеры, причём в качестве такого конвертера может выступать обычное lambda-выражение на питоне.
А теперь целиком кусок конфига, описывающий класс сущности Article:
- kind: Article connector: simplexml connector_options: xpath_to_nodes: "/blog/Articles/Article" style: element_centric property_map: - property: __key__ external_name: key export_transform: transform.key_id_or_name_as_string - property: rendered_html external_name: rendered-html export_transform: transform.key_id_or_name_as_string # deep key! It's required here! import_transform: transform.create_deep_key(('Article', 'key'), ('RenderedArticle', transform.CURRENT_PROPERTY)) - property: shortcut external_name: shortcut - property: body external_name: body - property: title external_name: title - property: html_preview external_name: html-preview - property: published external_name: published export_transform: transform.export_date_time('%Y-%m-%dT%H:%M') import_transform: transform.import_date_time('%Y-%m-%dT%H:%M') - property: updated external_name: updated export_transform: transform.export_date_time('%Y-%m-%dT%H:%M') import_transform: transform.import_date_time('%Y-%m-%dT%H:%M') - property: tags external_name: tags import_transform: "lambda x: x is not None and len(x) > 0 and eval(x) or []" - property: is_commentable external_name: is-commentable import_transform: transform.regexp_bool('^True$') - property: is_draft external_name: is-draft import_transform: transform.regexp_bool('^True$')
Давайте разберём его детально. Для каждого поля объекта задаётся параметр property, в котором описываются правила преобразования данных для этого поля.
В параметре external_name задаётся имя соответствующего элемента в XML-файле.
В параметре import_transform — функция для импорта данных, она преобразует данные из бэкапа в нужный тип данных поля. Можно считать, что это десериализация.
В параметре export_transform — функция преобразования поля в текст, который будет записан в бэкап, сериализация данных.
Для простых типов (String, например) явного описания функций импорта и экспорта не нужно, используется стандартная, которой вполне достаточно. Об остальных типах поговорим отдельно.
Начнём с поля rendered_html, оно является, во-первых, ссылкой (reference) на объект другого класса (в нашем случае — RenderedArticle), а во-вторых, этот объект класса RenderedArticle является дочерним по отношению к соответствующему объекту класса Article. Поэтому при десериализации необходимо «сконструировать» валидную ссылку на объект, делается это из значений двух полей при помощи стандартного метода transform.create_deep_key:
- property: rendered_html external_name: rendered-html export_transform: transform.key_id_or_name_as_string # deep key! It's required here! import_transform: transform.create_deep_key(('Article', 'key'), ('RenderedArticle', transform.CURRENT_PROPERTY))
Обратите внимание, в параметрах import/export_transform должны стоять выражения, приводящие в итоге к функции, принимающей один аргумент и возвращающей одно значение. А в примере выше мы видим вызов функции с конкретными аргументами, эта функция является своего рода декоратором и возвращает уже подготовленную функцию для преобразования данных. transform.create_deep_key принимает на вход несколько двухэлементых кортежей, каждый из которых отражает один уровень в цепочке отношений объектов, а сам кортеж содержит название класса сущности и название элемента (из XML-файла); из указанных полей происходит формирование ключевого значения.
В нашем случае цепочка состоит из двух объектов, причём мы используем значение transform.CURRENT_PROPERTY, чтобы избавиться от указания имени поля текущего объекта из цепочки отношений. В принципе, вместо transform.CURRENT_PROPERTY вполне можно написать rendered_html.
Поля с датами также требуют особого подхода, но здесь всё просто — используем генераторы функций из SDK, в аргументе указываем шаблон форматирования даты/времени:
- property: published external_name: published export_transform: transform.export_date_time('%Y-%m-%dT%H:%M') import_transform: transform.import_date_time('%Y-%m-%dT%H:%M')
Поля со списком строк, здесь для сериализации используется стандартный метод, поэтому ничего писать не надо, а вот для импорта нужен особый подход:
- property: tags external_name: tags import_transform: "lambda x: x is not None and len(x) > 0 and eval(x) or []"
При экспорте (сериализации) список строк преобразуется в элемент такого вида:
<tags>[u'x2', u'another string']</tags>
Однако пустой список строк преобразуется в пустую строку:
<tags></tags>
И при импорте с использованием стандартного конвертера пустое поле будет преобразовано в значение None, которое, очевидно, не является валидным списком и вызовет проблемы при попытке прочитать это поле в приложении. Поэтому используем лямбда-выражение, которое выполняет правильное (относительно) преобразование. Впрочем, из-за бага в SDK вам это всё равно мало поможет, поскольку ошибка в валидаторе типа поля.
При работе с булевскими полями также используем простой конвертер при десериализации:
- property: is_commentable external_name: is-commentable import_transform: transform.regexp_bool('^True$')
При стандартном экспорте булевские значения преобразуются в строки «True» и «False», а мы при импорте используем даже более общий метод — в значение True преобразуется только строка «True», а все остальные — в False.
Итоговый XML-файл с импортированными объектами класса Article выглядит примерно так:
<?xml version="1.0"?> <blog> <Articles> <Article> <body>aaa bbb ccc</body> <updated>2011-01-20T08:19</updated> <key>6</key> <is-draft>False</is-draft> <title>this is new article</title> <html-preview><p>aaa bbb ccc</p></html-preview> <tags></tags> <shortcut>short-cut-1295418565</shortcut> <rendered-html>7</rendered-html> <published>2011-01-19T06:29</published> <is-commentable>True</is-commentable> </Article> <Article> <body>ff gg hh</body> <updated>2011-01-19T06:30</updated> <key>8</key> <is-draft>False</is-draft> <title>another article</title> <html-preview><p>ff gg hh</p></html-preview> <tags>[u'x2']</tags> <shortcut>short-cut-1295418590</shortcut> <rendered-html>9</rendered-html> <published>2011-01-19T06:29</published> <is-commentable>True</is-commentable> </Article> </Articles> </blog>
Работа с цепочками отношений объектов
Отношения или зависимости между объектами выстраиваются при помощи аргумента parent при создании объекта некоторого класса сущностей. Новый объект при этом попадает в ту же группу сущностей, что и указанный в parent. Такой подход позволяет пользоваться, например, транзакциями для сохранения целостности данных. Цепочки отношений при импорте и экспорте необходимо обрабатывать специальным образом. И здесь есть несколько нюансов, которые мы рассмотрим ниже.
Итак, у нас есть класс сущностей Article, объекты этого типа представляют собой статьи, в нём содержится исходный код статьи в языке разметки, небольшое превью и прочая служебная информация. А отрендеренный в html код текст статьи хранится в отдельном объекте класса RenderedArticle. Отделение отрендеренного текста в отдельный класс сущностей было сделано для того, чтобы обойти ограничение на общий размер объекта, принятый в App Engine, и по сути объекты Article и RenderedArticle выступают между собой в отношении «один к одному». Объект RenderedArticle создаётся в той же группе сущностей, что и объект Article.
Вот как выглядит часть конфига config.yaml для класса сущностей RenderedArticle
- kind: RenderedArticle connector: simplexml connector_options: xpath_to_nodes: "/blog/RenderedArticles/Article" style: element_centric property_map: - property: __key__ external_name: key export: - external_name: ParentArticle export_transform: transform.key_id_or_name_as_string_n(0) - external_name: key export_transform: transform.key_id_or_name_as_string_n(1) import_transform: transform.create_deep_key(('Article', 'ParentArticle'), ('RenderedArticle', transform.CURRENT_PROPERTY)) - property: html_body external_name: html-body
Обратите внимание на то, как в примере выше описан экспорт данных. Во-первых, одно ключевое поле объекта преобразуется в два элемента в бэкапе. Во-вторых, при импорте ключевое поле «собирается» из значений двух элементов — ParentArticle и key. Код transform.key_id_or_name_as_string_n(0) возвращает функцию, которая в результате исполнения на ключевом поле выдаёт указанный компонент составного ключа.
Сгенерированный XML на основе этого конфига выглядит примерно так:
<?xml version="1.0"?> <blog> <RenderedArticles> <Article> <ParentArticle>6</ParentArticle> <html-body><p>aaa bbb ccc</p></html-body> <key>7</key> </Article> <Article> <ParentArticle>8</ParentArticle> <html-body><p>ff gg hh</p></html-body> <key>9</key> </Article> </RenderedArticles> </blog>
Теперь рассмотрим экспорт-импорт объекта класса ArticleComment, напомню, что комментарии представляют собой дерево, то есть у комментария может быть «родительский» комментарий, кроме того, у каждого комментария есть ссылка на родительский пост.
- kind: ArticleComment connector: simplexml connector_options: xpath_to_nodes: "/blog/Comments/Comment" style: element_centric property_map: - property: __key__ external_name: key export_transform: transform.key_id_or_name_as_string import_transform: transform.create_deep_key(('Article', 'article'), ('ArticleComment', transform.CURRENT_PROPERTY)) - property: parent_comment external_name: parent-comment export_transform: transform.key_id_or_name_as_string import_transform: helpers.create_deep_key(('Article', 'article'), ('ArticleComment', transform.CURRENT_PROPERTY)) - property: article external_name: article export_transform: transform.key_id_or_name_as_string import_transform: transform.create_foreign_key('Article') - property: name external_name: name - property: body external_name: body
На первый взгляд всё выглядит просто, однако на одном моменте «умолчальное» поведение конвертеров ломается. Обратите внимание, что поле parent_comment может быть равно None, что обозначает комментарий верхнего уровня. Если мы в процессе импорта воспользуемся методом transform.create_deep_key, то на значении None получим ошибку:
BadArgumentError: Expected an integer id or string name as argument 4; received None (a NoneType).
Про эту ошибку я тоже завёл баг, но пока никакой реакции от разработчиков на него ещё не получил. Для обхода этого бага воспользуется файлом helpers.py, куда поместим замену метода transform.create_deep_key. Воркэраунд очень простой, мы выполняем генерацию ключа, только если значение не равно None:
def create_deep_key(*path_info): f = transform.create_deep_key(*path_info) def create_deep_key_lambda(value, bulkload_state): if value is None: return None return f(value, bulkload_state) return create_deep_key_lambda
В комментариях я могу рассказать поподробнее о происходящем в этой функции, если кого заинтересует.
Таким образом, при опциональная ссылка на объект будет восстановлена корректно.
Теперь работаем с полем article, в котором содержится ссылка на статью, которой принадлежат комментарии. Для восстановления ссылки на объект используем метод transform.create_foreign_key, он работает аналогично методу transform.create_deep_key, только без учёта цепочек отношений. Тут хочу обратить внимание на потенциальный баг, если ссылка на объект будет пустая, при восстановлении вы столкнётесь с ровно той же ошибкой, что пару абзацев выше.
Заключение
Работать с bulkloader уже вполне можно, но очень осторожно. Нужно постоянно следить за анонсами и читать внимательно документацию после каждого релиза SDK, так как не все изменения попадают в ченжлог. Также за бортом остался обзор работы с бинарными данными, но тут всё просто:
- property: data external_name: data export_transform: base64.b64encode import_transform: transform.blobproperty_from_base64
В следующий раз поговорим об особенностях локализации в GAE-python-django приложениях.
Ссылки
- Главная страница документации по bulkloader
- PDF с презентацией bulkloader на Google IO'2010, настоятельно рекомендую посмотреть, там детально описана вся схема работы этой подсистемы
- Демо-сайт bulkloader, примеры с презентации
- Тестовый проект, на котором я «обкатываю» методы работы с GAE.
