понедельник, 3 ноября 2008 г.

Couchdb — первые шаги

Последний год то тут, то там появляются посты/новости о couchdb — одной из многих реализаций нереляционных, или документо-ориентированных СУБД. Основной идеей, и главным отличием от традиционных, реляционных, систем является отсутствие строго определённой структуры данных, хранимых в базе. В этом посте попробую рассмотреть идею нереляционных БД в общем, и пример использования couchdb совместно с питоном для хранения некоторых данных.

Документо-ориентированные СУБД

Определение

Wikipedia утверждает:

В отличии от реляционных баз данных, в которых данные организованы в виде таблиц, состоящих их записей одинаковой структуры (набор/размеры полей), в документо-ориентированных БД каждая запись хранится в виде документа, имеющего некоторые характеристики. Каждый документ может иметь произвольное количество полей любой длинны. Так же поля могут содержать структуры, объединяющие несколько значений.

То есть структура позволяет хранить в пределах одной базы, как аналогичные данные, например список пользователей в виде записей, условно выглядящих как

{type: 'user', username: 'foo', email: 'foo@example.com', active: true, hobby: 'street magic'}
{type: 'user', username: 'bar', email: 'bar.baz@example.com', active: false, age: 25, country: 'USA'}

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

Зачем это нужно? (и нужно ли вообще?)

Да, нужно. Хоть и не всегда. Можно предположить, что это может быть полезно для систем, работающих с большим количеством разнородных данных и необходимостью унифицированного способа обработки их (об этом дальше — в секции о views). Так же такой подход может быть полезен для систем, хранящих малосвязанные данные. При этом Вы получаете высокую скорость добавления/выборки данных даже при огромных размерах самой БД (по данным из рассылки couchdb-user максимальный размер базы, заявленный на сегодняшний день — 120ГБ, при времени отклика до 200мс на все запросы). Насколько я понимаю, документо-ориентированный подход плохо подходит для хранения взаимосвязанных систем частоизменяемых данных. Решение о выборе парадигмы хранения данных для каждой конкретной системы стоит принимать, внимательно оценив все “за” и “против” альтернативных подходов.

Например, традиционное приложение веб-блога, могло бы хранить данные о постах в следующей структуре:

{ type: 'post', subject: "I like Plankton", author: "Rusty", 
  date: "2006-08-15T17:30:12-04:00", tags: ["plankton", "baseball", "decisions"], 
  body: "I decided today that I don't like baseball. I like plankton"]}

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

Возможности couchdb, резко отличающие её от реляционных СУБД

К упомянутым отличиям можно добавить:

  • RESTful JSON API. Взаимодействие с БД происходит по HTTP с использованием json, что предоставляет огромное пространство для разработки клиентских приложений для couchdb. Так, пример блогодвижка, реализованного средствами couchdb + html/js/css, можно посмотреть в отличной презентации CouchDB Tech Talk by Chris Anderson; и пост на эту же тему: http://jchris.mfdz.com/posts/128
  • Распределённость. couchdb полностью написана на erlang. Это подразумевает беcпроблемную масштабируемость, свойственную большинству erlang-приложений, поэтому один сервер можно без проблем запускать на целом кластере, не беспокоясь о целостности данных. Кроме этого, couchdb поддерживает двунаправленную репликацию с offline-клиентами, то есть имеется возможность держать несколько независимых серверов, синхронизирующих информацию, например, по расписанию. При этом имеется встроенная система обнаружения и обработки конфликтов при репликации.
  • map/reduce механизм построения индекса, даёт возможность гибкой настройки выборок даже из произвольно разрозненных данных при помощи map и reduce функций (о них дальше), реализованных на любом языке.
  • Futon — встроенное веб-приложение для просмотре и управления базой данных. Предоставляет симпатичный js-интерфейс ко всем основным функциям СУБД. В том числе позволяет просматривать содержимое хранящихся документов, в т.ч. определений вьюх, а так же является отличным местом для их отладки.

Индекс, выборки, map, reduce

При добавлении документа в базу, ему присваивается некоторый уникальный идентификатор, который хранится в поле ‘_id’ документа. Наипростейшая выборка документа из базы — по его _id — выглядит как GET запрос по адресу

http://<couchdb_host>:5984/<db_name>/<id>`

Результатом такого запроса к базе, содержащей логи jabber-конференции, может быть запись:

{"_id":"0005b464fa851f2fdadf159822302fc5","_rev":"1796596029",
  "author": {"nick":"xa4a",  
             "uid":"pythonua@conference.jabber.ru\/xa4a"}, 
  "timestamp":"2008-10-21T21:41:08Z", "chatroom":"pythonua@conference.jabber.ru", 
  "message":"\u044d..", "Type":"chat_log", "event":"chat"}

