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. Исчерпывающая документация внутри в доктестах



Comments
Comment form for «couchdb-intro»