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.