Но выбирать документы только по одному ключу, не несущему никакой содержательной информации, очевидно, не интересно. Для более сложных выборок используется механизм map/reduce функций. В двух словах, он сводится к отображению каждого документа по некоторому правилу (map-функция) в произвольное количество пар ключ-значение и, возможно, последующего применения reduce функции для обработки групп значений, соответствующих одинаковым ключам.

Рассмотрим пример: пусть мы имеем базу записей адресной книги с полями first, last, phone. Как упоминалось ранее, мы можем выбирать записи только по их уникальному идентификатору. Пусть мы хотим иметь возможность выборки (создать соответствующий view) по имени/фамилии (поля first/last). Так как выборка из view всегда производится по ключу, то нам необходимо построить его таким образом, чтобы необходимые поля были ключами, а соответствующие записи — значениями. Например, для записей

{first: 'Vasiliy', last:'Pupkin', phone: '+38(093)7531919'}
{first: 'John', last: 'Doe', phone: '+38(091)3055003'}

мы хотим получить вью вида

{key: 'Vasiliy', value: {first: 'Vasiliy', last:'Pupkin', phone: '+38(093)7531919'}}
{key: 'Pupkin', value: {first: 'Vasiliy', last:'Pupkin', phone: '+38(093)7531919'}}
{key: 'John', value: {first: 'John', last: 'Doe', phone: '+38(091)3055003'}
{key: 'Doe', value: {first: 'John', last: 'Doe', phone: '+38(091)3055003'}

так, что выборка по имени/фамилии получается выборкой по ключу из этой вьюхи. Для этого необходимо использовать map функцию, первый и единственный аргумент которой — обрабатываемый документ. Результатом работы этой функции должен быть вызов(ы) функции emit(key, value). На JavaScript, который является языком по умолчанию для map/reduce функций, это выглядит так:

function(doc) {
  emit(doc.first, doc);
  emit(doc.last, doc);
}

Для примера использования reduce можно придумать надуманную задачу подсчёта количества контактов, обслуживаемых одним оператором (одинаковый код в скобках). Для этого сначала используется map функция, сопоставляющая каждому документу код его оператора, например emit(doc.phone.substring(4,7), doc). После этого, применив reduce функцию, с двумя аргументами[1]key, values, где values — список значений из map-функции, соответствующих ключу key, возвращающую единственное значение — соответствующее значению в результирующей выборке для данного ключа, на подобие:

function(key, values) {
  return values.length;
}

То есть для каждого ключа — кода оператора, вернули кол-во записей соответствующих этому коду. В результате - ответ:

{"rows":[{"key":"091","value":1},
         {"key":"093","value":1}]}

Возможно, не сразу видно, что механизм map/reduce функций очень мощный, но с его помощью можно строить довольно сложные выборки. Пример можно увидеть в Couchdb Joins или далее в этом посте.

Также неплохие примеры map/reduce есть в родных тестах.

Последнее, что хочу отметить тут — это то, что обработка map/reduce функциями может осуществляться внешними приложениями, что даёт возможность написания их на любом ЯП, в т. ч. C, Python, Ruby, PHP.

Мой опыт

Я решил опробовать couchdb в общеобразовательных целях, т.к. реальной необходимости пока не возникло. Объектом применения почти сразу выбрал систему хранения логов jabber-конференций ботом, о котором писал ранее.

Требования

Базовые функции такой системы должны включать: хранение сообщений (чат + presence) с соответствующими свойствами: временем, автором, названием конференции и пр., возможность просмотра логов конкретной конференции за определённую дату, то есть фильтрация сообщений по этому признаку.

Например, запись в такой структуре позволяет решать соответствующие задачи:

{"_id":"0009d0e15948c516f09c77ca4bfca752","_rev":"1287101409",
  "author": {"nick":"A2K",
             "uid":"pythonua@conference.jabber.ru\/A2K"}, "timestamp":"2008-10-30T18:39:16Z", 
  "chatroom":"pythonua@conference.jabber.ru", 
  "message":"\u043e\u043a\u043e\u043b\u043e 3 \u043c\u0438\u043b\u043b\u0438\u043e\u043d\u043e\u0432 \u0432\u0440\u043e\u0434\u0435", 
  "Type":"chat_log", "event":"chat"}

Реализация

Теперь, чтобы построить выборки нам нужно только оформить соответствующие им map и reduce функции. Так как знания python у меня более уверенные, чем javascript, то его и выбрал для написания данных аггрегирующих функций.

На этом месте в игру вступает небольшая, но неоценимая в процессе ознакомления с couchdb библиотека couchdb-python. Она состоит из трёх частей:

  • couchdb.client — непосредственно couchdb-клиента, который выполняет рутинные задачи обмена данными по HTTP и JSON-кодирования
  • couchdb.schema — простенький ORM для конвертирования couchdb-документов в питоновские объекты, и наоборот
  • couchdb.view — реализация view-сервера, то есть того самого внешнего приложения, которое выполняет преобразования документов в соответствии с map/reduce функциями

Тут первым делом настраиваем couchdb для обработки view, помеченных полем language: "python" соответствующим view-сервером. Для этого в /etc/couchdb/couch.ini в разделе [Couch Query Servers] добавляем строку python=/usr/local/bin/couchdb_python_view_server, указывающую на упомянутый couchdb.view. После этого можно смело добавлять вьюхи, написанные на питоне, и ожидать что они будут работать как надо.

Для решения своей задачи я использовал три вью, выполняющие:

Выборку списка логируемых конференций, например:

{"rows":[{"key":"byteflow-en@conference.jabber.ru","value":null},
         {"key":"ef@conference.jabber.org","value":null},
         {"key":"pythonua@conference.jabber.ru","value":null}
   ]}

Для заданной конференции и, возможно, части даты (год, месяц) выбор доступных уточнений даты (год, месяц, день), например:

{"rows":[{"key":["byteflow-en@conference.jabber.ru"],"value":[2008]},
         {"key":["byteflow-en@conference.jabber.ru",2008],"value":[10,11]},
         {"key":["byteflow-en@conference.jabber.ru",2008,10],"value":[28,26,30,20,22,21,29,24,23,27,18,31,19]},
         {"key":["byteflow-en@conference.jabber.ru",2008,11],"value":[4,1,3,2]}
]}

так, для этого вью в качестве ключа можно использовать как просто название конференции, так и наборы и названия и года, названия и года с месяцем.

Третья выборка — выборка непосредственно записей лога для заданной конференции и даты, то есть по ключу, вроде ["byteflow-en@conference.jabber.ru",2008,10,18]

Для получения таких вьюшек было использовано определение, которое после расстановки отступов выглядит как:

{                                                                                                             
    "_id":"_design\/chat_logs",                                                                               
    "_rev":"589976553",
    "language":"python",
    "views":{
        "by_chatroom_date":{
            "map": "def fun(doc):
 from datetime import datetime
 if doc['Type']=='chat_log' and doc['event']=='chat':
  tstamp = datetime.strptime(doc['timestamp'],'%Y-%m-%dT%H:%M:%SZ')
  yield ([doc['chatroom'],tstamp.year,tstamp.month,tstamp.day], doc)"

        },
        "all":{
            "map":"def fun(doc): yield (None, doc)"
        },
        "chatrooms":{
            "map":"def fun(doc):
 if doc['Type'] == 'chat_log':
  yield (doc['chatroom'],None)",
            "reduce":"def fun(key, values): return None"

        },
        "dates_for_chatroom":{
            "map":"def fun(doc):
 from datetime import datetime
if doc['Type'] == 'chat_log' and doc['chatroom']:
  tstamp = datetime.strptime(doc['timestamp'], '%Y-%m-%dT%H:%M:%SZ')
  yield [doc['chatroom']], tstamp.year
  yield [doc['chatroom'],tstamp.year],tstamp.month
  yield [doc['chatroom'],tstamp.year,tstamp.month], tstamp.day

",
            "reduce":"def fun(keys, values, combine):
 from operator import add
 result = values
 if combine:
  values = reduce(add, values)
 result = [ _x for _x in values if not _x in locals()['_[1]'] ]
 return result
"
        }
    },
}

На этом работа с самой СУБД закончена. Остаётся только организовать обновление данных и интерфейс для их просмотра.

Заполнение базы — это, пожалуй, самая простая часть. Для этого можно использовать couchdb.schema, определив структуру записи как:

 from couchdb.schema import * 
 class LogItem(Document):
    Type = TextField(default='chat_log')
    event = TextField(default='chat')
    chatroom = TextField()
    timestamp = DateTimeField(default=datetime.now())
    author = DictField(Schema.build(
        nick = TextField(),
        uid = TextField()
        ))
    message = TextField()

Теперь добавление записи сводится к заполнению соответствующих полей инстанса класса LogItem и вызову его метода .store()

В результате получаем гибкое, высокоскоростное хранилище, способное работать с большим количеством данных. Так, на момент написания, база логов содержит около 28 000 записей, и со всеми индексами занимает 170MB (около 6КБ на запись, для хранения данных во всех вью). При этом время ответа, кроме первого вызова, вызывающего инкрементальную индексацию, не превышает 50-80мс для любых запросов. Набросок интерфейса для отображения данных можно посмотреть на http://xa4a.org.ua/logs-ng/

[1] — на самом деле, существует вторая вариация функции reduce() — с третьим аргументом, называемым combine или rereduce. Если использовать этот вариант, то combine = true означает, что на вход в списке values поступили значения, полученные из предыдущих вызовов reduce для соответствующего ключа, что может повлиять на логику обработки этих значений.

Ссылки:

http://incubator.apache.org/couchdb/ — домашняя страничка проекта
http://wiki.apache.org/couchdb/ — основной источник документации
http://code.google.com/p/couchdb-python/ — couchdb-python. Исчерпывающая документация внутри в доктестах

Комментариев нет:

Отправить комментарий