Как стать автором
Обновить

Использование bulkloader для бэкапа, восстановления и миграции данных

Время на прочтение13 мин
Количество просмотров1.5K
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

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 приложениях.

Ссылки
Теги:
Хабы:
Всего голосов 35: ↑34 и ↓1+33
Комментарии3

Публикации