Вирішив на дозвіллі в робочий час поколупати CouchDB, якщо NoSQL вам в новинку, то ця статейка для вас виявиться корисною.

Встановлення CouchDB описувати не буду, власне його складанням я і не займався. Перейдемо відразу до Futon’у

Futon

Після складання і запуску CouchDB за адресою http://localhost:5984/_utils/ вам буде доступний web-інтерфейс для управління БД — це і є Futon. За допомогою даного інструменту ми і буде проводити всі описані далі маніпуляції.

Створення бази даних

Тут начебто все просто, натискаємо Create Database:

Спостерігав дивний баг, коли спробував створити БД “example” і “example/users”, Futon лаявся, я не зрозумів… Можливо він погано дружить з БД в назві яких присутня слеш.

Створення документів

Тепер пора створювати документи за якими будемо здійснювати подальший пошук. Візьмемо тривіальну задачу — будемо «забивати» бібліотеку:

Id документа повинен бути рядком, з цієї причини Id=1 укладено в лапки, звичайно, можна використовувати і автоматично генерується значення, і це навіть правильно (інакше вам доведеться стежити за ними на рівні програми), але аж надто вони громіздкі, з цієї причини я буду писати свої.

Трохи згодом розумієш, що необхідно зберігати не просто текстове поле, а набір даних, нам знадобиться масив або навіть об’єкт (після і перед скобочками необхідно ставити пробіл або перенесення каретки):

Тепер можемо трохи потренуватися опитуючи сервер:

Інформація про БД — http://localhost:5984/simple/:

{
“db_name”:”simple”,
“doc_count”:4,
“doc_del_count”:1,
“update_seq”:9,
“purge_seq”:0,
“compact_running”:false,
“disk_size”:36953,
“instance_start_time”:”1284023758033358″,
“disk_format_version”:5,
“committed_update_seq”:9
}

Висновок записи по Id — http://localhost:5984/simple/1/:

{
“_id”:”1″,
“_rev”:”3-eef52c024c2b27dadda90e0a510015b8″,
“title”:”Равлик на схилі”,
“ISBN”:[“978-5-17-057930-3″,”978-5-403-00677-4”]
}

Виведення всіх записів — http://localhost:5984/simple/_all_docs:

{“total_rows”:4,”offset”:0,”rows”:[
{“id”:”1″,”key”:”1″,”value”:{“rev”:”3-eef52c024c2b27dadda90e0a510015b8″}},
{“id”:”2″,”key”:”2″,”value”:{“rev”:”2-ccf196a429e2f118a7b558eb049e5773″}},
{“id”:”3″,”key”:”3″,”value”:{“rev”:”1-d49358e63c151f2f3ea7066146f24a66″}},
{“id”:”44″,”key”:”44″,”value”:{“rev”:”1-c0d28980139f7f4c372ca86306147b55″}}
]}

Так само є кілька додаткових опцій, але на них зупинимося трохи пізніше.

REST API

Але не Fucon’ом єдиним, CouchDB надає REST інтерфейс для роботи з базою (власне, Fucon через нього і працює, можете подивитися в консолі Firebug a — дуже наочно), а це означає приблизно наступне:

  • Якщо ми хочемо отримати інформацію — відправляємо GET запит (як у прикладах вище)
  • Якщо треба створити документ — POST
  • Необхідно щось змінити — PUT
  • COPY копіювання
  • DELETE для видалення

Що б не бути голослівним наведу приклад створення і зміни документа:

