Антон Шевчук // Web-разработчик

CouchDB для начинающих // PHP

Решил на досуге в рабочее время поковырять 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

Cохраняем 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:password@localhost: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:123456@localhost:5984/simple/_all_docs
>> {"error":"unauthorized","reason":"You are not authorized to access this db."}

Добавим ему роль reader и пробуем:

 curl -X GET http://guest:123456@localhost:5984/simple/_all_docs
>> {"total_rows":1,"offset":0,"rows":[{"id":"hello","key":"hello","value":{"rev":"4-2edebeac5a6e202b7d468c2d2fcd434c"}}]}

Теперь пробуем редактировать:

curl -X POST http://guest:123456@localhost: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
       }
   }
}

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

Обзор CouchDB 1.0 и сравнение с версией 0.11, полезно почитать:

P.S. Есть чем дополнить/исправить — пишите комментарии.

© Антон Шевчук 2007-2016