# створюємо документ, UUID генерується автоматично
curl -X POST http://localhost:5984/simple -d ‘{“title”:”Hello World!!!”}’ -H “Content-Type:application/json”
>> {“ok”:true,”id”:”dbc21298bfb3f78ebe815cdb7000ae98″,”rev”:”1-f33e3c9b23e594593e93181763f0fb1b”}
# створюємо документ, UUID задаємо явно
curl -X PUT http://localhost:5984/simple/hello -d ‘{“title”:”Hello World!”}’ -H “Content-Type:application/json”
>> {“ok”:true,”id”:”hello”,”rev”:”1-f33e3c9b23e594593e93181763f0fb1b”}
# одержання документа
curl -X GET http://localhost:5984/simple/hello
>> {“_id”:”hello”,”_rev”:”1-f33e3c9b23e594593e93181763f0fb1b”,”title”:”Hello World!!!”}
# редагування документа, необхідно вказувати ревізію редагованого документа
curl -X PUT http://localhost:5984/simple/hello -d ‘{“_rev”:”1-f33e3c9b23e594593e93181763f0fb1b”,”body”:”My world”}’ -H “Content-Type:application/json”
>> {“ok”:true,”id”:”hello”,”rev”:”2-493961fb199ea564f532de44d8be2650″}
# копіювання документа
curl -X COPY http://localhost:5984/simple/hello -H “Destination:hello2”
>> {“id”:”hello2″,”rev”:”1-1053fa7d0d20242aeeb1ba24ffd94977″}
# одержання всіх документів
curl -X GET http://localhost:5984/simple/_all_docs
{“total_rows”:2,”offset”:0,”rows”:[
{“id”:”hello”,”key”:”hello”,”value”:{“rev”:”2-493961fb199ea564f532de44d8be2650″}},
{“id”:”hello2″,”key”:”hello2″,”value”:{“rev”:”1-1053fa7d0d20242aeeb1ba24ffd94977″}}
]}
# видалення документа
curl -X DELETE http://localhost:5984/simple/hello2?rev=1-1053fa7d0d20242aeeb1ba24ffd94977
>> {“ok”:true,”id”:”hello2″,”rev”:”2-72d1c6aa20574c9444eae3d90715a862″}
# отримання UUID
curl -X GET http://localhost:5984/_uuids?count=1
>> {“uuids”:[“dbc21298bfb3f78ebe815cdb70014788”]}

View

Але повернемося до Fucon’у — настала черга створити view, йдемо в пункт Temporary View…”:

Потім створюємо map функцію:

function(doc) {
emit(doc.title, doc.genre);
}

Функція emit приймає в якості параметрів ключ і значення, цей параметри можуть бути простими, як у прикладі вище, або складовими — масив або об’єкт. Історію чому функція носить таке ім’я я розповісти не можу, зватися б їй push

Зберігаємо view як документ _design/books з ім’ям genre — таки view це звичайний документ в нашій БД, можемо його навіть помацати:

{
“_id”: “_design/books”,
“_rev”: “1-532d13f2b2ef9dea495df8230f31b0bf”,
“language”: “javascript”,
“views”: {
“genre”: {
“map”: “function(doc) {\n emit(doc.title, doc.genre);\n}”
}
}
}

Результатом нашої роботи буде наступний набір даних:

// URL: http://localhost:5984/simple/_design/books/_view/genre
// UTF я привів до російської
{“total_rows”:5,”offset”:0,”rows”:[
{“id”:”44″,”key”:”Майбутнє ХХ століття. Дослідники”,”value”:”fantastic”},
{“id”:”55″,”key”:”Вінні-Пух”,”value”:”child”},
{“id”:”2″,”key”:”Пікнік на узбіччі”,”value”:”fantastic”},
{“id”:”3″,”key”:”Важко бути богом”,”value”:”fantastic”},
{“id”:”1″,”key”:”Равлик на схилі”,”value”:”fantastic”}
]}

Тим хто з SQL знаком

ORDER BY

CouchDB сортує вибірку по ключу переданому в функцію emit першим параметром. Отже, якщо нам треба сортувати по назві книги, то map функцію доведеться змінити:

function(doc) {
emit(doc.title, {title:doc.title isbn:doc.ISBN});
}

Так само можна задати складовою ключ:

function(doc) {
emit([doc.genre, doc.title], {title:doc.title isbn:doc.ISBN});
}

Для зворотного порядку є параметр ?descending=true

WHERE

Більшість логіки лягає безпосередньо на функцію map — саме вона у відповіді за умови пошуку:

function(doc) {
if (doc.authors.length > 1)
emit(doc._id, {title:doc.title isbn:doc.ISBN});
}

Але так само є можливість простої фільтрації по ключу:

function(doc) {
// ключем буде ціна книги
emit(doc.price, {title:doc.title isbn:doc.ISBN});
}

Отримати книги з певною ціною: ?key=235
Отримати всі книги з діапазону цін: ?startkey=200&endkey=300

Якщо у нас складовою ключ, то startkey і endkey повинні бути складовими: ?startkey=[100]&endkey=[300] (фільтр накладається лише на перший елемент складеного ключа)

Якщо ми маємо справу з текстовим ключем, то можна отримати аналогічну конструкцію LIKE “Ul%” використовуючи наступний запит: ?startkey=”ul”&endkey=”UL\ufff0″.

Порядок сортування ключів наступний:

` ^ _ – , ; : ! ? . ‘” ( ) [ ] { } @ * / \ & # % + | ~ $ 0 1 2 3 4 5 6 7 8 9
a A b B c C d D e E f F g G h H i I j J k K l L m M n N o O p P q Q r R s S t T u U v V w W x X y Y z Z

Більше інформації про сортування знайдете в wiki: View сollation

LIMIT і OFFSET

Існують наступні параметри ?limit=5&skip=10, начебто те що потрібно, але, не ведіться на цю провокацію, CouchDb все одно буде зчитувати пропущені документи, а лише відображати їх не буде. Правильним є використання параметра startkey + limit. Алгоритм наступний:

  • Вибираємо необхідну кількість елементів rows_per_page + ще один
  • Відображаємо rows_per_page елементів користувачеві
  • Запасний елемент (ключ) використовуємо для отримання next_startkey (тобто посилання на наступну сторінку)
  • Ключ першого елемента використовуємо для отримання посилання на попередню сторінку

SUM, COUNT

Для подібних обчислень нам знадобиться створити reduce функцію, саме вона може обробити результати роботи map функції. Давайте обчислимо кількість книг:

// map
function(doc) {
// ключем буде жанр книги
emit(doc._id, doc.title);
}
// reduce
function(keys,values) {
return values.length;
}
// результат
{“rows”:[
{“key”:null,”value”:4}
]}
// можна ще спростити даний приклад

Або краще середню вартість книг:

// map
function(doc) {
emit(doc._id, doc.price);
}
// reduce
function(keys,values) {
return Math.floor(sum(values)/values.length);
}
// результат
{“rows”:[
{“key”:null,”value”:233}
]}

Якщо потреби в reduce немає, то можна вимкнути за допомогою параметра ?reduce=false

У функції reduce є ще третій параметр — rereduce. Наскільки я зрозумів, у тому випадку, коли у нас дуже велика база, документи в reduce функцію будуть надходити пачками, і поки rereduce=false у нас все добре, і функція працює як звичайно, але коли rereduce=true то до нас на вхід потраплять в якості values проміжні дані обчислень.

GROUP BY

Для організації угрупування нам знадобиться reduce функція і параметр ?group=true. Наведу приклад обчислення середньої вартості книг, з угрупованням за жанром:

// map
function(doc) {
// ключем буде жанр книги
emit(doc.genre, doc.price);
}
// reduce
function(keys,values) {
return Math.floor(sum(values)/values.length);
}
// результат
{“rows”:[
{“key”:”child”,”value”:145},
{“key”:”fantastic”,”value”:263}
]}

Так само можлива угруповання по складеному ключу, тут стане в нагоді параметр ?group_level=1, з його допомогою можна вказувати рівень групування, наведу приклад з керівництва:

// у нас є наступні ключі
[“a”,1,1]
[“a”,3,4]
[“a”,3,8]
[b,2,6]
[b,2,6]
[“c”,1,5]
[“c”,4,2]

При ?group_level=1 функція reduce буде запущена 3 рази, по одному разу для кожного з наступних сетів:

// set “a”
[“a”,1,1]
[“a”,3,4]
[“a”,3,8]
// set “b”
[b,2,6]
[b,2,6]
// set “c”
[“c”,1,5]
[“c”,4,2]

При ?group_level=2 функція reduce буде запущена 5 разів, по одному разу для кожного з наступних сетів:

// set a, 1
[“a”,1,1]
// set “a”, 3
[“a”,3,4]
[“a”,3,8]
// set b, 2
[b,2,6]
[b,2,6]
// set “c”,1
[“c”,1,5]
// set “c”,4
[“c”,4,2]

Повертаючись до наших книг, то можна зробити такий фінт вухами (?group_level=2):

// map
function(doc) {
// перший параметр ключа – жанр, другий – в сотнях ціна
emit([doc.genre, Math.floor(doc.price/100)], doc.price);
}
// reduce
function(keys,values) {
return Math.floor(sum(values)/values.length);
}
// результат
{“rows”:[
{“key”:[“child”,1],”value”:145},
{“key”:[“fantastic”,2],”value”:244},
{“key”:[“fantastic”,3],”value”:302}
]}

Пов’язані документи

Тепер треба б додати авторів книг, але якось не дуже хочеться додавати до кожного документу об’єкт з усіма властивостями, так і хочеться створити нову таблицю «користувачі», але в нас не та БД. З цієї причини створюємо нові документи з атрибутом type=author:

{
“_id”: “author_1”,
“name”: “Alan Alexander Milne”,
“type”: “author”
}

У книгах додаємо type=book і посилання на автора:

{
/*…*/
“authors”: [
“author_1”
],
“type”: “book”
}

Тепер треба створити view, щоб поверталися лише книги:

function(doc) {
if (doc.type != “book”) return;
emit(doc._id, {title:doc.title isbn:doc.ISBN});
}

Щоб отримати пов’язані документи (авторів з нашого прикладу) у функцію emit необхідно передати як параметр об’єкт наступного виду:

{
_id:”author_2″
}

А map функція матиме наступний вигляд:

function(doc) {
if (doc.type != “book”) return;
emit([doc._id, ‘book’, 0], {title:doc.title isbn:doc.ISBN});
if (doc.authors) {
for (var i in doc.authors) {
emit([doc._id, ‘author’, Number(i)+1], {_id:doc.authors[i]})
}
}
}

Але і цього мало, запит при цьому повинен мати вигляд http://localhost:5984/simple/_design/books/_view/all?include_docs=true:

{“total_rows”:14,”offset”:0,”rows”:[
{
“id”:”1″,
“key”:[“1″,”author”,1],
“value”:{“_id”:”author_2″},
“doc”:{“_id”:”author_2″,”_rev”:”1-d39dd295bf1b91f27e329362a727eaf8″,”name”:”Arcadiy Strugackiy”,”type”:”author”}
},
{
“id”:”1″,
“key”:[“1″,”author”,2],
“value”:{“_id”:”author_3″},
“doc”:{“_id”:”author_3″,”_rev”:”1-eeaadba334acb0afdd5d4d7a6eb3a11b”,”name”:”Boris Strugackiy”,”type”:”author”}
},
{
“id”:”1″,
“key”:[“1″,”doc”,0],
“value”:{“title”:”Равлик на схилі”,”isbn”:[“978-5-17-057930-3″,”978-5-403-00677-4”]},
“doc”:{
“_id”:”1″,
“_rev”:”8-1777bfc0f098b2382543ec5e48bc3b44″,
“title”:”Равлик на схилі”,”ISBN”:[“978-5-17-057930-3″,”978-5-403-00677-4”],
“genre”:”fantastic”,”price”:235,”type”:”book”,
“authors”:[“author_2″,”author_3”]
}
},
/* … skip … */
{
“id”:”55″,
“key”:[“55″,”author”,1],
“value”:{“_id”:”author_1″},
“doc”:{“_id”:”author_1″,”_rev”:”3-b3bce00a63620dbf4dd14b1337802e78″,”name”:”Alan Alexander Milne”,”type”:”author”}
},
{
“id”:”55″,
“key”:[“55″,”doc”,0],
“value”:{“title”:”Вінні-Пух”,”isbn”:[“978-5-17-066600-3″,”978-5-271-27610-1″,”978-985-16-8371-6”]},
“doc”:{
“_id”:”55″,
“_rev”:”4-ea5acfb3800e0414bf20fb2a5150a586″,
“title”:”Вінні-Пух”,”ISBN”:[“978-5-17-066600-3″,”978-5-271-27610-1″,”978-985-16-8371-6”],
“genre”:”child”,”authors”:[“author_1″],”type”:”book”
}
}
]}

Тепер залишається зібрати цей view в людський вигляд, і так хочеться використовувати reduce функцію, але от біда, з параметром ?include_docs=true це неможливо.

Я не думаю, що це правильно і відповідає ідеології CouchDB, скоріше за все варто забути про JOIN-подібних конструкціях

Трохи про права доступу

Якщо ви вже встигли трохи вивчити інтерфейс Fucon’а, то вже звернули увагу на кнопочку “Security”, натискаємо — у віконці, що з’явилося, можна встановити імена/ролі адмінів і користувачів (перші можуть редагувати документи, другі — не повинен):

Якщо список reader’ів порожній, то БД вважається публічною, і для читання даних пароль не потрібно, інакше буде потрібно авторизація:

# одержання документа + авторизація
curl -X GET http://username:[email protected]:5984/simple/hello
>> {“_id”:”hello”,”_rev”:”1-f33e3c9b23e594593e93181763f0fb1b”,”title”:”Hello World!!!”}

Власне, ми редагуємо документ db_name/_security, єдина відмінність, що він не має ревізії, щоб збільшити швидкість роботи авторизації

Підтримуються наступні обробники для авторизації:

  • OAuth
  • cookie
  • default (використовується для HTTP авторизації, описаної в RFC 2617)

Для кожного HTTP запиту запускаються всі обробники по черзі, як тільки хтось упізнає користувача подальше виконання переривається (черговість визначається конфіг).

Тепер можна спробувати створити кілька користувачів з різними ролями, Logout » Signup:

Результатом стане поява документа в БД _users:

{
“_id”: “org.couchdb.user:guest”,
“_rev”: “1-9eeb0dcc95d472849590885bcc5f242c”,
“name”: “guest”,
“salt”: “dbc21298bfb3f78ebe815cdb7000e180”,
“password_sha”: “3d447e52765d76064223f501f16a9213d43a5ee5”,
“type”: “user”,
“roles”: []
}

Побуем:

curl -X GET http://guest:[email protected]:5984/simple/_all_docs
>> {“error”:”unauthorized”,”reason”:”You are not authorized to access this db.”}

Додамо йому роль reader і пробуємо:

curl -X GET http://guest:[email protected]:5984/simple/_all_docs
> > {“total_rows”:1,”offset”:0,”rows”:[{“id”:”hello”,”key”:”hello”,”value”:{“rev”:”4-2edebeac5a6e202b7d468c2d2fcd434c”}}]}

Тепер пробуємо редагувати:

curl -X POST http://guest:[email protected]:5984/simple -d ‘{“title”:”Hello World!!!”}’ -H “Content-Type:application/json”
>> {“ok”:true,”id”:”dbc21298bfb3f78ebe815cdb7000eb31″,”rev”:”1-f33e3c9b23e594593e93181763f0fb1b”}

Результат трохи несподіваний, документ створили, хоч guest=reader. Насправді для розмежування прав доступу необхідно створити функцію валідації даних, і в ній організовувати логіку поділу прав. Для цієї мети створіть будь design документ з атрибутом validate_doc_update, в якому і буде описана наша функція:

{
“_id”: “_design/validate”,
“_rev”: “4-e53c54b69a5916ddc06a4fd16f4b299d”,
“validate_doc_update”: “function(new_doc, old_doc, userCtx) { /* code here */ }”
}

Наприклад, функція яка дозволяє доступ лише для редакторів буде мати наступний вигляд:

function(new_doc, old_doc, userCtx) {
if(userCtx.roles.indexOf(“editor”) === -1) {
throw({unauthorized: “You are not an editor.”});
}
}

Валідація

Прочитавши попередній параграф виникає резонна думка — CouchDB дозволяє перевіряти дані перед занесенням їх в БД. О, так, і ось наочний приклад:

function(newDoc, oldDoc, userCtx) {
function require(field, message) {
message = message || “Document must have a” + field;
if (!newDoc[field]) throw({forbidden : message});
};
if (newDoc.type == “post”) {
require(“title”);
require(“created_at”);
require(“body”);
require(“author”);
}
if (newDoc.type == “comment”) {
require(“name”);
require(“created_at”);
require(“comment”, “You may not leave an empty comment”);
}
}

Зберігання файлів

Окремо варто відзначити, що файли варто зберігати в самій CouchDB, так як вони зберігаються як є на файловій системі, без всяких збочень, як то властиво SQL. З Fucon’а ви зможете легко додати кілька аттачей до документа:

{
“_id”: “1”,
“_rev”: “10-6754a5ad1330a6b8ebb544d052f2b03c”,
“title”: “Ulitka na sklone”,
“_attachments”: {
“ulitka.jpeg”: { // даний файл доступний за адресою http://localhost:5984/simple/1/ulitka.jpeg
“content_type”: “image/jpeg”,
“revpos”: 10,
“length”: 25227,
“stub”: true
}
}
}

Документація

  • Introduction to CouchDB views
  • HTTP Document API
  • HTTP view API
  • View Snippets (корисні приклади)
  • Recipes
  • Security
  • Validation
  • Database Queries the Way CouchDB

Огляд CouchDB 1.0 і порівняння з версією 0.11, корисно почитати:

  • Nice URLs with Rewrite Rules and Virtual Hosts
  • Views; JOINs Redux, Raw Collation for Speed
  • New Features in Replication
  • Users, Authentication, Authorisation and Permissions

P. S. Є чим доповнити виправити — пишіть коментарі.